diff --git a/messages/ja/settings/config.json b/messages/ja/settings/config.json index 8628881ea..ca76775b5 100644 --- a/messages/ja/settings/config.json +++ b/messages/ja/settings/config.json @@ -87,6 +87,8 @@ "saveFailed": "保存に失敗しました", "saveSettings": "設定を保存", "saveSuccess": "正常に保存されました", + "publicStatusProjectionWarning": "システム設定は保存されましたが、public status の Redis 投影は更新されませんでした。", + "publicStatusBackgroundRefreshPending": "システム設定は保存されましたが、バックグラウンド更新が成功するまで公開ステータスページに古いデータが表示される場合があります。", "siteTitle": "サイトタイトル", "siteTitleDesc": "ブラウザタブのタイトルとシステムのデフォルト表示名を設定するために使用されます。", "siteTitlePlaceholder": "例:Claude Code Hub", diff --git a/messages/ru/settings/config.json b/messages/ru/settings/config.json index c8165d266..167e8824a 100644 --- a/messages/ru/settings/config.json +++ b/messages/ru/settings/config.json @@ -87,6 +87,8 @@ "saveFailed": "Ошибка сохранения", "saveSettings": "Сохранить настройки", "saveSuccess": "Сохранено успешно", + "publicStatusProjectionWarning": "Системные настройки сохранены, но Redis-проекция public status не была обновлена.", + "publicStatusBackgroundRefreshPending": "Системные настройки сохранены, но публичная статус-страница может временно показывать устаревшие данные, пока фоновое обновление не завершится.", "siteTitle": "Название сайта", "siteTitleDesc": "Используется для установки заголовка вкладки браузера и имени системы по умолчанию.", "siteTitlePlaceholder": "например: Claude Code Hub", diff --git a/messages/zh-TW/settings/config.json b/messages/zh-TW/settings/config.json index a061562a8..8d2cdfd36 100644 --- a/messages/zh-TW/settings/config.json +++ b/messages/zh-TW/settings/config.json @@ -87,6 +87,8 @@ "saveFailed": "儲存失敗", "saveSettings": "儲存設定", "saveSuccess": "儲存成功", + "publicStatusProjectionWarning": "系統設定已儲存,但 public status Redis 投影尚未刷新。", + "publicStatusBackgroundRefreshPending": "系統設定已儲存,但公開狀態頁可能會在背景刷新成功前暫時維持舊資料。", "siteTitle": "站台標題", "siteTitleDesc": "用於設定瀏覽器分頁標題以及系統預設顯示名稱。", "siteTitlePlaceholder": "例:Claude Code Hub", diff --git a/src/app/[locale]/layout.tsx b/src/app/[locale]/layout.tsx index 34f69fa66..cbbc97041 100644 --- a/src/app/[locale]/layout.tsx +++ b/src/app/[locale]/layout.tsx @@ -27,7 +27,7 @@ export async function generateMetadata({ try { const metadata = await resolveSiteMetadataSource({ isPublicStatusRequest }); const title = metadata?.siteTitle?.trim() || DEFAULT_SITE_TITLE; - const description = metadata?.siteDescription?.trim() || DEFAULT_SITE_TITLE; + const description = metadata?.siteDescription?.trim() || title; // Generate alternates for all locales const alternates: Record = {}; @@ -77,7 +77,7 @@ export default async function RootLayout({ } // Load translation messages - const messages = await getMessages(); + const messages = await getMessages({ locale }); const timeZone = await resolveLayoutTimeZone({ isPublicStatusRequest }); // Create a stable `now` timestamp to avoid SSR/CSR hydration mismatch for relative time const now = new Date(); diff --git a/src/app/[locale]/status/page.tsx b/src/app/[locale]/status/page.tsx index ac2f45069..96c1ddce0 100644 --- a/src/app/[locale]/status/page.tsx +++ b/src/app/[locale]/status/page.tsx @@ -1,22 +1,25 @@ import { getTranslations } from "next-intl/server"; -import { readPublicSiteMeta } from "@/lib/public-site-meta"; -import { readCurrentPublicStatusConfigSnapshot } from "@/lib/public-status/config-snapshot"; +import { + readCurrentPublicStatusConfigSnapshot, + readPublicStatusSiteMetadata, +} from "@/lib/public-status/config-snapshot"; import { readPublicStatusPayload } from "@/lib/public-status/read-store"; import { schedulePublicStatusRebuild } from "@/lib/public-status/rebuild-hints"; -import { resolveSiteTitle } from "@/lib/site-title"; import { PublicStatusView } from "./_components/public-status-view"; export const dynamic = "force-dynamic"; +const FALLBACK_SITE_TITLE = "Claude Code Hub"; + export default async function PublicStatusPage({ params, }: { params: Promise<{ locale: string }>; }) { const { locale } = await params; - const t = await getTranslations("settings"); + const t = await getTranslations({ locale, namespace: "settings.statusPage.public" }); const configSnapshot = await readCurrentPublicStatusConfigSnapshot(); - const siteMeta = await readPublicSiteMeta(); + const siteMetadata = await readPublicStatusSiteMetadata(); const intervalMinutes = configSnapshot?.defaultIntervalMinutes ?? 5; const rangeHours = configSnapshot?.defaultRangeHours ?? 24; const followServerDefaults = !configSnapshot; @@ -27,13 +30,11 @@ export default async function PublicStatusPage({ hasConfiguredGroups: configSnapshot ? configSnapshot.groups.length > 0 : undefined, nowIso: new Date().toISOString(), triggerRebuildHint: async (reason) => { - if (followServerDefaults) { - await schedulePublicStatusRebuild({ - intervalMinutes, - rangeHours, - reason, - }); - } + await schedulePublicStatusRebuild({ + intervalMinutes, + rangeHours, + reason, + }); }, }); @@ -44,51 +45,53 @@ export default async function PublicStatusPage({ rangeHours={rangeHours} followServerDefaults={followServerDefaults} locale={locale} - siteTitle={resolveSiteTitle(configSnapshot?.siteTitle, siteMeta.siteTitle)} + siteTitle={ + siteMetadata?.siteTitle?.trim() || configSnapshot?.siteTitle?.trim() || FALLBACK_SITE_TITLE + } timeZone={configSnapshot?.timeZone ?? "UTC"} labels={{ - systemStatus: t("statusPage.public.systemStatus"), - heroPrimary: t("statusPage.public.heroPrimary"), - heroSecondary: t("statusPage.public.heroSecondary"), - generatedAt: t("statusPage.public.generatedAt"), - history: t("statusPage.public.history"), - availability: t("statusPage.public.availability"), - ttfb: t("statusPage.public.ttfb"), - freshnessWindow: t("statusPage.public.freshnessWindow"), - fresh: t("statusPage.public.fresh"), - stale: t("statusPage.public.stale"), - staleDetail: t("statusPage.public.staleDetail"), - rebuilding: t("statusPage.public.rebuilding"), - noData: t("statusPage.public.noData"), - emptyDescription: t("statusPage.public.emptyDescription"), + systemStatus: t("systemStatus"), + heroPrimary: t("heroPrimary"), + heroSecondary: t("heroSecondary"), + generatedAt: t("generatedAt"), + history: t("history"), + availability: t("availability"), + ttfb: t("ttfb"), + freshnessWindow: t("freshnessWindow"), + fresh: t("fresh"), + stale: t("stale"), + staleDetail: t("staleDetail"), + rebuilding: t("rebuilding"), + noData: t("noData"), + emptyDescription: t("emptyDescription"), requestTypes: { - openaiCompatible: t("statusPage.public.requestTypes.openaiCompatible"), - codex: t("statusPage.public.requestTypes.codex"), - anthropic: t("statusPage.public.requestTypes.anthropic"), - gemini: t("statusPage.public.requestTypes.gemini"), + openaiCompatible: t("requestTypes.openaiCompatible"), + codex: t("requestTypes.codex"), + anthropic: t("requestTypes.anthropic"), + gemini: t("requestTypes.gemini"), }, statusBadge: { - operational: t("statusPage.public.statusBadge.operational"), - degraded: t("statusPage.public.statusBadge.degraded"), - failed: t("statusPage.public.statusBadge.failed"), - noData: t("statusPage.public.statusBadge.noData"), + operational: t("statusBadge.operational"), + degraded: t("statusBadge.degraded"), + failed: t("statusBadge.failed"), + noData: t("statusBadge.noData"), }, tooltip: { - availability: t("statusPage.public.tooltip.availability"), - ttfb: t("statusPage.public.tooltip.ttfb"), - tps: t("statusPage.public.tooltip.tps"), - historyAriaLabel: t("statusPage.public.tooltip.historyAriaLabel"), + availability: t("tooltip.availability"), + ttfb: t("tooltip.ttfb"), + tps: t("tooltip.tps"), + historyAriaLabel: t("tooltip.historyAriaLabel"), }, - searchPlaceholder: t("statusPage.public.searchPlaceholder"), - customSort: t("statusPage.public.customSort"), - resetSort: t("statusPage.public.resetSort"), - emptyByFilter: t("statusPage.public.emptyByFilter"), - modelsLabel: t("statusPage.public.modelsLabel"), - issuesLabel: t("statusPage.public.issuesLabel"), - clearSearch: t("statusPage.public.clearSearch"), - dragHandle: t("statusPage.public.dragHandle"), - toggleGroup: t("statusPage.public.toggleGroup"), - openGroupPage: t("statusPage.public.openGroupPage"), + searchPlaceholder: t("searchPlaceholder"), + customSort: t("customSort"), + resetSort: t("resetSort"), + emptyByFilter: t("emptyByFilter"), + modelsLabel: t("modelsLabel"), + issuesLabel: t("issuesLabel"), + clearSearch: t("clearSearch"), + dragHandle: t("dragHandle"), + toggleGroup: t("toggleGroup"), + openGroupPage: t("openGroupPage"), }} /> ); diff --git a/src/app/api/public-status/route.ts b/src/app/api/public-status/route.ts index cb46ea1fe..6ea65f273 100644 --- a/src/app/api/public-status/route.ts +++ b/src/app/api/public-status/route.ts @@ -26,7 +26,6 @@ export async function GET(request: Request): Promise { const defaultRange = configSnapshot?.defaultRangeHours ?? 24; const intervalMinutes = clampInterval(url.searchParams.get("interval"), defaultInterval); const rangeHours = clampRange(url.searchParams.get("rangeHours"), defaultRange); - const canTriggerRebuild = intervalMinutes === defaultInterval && rangeHours === defaultRange; const payload = await readPublicStatusPayload({ intervalMinutes, @@ -35,9 +34,6 @@ export async function GET(request: Request): Promise { hasConfiguredGroups: configSnapshot ? configSnapshot.groups.length > 0 : undefined, nowIso: new Date().toISOString(), triggerRebuildHint: async (reason) => { - if (!canTriggerRebuild) { - return; - } await schedulePublicStatusRebuild({ intervalMinutes, rangeHours, @@ -47,6 +43,5 @@ export async function GET(request: Request): Promise { }); const status = payload.rebuildState === "rebuilding" && !payload.generatedAt ? 503 : 200; - return NextResponse.json(payload, { status }); } diff --git a/src/lib/config/system-settings-cache.ts b/src/lib/config/system-settings-cache.ts index a73ab8e2d..d84b2ccc0 100644 --- a/src/lib/config/system-settings-cache.ts +++ b/src/lib/config/system-settings-cache.ts @@ -46,6 +46,8 @@ const DEFAULT_SETTINGS: Pick< | "enableClaudeMetadataUserIdInjection" | "enableResponseFixer" | "responseFixerConfig" + | "publicStatusWindowHours" + | "publicStatusAggregationIntervalMinutes" > = { enableHttp2: false, enableHighConcurrencyMode: false, @@ -65,6 +67,8 @@ const DEFAULT_SETTINGS: Pick< maxJsonDepth: 200, maxFixSize: 1024 * 1024, }, + publicStatusWindowHours: 24, + publicStatusAggregationIntervalMinutes: 5, }; /** @@ -135,14 +139,15 @@ export async function getCachedSystemSettings(): Promise { enableClaudeMetadataUserIdInjection: DEFAULT_SETTINGS.enableClaudeMetadataUserIdInjection, enableResponseFixer: DEFAULT_SETTINGS.enableResponseFixer, responseFixerConfig: DEFAULT_SETTINGS.responseFixerConfig, + publicStatusWindowHours: DEFAULT_SETTINGS.publicStatusWindowHours, + publicStatusAggregationIntervalMinutes: + DEFAULT_SETTINGS.publicStatusAggregationIntervalMinutes, quotaDbRefreshIntervalSeconds: 10, quotaLeasePercent5h: 0.05, quotaLeasePercentDaily: 0.05, quotaLeasePercentWeekly: 0.05, quotaLeasePercentMonthly: 0.05, quotaLeaseCapUsd: null, - publicStatusWindowHours: 24, - publicStatusAggregationIntervalMinutes: 5, ipExtractionConfig: null, ipGeoLookupEnabled: true, createdAt: new Date(), diff --git a/src/lib/public-status/config.ts b/src/lib/public-status/config.ts index bfee8f18d..d4ffadd13 100644 --- a/src/lib/public-status/config.ts +++ b/src/lib/public-status/config.ts @@ -57,6 +57,18 @@ const CONFIG_CACHE_TTL_MS = 60 * 1000; let cachedConfiguredGroups: EnabledPublicStatusGroup[] | null = null; let cachedConfiguredGroupsAt = 0; +export class DuplicatePublicStatusGroupSlugError extends Error { + constructor( + public readonly publicGroupSlug: string, + groupNames: string[] + ) { + super( + `Duplicate normalized publicGroupSlug "${publicGroupSlug}" for groups: ${groupNames.join(", ")}` + ); + this.name = "DuplicatePublicStatusGroupSlugError"; + } +} + function sanitizeString(value: unknown): string | undefined { if (typeof value !== "string") { return undefined; @@ -136,6 +148,11 @@ export function slugifyPublicGroup(input: string): string { .slice(0, 64); } +function normalizePublicGroupSlug(groupName: string, publicGroupSlug?: string): string { + const normalized = slugifyPublicGroup(publicGroupSlug?.trim() || groupName); + return normalized || slugifyPublicGroup(groupName); +} + export function parsePublicStatusDescription( description: string | null | undefined ): ParsedPublicStatusDescription { @@ -252,15 +269,30 @@ export function serializePublicStatusDescription( export function collectEnabledPublicStatusGroups( groups: PublicStatusConfiguredGroupInput[] ): EnabledPublicStatusGroup[] { + const seenGroupNamesBySlug = new Map(); + return groups .map((group) => { const publicModels = sanitizePublicModels(group.publicStatus?.publicModels); + const publicGroupSlug = normalizePublicGroupSlug( + group.groupName, + group.publicStatus?.publicGroupSlug + ); + + const existingGroupName = seenGroupNamesBySlug.get(publicGroupSlug); + if (existingGroupName) { + throw new DuplicatePublicStatusGroupSlugError(publicGroupSlug, [ + existingGroupName, + group.groupName, + ]); + } + + seenGroupNamesBySlug.set(publicGroupSlug, group.groupName); return { groupName: group.groupName, displayName: group.publicStatus?.displayName?.trim() || group.groupName, - publicGroupSlug: - group.publicStatus?.publicGroupSlug?.trim() || slugifyPublicGroup(group.groupName), + publicGroupSlug, explanatoryCopy: group.publicStatus?.explanatoryCopy?.trim() || null, sortOrder: group.publicStatus?.sortOrder ?? 0, publicModels, diff --git a/src/lib/public-status/layout-metadata.ts b/src/lib/public-status/layout-metadata.ts index 602965ecb..cba8f2627 100644 --- a/src/lib/public-status/layout-metadata.ts +++ b/src/lib/public-status/layout-metadata.ts @@ -1,4 +1,4 @@ -import { DEFAULT_SITE_TITLE, resolveSiteTitle } from "@/lib/site-title"; +const FALLBACK_SITE_TITLE = "Claude Code Hub"; export async function resolveSiteMetadataSource(input: { isPublicStatusRequest: boolean; @@ -7,19 +7,17 @@ export async function resolveSiteMetadataSource(input: { siteDescription: string; } | null> { if (input.isPublicStatusRequest) { - const { readPublicSiteMeta } = await import("@/lib/public-site-meta"); - const metadata = await readPublicSiteMeta(); - return { - siteTitle: metadata.siteTitle, - siteDescription: metadata.siteDescription, - }; + const { readPublicStatusSiteMetadata } = await import("./config-snapshot"); + return await readPublicStatusSiteMetadata(); } const { getSystemSettings } = await import("@/repository/system-config"); const settings = await getSystemSettings(); + const title = settings.siteTitle?.trim() || FALLBACK_SITE_TITLE; + return { - siteTitle: resolveSiteTitle(settings.siteTitle, DEFAULT_SITE_TITLE), - siteDescription: resolveSiteTitle(settings.siteTitle, DEFAULT_SITE_TITLE), + siteTitle: title, + siteDescription: title, }; } diff --git a/src/lib/public-status/read-store.ts b/src/lib/public-status/read-store.ts index ffe9619bf..7aeb7dced 100644 --- a/src/lib/public-status/read-store.ts +++ b/src/lib/public-status/read-store.ts @@ -1,5 +1,11 @@ import { getRedisClient } from "@/lib/redis"; -import type { PublicStatusPayload } from "./payload"; +import type { + PublicStatusGroupSnapshot, + PublicStatusModelSnapshot, + PublicStatusPayload, + PublicStatusTimelineBucket, + PublicStatusTimelineState, +} from "./payload"; import { buildPublicStatusCurrentSnapshotKey, buildPublicStatusManifestKey, @@ -12,10 +18,11 @@ interface RedisReader { status?: string; } -interface PublicStatusSnapshotRecord extends PublicStatusPayload { +interface PublicStatusSnapshotRecord { sourceGeneration: string; generatedAt: string; freshUntil: string; + groups: unknown; } async function safeGet(redis: RedisReader, key: string): Promise { @@ -58,6 +65,119 @@ function buildNoDataPayload(): PublicStatusPayload { }; } +function normalizeTimelineState(value: unknown): PublicStatusTimelineState { + if ( + value === "operational" || + value === "degraded" || + value === "failed" || + value === "no_data" + ) { + return value; + } + return "no_data"; +} + +function normalizeNullableNumber(value: unknown): number | null { + return typeof value === "number" && Number.isFinite(value) ? value : null; +} + +function sanitizeTimelineBuckets(input: unknown): PublicStatusTimelineBucket[] { + if (!Array.isArray(input)) { + return []; + } + + return input.flatMap((bucket) => { + if (!bucket || typeof bucket !== "object") { + return []; + } + + const value = bucket as Record; + if ( + typeof value.bucketStart !== "string" || + typeof value.bucketEnd !== "string" || + typeof value.sampleCount !== "number" || + !Number.isFinite(value.sampleCount) + ) { + return []; + } + + return [ + { + bucketStart: value.bucketStart, + bucketEnd: value.bucketEnd, + state: normalizeTimelineState(value.state), + availabilityPct: normalizeNullableNumber(value.availabilityPct), + ttfbMs: normalizeNullableNumber(value.ttfbMs), + tps: normalizeNullableNumber(value.tps), + sampleCount: value.sampleCount, + }, + ]; + }); +} + +function sanitizeModelSnapshots(input: unknown): PublicStatusModelSnapshot[] { + if (!Array.isArray(input)) { + return []; + } + + return input.flatMap((model) => { + if (!model || typeof model !== "object") { + return []; + } + + const value = model as Record; + if ( + typeof value.publicModelKey !== "string" || + typeof value.label !== "string" || + typeof value.vendorIconKey !== "string" || + typeof value.requestTypeBadge !== "string" + ) { + return []; + } + + return [ + { + publicModelKey: value.publicModelKey, + label: value.label, + vendorIconKey: value.vendorIconKey, + requestTypeBadge: value.requestTypeBadge, + latestState: normalizeTimelineState(value.latestState), + availabilityPct: normalizeNullableNumber(value.availabilityPct), + latestTtfbMs: normalizeNullableNumber(value.latestTtfbMs), + latestTps: normalizeNullableNumber(value.latestTps), + timeline: sanitizeTimelineBuckets(value.timeline), + }, + ]; + }); +} + +// Redis 快照跨版本持久化,公开响应必须按白名单重建,避免内部字段泄漏到 /status。 +function sanitizeGroupSnapshots(input: unknown): PublicStatusGroupSnapshot[] { + if (!Array.isArray(input)) { + return []; + } + + return input.flatMap((group) => { + if (!group || typeof group !== "object") { + return []; + } + + const value = group as Record; + if (typeof value.publicGroupSlug !== "string" || typeof value.displayName !== "string") { + return []; + } + + return [ + { + publicGroupSlug: value.publicGroupSlug, + displayName: value.displayName, + explanatoryCopy: typeof value.explanatoryCopy === "string" ? value.explanatoryCopy : null, + models: sanitizeModelSnapshots(value.models), + }, + ]; + }); +} + export async function readPublicStatusPayload(input: { intervalMinutes: number; rangeHours: number; @@ -92,6 +212,7 @@ export async function readPublicStatusPayload(input: { let selectedManifest = manifest; let resolution = resolvePublicStatusManifestState(selectedManifest, input.nowIso); + if (!resolution.sourceGeneration && currentManifest) { selectedManifest = currentManifest; resolution = { @@ -111,6 +232,7 @@ export async function readPublicStatusPayload(input: { generation: resolution.sourceGeneration, }); const snapshot = parseJson(await safeGet(redis, snapshotKey)); + if (!snapshot) { await input.triggerRebuildHint("snapshot-missing"); return buildRebuildingPayload(); @@ -119,6 +241,7 @@ export async function readPublicStatusPayload(input: { if (resolution.rebuildState !== "fresh") { await input.triggerRebuildHint("stale-generation"); } + if (input.configVersion && selectedManifest?.configVersion !== input.configVersion) { await input.triggerRebuildHint("config-version-mismatch"); resolution = { @@ -132,6 +255,6 @@ export async function readPublicStatusPayload(input: { sourceGeneration: snapshot.sourceGeneration, generatedAt: snapshot.generatedAt, freshUntil: snapshot.freshUntil, - groups: snapshot.groups ?? [], + groups: sanitizeGroupSnapshots(snapshot.groups), }; } diff --git a/tests/integration/public-status/config-publish.test.ts b/tests/integration/public-status/config-publish.test.ts index 376b83477..9644ed606 100644 --- a/tests/integration/public-status/config-publish.test.ts +++ b/tests/integration/public-status/config-publish.test.ts @@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const mockGetSession = vi.hoisted(() => vi.fn()); const mockUpdateSystemSettings = vi.hoisted(() => vi.fn()); +const mockGetSystemSettings = vi.hoisted(() => vi.fn()); const mockFindAllProviderGroups = vi.hoisted(() => vi.fn()); const mockFindProviderGroupById = vi.hoisted(() => vi.fn()); const mockUpdateProviderGroup = vi.hoisted(() => vi.fn()); @@ -11,6 +12,7 @@ const mockSchedulePublicStatusRebuild = vi.hoisted(() => vi.fn()); const mockInvalidateSystemSettingsCache = vi.hoisted(() => vi.fn()); const mockRevalidatePath = vi.hoisted(() => vi.fn()); const mockLoggerError = vi.hoisted(() => vi.fn()); +const mockLoggerWarn = vi.hoisted(() => vi.fn()); const mockDbTransaction = vi.hoisted(() => vi.fn(async (callback: (tx: object) => unknown) => callback({})) ); @@ -20,6 +22,7 @@ vi.mock("@/lib/auth", () => ({ })); vi.mock("@/repository/system-config", () => ({ + getSystemSettings: mockGetSystemSettings, updateSystemSettings: mockUpdateSystemSettings, })); @@ -62,6 +65,7 @@ vi.mock("next-intl/server", () => ({ vi.mock("@/lib/logger", () => ({ logger: { error: mockLoggerError, + warn: mockLoggerWarn, }, })); @@ -78,6 +82,14 @@ describe("public-status config publish integration", () => { mockUpdateSystemSettings.mockResolvedValue({ id: 1, siteTitle: "Claude Code Hub", + timezone: "UTC", + publicStatusWindowHours: 24, + publicStatusAggregationIntervalMinutes: 5, + }); + mockGetSystemSettings.mockResolvedValue({ + id: 1, + siteTitle: "Claude Code Hub", + timezone: "UTC", publicStatusWindowHours: 24, publicStatusAggregationIntervalMinutes: 5, }); @@ -164,6 +176,42 @@ describe("public-status config publish integration", () => { expect(mockRevalidatePath).toHaveBeenCalled(); }); + it("republishes Redis snapshot and queues rebuild metadata for relevant system setting changes", async () => { + const { saveSystemSettings } = await import("@/actions/system-config"); + + const result = await saveSystemSettings({ + siteTitle: "Status Hub", + timezone: "Asia/Shanghai", + }); + + expect(result).toMatchObject({ + ok: true, + data: { + publicStatusProjectionWarningCode: null, + }, + }); + expect(mockPublishCurrentPublicStatusConfigProjection).toHaveBeenCalledWith({ + reason: "save-system-settings", + }); + expect(mockSchedulePublicStatusRebuild).toHaveBeenCalledWith({ + intervalMinutes: 5, + rangeHours: 24, + reason: "system-settings-updated", + }); + }); + + it("does not republish Redis snapshot for unrelated system setting changes", async () => { + const { saveSystemSettings } = await import("@/actions/system-config"); + + const result = await saveSystemSettings({ + verboseProviderError: true, + }); + + expect(result.ok).toBe(true); + expect(mockPublishCurrentPublicStatusConfigProjection).not.toHaveBeenCalled(); + expect(mockSchedulePublicStatusRebuild).not.toHaveBeenCalled(); + }); + it("returns success with a warning when DB truth is saved but Redis projection is unavailable", async () => { mockPublishCurrentPublicStatusConfigProjection.mockResolvedValue({ configVersion: "cfg-2", @@ -207,5 +255,99 @@ describe("public-status config publish integration", () => { expect(result.ok).toBe(false); expect(mockUpdateSystemSettings).not.toHaveBeenCalled(); + expect(mockPublishCurrentPublicStatusConfigProjection).not.toHaveBeenCalled(); + expect(mockSchedulePublicStatusRebuild).not.toHaveBeenCalled(); + }); + + it("rejects duplicate normalized publicGroupSlug values before saving DB truth", async () => { + const { savePublicStatusSettings } = await import("@/actions/public-status"); + + const result = await savePublicStatusSettings({ + publicStatusWindowHours: 24, + publicStatusAggregationIntervalMinutes: 5, + groups: [ + { + groupName: "openai-primary", + displayName: "OpenAI Primary", + publicGroupSlug: "Open AI", + publicModels: [{ modelKey: "gpt-4.1" }], + }, + { + groupName: "openai-fallback", + displayName: "OpenAI Fallback", + publicGroupSlug: "open-ai", + publicModels: [{ modelKey: "gpt-4.1" }], + }, + ], + }); + + expect(result.ok).toBe(false); + expect(mockDbTransaction).not.toHaveBeenCalled(); + expect(mockUpdateSystemSettings).not.toHaveBeenCalled(); + expect(mockPublishCurrentPublicStatusConfigProjection).not.toHaveBeenCalled(); + expect(mockSchedulePublicStatusRebuild).not.toHaveBeenCalled(); + }); + + it("keeps the last complete generation readable while the new config version is still rebuilding", async () => { + const { buildPublicStatusCurrentSnapshotKey, buildPublicStatusManifestKey } = await import( + "@/lib/public-status/redis-contract" + ); + const { readPublicStatusPayload } = await import("@/lib/public-status/read-store"); + + const redis = { + get: vi.fn(async (key: string) => { + const entries: Record = { + [buildPublicStatusManifestKey({ + configVersion: "current", + intervalMinutes: 5, + rangeHours: 24, + })]: { + configVersion: "cfg-older", + intervalMinutes: 5, + rangeHours: 24, + generation: "gen-stale", + sourceGeneration: "gen-stale", + coveredFrom: "2026-04-20T10:00:00.000Z", + coveredTo: "2026-04-21T10:00:00.000Z", + generatedAt: "2026-04-21T09:55:00.000Z", + freshUntil: "2026-04-21T10:00:00.000Z", + rebuildState: "idle", + lastCompleteGeneration: "gen-stale", + }, + [buildPublicStatusCurrentSnapshotKey({ + intervalMinutes: 5, + rangeHours: 24, + generation: "gen-stale", + })]: { + rebuildState: "fresh", + sourceGeneration: "gen-stale", + generatedAt: "2026-04-21T09:55:00.000Z", + freshUntil: "2026-04-21T10:00:00.000Z", + groups: [], + }, + }; + const value = entries[key]; + return value == null ? null : JSON.stringify(value); + }), + status: "ready", + }; + const triggerRebuildHint = vi.fn(); + + const payload = await readPublicStatusPayload({ + intervalMinutes: 5, + rangeHours: 24, + nowIso: "2026-04-21T10:10:00.000Z", + configVersion: "cfg-newer", + hasConfiguredGroups: true, + redis, + triggerRebuildHint, + }); + + expect(payload).toMatchObject({ + rebuildState: "stale", + sourceGeneration: "gen-stale", + generatedAt: "2026-04-21T09:55:00.000Z", + }); + expect(triggerRebuildHint).toHaveBeenCalledWith("stale-generation"); }); }); diff --git a/tests/integration/public-status/route-redis-only.test.ts b/tests/integration/public-status/route-redis-only.test.ts index b8b50c11f..d9fd435d3 100644 --- a/tests/integration/public-status/route-redis-only.test.ts +++ b/tests/integration/public-status/route-redis-only.test.ts @@ -67,6 +67,45 @@ describe("GET /api/public-status", () => { }); }); + it("returns 200 with stale payload and queues rebuild for the default query", async () => { + mockReadCurrentPublicStatusConfigSnapshot.mockResolvedValue({ + configVersion: "cfg-1", + defaultIntervalMinutes: 5, + defaultRangeHours: 24, + groups: [{ slug: "openai" }], + }); + mockReadPublicStatusPayload.mockImplementation( + async ({ triggerRebuildHint }: { triggerRebuildHint: (reason: string) => Promise }) => { + await triggerRebuildHint("stale-generation"); + return { + rebuildState: "stale", + sourceGeneration: "gen-stale", + generatedAt: "2026-04-21T09:55:00.000Z", + freshUntil: "2026-04-21T10:00:00.000Z", + groups: [], + }; + } + ); + mockSchedulePublicStatusRebuild.mockResolvedValue({ + accepted: true, + rebuildState: "rebuilding", + }); + + const { GET } = await import("@/app/api/public-status/route"); + const response = await GET(new Request("http://localhost/api/public-status")); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toMatchObject({ + rebuildState: "stale", + sourceGeneration: "gen-stale", + }); + expect(mockSchedulePublicStatusRebuild).toHaveBeenCalledWith({ + intervalMinutes: 5, + rangeHours: 24, + reason: "stale-generation", + }); + }); + it("returns 200 with no-data payload when no public groups are configured", async () => { mockReadCurrentPublicStatusConfigSnapshot.mockResolvedValue({ configVersion: "cfg-empty", @@ -98,7 +137,7 @@ describe("GET /api/public-status", () => { expect(mockSchedulePublicStatusRebuild).not.toHaveBeenCalled(); }); - it("does not trigger rebuild for non-default public queries", async () => { + it("queues rebuilds for non-default public queries when wider data is missing", async () => { mockReadCurrentPublicStatusConfigSnapshot.mockResolvedValue({ configVersion: "cfg-1", defaultIntervalMinutes: 5, @@ -124,6 +163,10 @@ describe("GET /api/public-status", () => { ); expect(response.status).toBe(503); - expect(mockSchedulePublicStatusRebuild).not.toHaveBeenCalled(); + expect(mockSchedulePublicStatusRebuild).toHaveBeenCalledWith({ + intervalMinutes: 15, + rangeHours: 48, + reason: "manifest-missing", + }); }); }); diff --git a/tests/unit/actions/system-config-save.test.ts b/tests/unit/actions/system-config-save.test.ts index 683a6231b..ee8c6e982 100644 --- a/tests/unit/actions/system-config-save.test.ts +++ b/tests/unit/actions/system-config-save.test.ts @@ -155,6 +155,61 @@ describe("saveSystemSettings", () => { expect(invalidateSystemSettingsCacheMock).toHaveBeenCalled(); }); + it("should republish the public-status projection and queue a rebuild for relevant config changes", async () => { + await saveSystemSettings({ + siteTitle: "New Title", + timezone: "UTC", + }); + + expect(publishCurrentPublicStatusConfigProjectionMock).toHaveBeenCalledWith({ + reason: "save-system-settings", + }); + expect(schedulePublicStatusRebuildMock).toHaveBeenCalledWith({ + intervalMinutes: 5, + rangeHours: 24, + reason: "system-settings-updated", + }); + }); + + it("should skip public-status projection updates for unrelated system settings", async () => { + await saveSystemSettings({ verboseProviderError: true }); + + expect(publishCurrentPublicStatusConfigProjectionMock).not.toHaveBeenCalled(); + expect(schedulePublicStatusRebuildMock).not.toHaveBeenCalled(); + }); + + it("should surface a warning when the public-status projection publish fails", async () => { + publishCurrentPublicStatusConfigProjectionMock.mockResolvedValueOnce({ + configVersion: "cfg-2", + key: "public-status:v1:config:cfg-2", + written: false, + groupCount: 0, + }); + + const result = await saveSystemSettings({ siteTitle: "New Title" }); + + expect(result).toMatchObject({ + ok: true, + data: { + publicStatusProjectionWarningCode: "PUBLIC_STATUS_PROJECTION_PUBLISH_FAILED", + }, + }); + expect(schedulePublicStatusRebuildMock).not.toHaveBeenCalled(); + }); + + it("should surface a warning when rebuild hint scheduling fails", async () => { + schedulePublicStatusRebuildMock.mockRejectedValueOnce(new Error("redis unavailable")); + + const result = await saveSystemSettings({ siteTitle: "New Title" }); + + expect(result).toMatchObject({ + ok: true, + data: { + publicStatusProjectionWarningCode: "PUBLIC_STATUS_BACKGROUND_REFRESH_PENDING", + }, + }); + }); + describe("revalidatePath locale coverage", () => { it("should revalidate paths for ALL supported locales", async () => { await saveSystemSettings({ siteTitle: "New Title" }); diff --git a/tests/unit/public-status/config-snapshot.test.ts b/tests/unit/public-status/config-snapshot.test.ts index d8e415551..f8e57c7cf 100644 --- a/tests/unit/public-status/config-snapshot.test.ts +++ b/tests/unit/public-status/config-snapshot.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { importPublicStatusModule } from "../../helpers/public-status-test-helpers"; interface ConfigSnapshotModule { @@ -50,12 +50,6 @@ interface ConfigSnapshotModule { eval: (script: string, numKeys: number, ...args: string[]) => Promise; }; }): Promise; - readPublicStatusTimeZone(input: { - redis: { - status: string; - get: (key: string) => Promise; - }; - }): Promise; } describe("public-status config snapshot", () => { @@ -139,69 +133,6 @@ describe("public-status config snapshot", () => { }); }); - it("reads site metadata from the shared raw configVersion pointer", async () => { - const mod = await importPublicStatusModule( - "@/lib/public-status/config-snapshot" - ); - - const redis = { - status: "ready", - get: vi - .fn() - .mockResolvedValueOnce("cfg-3") - .mockResolvedValueOnce( - JSON.stringify({ - configVersion: "cfg-3", - siteTitle: "Claude Code Hub Status", - siteDescription: "Request-derived public status", - }) - ), - }; - - await expect(mod.readPublicStatusSiteMetadata({ redis })).resolves.toEqual({ - siteTitle: "Claude Code Hub Status", - siteDescription: "Request-derived public status", - }); - }); - - it("synthesizes siteDescription when the stored snapshot description is blank", async () => { - const mod = await importPublicStatusModule( - "@/lib/public-status/config-snapshot" - ); - - const redis = { - status: "ready", - get: vi - .fn() - .mockResolvedValueOnce("cfg-4") - .mockResolvedValueOnce( - JSON.stringify({ - configVersion: "cfg-4", - siteTitle: "Acme AI Hub", - siteDescription: " ", - }) - ), - }; - - await expect(mod.readPublicStatusSiteMetadata({ redis })).resolves.toEqual({ - siteTitle: "Acme AI Hub", - siteDescription: "Acme AI Hub public status", - }); - }); - - it("returns null on malformed pointer records instead of throwing", async () => { - const mod = await importPublicStatusModule( - "@/lib/public-status/config-snapshot" - ); - - const redis = { - status: "ready", - get: vi.fn().mockResolvedValueOnce("{broken-json"), - }; - - await expect(mod.readPublicStatusSiteMetadata({ redis })).resolves.toBeNull(); - }); - it("does not let an older configVersion overwrite the current pointer", async () => { const mod = await importPublicStatusModule( "@/lib/public-status/config-snapshot" diff --git a/tests/unit/public-status/layout-metadata.test.ts b/tests/unit/public-status/layout-metadata.test.ts index 452186297..adc47cba7 100644 --- a/tests/unit/public-status/layout-metadata.test.ts +++ b/tests/unit/public-status/layout-metadata.test.ts @@ -1,67 +1,66 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; +import { importPublicStatusModule } from "../../helpers/public-status-test-helpers"; -const mockReadPublicStatusSiteMetadata = vi.hoisted(() => vi.fn()); -const mockGetSystemSettings = vi.hoisted(() => vi.fn()); - -vi.mock("@/lib/public-status/config-snapshot", async () => { - const actual = await vi.importActual( - "@/lib/public-status/config-snapshot" - ); - - return { - ...actual, - readPublicStatusSiteMetadata: mockReadPublicStatusSiteMetadata, - readPublicStatusTimeZone: vi.fn(), - }; -}); +vi.mock("@/lib/public-status/config-snapshot", () => ({ + readPublicStatusSiteMetadata: vi.fn(), + readPublicStatusTimeZone: vi.fn(), +})); vi.mock("@/repository/system-config", () => ({ - getSystemSettings: mockGetSystemSettings, + getSystemSettings: vi.fn(), })); -vi.mock("@/lib/logger", () => ({ - logger: { - error: () => {}, - warn: () => {}, - }, +vi.mock("@/lib/utils/timezone", () => ({ + resolveSystemTimezone: vi.fn(), })); -describe("layout metadata", () => { +interface LayoutMetadataModule { + resolveSiteMetadataSource(input: { isPublicStatusRequest: boolean }): Promise<{ + siteTitle: string; + siteDescription: string; + } | null>; + resolveLayoutTimeZone(input: { isPublicStatusRequest: boolean }): Promise; +} + +describe("public-status layout metadata", () => { beforeEach(() => { vi.clearAllMocks(); + vi.resetModules(); }); - it("uses public-status redis metadata only for public status requests", async () => { - mockGetSystemSettings.mockResolvedValue({ - siteTitle: " ", - }); - mockReadPublicStatusSiteMetadata.mockResolvedValue({ - siteTitle: "Status Title", - siteDescription: "Status Description", + it("reads site metadata from the redis projection for public status requests", async () => { + const { readPublicStatusSiteMetadata } = await import("@/lib/public-status/config-snapshot"); + vi.mocked(readPublicStatusSiteMetadata).mockResolvedValue({ + siteTitle: "Claude Code Hub Status", + siteDescription: "Redis-only public status", }); - const { resolveSiteMetadataSource } = await import("@/lib/public-status/layout-metadata"); - const metadata = await resolveSiteMetadataSource({ - isPublicStatusRequest: true, + const mod = await importPublicStatusModule( + "@/lib/public-status/layout-metadata" + ); + + await expect(mod.resolveSiteMetadataSource({ isPublicStatusRequest: true })).resolves.toEqual({ + siteTitle: "Claude Code Hub Status", + siteDescription: "Redis-only public status", }); - expect(metadata?.siteTitle).toBe("Status Title"); - expect(metadata?.siteDescription).toBe("Status Description"); - expect(mockGetSystemSettings).not.toHaveBeenCalled(); + const { getSystemSettings } = await import("@/repository/system-config"); + expect(vi.mocked(getSystemSettings)).not.toHaveBeenCalled(); }); - it("keeps non-status pages on system settings metadata", async () => { - mockGetSystemSettings.mockResolvedValue({ - siteTitle: "Custom Site", - }); + it("reads timezone from the redis projection for public status requests", async () => { + const { readPublicStatusTimeZone } = await import("@/lib/public-status/config-snapshot"); + vi.mocked(readPublicStatusTimeZone).mockResolvedValue("Asia/Shanghai"); - const { resolveSiteMetadataSource } = await import("@/lib/public-status/layout-metadata"); - const metadata = await resolveSiteMetadataSource({ - isPublicStatusRequest: false, - }); + const mod = await importPublicStatusModule( + "@/lib/public-status/layout-metadata" + ); + + await expect(mod.resolveLayoutTimeZone({ isPublicStatusRequest: true })).resolves.toBe( + "Asia/Shanghai" + ); - expect(metadata?.siteTitle).toBe("Custom Site"); - expect(metadata?.siteDescription).toBe("Custom Site"); - expect(mockReadPublicStatusSiteMetadata).not.toHaveBeenCalled(); + const { resolveSystemTimezone } = await import("@/lib/utils/timezone"); + expect(vi.mocked(resolveSystemTimezone)).not.toHaveBeenCalled(); }); }); diff --git a/tests/unit/public-status/no-db-import-guard.test.ts b/tests/unit/public-status/no-db-import-guard.test.ts index 711bbab84..5a1f4d32d 100644 --- a/tests/unit/public-status/no-db-import-guard.test.ts +++ b/tests/unit/public-status/no-db-import-guard.test.ts @@ -7,11 +7,11 @@ import { const guardedFiles = [ "src/app/api/public-status/route.ts", + "src/app/[locale]/status/page.tsx", + "src/app/[locale]/layout.tsx", "src/lib/public-status/read-store.ts", "src/lib/public-status/config-snapshot.ts", "src/lib/public-status/layout-metadata.ts", - "src/app/[locale]/status/page.tsx", - "src/app/[locale]/layout.tsx", ]; const bannedImports = [ @@ -25,9 +25,9 @@ const bannedImports = [ const bannedTokens = ["findLatestPriceByModel", "getSystemSettings", "queryProviderAvailability"]; const directTokenGuardFiles = new Set([ "src/app/api/public-status/route.ts", - "src/lib/public-status/read-store.ts", "src/app/[locale]/status/page.tsx", "src/app/[locale]/layout.tsx", + "src/lib/public-status/read-store.ts", ]); describe("public-status no-db import guard", () => { diff --git a/tests/unit/public-status/proxy-public-status.test.ts b/tests/unit/public-status/proxy-public-status.test.ts new file mode 100644 index 000000000..72bb9bf95 --- /dev/null +++ b/tests/unit/public-status/proxy-public-status.test.ts @@ -0,0 +1,71 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const nextResponseNext = vi.hoisted(() => vi.fn((input?: unknown) => input ?? { ok: true })); +const nextResponseRedirect = vi.hoisted(() => vi.fn((url: URL) => ({ url: url.toString() }))); +const intlMiddlewareMock = vi.hoisted(() => vi.fn(() => ({ ok: true }))); + +vi.mock("next/server", () => ({ + NextResponse: { + next: nextResponseNext, + redirect: nextResponseRedirect, + }, +})); + +vi.mock("next-intl/middleware", () => ({ + default: vi.fn(() => intlMiddlewareMock), +})); + +vi.mock("@/i18n/routing", () => ({ + routing: { + locales: ["en", "zh-CN"], + defaultLocale: "en", + }, +})); + +vi.mock("@/lib/auth", () => ({ + AUTH_COOKIE_NAME: "cch-auth", +})); + +vi.mock("@/lib/config/env.schema", () => ({ + isDevelopment: () => false, +})); + +vi.mock("@/lib/logger", () => ({ + logger: { + info: vi.fn(), + }, +})); + +describe("public-status proxy header", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.resetModules(); + }); + + it("adds the public-status header for locale-prefixed status pages", async () => { + const mod = await import("@/proxy"); + const request = { + method: "GET", + headers: new Headers(), + nextUrl: { + pathname: "/en/status", + clone: () => new URL("http://localhost/en/status"), + }, + cookies: { + get: vi.fn(), + }, + } as never; + + mod.default(request); + + expect(nextResponseNext).toHaveBeenCalledTimes(1); + expect(intlMiddlewareMock).not.toHaveBeenCalled(); + + const [{ request: nextRequest }] = nextResponseNext.mock.calls[0] as [ + { + request: { headers: Headers }; + }, + ]; + expect(nextRequest.headers.get("x-cch-public-status")).toBe("1"); + }); +}); diff --git a/tests/unit/public-status/read-store.test.ts b/tests/unit/public-status/read-store.test.ts index 625a51922..dfbb41cdb 100644 --- a/tests/unit/public-status/read-store.test.ts +++ b/tests/unit/public-status/read-store.test.ts @@ -1,281 +1,471 @@ import { describe, expect, it, vi } from "vitest"; import { - createForbiddenCallSpy, - createRedisClientSpy, - importPublicStatusModule, -} from "../../helpers/public-status-test-helpers"; - -interface ReadStoreModule { - readPublicStatusPayload(input: { - intervalMinutes: number; - rangeHours: number; - nowIso: string; - hasConfiguredGroups?: boolean; - redis: ReturnType; - triggerRebuildHint: (reason: string) => Promise | void; - }): Promise<{ - rebuildState: string; - sourceGeneration: string; - }>; + buildPublicStatusCurrentSnapshotKey, + buildPublicStatusManifestKey, +} from "@/lib/public-status/redis-contract"; +import { readPublicStatusPayload } from "@/lib/public-status/read-store"; + +function createRedisReader(entries: Record) { + return { + get: vi.fn(async (key: string) => { + const value = entries[key]; + return value == null ? null : JSON.stringify(value); + }), + status: "ready", + }; } -describe("public-status read store", () => { - it("returns rebuilding when redis is unavailable", async () => { - const triggerRebuildHint = vi.fn(); - const mod = await importPublicStatusModule("@/lib/public-status/read-store"); - - const result = await mod.readPublicStatusPayload({ - intervalMinutes: 5, - rangeHours: 24, - nowIso: "2026-04-21T10:05:00.000Z", - redis: null as never, - triggerRebuildHint, - }); - - expect(result.rebuildState).toBe("rebuilding"); - expect(triggerRebuildHint).toHaveBeenCalledWith("redis-unavailable"); - }); - - it("returns no-data when public status has no configured groups", async () => { +describe("readPublicStatusPayload", () => { + it("returns no-data immediately when no public groups are configured", async () => { const triggerRebuildHint = vi.fn(); - const mod = await importPublicStatusModule("@/lib/public-status/read-store"); - - const redis = createRedisClientSpy({ - status: "ready", - get: vi.fn(), - }); - const result = await mod.readPublicStatusPayload({ + const payload = await readPublicStatusPayload({ intervalMinutes: 5, rangeHours: 24, - nowIso: "2026-04-21T10:05:00.000Z", + nowIso: "2026-04-21T10:00:00.000Z", hasConfiguredGroups: false, - redis, triggerRebuildHint, }); - expect(result.rebuildState).toBe("no-data"); + expect(payload).toEqual({ + rebuildState: "no-data", + sourceGeneration: "", + generatedAt: null, + freshUntil: null, + groups: [], + }); expect(triggerRebuildHint).not.toHaveBeenCalled(); - expect(redis.get).not.toHaveBeenCalled(); }); - it("returns rebuilding when manifest is missing", async () => { + it("serves stale data from the current manifest when the versioned manifest is missing", async () => { const triggerRebuildHint = vi.fn(); - const mod = await importPublicStatusModule("@/lib/public-status/read-store"); - - const redis = createRedisClientSpy({ - status: "ready", - get: vi.fn().mockResolvedValueOnce(null), + const redis = createRedisReader({ + [buildPublicStatusManifestKey({ + configVersion: "current", + intervalMinutes: 5, + rangeHours: 24, + })]: { + configVersion: "cfg-older", + intervalMinutes: 5, + rangeHours: 24, + generation: "gen-stale", + sourceGeneration: "gen-stale", + coveredFrom: "2026-04-20T10:00:00.000Z", + coveredTo: "2026-04-21T10:00:00.000Z", + generatedAt: "2026-04-21T09:55:00.000Z", + freshUntil: "2026-04-21T10:00:00.000Z", + rebuildState: "idle", + lastCompleteGeneration: "gen-stale", + }, + [buildPublicStatusCurrentSnapshotKey({ + intervalMinutes: 5, + rangeHours: 24, + generation: "gen-stale", + })]: { + rebuildState: "fresh", + sourceGeneration: "gen-stale", + generatedAt: "2026-04-21T09:55:00.000Z", + freshUntil: "2026-04-21T10:00:00.000Z", + groups: [ + { + publicGroupSlug: "openai", + displayName: "OpenAI", + explanatoryCopy: null, + models: [], + }, + ], + }, }); - const result = await mod.readPublicStatusPayload({ + const payload = await readPublicStatusPayload({ intervalMinutes: 5, rangeHours: 24, - nowIso: "2026-04-21T10:05:00.000Z", + nowIso: "2026-04-21T10:10:00.000Z", + configVersion: "cfg-1", + hasConfiguredGroups: true, redis, triggerRebuildHint, }); - expect(result.rebuildState).toBe("rebuilding"); - expect(triggerRebuildHint).toHaveBeenCalledWith("manifest-missing"); - }); - - it("degrades to rebuilding when redis.get rejects", async () => { - const triggerRebuildHint = vi.fn(); - const mod = await importPublicStatusModule("@/lib/public-status/read-store"); - - const redis = createRedisClientSpy({ - status: "ready", - get: vi.fn().mockRejectedValueOnce(new Error("redis down")), + expect(payload).toMatchObject({ + rebuildState: "stale", + sourceGeneration: "gen-stale", + generatedAt: "2026-04-21T09:55:00.000Z", + freshUntil: "2026-04-21T10:00:00.000Z", }); - - const result = await mod.readPublicStatusPayload({ - intervalMinutes: 5, - rangeHours: 24, - nowIso: "2026-04-21T10:05:00.000Z", - redis, - triggerRebuildHint, - }); - - expect(result.rebuildState).toBe("rebuilding"); - expect(triggerRebuildHint).toHaveBeenCalledWith("manifest-missing"); + expect(triggerRebuildHint).toHaveBeenCalledWith("stale-generation"); }); - it("returns rebuilding when snapshot is missing", async () => { + it("returns rebuilding when the manifest exists but the snapshot payload is missing", async () => { const triggerRebuildHint = vi.fn(); - const mod = await importPublicStatusModule("@/lib/public-status/read-store"); - - const redis = createRedisClientSpy({ - status: "ready", - get: vi - .fn() - .mockResolvedValueOnce( - JSON.stringify({ - generation: "gen-1", - freshUntil: "2026-04-21T10:05:00.000Z", - lastCompleteGeneration: "gen-1", - rebuildState: "idle", - }) - ) - .mockResolvedValueOnce(null), + const redis = createRedisReader({ + [buildPublicStatusManifestKey({ + configVersion: "cfg-1", + intervalMinutes: 5, + rangeHours: 24, + })]: { + configVersion: "cfg-1", + intervalMinutes: 5, + rangeHours: 24, + generation: "gen-1", + sourceGeneration: "gen-1", + coveredFrom: "2026-04-20T10:00:00.000Z", + coveredTo: "2026-04-21T10:00:00.000Z", + generatedAt: "2026-04-21T09:59:00.000Z", + freshUntil: "2026-04-21T10:04:00.000Z", + rebuildState: "idle", + lastCompleteGeneration: "gen-1", + }, }); - const result = await mod.readPublicStatusPayload({ + const payload = await readPublicStatusPayload({ intervalMinutes: 5, rangeHours: 24, nowIso: "2026-04-21T10:00:00.000Z", + configVersion: "cfg-1", + hasConfiguredGroups: true, redis, triggerRebuildHint, }); - expect(result.rebuildState).toBe("rebuilding"); + expect(payload).toEqual({ + rebuildState: "rebuilding", + sourceGeneration: "", + generatedAt: null, + freshUntil: null, + groups: [], + }); expect(triggerRebuildHint).toHaveBeenCalledWith("snapshot-missing"); }); - it("serves current manifest as stale fallback when the requested config version is not ready", async () => { + it("marks config-version drift as stale and strips unexpected fields from redis snapshots", async () => { const triggerRebuildHint = vi.fn(); - const mod = await importPublicStatusModule("@/lib/public-status/read-store"); - - const redis = createRedisClientSpy({ - status: "ready", - get: vi - .fn() - .mockResolvedValueOnce(null) - .mockResolvedValueOnce( - JSON.stringify({ - configVersion: "cfg-old", - generation: "gen-old", - freshUntil: "2026-04-21T10:05:00.000Z", - lastCompleteGeneration: "gen-old", - rebuildState: "idle", - }) - ) - .mockResolvedValueOnce( - JSON.stringify({ - sourceGeneration: "gen-old", - generatedAt: "2026-04-21T10:00:00.000Z", - freshUntil: "2026-04-21T10:05:00.000Z", - groups: [], - }) - ), + const redis = createRedisReader({ + [buildPublicStatusManifestKey({ + configVersion: "current", + intervalMinutes: 5, + rangeHours: 24, + })]: { + configVersion: "cfg-old", + intervalMinutes: 5, + rangeHours: 24, + generation: "gen-stale", + sourceGeneration: "gen-stale", + coveredFrom: "2026-04-20T10:00:00.000Z", + coveredTo: "2026-04-21T10:00:00.000Z", + generatedAt: "2026-04-21T10:00:00.000Z", + freshUntil: "2026-04-21T10:05:00.000Z", + rebuildState: "idle", + lastCompleteGeneration: "gen-stale", + }, + [buildPublicStatusCurrentSnapshotKey({ + intervalMinutes: 5, + rangeHours: 24, + generation: "gen-stale", + })]: { + rebuildState: "fresh", + sourceGeneration: "gen-stale", + generatedAt: "2026-04-21T10:00:00.000Z", + freshUntil: "2026-04-21T10:05:00.000Z", + groups: [ + { + publicGroupSlug: "openai", + displayName: "OpenAI", + explanatoryCopy: "Public projection only", + sourceGroupName: "internal-openai", + models: [ + { + publicModelKey: "gpt-4.1", + label: "GPT-4.1", + vendorIconKey: "openai", + requestTypeBadge: "openaiCompatible", + latestState: "operational", + availabilityPct: 99.5, + latestTtfbMs: 120, + latestTps: 4.2, + endpointUrl: "https://internal.example.com", + timeline: [ + { + bucketStart: "2026-04-21T09:55:00.000Z", + bucketEnd: "2026-04-21T10:00:00.000Z", + state: "operational", + availabilityPct: 99.5, + ttfbMs: 120, + tps: 4.2, + sampleCount: 10, + providerFailures: 1, + }, + ], + }, + ], + }, + ], + }, }); - const result = await mod.readPublicStatusPayload({ + const payload = await readPublicStatusPayload({ intervalMinutes: 5, rangeHours: 24, + nowIso: "2026-04-21T10:01:00.000Z", configVersion: "cfg-new", - nowIso: "2026-04-21T10:00:00.000Z", + hasConfiguredGroups: true, redis, triggerRebuildHint, }); - expect(result.rebuildState).toBe("stale"); - expect(result.sourceGeneration).toBe("gen-old"); - expect(triggerRebuildHint).toHaveBeenCalledWith("config-version-mismatch"); + expect(triggerRebuildHint.mock.calls.map(([reason]) => reason)).toEqual([ + "stale-generation", + "config-version-mismatch", + ]); + expect(payload).toEqual({ + rebuildState: "stale", + sourceGeneration: "gen-stale", + generatedAt: "2026-04-21T10:00:00.000Z", + freshUntil: "2026-04-21T10:05:00.000Z", + groups: [ + { + publicGroupSlug: "openai", + displayName: "OpenAI", + explanatoryCopy: "Public projection only", + models: [ + { + publicModelKey: "gpt-4.1", + label: "GPT-4.1", + vendorIconKey: "openai", + requestTypeBadge: "openaiCompatible", + latestState: "operational", + availabilityPct: 99.5, + latestTtfbMs: 120, + latestTps: 4.2, + timeline: [ + { + bucketStart: "2026-04-21T09:55:00.000Z", + bucketEnd: "2026-04-21T10:00:00.000Z", + state: "operational", + availabilityPct: 99.5, + ttfbMs: 120, + tps: 4.2, + sampleCount: 10, + }, + ], + }, + ], + }, + ], + }); }); - it("treats malformed redis records as rebuilding instead of throwing", async () => { + it("preserves degraded latestState and timeline states from redis snapshots", async () => { const triggerRebuildHint = vi.fn(); - const mod = await importPublicStatusModule("@/lib/public-status/read-store"); - - const redis = createRedisClientSpy({ - status: "ready", - get: vi.fn().mockResolvedValueOnce("{not-json"), + const redis = createRedisReader({ + [buildPublicStatusManifestKey({ + configVersion: "cfg-1", + intervalMinutes: 5, + rangeHours: 24, + })]: { + configVersion: "cfg-1", + intervalMinutes: 5, + rangeHours: 24, + generation: "gen-1", + sourceGeneration: "gen-1", + coveredFrom: "2026-04-20T10:00:00.000Z", + coveredTo: "2026-04-21T10:00:00.000Z", + generatedAt: "2026-04-21T09:59:00.000Z", + freshUntil: "2026-04-21T10:04:00.000Z", + rebuildState: "idle", + lastCompleteGeneration: "gen-1", + }, + [buildPublicStatusCurrentSnapshotKey({ + intervalMinutes: 5, + rangeHours: 24, + generation: "gen-1", + })]: { + sourceGeneration: "gen-1", + generatedAt: "2026-04-21T09:59:00.000Z", + freshUntil: "2026-04-21T10:04:00.000Z", + groups: [ + { + publicGroupSlug: "openai", + displayName: "OpenAI", + explanatoryCopy: null, + models: [ + { + publicModelKey: "gpt-4.1", + label: "GPT-4.1", + vendorIconKey: "openai", + requestTypeBadge: "openaiCompatible", + latestState: "degraded", + availabilityPct: 92.5, + latestTtfbMs: 180, + latestTps: 3.1, + timeline: [ + { + bucketStart: "2026-04-21T09:55:00.000Z", + bucketEnd: "2026-04-21T10:00:00.000Z", + state: "degraded", + availabilityPct: 92.5, + ttfbMs: 180, + tps: 3.1, + sampleCount: 8, + }, + ], + }, + ], + }, + ], + }, }); - const result = await mod.readPublicStatusPayload({ + const payload = await readPublicStatusPayload({ intervalMinutes: 5, rangeHours: 24, nowIso: "2026-04-21T10:00:00.000Z", + configVersion: "cfg-1", + hasConfiguredGroups: true, redis, triggerRebuildHint, }); - expect(result.rebuildState).toBe("rebuilding"); - expect(triggerRebuildHint).toHaveBeenCalledWith("manifest-missing"); + expect(payload.groups[0]?.models[0]).toMatchObject({ + latestState: "degraded", + timeline: [ + { + bucketStart: "2026-04-21T09:55:00.000Z", + bucketEnd: "2026-04-21T10:00:00.000Z", + state: "degraded", + availabilityPct: 92.5, + ttfbMs: 180, + tps: 3.1, + sampleCount: 8, + }, + ], + }); }); - it("treats malformed snapshot payload as rebuilding instead of throwing", async () => { + it("drops timeline buckets with non-finite sampleCount values", async () => { const triggerRebuildHint = vi.fn(); - const mod = await importPublicStatusModule("@/lib/public-status/read-store"); - - const redis = createRedisClientSpy({ - status: "ready", - get: vi - .fn() - .mockResolvedValueOnce( - JSON.stringify({ - generation: "gen-1", - freshUntil: "2026-04-21T10:05:00.000Z", - lastCompleteGeneration: "gen-1", - rebuildState: "idle", - }) - ) - .mockResolvedValueOnce("{broken-json"), - }); - - const result = await mod.readPublicStatusPayload({ + const manifestKey = buildPublicStatusManifestKey({ + configVersion: "cfg-1", intervalMinutes: 5, rangeHours: 24, - nowIso: "2026-04-21T10:00:00.000Z", - redis, - triggerRebuildHint, }); - - expect(result.rebuildState).toBe("rebuilding"); - expect(triggerRebuildHint).toHaveBeenCalledWith("snapshot-missing"); - }); - - it("serves stale data and requests a background rebuild without DB reads", async () => { - const forbiddenDbRead = createForbiddenCallSpy("db-read"); - const forbiddenPriceLookup = createForbiddenCallSpy("findLatestPriceByModel"); - const triggerRebuildHint = vi.fn(); - - const mod = await importPublicStatusModule("@/lib/public-status/read-store"); - - const redis = createRedisClientSpy({ - status: "ready", - get: vi - .fn() - .mockResolvedValueOnce( - JSON.stringify({ - generation: "gen-1", - freshUntil: "2026-04-21T10:00:00.000Z", - lastCompleteGeneration: "gen-1", - rebuildState: "idle", - }) - ) - .mockResolvedValueOnce( - JSON.stringify({ - generation: "gen-1", - freshUntil: "2026-04-21T10:00:00.000Z", - lastCompleteGeneration: "gen-1", - rebuildState: "idle", - }) - ) - .mockResolvedValueOnce( - JSON.stringify({ - sourceGeneration: "gen-1", - generatedAt: "2026-04-21T09:55:00.000Z", - freshUntil: "2026-04-21T10:00:00.000Z", - }) - ), - dbRead: forbiddenDbRead, - priceLookup: forbiddenPriceLookup, - }); - - const result = await mod.readPublicStatusPayload({ + const snapshotKey = buildPublicStatusCurrentSnapshotKey({ intervalMinutes: 5, rangeHours: 24, - nowIso: "2026-04-21T10:05:00.000Z", - redis, - triggerRebuildHint, + generation: "gen-1", + }); + const redis = { + get: vi.fn(async (key: string) => { + if (key === manifestKey) { + return "__manifest__"; + } + if (key === snapshotKey) { + return "__snapshot__"; + } + return null; + }), + status: "ready", + }; + const parseSpy = vi.spyOn(JSON, "parse").mockImplementation((raw: string) => { + if (raw === "__manifest__") { + return { + configVersion: "cfg-1", + intervalMinutes: 5, + rangeHours: 24, + generation: "gen-1", + sourceGeneration: "gen-1", + coveredFrom: "2026-04-20T10:00:00.000Z", + coveredTo: "2026-04-21T10:00:00.000Z", + generatedAt: "2026-04-21T09:59:00.000Z", + freshUntil: "2026-04-21T10:04:00.000Z", + rebuildState: "idle", + lastCompleteGeneration: "gen-1", + }; + } + + if (raw === "__snapshot__") { + return { + sourceGeneration: "gen-1", + generatedAt: "2026-04-21T09:59:00.000Z", + freshUntil: "2026-04-21T10:04:00.000Z", + groups: [ + { + publicGroupSlug: "openai", + displayName: "OpenAI", + explanatoryCopy: null, + models: [ + { + publicModelKey: "gpt-4.1", + label: "GPT-4.1", + vendorIconKey: "openai", + requestTypeBadge: "openaiCompatible", + latestState: "operational", + availabilityPct: 99.5, + latestTtfbMs: 120, + latestTps: 4.2, + timeline: [ + { + bucketStart: "2026-04-21T09:45:00.000Z", + bucketEnd: "2026-04-21T09:50:00.000Z", + state: "operational", + availabilityPct: 99.1, + ttfbMs: 110, + tps: 4, + sampleCount: Number.NaN, + }, + { + bucketStart: "2026-04-21T09:50:00.000Z", + bucketEnd: "2026-04-21T09:55:00.000Z", + state: "operational", + availabilityPct: 99.3, + ttfbMs: 115, + tps: 4.1, + sampleCount: Number.POSITIVE_INFINITY, + }, + { + bucketStart: "2026-04-21T09:55:00.000Z", + bucketEnd: "2026-04-21T10:00:00.000Z", + state: "operational", + availabilityPct: 99.5, + ttfbMs: 120, + tps: 4.2, + sampleCount: 10, + }, + ], + }, + ], + }, + ], + }; + } + + throw new Error(`Unexpected JSON.parse input: ${raw}`); }); - expect(result.rebuildState).toBe("stale"); - expect(result.sourceGeneration).toBe("gen-1"); - expect(triggerRebuildHint).toHaveBeenCalledTimes(1); - expect(forbiddenDbRead).not.toHaveBeenCalled(); - expect(forbiddenPriceLookup).not.toHaveBeenCalled(); + try { + const payload = await readPublicStatusPayload({ + intervalMinutes: 5, + rangeHours: 24, + nowIso: "2026-04-21T10:00:00.000Z", + configVersion: "cfg-1", + hasConfiguredGroups: true, + redis, + triggerRebuildHint, + }); + + expect(payload.groups[0]?.models[0]?.timeline).toEqual([ + { + bucketStart: "2026-04-21T09:55:00.000Z", + bucketEnd: "2026-04-21T10:00:00.000Z", + state: "operational", + availabilityPct: 99.5, + ttfbMs: 120, + tps: 4.2, + sampleCount: 10, + }, + ]); + } finally { + parseSpy.mockRestore(); + } }); }); diff --git a/tests/unit/public-status/rebuild-worker.test.ts b/tests/unit/public-status/rebuild-worker.test.ts index fcd0673e3..1251791a1 100644 --- a/tests/unit/public-status/rebuild-worker.test.ts +++ b/tests/unit/public-status/rebuild-worker.test.ts @@ -4,6 +4,7 @@ import { buildPublicStatusManifestKey, buildPublicStatusRebuildHintKey, } from "@/lib/public-status/redis-contract"; +import { importPublicStatusModule } from "../../helpers/public-status-test-helpers"; const mockRedisSet = vi.hoisted(() => vi.fn()); const mockRedisDel = vi.hoisted(() => vi.fn()); @@ -15,30 +16,141 @@ const mockQueryPublicStatusRequests = vi.hoisted(() => vi.fn()); const mockBuildPublicStatusPayloadFromRequests = vi.hoisted(() => vi.fn()); const mockPublishCurrentPublicStatusConfigProjection = vi.hoisted(() => vi.fn()); -vi.mock("@/lib/redis", () => ({ - getRedisClient: () => ({ - get: mockRedisGet, - pttl: mockRedisPttl, - set: mockRedisSet, - del: mockRedisDel, - eval: mockRedisEval, - status: "ready", - }), -})); - -vi.mock("@/lib/public-status/config-snapshot", () => ({ - readCurrentInternalPublicStatusConfigSnapshot: mockReadCurrentInternalPublicStatusConfigSnapshot, -})); - -vi.mock("@/lib/public-status/config-publisher", () => ({ - publishCurrentPublicStatusConfigProjection: mockPublishCurrentPublicStatusConfigProjection, -})); - -vi.mock("@/lib/public-status/aggregation", () => ({ - getConfiguredPublicStatusGroups: (snapshot: { groups: unknown[] }) => snapshot.groups, - queryPublicStatusRequests: mockQueryPublicStatusRequests, - buildPublicStatusPayloadFromRequests: mockBuildPublicStatusPayloadFromRequests, -})); +async function importAggregationModule() { + vi.resetModules(); + vi.doUnmock("@/lib/public-status/aggregation"); + + return importPublicStatusModule<{ + buildPublicStatusPayloadFromRequests(input: { + rangeHours: number; + intervalMinutes: number; + now: string | Date; + groups: Array<{ + sourceGroupName: string; + publicGroupSlug: string; + displayName: string; + explanatoryCopy: string | null; + sortOrder: number; + models: Array<{ + publicModelKey: string; + label: string; + vendorIconKey: string; + requestTypeBadge: string; + }>; + }>; + requests: Array<{ + id: number; + createdAt: string | Date; + originalModel?: string | null; + model?: string | null; + durationMs?: number | null; + ttfbMs?: number | null; + outputTokens?: number | null; + providerChain?: Array<{ + id: number; + name: string; + groupTag?: string | null; + reason?: string | null; + statusCode?: number | null; + errorMessage?: string | null; + }> | null; + }>; + }): { + coveredFrom: string; + coveredTo: string; + groups: Array<{ + publicGroupSlug: string; + models: Array<{ + publicModelKey: string; + latestState: string; + timeline: Array<{ + bucketStart: string; + state: string; + sampleCount: number; + }>; + }>; + }>; + }; + }>("@/lib/public-status/aggregation"); +} + +async function importRebuildWorkerModule() { + vi.resetModules(); + vi.doMock("@/lib/redis", () => ({ + getRedisClient: () => ({ + get: mockRedisGet, + pttl: mockRedisPttl, + set: mockRedisSet, + del: mockRedisDel, + eval: mockRedisEval, + status: "ready", + }), + })); + vi.doMock("@/lib/public-status/config-snapshot", () => ({ + readCurrentInternalPublicStatusConfigSnapshot: + mockReadCurrentInternalPublicStatusConfigSnapshot, + })); + vi.doMock("@/lib/public-status/config-publisher", () => ({ + publishCurrentPublicStatusConfigProjection: mockPublishCurrentPublicStatusConfigProjection, + })); + vi.doMock("@/lib/public-status/aggregation", () => ({ + getConfiguredPublicStatusGroups: (snapshot: { groups: unknown[] }) => snapshot.groups, + queryPublicStatusRequests: mockQueryPublicStatusRequests, + buildPublicStatusPayloadFromRequests: mockBuildPublicStatusPayloadFromRequests, + })); + + return importPublicStatusModule<{ + runPublicStatusRebuild(input: { + flightKey: string; + computeGeneration: () => Promise<{ + sourceGeneration: string; + skippedDueToDistributedLock?: boolean; + }>; + }): Promise<{ + sourceGeneration: string; + skippedDueToDistributedLock?: boolean; + }>; + rebuildPublicStatusProjection(input: { + intervalMinutes: number; + rangeHours: number; + now?: Date; + }): Promise< + | { status: "disabled"; reason: string } + | { status: "skipped"; reason: string; sourceGeneration: string } + | { status: "updated"; sourceGeneration: string } + >; + }>("@/lib/public-status/rebuild-worker"); +} + +async function importRebuildHintsModule() { + vi.resetModules(); + vi.doMock("@/lib/redis", () => ({ + getRedisClient: () => ({ + get: mockRedisGet, + pttl: mockRedisPttl, + set: mockRedisSet, + del: mockRedisDel, + eval: mockRedisEval, + status: "ready", + }), + })); + vi.doMock("@/lib/public-status/config-snapshot", () => ({ + readCurrentInternalPublicStatusConfigSnapshot: + mockReadCurrentInternalPublicStatusConfigSnapshot, + })); + + return importPublicStatusModule<{ + schedulePublicStatusRebuild(input: { + intervalMinutes: number; + rangeHours: number; + reason: string; + }): Promise<{ + accepted: boolean; + rebuildState: string; + key?: string; + }>; + }>("@/lib/public-status/rebuild-hints"); +} describe("public-status rebuild worker", () => { beforeEach(() => { @@ -54,8 +166,80 @@ describe("public-status rebuild worker", () => { }); }); + it("aggregates canonical request rows by public group, model key, and UTC bucket", async () => { + const mod = await importAggregationModule(); + + const result = mod.buildPublicStatusPayloadFromRequests({ + rangeHours: 1, + intervalMinutes: 15, + now: "2026-04-21T11:00:00.000Z", + groups: [ + { + sourceGroupName: "openai", + publicGroupSlug: "openai", + displayName: "OpenAI", + explanatoryCopy: "Primary fleet", + sortOrder: 1, + models: [ + { + publicModelKey: "gpt-4.1", + label: "GPT-4.1", + vendorIconKey: "openai", + requestTypeBadge: "openaiCompatible", + }, + ], + }, + ], + requests: [ + { + id: 1, + createdAt: "2026-04-21T10:10:00.000Z", + originalModel: "gpt-4.1", + durationMs: 1000, + ttfbMs: 200, + outputTokens: 80, + providerChain: [ + { + id: 11, + name: "provider-1", + groupTag: "openai", + reason: "request_success", + statusCode: 200, + }, + ], + }, + { + id: 2, + createdAt: "2026-04-21T10:40:00.000Z", + originalModel: "gpt-4.1", + durationMs: 1400, + ttfbMs: 300, + outputTokens: 60, + providerChain: [ + { + id: 11, + name: "provider-1", + groupTag: "openai", + reason: "retry_failed", + statusCode: 500, + }, + ], + }, + ], + }); + + expect(result.coveredFrom).toBe("2026-04-21T10:00:00.000Z"); + expect(result.coveredTo).toBe("2026-04-21T11:00:00.000Z"); + expect(result.groups).toHaveLength(1); + expect(result.groups[0]?.publicGroupSlug).toBe("openai"); + expect(result.groups[0]?.models[0]?.publicModelKey).toBe("gpt-4.1"); + expect(result.groups[0]?.models[0]?.timeline).toHaveLength(4); + expect(result.groups[0]?.models[0]?.timeline[0]?.bucketStart).toBe("2026-04-21T10:00:00.000Z"); + expect(result.groups[0]?.models[0]?.latestState).toBe("failed"); + }); + it("collapses concurrent rebuild requests into a single in-flight computation", async () => { - const mod = await import("@/lib/public-status/rebuild-worker"); + const mod = await importRebuildWorkerModule(); let releaseCompute: (() => void) | undefined; const computeGate = new Promise((resolve) => { @@ -95,7 +279,7 @@ describe("public-status rebuild worker", () => { }); it("propagates distributed-lock skip state to piggyback callers", async () => { - const mod = await import("@/lib/public-status/rebuild-worker"); + const mod = await importRebuildWorkerModule(); let releaseCompute: (() => void) | undefined; const computeGate = new Promise((resolve) => { @@ -136,7 +320,7 @@ describe("public-status rebuild worker", () => { }); it("publishes snapshot and manifest records for a rebuilt generation", async () => { - const mod = await import("@/lib/public-status/rebuild-worker"); + const mod = await importRebuildWorkerModule(); mockReadCurrentInternalPublicStatusConfigSnapshot.mockResolvedValue({ configVersion: "cfg-1", @@ -216,7 +400,7 @@ describe("public-status rebuild worker", () => { }); it("re-publishes config projection before rebuild when redis config keys are missing", async () => { - const mod = await import("@/lib/public-status/rebuild-worker"); + const mod = await importRebuildWorkerModule(); mockReadCurrentInternalPublicStatusConfigSnapshot .mockResolvedValueOnce(null) @@ -268,7 +452,7 @@ describe("public-status rebuild worker", () => { }); it("writes rebuild hints with ttl and reason payload", async () => { - const mod = await import("@/lib/public-status/rebuild-hints"); + const mod = await importRebuildHintsModule(); await mod.schedulePublicStatusRebuild({ intervalMinutes: 15, @@ -289,7 +473,7 @@ describe("public-status rebuild worker", () => { }); it("preserves manifest ttl when marking rebuildState as rebuilding", async () => { - const mod = await import("@/lib/public-status/rebuild-hints"); + const mod = await importRebuildHintsModule(); mockReadCurrentInternalPublicStatusConfigSnapshot.mockResolvedValue({ configVersion: "cfg-1", diff --git a/tests/unit/public-status/status-page-locale.test.ts b/tests/unit/public-status/status-page-locale.test.ts new file mode 100644 index 000000000..54af916fa --- /dev/null +++ b/tests/unit/public-status/status-page-locale.test.ts @@ -0,0 +1,117 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mockGetTranslations = vi.hoisted(() => vi.fn()); +const mockReadCurrentPublicStatusConfigSnapshot = vi.hoisted(() => vi.fn()); +const mockReadPublicStatusSiteMetadata = vi.hoisted(() => vi.fn()); +const mockResolvePublicStatusSiteDescription = vi.hoisted(() => vi.fn()); +const mockReadPublicStatusPayload = vi.hoisted(() => vi.fn()); +const mockSchedulePublicStatusRebuild = vi.hoisted(() => vi.fn()); +const mockPublicStatusView = vi.hoisted(() => vi.fn(() => null)); + +vi.mock("next-intl/server", () => ({ + getTranslations: mockGetTranslations, +})); + +vi.mock("@/lib/public-status/config-snapshot", () => ({ + readCurrentPublicStatusConfigSnapshot: mockReadCurrentPublicStatusConfigSnapshot, + readPublicStatusSiteMetadata: mockReadPublicStatusSiteMetadata, + resolvePublicStatusSiteDescription: mockResolvePublicStatusSiteDescription, +})); + +vi.mock("@/lib/public-status/read-store", () => ({ + readPublicStatusPayload: mockReadPublicStatusPayload, +})); + +vi.mock("@/lib/public-status/rebuild-hints", () => ({ + schedulePublicStatusRebuild: mockSchedulePublicStatusRebuild, +})); + +vi.mock("@/app/[locale]/status/_components/public-status-view", () => ({ + PublicStatusView: mockPublicStatusView, +})); + +describe("PublicStatusPage locale handling", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.resetModules(); + + mockGetTranslations.mockImplementation(async (input?: unknown) => { + if ( + input && + typeof input === "object" && + "locale" in input && + input.locale === "en" && + "namespace" in input && + input.namespace === "settings.statusPage.public" + ) { + return (key: string) => { + const entries: Record = { + systemStatus: "System Status", + heroPrimary: "AI SERVICES", + heroSecondary: "Redis-backed public runtime health overview.", + generatedAt: "Updated", + history: "History", + availability: "Availability", + ttfb: "TTFB", + tps: "TPS", + freshnessWindow: "Snapshot freshness", + fresh: "Fresh", + stale: "Stale", + staleDetail: "The latest completed snapshot is still shown while a refresh is pending.", + rebuilding: "Rebuilding", + noData: "No data", + operational: "Operational", + failed: "Failed", + emptyDescription: "We are preparing the first public snapshot for this page.", + past: "Past", + now: "Now", + "requestTypes.openaiCompatible": "OpenAI Compatible", + "requestTypes.codex": "Codex", + "requestTypes.anthropic": "Anthropic", + "requestTypes.gemini": "Gemini", + }; + + return entries[key] ?? key; + }; + } + + return (key: string) => `zh:${key}`; + }); + mockReadCurrentPublicStatusConfigSnapshot.mockResolvedValue(null); + mockReadPublicStatusSiteMetadata.mockResolvedValue(null); + mockResolvePublicStatusSiteDescription.mockImplementation( + ({ siteTitle, siteDescription }: { siteTitle?: string; siteDescription?: string }) => + siteDescription ?? `${siteTitle ?? "Claude Code Hub"} public status` + ); + mockReadPublicStatusPayload.mockResolvedValue({ + rebuildState: "fresh", + sourceGeneration: "gen-1", + generatedAt: "2026-04-22T10:00:00.000Z", + freshUntil: "2026-04-22T10:05:00.000Z", + groups: [], + }); + }); + + it("passes the route locale into public status translations", async () => { + const mod = await import("@/app/[locale]/status/page"); + + const element = await mod.default({ + params: Promise.resolve({ locale: "en" }), + }); + + expect(mockGetTranslations).toHaveBeenCalledWith({ + locale: "en", + namespace: "settings.statusPage.public", + }); + + const props = ( + element as { + props: { labels: { heroPrimary: string; heroSecondary: string; fresh: string } }; + } + ).props; + + expect(props.labels.heroPrimary).toBe("AI SERVICES"); + expect(props.labels.heroSecondary).toBe("Redis-backed public runtime health overview."); + expect(props.labels.fresh).toBe("Fresh"); + }); +});