diff --git a/src/__tests__/site-detail.test.ts b/src/__tests__/site-detail.test.ts new file mode 100644 index 0000000..a737f8f --- /dev/null +++ b/src/__tests__/site-detail.test.ts @@ -0,0 +1,100 @@ +import { describe, test, expect, vi } from "vitest" + +// Mock next/headers +vi.mock("next/headers", () => ({ + cookies: () => + Promise.resolve({ + getAll: () => [], + set: vi.fn(), + }), +})) + +// Mock Supabase server client +const mockFrom = vi.fn() +const mockGetUser = vi.fn() +vi.mock("@/lib/supabase/server", () => ({ + createClient: () => + Promise.resolve({ + from: mockFrom, + auth: { getUser: mockGetUser }, + }), +})) + +describe("site detail data fetching", () => { + test("site query chain works with mock", () => { + const mockSingle = vi.fn().mockReturnValue({ + data: { id: "site-1", name: "Test Site", url: "https://example.com", created_at: "2025-01-01" }, + }) + const mockEq = vi.fn().mockReturnValue({ single: mockSingle }) + const mockSelect = vi.fn().mockReturnValue({ eq: mockEq }) + mockFrom.mockReturnValue({ select: mockSelect }) + + const result = mockFrom("sites").select("*").eq("id", "site-1").single() + expect(result.data.name).toBe("Test Site") + }) + + test("incidents query chain works with mock", () => { + const mockOrder = vi.fn().mockReturnValue({ + data: [ + { + id: "inc-1", + site_id: "site-1", + status: "open", + opened_at: "2025-01-01", + check: { id: "check-1", error: "HTTP 503" }, + }, + ], + }) + const mockEq = vi.fn().mockReturnValue({ order: mockOrder }) + const mockSelect = vi.fn().mockReturnValue({ eq: mockEq }) + mockFrom.mockReturnValue({ select: mockSelect }) + + const result = mockFrom("incidents") + .select("*, check:checks(*)") + .eq("site_id", "site-1") + .order("opened_at", { ascending: false }) + expect(result.data).toHaveLength(1) + expect(result.data[0].check.error).toBe("HTTP 503") + }) + + test("checks query chain works with mock", () => { + const mockLimit = vi.fn().mockReturnValue({ + data: [ + { id: "check-1", site_id: "site-1", status: "success", status_code: 200, checked_at: "2025-01-01T12:00:00Z" }, + { id: "check-2", site_id: "site-1", status: "failure", status_code: 503, error: "HTTP 503", checked_at: "2025-01-01T11:55:00Z" }, + ], + }) + const mockOrder = vi.fn().mockReturnValue({ limit: mockLimit }) + const mockEq = vi.fn().mockReturnValue({ order: mockOrder }) + const mockSelect = vi.fn().mockReturnValue({ eq: mockEq }) + mockFrom.mockReturnValue({ select: mockSelect }) + + const result = mockFrom("checks") + .select("*") + .eq("site_id", "site-1") + .order("checked_at", { ascending: false }) + .limit(50) + expect(result.data).toHaveLength(2) + expect(result.data[0].status).toBe("success") + expect(result.data[1].status).toBe("failure") + }) + + test("auth user check works with mock", async () => { + mockGetUser.mockResolvedValue({ + data: { user: { id: "user-1", email: "test@example.com" } }, + }) + + const supabase = { auth: { getUser: mockGetUser } } + const { data: { user } } = await supabase.auth.getUser() + expect(user).toBeDefined() + expect(user!.email).toBe("test@example.com") + }) + + test("unauthenticated user gets null", async () => { + mockGetUser.mockResolvedValue({ data: { user: null } }) + + const supabase = { auth: { getUser: mockGetUser } } + const { data: { user } } = await supabase.auth.getUser() + expect(user).toBeNull() + }) +}) diff --git a/src/app/sites/[id]/page.tsx b/src/app/sites/[id]/page.tsx new file mode 100644 index 0000000..0038490 --- /dev/null +++ b/src/app/sites/[id]/page.tsx @@ -0,0 +1,269 @@ +import Link from "next/link" +import { notFound } from "next/navigation" +import { createClient } from "@/lib/supabase/server" +import type { Site, Check, Incident } from "@/lib/supabase/types" +import { formatTimeAgo } from "@/lib/format" + +export const revalidate = 0 + +async function getSiteData(id: string) { + const supabase = await createClient() + + const { data: site } = await supabase + .from("sites") + .select("*") + .eq("id", id) + .single() + + if (!site) return null + + const { data: incidents } = await supabase + .from("incidents") + .select("*, check:checks(*)") + .eq("site_id", id) + .order("opened_at", { ascending: false }) + + const { data: checks } = await supabase + .from("checks") + .select("*") + .eq("site_id", id) + .order("checked_at", { ascending: false }) + .limit(50) + + const latestCheck = checks?.[0] ?? null + const isDown = latestCheck?.status === "failure" + + return { + site: site as Site, + incidents: (incidents ?? []) as (Incident & { check: Check })[], + checks: (checks ?? []) as Check[], + isDown, + } +} + +export default async function SiteDetailPage({ + params, +}: { + params: Promise<{ id: string }> +}) { + const { id } = await params + const data = await getSiteData(id) + if (!data) notFound() + + const { site, incidents, checks, isDown } = data + const supabase = await createClient() + const { + data: { user }, + } = await supabase.auth.getUser() + + return ( +
+
+ + ← All Sites + +
+ +
+
+

+ {site.name} +

+
+ {site.url} +
+
+ + + {isDown ? "Down" : "Up"} + +
+
+ + {user && ( +
+ + Edit + +
+ )} +
+ +
+

+ Incidents +

+
+ {incidents.length === 0 ? ( +
+ No incidents recorded. +
+ ) : ( + incidents.map((incident, i) => { + const isOpen = incident.status === "open" + return ( + 0 ? "1px solid #F0EEEA" : undefined, + }} + > +
+ +
+
+
+ + {incident.check?.error ?? "Unknown error"} + + + {incident.status} + +
+
+ {isOpen + ? `Opened ${formatTimeAgo(incident.opened_at)}` + : `${new Date(incident.opened_at).toLocaleDateString("en-US", { month: "short", day: "numeric" })} – ${new Date(incident.resolved_at!).toLocaleDateString("en-US", { month: "short", day: "numeric" })}`} +
+
+ + ) + }) + )} +
+
+ +
+

+ Check Log +

+
+ + + + + + + + + {checks.length === 0 ? ( + + + + ) : ( + checks.map((check, i) => { + const isFailure = check.status === "failure" + return ( + + + + + ) + }) + )} + +
+ Timestamp + + Result +
+ No checks recorded yet. +
+ {new Date(check.checked_at).toLocaleDateString("en-US", { + month: "short", + day: "numeric", + })}{" "} + {new Date(check.checked_at).toLocaleTimeString("en-US", { + hour: "numeric", + minute: "2-digit", + })} + +
+ + + {isFailure + ? check.error ?? "Failure" + : `HTTP ${check.status_code ?? 200} OK`} + +
+
+
+
+
+ ) +}