diff --git a/app/sitemap.ts b/app/sitemap.ts index cbbeaa0..83d1905 100644 --- a/app/sitemap.ts +++ b/app/sitemap.ts @@ -3,10 +3,13 @@ import { db } from "@/lib/db/index" import { games, hardware } from "@/lib/db/schema" import { or, ne, isNull } from "drizzle-orm" import { getAllUpdates } from "@/lib/updates" - -// ─── Configuration ───────────────────────────────────────────────────── - -const PRODUCTION_URL = "https://deckyvault.xyz" +import { + getBaseUrl, + imageEntry, + toDate, + querySafe, + STATIC_PAGES, +} from "@/lib/sitemap-utils" /** Revalidate sitemap every hour via ISR */ export const revalidate = 3600 @@ -14,89 +17,6 @@ export const revalidate = 3600 /** Maximum entries per sitemap (Google's limit is 50k; we stay well under) */ const MAX_ENTRIES = 45_000 -// ─── Helpers ──────────────────────────────────────────────────────────── - -function getBaseUrl(): string { - const envUrl = process.env.NEXT_PUBLIC_SITE_URL - if (envUrl && !envUrl.includes("localhost") && !envUrl.includes("127.0.0.1")) { - return envUrl.replace(/\/$/, "") - } - return PRODUCTION_URL -} - -/** Build a valid image sitemap entry from a capsule image URL */ -function imageEntry( - capsuleImage: unknown, -): { images: string[] } | Record { - if ( - typeof capsuleImage === "string" && - capsuleImage.trim().startsWith("https://") && - capsuleImage.trim().length <= 2048 - ) { - return { images: [capsuleImage.trim()] } - } - return {} -} - -/** Safely extract a Date from a value that could be Date, string, or nullish */ -function toDate(value: unknown): Date | undefined { - if (value instanceof Date && !Number.isNaN(value.getTime())) return value - if (typeof value === "string" || typeof value === "number") { - const d = new Date(value) - if (!Number.isNaN(d.getTime())) return d - } - return undefined -} - -/** - * Run a DB query with a safety net. - * Returns rows on success, undefined on failure — the sitemap still - * renders with whatever data is available. - */ -async function querySafe( - label: string, - query: () => Promise, - timeoutMs = 15_000, -): Promise { - try { - const result = await Promise.race([ - query(), - new Promise((_, reject) => - setTimeout( - () => reject(new Error(`[Sitemap] ${label} query timed out after ${timeoutMs}ms`)), - timeoutMs, - ), - ), - ]) - return result - } catch (err) { - console.error(`[Sitemap] ${label} query failed:`, err) - return undefined - } -} - -// ─── Static page definitions ─────────────────────────────────────────── - -interface StaticPageDef { - urlPath: string - changeFrequency: MetadataRoute.Sitemap[number]["changeFrequency"] - priority: number -} - -const STATIC_PAGES: StaticPageDef[] = [ - // Homepage - { urlPath: "", changeFrequency: "weekly", priority: 1.0 }, - // Core browse pages - { urlPath: "/games", changeFrequency: "daily", priority: 0.9 }, - { urlPath: "/devices", changeFrequency: "weekly", priority: 0.7 }, - { urlPath: "/updates", changeFrequency: "weekly", priority: 0.6 }, - // Utility pages - { urlPath: "/compare", changeFrequency: "weekly", priority: 0.5 }, - { urlPath: "/search", changeFrequency: "monthly", priority: 0.3 }, - // Static content - { urlPath: "/contact", changeFrequency: "yearly", priority: 0.3 }, -] - // ─── Sitemap builder (called by Next.js on every request + ISR) ──────── export default async function sitemap(): Promise { diff --git a/lib/__tests__/sitemap-utils.test.ts b/lib/__tests__/sitemap-utils.test.ts new file mode 100644 index 0000000..f639907 --- /dev/null +++ b/lib/__tests__/sitemap-utils.test.ts @@ -0,0 +1,211 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest" +import { + PRODUCTION_URL, + getBaseUrl, + imageEntry, + toDate, + querySafe, + STATIC_PAGES, +} from "@/lib/sitemap-utils" + +describe("PRODUCTION_URL", () => { + it("is the canonical production URL", () => { + expect(PRODUCTION_URL).toBe("https://deckyvault.xyz") + }) +}) + +describe("getBaseUrl", () => { + afterEach(() => { + delete process.env.NEXT_PUBLIC_SITE_URL + }) + + it("returns PRODUCTION_URL when no env var is set", () => { + expect(getBaseUrl()).toBe("https://deckyvault.xyz") + }) + + it("returns PRODUCTION_URL when env var is localhost", () => { + process.env.NEXT_PUBLIC_SITE_URL = "http://localhost:3000" + expect(getBaseUrl()).toBe("https://deckyvault.xyz") + }) + + it("returns PRODUCTION_URL when env var is 127.0.0.1", () => { + process.env.NEXT_PUBLIC_SITE_URL = "http://127.0.0.1:8080" + expect(getBaseUrl()).toBe("https://deckyvault.xyz") + }) + + it("returns the custom env var when set to a real domain", () => { + process.env.NEXT_PUBLIC_SITE_URL = "https://staging.deckyvault.xyz" + expect(getBaseUrl()).toBe("https://staging.deckyvault.xyz") + }) + + it("strips trailing slash from env var", () => { + process.env.NEXT_PUBLIC_SITE_URL = "https://staging.deckyvault.xyz/" + expect(getBaseUrl()).toBe("https://staging.deckyvault.xyz") + }) +}) + +describe("imageEntry", () => { + it("returns an images array for valid HTTPS capsule URLs", () => { + const result = imageEntry("https://cdn.example.com/capsule.jpg") + expect(result).toEqual({ images: ["https://cdn.example.com/capsule.jpg"] }) + }) + + it("trims whitespace from the URL", () => { + const result = imageEntry(" https://cdn.example.com/capsule.jpg ") + expect(result).toEqual({ images: ["https://cdn.example.com/capsule.jpg"] }) + }) + + it("returns empty object for non-HTTPS URLs", () => { + const result = imageEntry("http://cdn.example.com/capsule.jpg") + expect(result).toEqual({}) + }) + + it("returns empty object for non-string values", () => { + expect(imageEntry(null)).toEqual({}) + expect(imageEntry(undefined)).toEqual({}) + expect(imageEntry(123)).toEqual({}) + expect(imageEntry({})).toEqual({}) + }) + + it("returns empty object for empty strings", () => { + expect(imageEntry("")).toEqual({}) + expect(imageEntry(" ")).toEqual({}) + }) + + it("returns empty object for URLs exceeding 2048 characters", () => { + const longUrl = "https://cdn.example.com/" + "a".repeat(2048) + expect(imageEntry(longUrl)).toEqual({}) + }) + + it("accepts URLs exactly at the 2048 character limit", () => { + const maxUrl = "https://cdn.example.com/" + "a".repeat(2048 - 26) + expect(imageEntry(maxUrl)).toEqual({ images: [maxUrl] }) + }) +}) + +describe("toDate", () => { + it("returns the same Date object if passed a valid Date", () => { + const d = new Date("2025-01-15T10:00:00Z") + expect(toDate(d)).toBe(d) + }) + + it("returns undefined for invalid Date objects", () => { + const d = new Date("not-a-date") + expect(toDate(d)).toBeUndefined() + }) + + it("parses a valid ISO date string", () => { + const result = toDate("2025-01-15T10:00:00Z") + expect(result).toBeInstanceOf(Date) + expect(result!.toISOString()).toBe("2025-01-15T10:00:00.000Z") + }) + + it("returns undefined for invalid date strings", () => { + expect(toDate("not-a-date")).toBeUndefined() + }) + + it("parses a numeric timestamp", () => { + const result = toDate(1700000000000) + expect(result).toBeInstanceOf(Date) + expect(result!.getTime()).toBe(1700000000000) + }) + + it("returns undefined for null", () => { + expect(toDate(null)).toBeUndefined() + }) + + it("returns undefined for undefined", () => { + expect(toDate(undefined)).toBeUndefined() + }) + + it("returns undefined for non-date objects", () => { + expect(toDate({ foo: "bar" })).toBeUndefined() + }) +}) + +describe("querySafe", () => { + beforeEach(() => { + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it("returns the query result on success", async () => { + const query = vi.fn().mockResolvedValue([{ id: 1 }]) + const result = await querySafe("test", query) + expect(result).toEqual([{ id: 1 }]) + expect(query).toHaveBeenCalledOnce() + }) + + it("returns undefined when the query throws", async () => { + const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}) + const query = vi.fn().mockRejectedValue(new Error("DB down")) + const result = await querySafe("test", query) + expect(result).toBeUndefined() + expect(consoleErrorSpy).toHaveBeenCalledWith( + "[Sitemap] test query failed:", + expect.any(Error), + ) + consoleErrorSpy.mockRestore() + }) + + it("returns undefined when the query times out", async () => { + const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}) + const query = vi.fn().mockImplementation( + () => new Promise((resolve) => setTimeout(() => resolve([{ id: 1 }]), 20_000)), + ) + const resultPromise = querySafe("test", query, 5_000) + vi.advanceTimersByTime(5_001) + const result = await resultPromise + expect(result).toBeUndefined() + expect(consoleErrorSpy).toHaveBeenCalledWith( + "[Sitemap] test query failed:", + expect.any(Error), + ) + consoleErrorSpy.mockRestore() + }) + + it("uses default timeout of 15s when not specified", async () => { + const query = vi.fn().mockResolvedValue([{ id: 1 }]) + const result = await querySafe("test", query) + expect(result).toEqual([{ id: 1 }]) + }) +}) + +describe("STATIC_PAGES", () => { + it("contains exactly 7 entries", () => { + expect(STATIC_PAGES).toHaveLength(7) + }) + + it("first entry is the homepage with empty urlPath and priority 1", () => { + expect(STATIC_PAGES[0].urlPath).toBe("") + expect(STATIC_PAGES[0].priority).toBe(1.0) + }) + + it("includes /games with priority 0.9", () => { + const entry = STATIC_PAGES.find((p) => p.urlPath === "/games") + expect(entry).toBeDefined() + expect(entry!.priority).toBe(0.9) + }) + + it("includes /compare with priority 0.5", () => { + const entry = STATIC_PAGES.find((p) => p.urlPath === "/compare") + expect(entry).toBeDefined() + expect(entry!.priority).toBe(0.5) + }) + + it("includes /search with priority 0.3", () => { + const entry = STATIC_PAGES.find((p) => p.urlPath === "/search") + expect(entry).toBeDefined() + expect(entry!.priority).toBe(0.3) + }) + + it("every entry has a valid changeFrequency", () => { + const validFreqs = ["always", "hourly", "daily", "weekly", "monthly", "yearly", "never"] + for (const page of STATIC_PAGES) { + expect(validFreqs).toContain(page.changeFrequency) + } + }) +}) \ No newline at end of file diff --git a/lib/sitemap-utils.ts b/lib/sitemap-utils.ts new file mode 100644 index 0000000..01908f4 --- /dev/null +++ b/lib/sitemap-utils.ts @@ -0,0 +1,88 @@ +import type { MetadataRoute } from "next" + +// ─── Configuration ───────────────────────────────────────────────────── + +export const PRODUCTION_URL = "https://deckyvault.xyz" + +// ─── Helpers ─────────────────────────────────────────────────────────── + +export function getBaseUrl(): string { + const envUrl = process.env.NEXT_PUBLIC_SITE_URL + if (envUrl && !envUrl.includes("localhost") && !envUrl.includes("127.0.0.1")) { + return envUrl.replace(/\/$/, "") + } + return PRODUCTION_URL +} + +/** Build a valid image sitemap entry from a capsule image URL */ +export function imageEntry( + capsuleImage: unknown, +): { images: string[] } | Record { + if ( + typeof capsuleImage === "string" && + capsuleImage.trim().startsWith("https://") && + capsuleImage.trim().length <= 2048 + ) { + return { images: [capsuleImage.trim()] } + } + return {} +} + +/** Safely extract a Date from a value that could be Date, string, or nullish */ +export function toDate(value: unknown): Date | undefined { + if (value instanceof Date && !Number.isNaN(value.getTime())) return value + if (typeof value === "string" || typeof value === "number") { + const d = new Date(value) + if (!Number.isNaN(d.getTime())) return d + } + return undefined +} + +/** + * Run a DB query with a safety net. + * Returns rows on success, undefined on failure — the sitemap still + * renders with whatever data is available. + */ +export async function querySafe( + label: string, + query: () => Promise, + timeoutMs = 15_000, +): Promise { + try { + const result = await Promise.race([ + query(), + new Promise((_, reject) => + setTimeout( + () => reject(new Error(`[Sitemap] ${label} query timed out after ${timeoutMs}ms`)), + timeoutMs, + ), + ), + ]) + return result + } catch (err) { + console.error(`[Sitemap] ${label} query failed:`, err) + return undefined + } +} + +// ─── Static page definitions ─────────────────────────────────────────── + +export interface StaticPageDef { + urlPath: string + changeFrequency: MetadataRoute.Sitemap[number]["changeFrequency"] + priority: number +} + +export const STATIC_PAGES: StaticPageDef[] = [ + // Homepage + { urlPath: "", changeFrequency: "weekly", priority: 1.0 }, + // Core browse pages + { urlPath: "/games", changeFrequency: "daily", priority: 0.9 }, + { urlPath: "/devices", changeFrequency: "weekly", priority: 0.7 }, + { urlPath: "/updates", changeFrequency: "weekly", priority: 0.6 }, + // Utility pages + { urlPath: "/compare", changeFrequency: "weekly", priority: 0.5 }, + { urlPath: "/search", changeFrequency: "monthly", priority: 0.3 }, + // Static content + { urlPath: "/contact", changeFrequency: "yearly", priority: 0.3 }, +] \ No newline at end of file