feat: add sitemap dynamic entries fetcher with LIMIT, validation, and error recovery

This commit is contained in:
2026-05-04 13:09:02 +08:00
parent 4fbaea9d9d
commit 26e0b510da
2 changed files with 241 additions and 0 deletions
@@ -0,0 +1,171 @@
import { describe, it, expect, vi, beforeEach } from "vitest"
import { fetchDynamicEntries } from "@/lib/sitemap/fetch-dynamic-entries"
vi.mock("@/lib/db/index", () => ({
db: {
select: vi.fn(),
},
}))
vi.mock("@/lib/db/schema", () => ({
games: {
id: "id",
updatedAt: "updatedAt",
capsuleImage: "capsuleImage",
syncStatus: "syncStatus",
},
hardware: {
slug: "slug",
createdAt: "createdAt",
},
}))
function mockDrizzleQuery(rows: Record<string, unknown>[]) {
const limit = vi.fn().mockResolvedValue(rows)
const where = vi.fn().mockReturnValue({ limit })
const from = vi.fn().mockReturnValue({ where, limit })
return { select: vi.fn().mockReturnValue({ from, where, limit }) }
}
function mockDrizzleFailingQuery() {
const limit = vi.fn().mockRejectedValue(new Error("DB error"))
const where = vi.fn().mockReturnValue({ limit })
const from = vi.fn().mockReturnValue({ where, limit })
return { select: vi.fn().mockReturnValue({ from, where, limit }) }
}
describe("fetchDynamicEntries", () => {
beforeEach(() => {
vi.clearAllMocks()
})
it("returns game entries with validated images", async () => {
const { db } = await import("@/lib/db/index")
const mockDb = db as unknown as { select: ReturnType<typeof vi.fn> }
const gameRows = [
{ id: "game-1", updatedAt: new Date("2024-01-01"), capsuleImage: "https://cdn.example.com/img1.jpg", syncStatus: "synced" },
{ id: "game-2", updatedAt: new Date("2024-02-01"), capsuleImage: null, syncStatus: "synced" },
]
const deviceRows = [
{ slug: "steam-deck", createdAt: new Date("2023-06-01") },
]
const gamesChain = mockDrizzleQuery(gameRows)
const devicesChain = mockDrizzleQuery(deviceRows)
let selectCallIndex = 0
mockDb.select.mockImplementation(() => {
selectCallIndex++
if (selectCallIndex === 1) return gamesChain.select()
return devicesChain.select()
})
const result = await fetchDynamicEntries()
expect(result.gameEntries).toHaveLength(2)
expect(result.gameEntries[0].url).toBe("https://deckyvault.xyz/games/game-1")
expect(result.gameEntries[0].images).toEqual(["https://cdn.example.com/img1.jpg"])
expect(result.gameEntries[1].url).toBe("https://deckyvault.xyz/games/game-2")
expect(result.gameEntries[1].images).toBeUndefined()
expect(result.deviceEntries).toHaveLength(1)
})
it("returns device entries from hardware table", async () => {
const { db } = await import("@/lib/db/index")
const mockDb = db as unknown as { select: ReturnType<typeof vi.fn> }
const deviceRows = [
{ slug: "steam-deck-oled", createdAt: new Date("2023-11-01") },
{ slug: "rog-ally", createdAt: new Date("2024-01-15") },
]
const gamesChain = mockDrizzleQuery([])
const devicesChain = mockDrizzleQuery(deviceRows)
let selectCallIndex = 0
mockDb.select.mockImplementation(() => {
selectCallIndex++
if (selectCallIndex === 1) return gamesChain.select()
return devicesChain.select()
})
const result = await fetchDynamicEntries()
expect(result.deviceEntries).toHaveLength(2)
expect(result.deviceEntries[0].url).toBe("https://deckyvault.xyz/devices/steam-deck-oled")
expect(result.deviceEntries[1].url).toBe("https://deckyvault.xyz/devices/rog-ally")
})
it("returns empty arrays when DB query fails", async () => {
const { db } = await import("@/lib/db/index")
const mockDb = db as unknown as { select: ReturnType<typeof vi.fn> }
const gamesChain = mockDrizzleFailingQuery()
const devicesChain = mockDrizzleFailingQuery()
let selectCallIndex = 0
mockDb.select.mockImplementation(() => {
selectCallIndex++
if (selectCallIndex === 1) return gamesChain.select()
return devicesChain.select()
})
const result = await fetchDynamicEntries()
expect(result.gameEntries).toEqual([])
expect(result.deviceEntries).toEqual([])
})
it("excludes invalid image URLs", async () => {
const { db } = await import("@/lib/db/index")
const mockDb = db as unknown as { select: ReturnType<typeof vi.fn> }
const gameRows = [
{ id: "game-a", updatedAt: new Date("2024-01-01"), capsuleImage: "", syncStatus: "synced" },
{ id: "game-b", updatedAt: new Date("2024-02-01"), capsuleImage: "http://cdn.example.com/img.jpg", syncStatus: "synced" },
{ id: "game-c", updatedAt: new Date("2024-03-01"), capsuleImage: "https://cdn.example.com/valid.jpg", syncStatus: "synced" },
]
const gamesChain = mockDrizzleQuery(gameRows)
const devicesChain = mockDrizzleQuery([])
let selectCallIndex = 0
mockDb.select.mockImplementation(() => {
selectCallIndex++
if (selectCallIndex === 1) return gamesChain.select()
return devicesChain.select()
})
const result = await fetchDynamicEntries()
expect(result.gameEntries).toHaveLength(3)
expect(result.gameEntries[0].images).toBeUndefined()
expect(result.gameEntries[1].images).toBeUndefined()
expect(result.gameEntries[2].images).toEqual(["https://cdn.example.com/valid.jpg"])
})
it("returns partial data when one query succeeds and the other fails", async () => {
const { db } = await import("@/lib/db/index")
const mockDb = db as unknown as { select: ReturnType<typeof vi.fn> }
const gamesChain = mockDrizzleFailingQuery()
const deviceRows = [
{ slug: "lenovo-legion-go", createdAt: new Date("2024-03-01") },
]
const devicesChain = mockDrizzleQuery(deviceRows)
let selectCallIndex = 0
mockDb.select.mockImplementation(() => {
selectCallIndex++
if (selectCallIndex === 1) return gamesChain.select()
return devicesChain.select()
})
const result = await fetchDynamicEntries()
expect(result.gameEntries).toEqual([])
expect(result.deviceEntries).toHaveLength(1)
expect(result.deviceEntries[0].url).toBe("https://deckyvault.xyz/devices/lenovo-legion-go")
})
})
+70
View File
@@ -0,0 +1,70 @@
import type { MetadataRoute } from "next"
import { db } from "@/lib/db/index"
import { games, hardware } from "@/lib/db/schema"
import { ne } from "drizzle-orm"
import { validateImageUrl } from "./validate-image-url"
const BASE_URL = "https://deckyvault.xyz"
const MAX_GAME_ENTRIES = 49_700
const MAX_DEVICE_ENTRIES = 200
async function fetchGameEntries(): Promise<MetadataRoute.Sitemap> {
try {
const rows = await db
.select({
id: games.id,
updatedAt: games.updatedAt,
capsuleImage: games.capsuleImage,
syncStatus: games.syncStatus,
})
.from(games)
.where(ne(games.syncStatus, "failed"))
.limit(MAX_GAME_ENTRIES)
return rows.map((row) => {
const validatedImage = validateImageUrl(row.capsuleImage)
return {
url: `${BASE_URL}/games/${row.id}`,
lastModified: row.updatedAt,
changeFrequency: "weekly" as const,
priority: 0.7,
...(validatedImage ? { images: [validatedImage] } : {}),
}
})
} catch {
return []
}
}
async function fetchDeviceEntries(): Promise<MetadataRoute.Sitemap> {
try {
const rows = await db
.select({
slug: hardware.slug,
createdAt: hardware.createdAt,
})
.from(hardware)
.limit(MAX_DEVICE_ENTRIES)
return rows.map((row) => ({
url: `${BASE_URL}/devices/${row.slug}`,
lastModified: row.createdAt,
changeFrequency: "monthly" as const,
priority: 0.5,
}))
} catch {
return []
}
}
export async function fetchDynamicEntries(): Promise<{
gameEntries: MetadataRoute.Sitemap
deviceEntries: MetadataRoute.Sitemap
}> {
const [gameEntries, deviceEntries] = await Promise.all([
fetchGameEntries(),
fetchDeviceEntries(),
])
return { gameEntries, deviceEntries }
}