diff --git a/src/lib/services/site-settings.test.ts b/src/lib/services/site-settings.test.ts index 3d80e7e8..53f2cabd 100644 --- a/src/lib/services/site-settings.test.ts +++ b/src/lib/services/site-settings.test.ts @@ -1,6 +1,14 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -const { selectFrom } = vi.hoisted(() => ({ selectFrom: vi.fn() })); +const { selectFrom, redisMock } = vi.hoisted(() => ({ + selectFrom: vi.fn(), + redisMock: { + get: vi.fn(), + setex: vi.fn(), + del: vi.fn(), + }, +})); + vi.mock("@/lib/db", () => ({ db: { select: () => ({ @@ -15,10 +23,18 @@ vi.mock("@/lib/db", () => ({ WebsiteSetting: { key: "WebsiteSetting.key", value: "WebsiteSetting.value" }, })); +vi.mock("@/lib/redis", () => ({ redis: redisMock })); + import { siteSettings } from "./site-settings"; beforeEach(async () => { selectFrom.mockReset(); + redisMock.get.mockReset(); + redisMock.setex.mockReset(); + redisMock.del.mockReset(); + redisMock.get.mockResolvedValue(null); + redisMock.setex.mockResolvedValue(undefined); + redisMock.del.mockResolvedValue(undefined); await siteSettings.reload(); }); @@ -53,4 +69,33 @@ describe("siteSettings", () => { await siteSettings.get("hotel_name"); expect(selectFrom).toHaveBeenCalledTimes(2); }); + + it("re-reads the database on a Redis miss instead of serving stale cache", async () => { + vi.useFakeTimers(); + try { + selectFrom.mockResolvedValue([{ key: "hotel_name", value: "First" }]); + expect(await siteSettings.get("hotel_name")).toBe("First"); + + selectFrom.mockResolvedValue([{ key: "hotel_name", value: "Second" }]); + vi.setSystemTime(Date.now() + 2 * 60_000); // expire the in-memory cache + expect(await siteSettings.get("hotel_name")).toBe("Second"); + } finally { + vi.useRealTimers(); + } + }); + + it("does not promote defaults to Redis when the database fails", async () => { + selectFrom.mockRejectedValue(new Error("db down")); + expect(await siteSettings.get("hotel_name", "fallback")).toBe("fallback"); + expect(redisMock.setex).not.toHaveBeenCalled(); + }); + + it("recovers from a transient database failure on the next load", async () => { + selectFrom.mockRejectedValueOnce(new Error("db down")); + expect(await siteSettings.get("hotel_name", "fallback")).toBe("fallback"); + + selectFrom.mockResolvedValueOnce([{ key: "hotel_name", value: "Live" }]); + expect(await siteSettings.get("hotel_name")).toBe("Live"); + expect(redisMock.setex).toHaveBeenCalledTimes(1); + }); }); diff --git a/src/lib/services/site-settings.ts b/src/lib/services/site-settings.ts index a008e288..8ce01d18 100644 --- a/src/lib/services/site-settings.ts +++ b/src/lib/services/site-settings.ts @@ -32,15 +32,15 @@ class SiteSettings { // not each hammer Redis/DB (cache-stampede protection). private inFlight: Promise> | null = null; - private async loadFromDb(): Promise> { + private async loadFromDb(): Promise | null> { try { const rows = await db .select({ key: WebsiteSetting.key, value: WebsiteSetting.value }) .from(WebsiteSetting); return new Map(rows.map((r) => [r.key, r.value])); } catch { - logger.warn("Failed to load site settings from database, using defaults"); - return new Map(Object.entries(DEFAULTS)); + logger.warn("Failed to load site settings from database"); + return null; } } @@ -59,26 +59,43 @@ class SiteSettings { } } - // Expired but usable fallback — keeps the site up if both Redis and DB fail. - if (this.cache !== null) return this.cache.map; - + // Redis miss/unavailable → the database is authoritative. Reloading here + // (instead of serving the in-process map) is what picks up changes made + // by other instances after the Redis TTL expires. const map = await this.loadFromDb(); - this.cache = { map, expiresAt: Date.now() + MEMORY_TTL_MS }; + if (map) { + this.cache = { map, expiresAt: Date.now() + MEMORY_TTL_MS }; - if (redis) { - try { - const obj = Object.fromEntries(map.entries()); - await redis.setex( - REDIS_CACHE_KEY, - Math.ceil(CACHE_TTL_MS / 1000), - JSON.stringify(obj), - ); - } catch { - logger.warn("Failed to write site settings to Redis cache"); + if (redis) { + try { + const obj = Object.fromEntries(map.entries()); + await redis.setex( + REDIS_CACHE_KEY, + Math.ceil(CACHE_TTL_MS / 1000), + JSON.stringify(obj), + ); + } catch { + logger.warn("Failed to write site settings to Redis cache"); + } } + + return map; } - return map; + // DB unavailable → keep serving the last known-good map from memory. + if (this.cache !== null) { + logger.warn("Site settings: database unreachable, serving stale cache"); + return this.cache.map; + } + + // Nothing cached in this process and the DB is down → last resort. These + // defaults are kept in-memory only and never written to Redis, so a + // momentary DB failure at startup can't poison the shared cache and + // downgrade every instance to the default/preset branding. + logger.warn( + "Site settings: database unreachable and no cached copy, using defaults", + ); + return new Map(Object.entries(DEFAULTS)); } private async load(): Promise> {