From 535836b6ba62556b807262a89487116f185429a4 Mon Sep 17 00:00:00 2001 From: Adrian Bonpin Date: Mon, 25 May 2026 21:29:10 +0800 Subject: [PATCH] test: add sitemap integration constraint verification (Task 3) --- app/__tests__/sitemap.integration.test.ts | 159 ++++++++++++++++++++++ 1 file changed, 159 insertions(+) create mode 100644 app/__tests__/sitemap.integration.test.ts diff --git a/app/__tests__/sitemap.integration.test.ts b/app/__tests__/sitemap.integration.test.ts new file mode 100644 index 0000000..fa8909f --- /dev/null +++ b/app/__tests__/sitemap.integration.test.ts @@ -0,0 +1,159 @@ +import { describe, it, expect, vi, beforeEach } from "vitest" + +// ── Mock data ────────────────────────────────────────────────────────── +let mockData: unknown[] = [] + +// ── Chainable query builder mock ────────────────────────────────────── +function createChainableQuery(resolveWith: unknown[]) { + const then = (resolve: (v: unknown) => unknown, reject: (e: unknown) => unknown) => + Promise.resolve(resolveWith).then(resolve, reject) + + const chain: Record = { + where: vi.fn(() => ({ then, [Symbol.toPrimitive]: () => resolveWith })), + then, + [Symbol.toPrimitive]: () => resolveWith, + } + + return chain +} + +vi.mock("@/lib/db/index", () => ({ + db: { + select: vi.fn(() => ({ + from: vi.fn(() => createChainableQuery(mockData)), + })), + }, +})) + +vi.mock("@/lib/db/schema", () => ({ + games: { + id: "id", + updatedAt: "updatedAt", + capsuleImage: "capsuleImage", + syncStatus: "syncStatus", + }, + hardware: { slug: "slug", createdAt: "createdAt" }, +})) + +vi.mock("drizzle-orm", () => ({ + or: vi.fn((...args: unknown[]) => args[0]), + ne: vi.fn((col: unknown) => col), + isNull: vi.fn((col: unknown) => col), +})) + +const mockGetAllUpdates = vi.fn(() => []) +vi.mock("@/lib/updates", () => ({ + getAllUpdates: mockGetAllUpdates, +})) + +describe("Sitemap Integration — Constraint Verification", () => { + beforeEach(() => { + vi.clearAllMocks() + mockData = [] + mockGetAllUpdates.mockReturnValue([]) + }) + + // ── Spec §2: Non-negotiables ───────────────────────────────────── + + it("PERFORMANCE: sitemap index generation is synchronous (no DB for index)", async () => { + const mod = await import("@/app/sitemap") + const start = Date.now() + await mod.generateSitemaps() + const elapsed = Date.now() - start + // generateSitemaps should resolve quickly (< 500ms even with mocked slow DB) + expect(elapsed).toBeLessThan(500) + }) + + it("RELIABILITY: generateSitemaps always returns required base IDs", async () => { + mockData = [{ count: 500 }] + const mod = await import("@/app/sitemap") + const ids = await mod.generateSitemaps() + const idValues = ids.map((x: { id: string }) => x.id) + + expect(idValues).toContain("static") + expect(idValues).toContain("devices") + expect(idValues).toContain("updates") + // Either 'games' or at least one 'games-N' must exist + const hasGames = idValues.some( + (id: string) => id === "games" || /^games-\d+$/.test(id), + ) + expect(hasGames).toBe(true) + }) + + it("RELIABILITY: all child sitemaps return valid arrays even on failure", async () => { + const mod = await import("@/app/sitemap") + + for (const id of ["static", "games", "devices", "updates"]) { + const result = await mod.default({ id: Promise.resolve(id) }) + expect(Array.isArray(result)).toBe(true) + } + }) + + it("SECURITY: no non-public routes in static pages", async () => { + const mod = await import("@/app/sitemap") + const result = await mod.default({ id: Promise.resolve("static") }) + + const urls = result.map((e: { url: string }) => e.url) + for (const url of urls) { + expect(url).not.toContain("/manage") + expect(url).not.toContain("/api") + expect(url).not.toContain("/profile") + } + }) + + it("DATA: games sitemap entries have correct shape", async () => { + mockData = [ + { id: "abc123", updatedAt: new Date("2025-06-01"), capsuleImage: "https://cdn.example.com/img.jpg" }, + ] + const mod = await import("@/app/sitemap") + const result = await mod.default({ id: Promise.resolve("games") }) + + for (const entry of result) { + expect(entry).toHaveProperty("url") + expect(typeof entry.url).toBe("string") + expect(entry.url).toMatch(/^https:\/\/deckyvault\.xyz\/game\//) + if (entry.lastModified) { + expect(entry.lastModified).toBeInstanceOf(Date) + } + if (entry.images) { + expect(Array.isArray(entry.images)).toBe(true) + } + } + }) + + it("DATA: devices sitemap entries have correct shape", async () => { + mockData = [ + { slug: "steam-deck-oled", createdAt: new Date("2025-03-01") }, + ] + const mod = await import("@/app/sitemap") + const result = await mod.default({ id: Promise.resolve("devices") }) + + for (const entry of result) { + expect(entry).toHaveProperty("url") + expect(typeof entry.url).toBe("string") + expect(entry.url).toMatch(/^https:\/\/deckyvault\.xyz\/devices\//) + } + }) + + it("DATA: updates sitemap entries have correct shape", async () => { + mockGetAllUpdates.mockReturnValue([ + { slug: "2026-01-01", title: "Release", date: "2026-01-01", version: "1.0.0", summary: "First" }, + ]) + const mod = await import("@/app/sitemap") + const result = await mod.default({ id: Promise.resolve("updates") }) + + for (const entry of result) { + expect(entry).toHaveProperty("url") + expect(typeof entry.url).toBe("string") + expect(entry.url).toMatch(/^https:\/\/deckyvault\.xyz\/updates\//) + } + }) + + it("ISOLATION: static sitemap has no DB dependency", async () => { + const mod = await import("@/app/sitemap") + const result = await mod.default({ id: Promise.resolve("static") }) + + // Result must be 7 entries regardless of DB state + expect(result).toHaveLength(7) + }) +}) \ No newline at end of file