feat: extract sitemap utilities to lib/sitemap-utils.ts (Task 1)
This commit is contained in:
@@ -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)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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<string, never> {
|
||||
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<T>(
|
||||
label: string,
|
||||
query: () => Promise<T>,
|
||||
timeoutMs = 15_000,
|
||||
): Promise<T | undefined> {
|
||||
try {
|
||||
const result = await Promise.race([
|
||||
query(),
|
||||
new Promise<never>((_, 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 },
|
||||
]
|
||||
Reference in New Issue
Block a user