diff --git a/src/__tests__/incident-detail.test.ts b/src/__tests__/incident-detail.test.ts new file mode 100644 index 0000000..9097c5e --- /dev/null +++ b/src/__tests__/incident-detail.test.ts @@ -0,0 +1,105 @@ +import { describe, test, expect, vi, beforeEach } from "vitest" + +// Mock next/cache +vi.mock("next/cache", () => ({ + revalidatePath: vi.fn(), +})) + +// Mock next/navigation +const mockRedirect = vi.fn() +vi.mock("next/navigation", () => ({ + redirect: (url: string) => { + mockRedirect(url) + throw new Error(`NEXT_REDIRECT:${url}`) + }, + notFound: () => { + throw new Error("NEXT_NOT_FOUND") + }, +})) + +// Mock Supabase server client +const mockFrom = vi.fn() +const mockGetUser = vi.fn() +const mockUpdate = vi.fn() + +vi.mock("@/lib/supabase/server", () => ({ + createClient: () => + Promise.resolve({ + from: mockFrom, + auth: { getUser: mockGetUser }, + }), +})) + +import { resolveIncident } from "@/app/incidents/[id]/actions" + +describe("incident detail data fetching", () => { + test("incident query chain works with mock", () => { + const mockSingle = vi.fn().mockReturnValue({ + data: { + id: "inc-1", + site_id: "site-1", + status: "open", + opened_at: "2025-01-01", + site: { id: "site-1", name: "Test Site" }, + check: { id: "check-1", error: "HTTP 503" }, + }, + }) + const mockEq = vi.fn().mockReturnValue({ single: mockSingle }) + const mockSelect = vi.fn().mockReturnValue({ eq: mockEq }) + mockFrom.mockReturnValue({ select: mockSelect }) + + const result = mockFrom("incidents") + .select("*, site:sites(*), check:checks(*)") + .eq("id", "inc-1") + .single() + expect(result.data.site.name).toBe("Test Site") + expect(result.data.check.error).toBe("HTTP 503") + }) + + test("checks since incident query works with mock", () => { + const mockOrder = vi.fn().mockReturnValue({ + data: [ + { id: "check-2", status: "failure", checked_at: "2025-01-01T12:10:00Z" }, + { id: "check-1", status: "failure", checked_at: "2025-01-01T12:05:00Z" }, + ], + }) + const mockGte = vi.fn().mockReturnValue({ order: mockOrder }) + const mockEq = vi.fn().mockReturnValue({ gte: mockGte }) + const mockSelect = vi.fn().mockReturnValue({ eq: mockEq }) + mockFrom.mockReturnValue({ select: mockSelect }) + + const result = mockFrom("checks") + .select("*") + .eq("site_id", "site-1") + .gte("checked_at", "2025-01-01T12:00:00Z") + .order("checked_at", { ascending: false }) + expect(result.data).toHaveLength(2) + }) +}) + +describe("resolveIncident action", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + test("redirects unauthenticated users to login", async () => { + mockGetUser.mockResolvedValue({ data: { user: null } }) + + await expect(resolveIncident("inc-1")).rejects.toThrow("NEXT_REDIRECT:/login") + expect(mockRedirect).toHaveBeenCalledWith("/login") + }) + + test("resolves incident for authenticated users", async () => { + mockGetUser.mockResolvedValue({ + data: { user: { id: "user-1" } }, + }) + + const mockEq = vi.fn().mockReturnValue({ error: null }) + mockUpdate.mockReturnValue({ eq: mockEq }) + mockFrom.mockReturnValue({ update: mockUpdate }) + + const result = await resolveIncident("inc-1") + expect(result).toBeUndefined() + expect(mockFrom).toHaveBeenCalledWith("incidents") + }) +}) diff --git a/src/app/incidents/[id]/actions.ts b/src/app/incidents/[id]/actions.ts new file mode 100644 index 0000000..c30678b --- /dev/null +++ b/src/app/incidents/[id]/actions.ts @@ -0,0 +1,28 @@ +"use server" + +import { revalidatePath } from "next/cache" +import { redirect } from "next/navigation" +import { createClient } from "@/lib/supabase/server" + +export async function resolveIncident(incidentId: string) { + const supabase = await createClient() + + const { + data: { user }, + } = await supabase.auth.getUser() + + if (!user) { + redirect("/login") + } + + await supabase + .from("incidents") + .update({ + status: "resolved", + resolved_at: new Date().toISOString(), + }) + .eq("id", incidentId) + + revalidatePath(`/incidents/${incidentId}`) + revalidatePath("/") +} diff --git a/src/app/incidents/[id]/page.tsx b/src/app/incidents/[id]/page.tsx new file mode 100644 index 0000000..1fca901 --- /dev/null +++ b/src/app/incidents/[id]/page.tsx @@ -0,0 +1,267 @@ +import Link from "next/link" +import { notFound } from "next/navigation" +import { createClient } from "@/lib/supabase/server" +import type { Incident, Site, Check } from "@/lib/supabase/types" +import { resolveIncident } from "./actions" + +export const revalidate = 0 + +type IncidentWithRelations = Incident & { site: Site; check: Check } + +async function getIncidentData(id: string) { + const supabase = await createClient() + + const { data: incident } = await supabase + .from("incidents") + .select("*, site:sites(*), check:checks(*)") + .eq("id", id) + .single() + + if (!incident) return null + + const typedIncident = incident as IncidentWithRelations + + const { data: checks } = await supabase + .from("checks") + .select("*") + .eq("site_id", typedIncident.site_id) + .gte("checked_at", typedIncident.opened_at) + .order("checked_at", { ascending: false }) + + return { + incident: typedIncident, + checks: (checks ?? []) as Check[], + } +} + +export default async function IncidentDetailPage({ + params, +}: { + params: Promise<{ id: string }> +}) { + const { id } = await params + const data = await getIncidentData(id) + if (!data) notFound() + + const { incident, checks } = data + const supabase = await createClient() + const { + data: { user }, + } = await supabase.auth.getUser() + + const isOpen = incident.status === "open" + + return ( +
+
+ + ← {incident.site?.name} + +
+ +
+
+ {isOpen && ( + + Open + + )} + {!isOpen && ( + + Resolved + + )} +

+ Incident on {incident.site?.name} +

+
+ +
+
+ + Opened{" "} + {new Date(incident.opened_at).toLocaleDateString("en-US", { + month: "short", + day: "numeric", + })},{" "} + {new Date(incident.opened_at).toLocaleTimeString("en-US", { + hour: "numeric", + minute: "2-digit", + })} + +
+ {incident.resolved_at && ( +
+ Resolved{" "} + {new Date(incident.resolved_at).toLocaleDateString("en-US", { + month: "short", + day: "numeric", + })},{" "} + {new Date(incident.resolved_at).toLocaleTimeString("en-US", { + hour: "numeric", + minute: "2-digit", + })} +
+ )} +
+ Triggered by: + + {incident.check?.error ?? "Unknown"} + +
+
+ + {isOpen && user && ( +
+
+ +
+
+ )} +
+ +
+

+ Checks Since Incident Opened +

+ +
+
+ ) +}