import { MemoryBackend, memoryBackend } from "@suaveplan/i18n-backend-memory"; import { I18N_CODES, I18nError } from "@suaveplan/i18n-shared"; import { runBackendConformance } from "@suaveplan/i18n-shared/testing"; import { beforeEach, describe, expect, expectTypeOf, it, } from "@suaveplan/testing/runner"; import type { Backend, BackendCapabilities, BackendInitContext, TranslationRecord, } from "@suaveplan/types"; import { getChainedBackendMetricsState, resetChainedBackendMetricsCache, resetChainedBackendMetricsState, } from "../observability/observability.js"; import { ChainedBackend, ChainedBackendOptionsSchema } from "./backend.js"; import type { ChainedBackendOptions, MergeStrategy } from "./backend.types.js"; // ───────────────────────────────────────────────────────────────────── // Stub backends used across the suite // ───────────────────────────────────────────────────────────────────── interface ReadOnlyStubOptions { readonly name: string; readonly response?: TranslationRecord; readonly error?: I18nError; readonly throwGeneric?: Error; readonly capabilities?: Partial; readonly recordSignal?: { last: AbortSignal | undefined }; readonly initOrder?: string[]; readonly disposeOrder?: string[]; readonly throwOnSave?: Error; readonly recordSaves?: Array<{ locale: string; namespace: string; record: TranslationRecord; }>; } function makeStub(opts: ReadOnlyStubOptions): Backend { const baseCaps: BackendCapabilities = { canRead: true, canReadMulti: false, canSave: false, canTrackModification: false, canWatch: false, ...opts.capabilities, } as BackendCapabilities; const backend: Backend & { save?: ( l: string, n: string, r: TranslationRecord, s?: AbortSignal ) => Promise; } = { name: opts.name, capabilities: baseCaps, async read(_locale, _namespace, signal?: AbortSignal) { if (opts.recordSignal) opts.recordSignal.last = signal; if (opts.throwGeneric) throw opts.throwGeneric; if (opts.error) throw opts.error; if (opts.response) return opts.response; throw new I18nError( I18N_CODES.BACKEND_LOCALE_NOT_AVAILABLE, `${opts.name}: locale not available` ); }, async init(_context: BackendInitContext) { opts.initOrder?.push(opts.name); }, async dispose() { opts.disposeOrder?.push(opts.name); }, }; if (baseCaps.canSave || opts.throwOnSave || opts.recordSaves) { backend.save = async (locale, namespace, record, _signal) => { if (opts.throwOnSave) throw opts.throwOnSave; opts.recordSaves?.push({ locale, namespace, record }); }; } return backend; } // ───────────────────────────────────────────────────────────────────── // Universal Backend conformance suite // ───────────────────────────────────────────────────────────────────── runBackendConformance("ChainedBackend", async () => { const cache = new MemoryBackend(); const primary = new MemoryBackend(); const chain = new ChainedBackend({ backends: [cache, primary] }); return { backend: chain, async seed(locale, namespace, record) { await primary.save(locale, namespace, record as never); }, }; }); // ───────────────────────────────────────────────────────────────────── // ChainedBackend specifics // ───────────────────────────────────────────────────────────────────── describe("ChainedBackend constructor", () => { it("default name is chained", { timeout: 5000 }, () => { const chain = new ChainedBackend({ backends: [memoryBackend()] }); expect(chain.name).toBe("chained"); }); it("accepts a custom name", { timeout: 5000 }, () => { const chain = new ChainedBackend({ backends: [memoryBackend()], name: "custom-chain", }); expect(chain.name).toBe("custom-chain"); }); it("rejects empty backends with INVALID_OPTIONS", { timeout: 5000 }, () => { expect(() => new ChainedBackend({ backends: [] })).toThrow(I18nError); try { new ChainedBackend({ backends: [] }); } catch (err) { expect((err as I18nError).code).toBe(I18N_CODES.INVALID_OPTIONS); } }); it("rejects non-string name with INVALID_OPTIONS", { timeout: 5000, }, () => { try { new ChainedBackend({ backends: [memoryBackend()], name: "" as string, }); expect.fail("expected throw"); } catch (err) { expect(err).toBeInstanceOf(I18nError); expect((err as I18nError).code).toBe(I18N_CODES.INVALID_OPTIONS); } }); it("rejects unknown mergeStrategy with INVALID_OPTIONS", { timeout: 5000, }, () => { try { new ChainedBackend({ backends: [memoryBackend()], mergeStrategy: "unknown" as MergeStrategy, }); expect.fail("expected throw"); } catch (err) { expect(err).toBeInstanceOf(I18nError); expect((err as I18nError).code).toBe(I18N_CODES.INVALID_OPTIONS); } }); it("MergeStrategy is a discriminated union", { timeout: 5000 }, () => { expectTypeOf().toEqualTypeOf< "first-wins" | "last-wins" >(); }); }); describe("ChainedBackend capabilities (union over children)", () => { it("capabilities are derived from child union", { timeout: 5000 }, () => { const a = makeStub({ name: "a", capabilities: { canSave: true } }); const b = makeStub({ name: "b" }); const chain = new ChainedBackend({ backends: [a, b] }); expect(chain.capabilities.canSave).toBe(true); }); it("canTrackModification union over children", { timeout: 5000 }, () => { const a = makeStub({ name: "a" }); const b = makeStub({ name: "b", capabilities: { canTrackModification: true }, }); const chain = new ChainedBackend({ backends: [a, b] }); expect(chain.capabilities.canTrackModification).toBe(true); }); it("canWatch union over children", { timeout: 5000 }, () => { const a = makeStub({ name: "a", capabilities: { canWatch: true } }); const b = makeStub({ name: "b" }); const chain = new ChainedBackend({ backends: [a, b] }); expect(chain.capabilities.canWatch).toBe(true); }); it("canRead is always true literal", { timeout: 5000 }, () => { const chain = new ChainedBackend({ backends: [makeStub({ name: "a" })], }); expect(chain.capabilities.canRead).toBe(true); }); }); describe("ChainedBackend.read fallthrough semantics", () => { beforeEach(() => { resetChainedBackendMetricsState(); resetChainedBackendMetricsCache(); }); it("fallthrough on BACKEND_LOCALE_NOT_AVAILABLE", { timeout: 5000, }, async () => { const empty = makeStub({ name: "empty" }); const seeded = makeStub({ name: "seeded", response: { x: "y" }, capabilities: { canSave: true }, }); const chain = new ChainedBackend({ backends: [empty, seeded] }); const record = await chain.read("en", "common"); expect(record).toEqual({ x: "y" }); }); it("BACKEND_READ_FAILED propagates immediately", { timeout: 5000, }, async () => { const failing = makeStub({ name: "failing", error: new I18nError(I18N_CODES.BACKEND_READ_FAILED, "boom"), }); let nextCalled = false; const next: Backend = { name: "next", capabilities: { canRead: true, canReadMulti: false, canSave: false, canTrackModification: false, canWatch: false, }, async read() { nextCalled = true; return { x: "y" }; }, }; const chain = new ChainedBackend({ backends: [failing, next] }); await expect(chain.read("en", "common")).rejects.toMatchObject({ code: I18N_CODES.BACKEND_READ_FAILED, }); expect(nextCalled).toBe(false); }); it("all children miss throws BACKEND_LOCALE_NOT_AVAILABLE", { timeout: 5000, }, async () => { const a = makeStub({ name: "a" }); const b = makeStub({ name: "b" }); const chain = new ChainedBackend({ backends: [a, b] }); await expect(chain.read("xx", "common")).rejects.toMatchObject({ code: I18N_CODES.BACKEND_LOCALE_NOT_AVAILABLE, }); }); it("rethrows generic Error from child", { timeout: 5000 }, async () => { const failing = makeStub({ name: "failing", throwGeneric: new Error("generic boom"), }); const chain = new ChainedBackend({ backends: [failing] }); await expect(chain.read("en", "common")).rejects.toThrow( "generic boom" ); }); it("fallthrough increments fallthrough_total", { timeout: 5000, }, async () => { const a = makeStub({ name: "a" }); const b = makeStub({ name: "b", response: { x: "y" } }); const chain = new ChainedBackend({ backends: [a, b] }); await chain.read("en", "common"); const state = getChainedBackendMetricsState(); expect(state.fallthroughTotal.length).toBe(1); expect(state.fallthroughTotal[0]?.attributes).toEqual({ from_backend: "a", to_backend: "b", }); }); it("single-backend chain reports primary outcome on hit", { timeout: 5000, }, async () => { const only = makeStub({ name: "only", response: { x: "y" } }); const chain = new ChainedBackend({ backends: [only] }); await chain.read("en", "common"); const state = getChainedBackendMetricsState(); const outcomes = state.readTotal.map((s) => s.attributes["outcome"]); expect(outcomes).toContain("primary"); }); it("multi-backend chain reports cache_hit when index 0 serves", { timeout: 5000, }, async () => { const cache = makeStub({ name: "cache", response: { x: "y" }, capabilities: { canSave: true }, }); const primary = makeStub({ name: "primary" }); const chain = new ChainedBackend({ backends: [cache, primary] }); await chain.read("en", "common"); const state = getChainedBackendMetricsState(); const outcomes = state.readTotal.map((s) => s.attributes["outcome"]); expect(outcomes).toContain("cache_hit"); }); it("multi-backend chain reports cache_miss when later backend serves", { timeout: 5000, }, async () => { const cache = makeStub({ name: "cache" }); const primary = makeStub({ name: "primary", response: { x: "y" } }); const chain = new ChainedBackend({ backends: [cache, primary] }); await chain.read("en", "common"); const state = getChainedBackendMetricsState(); const outcomes = state.readTotal.map((s) => s.attributes["outcome"]); expect(outcomes).toContain("cache_miss"); }); it("miss outcome on total fallthrough exhaustion", { timeout: 5000, }, async () => { const a = makeStub({ name: "a" }); const b = makeStub({ name: "b" }); const chain = new ChainedBackend({ backends: [a, b] }); await expect(chain.read("xx", "common")).rejects.toBeInstanceOf( I18nError ); const state = getChainedBackendMetricsState(); const outcomes = state.readTotal.map((s) => s.attributes["outcome"]); expect(outcomes).toContain("miss"); }); it("error outcome on propagation", { timeout: 5000 }, async () => { const failing = makeStub({ name: "failing", error: new I18nError(I18N_CODES.BACKEND_READ_FAILED, "boom"), }); const chain = new ChainedBackend({ backends: [failing] }); await expect(chain.read("en", "common")).rejects.toBeInstanceOf( I18nError ); const state = getChainedBackendMetricsState(); const errCodes = state.readErrorsTotal.map((s) => s.attributes["code"]); expect(errCodes).toContain(I18N_CODES.BACKEND_READ_FAILED); }); }); describe("ChainedBackend write-through cache semantics", () => { it("write-through populates cache layer", { timeout: 5000 }, async () => { const cache = new MemoryBackend({ name: "cache" }); const primary = new MemoryBackend({ name: "primary" }); await primary.save("en", "common", { x: "y" }); const chain = new ChainedBackend({ backends: [cache, primary] }); await chain.read("en", "common"); // Cache should now have the record. expect(await cache.read("en", "common")).toEqual({ x: "y" }); }); it("write-through failure does not block read", { timeout: 5000, }, async () => { const recordedSaves: Array<{ locale: string; namespace: string; record: TranslationRecord; }> = []; const broken = makeStub({ name: "broken", capabilities: { canSave: true }, throwOnSave: new Error("disk full"), recordSaves: recordedSaves, }); const seeded = makeStub({ name: "seeded", response: { x: "y" }, }); const chain = new ChainedBackend({ backends: [broken, seeded] }); const record = await chain.read("en", "common"); expect(record).toEqual({ x: "y" }); }); it("write-through skips backends without save", { timeout: 5000, }, async () => { const readOnlyCache = makeStub({ name: "ro" }); const seeded = makeStub({ name: "seeded", response: { x: "y" }, }); const chain = new ChainedBackend({ backends: [readOnlyCache, seeded], }); const record = await chain.read("en", "common"); expect(record).toEqual({ x: "y" }); }); }); describe("ChainedBackend.readMulti merge strategies", () => { it("first-wins prefers earlier backend", { timeout: 5000 }, async () => { const a = memoryBackend({ seed: { en: { auth: { x: "A" } } } }); const b = memoryBackend({ seed: { en: { auth: { x: "B" } } } }); const chain = new ChainedBackend({ backends: [a, b] }); const merged = await chain.readMulti("en", ["auth"]); expect(merged["auth"]).toEqual({ x: "A" }); }); it("last-wins prefers later backend", { timeout: 5000 }, async () => { const a = memoryBackend({ seed: { en: { auth: { x: "A" } } } }); const b = memoryBackend({ seed: { en: { auth: { x: "B" } } } }); const chain = new ChainedBackend({ backends: [a, b], mergeStrategy: "last-wins", }); const merged = await chain.readMulti("en", ["auth"]); expect(merged["auth"]).toEqual({ x: "B" }); }); it("gaps are filled regardless of strategy", { timeout: 5000, }, async () => { const a = memoryBackend({ seed: { en: { auth: { x: "A" } } } }); const b = memoryBackend({ seed: { en: { common: { y: "B" } } } }); const chain = new ChainedBackend({ backends: [a, b] }); const merged = await chain.readMulti("en", ["auth", "common"]); expect(merged).toEqual({ auth: { x: "A" }, common: { y: "B" } }); }); it("readMulti falls back to per-namespace read when child has no readMulti", { timeout: 5000, }, async () => { const stub = makeStub({ name: "stub", response: { x: "y" }, }); const chain = new ChainedBackend({ backends: [stub] }); const merged = await chain.readMulti("en", ["a", "b"]); expect(merged).toEqual({ a: { x: "y" }, b: { x: "y" } }); }); it("readMulti per-namespace fallthrough on BACKEND_LOCALE_NOT_AVAILABLE", { timeout: 5000, }, async () => { const empty = makeStub({ name: "empty" }); const chain = new ChainedBackend({ backends: [empty] }); const merged = await chain.readMulti("xx", ["a", "b"]); expect(merged).toEqual({}); }); it("readMulti per-namespace propagates non-LOCALE_NOT_AVAILABLE errors", { timeout: 5000, }, async () => { // When a child has no readMulti, the chained backend falls // back to per-namespace `read()` calls. BACKEND_READ_FAILED // from any per-namespace read in that path propagates // upward (not swallowed) — the swallow only applies to the // child.readMulti() path. const failing = makeStub({ name: "failing", error: new I18nError(I18N_CODES.BACKEND_READ_FAILED, "boom"), }); const chain = new ChainedBackend({ backends: [failing] }); await expect(chain.readMulti("en", ["a"])).rejects.toMatchObject({ code: I18N_CODES.BACKEND_READ_FAILED, }); }); it("readMulti uses child readMulti when present", { timeout: 5000, }, async () => { const seed = memoryBackend({ seed: { en: { auth: { x: "A" }, common: { y: "B" } } }, }); const chain = new ChainedBackend({ backends: [seed] }); const merged = await chain.readMulti("en", ["auth", "common"]); expect(merged).toEqual({ auth: { x: "A" }, common: { y: "B" } }); }); it("readMulti swallows child readMulti errors per backend", { timeout: 5000, }, async () => { const failing: Backend = { name: "failing", capabilities: { canRead: true, canReadMulti: true, canSave: false, canTrackModification: false, canWatch: false, }, async read() { throw new I18nError( I18N_CODES.BACKEND_LOCALE_NOT_AVAILABLE, "miss" ); }, async readMulti() { throw new I18nError(I18N_CODES.BACKEND_READ_FAILED, "boom"); }, }; const seed = memoryBackend({ seed: { en: { auth: { x: "A" } } }, }); const chain = new ChainedBackend({ backends: [failing, seed] }); const merged = await chain.readMulti("en", ["auth"]); expect(merged).toEqual({ auth: { x: "A" } }); }); }); describe("ChainedBackend.save fan-out", () => { it("save writes to every save-capable child", { timeout: 5000, }, async () => { const a = memoryBackend({ name: "a" }); const b = memoryBackend({ name: "b" }); const chain = new ChainedBackend({ backends: [a, b] }); await chain.save("en", "common", { x: "y" }); expect(await a.read("en", "common")).toEqual({ x: "y" }); expect(await b.read("en", "common")).toEqual({ x: "y" }); }); it("save skips backends without save", { timeout: 5000 }, async () => { const ro = makeStub({ name: "ro" }); const writable = memoryBackend({ name: "writable" }); const chain = new ChainedBackend({ backends: [ro, writable] }); await chain.save("en", "common", { x: "y" }); expect(await writable.read("en", "common")).toEqual({ x: "y" }); }); it("save failure throws BACKEND_WRITE_FAILED", { timeout: 5000, }, async () => { const broken = makeStub({ name: "broken", capabilities: { canSave: true }, throwOnSave: new Error("disk full"), }); const chain = new ChainedBackend({ backends: [broken] }); try { await chain.save("en", "common", { x: "y" }); expect.fail("expected throw"); } catch (err) { expect(err).toBeInstanceOf(I18nError); expect((err as I18nError).code).toBe( I18N_CODES.BACKEND_WRITE_FAILED ); expect(((err as I18nError).cause as Error).message).toBe( "disk full" ); } }); it("save preserves I18nError thrown by child", { timeout: 5000, }, async () => { const broken = makeStub({ name: "broken", capabilities: { canSave: true }, throwOnSave: new I18nError( I18N_CODES.BACKEND_WRITE_FAILED, "child write failed" ), }); const chain = new ChainedBackend({ backends: [broken] }); await expect( chain.save("en", "common", { x: "y" }) ).rejects.toMatchObject({ code: I18N_CODES.BACKEND_WRITE_FAILED, message: "child write failed", }); }); }); describe("ChainedBackend.lastModified", () => { it("lastModified returns first defined value", { timeout: 5000, }, async () => { const a: Backend = { name: "a", capabilities: { canRead: true, canReadMulti: false, canSave: false, canTrackModification: true, canWatch: false, }, async read() { return {}; }, async lastModified() { return undefined; }, }; const b: Backend = { name: "b", capabilities: { canRead: true, canReadMulti: false, canSave: false, canTrackModification: true, canWatch: false, }, async read() { return {}; }, async lastModified() { return 4242; }, }; const chain = new ChainedBackend({ backends: [a, b] }); expect(await chain.lastModified("en", "common")).toBe(4242); }); it("lastModified returns undefined when no child reports", { timeout: 5000, }, async () => { const stub = makeStub({ name: "stub" }); const chain = new ChainedBackend({ backends: [stub] }); expect(await chain.lastModified("en", "common")).toBeUndefined(); }); }); describe("ChainedBackend lifecycle (init/dispose)", () => { it("init forwards in registration order", { timeout: 5000 }, async () => { const initOrder: string[] = []; const a = makeStub({ name: "a", initOrder }); const b = makeStub({ name: "b", initOrder }); const c = makeStub({ name: "c", initOrder }); const chain = new ChainedBackend({ backends: [a, b, c] }); await chain.init({ catalogName: "test", emitLoaded: () => {}, }); expect(initOrder).toEqual(["a", "b", "c"]); }); it("dispose runs in reverse order", { timeout: 5000 }, async () => { const disposeOrder: string[] = []; const a = makeStub({ name: "a", disposeOrder }); const b = makeStub({ name: "b", disposeOrder }); const c = makeStub({ name: "c", disposeOrder }); const chain = new ChainedBackend({ backends: [a, b, c] }); await chain.dispose(); expect(disposeOrder).toEqual(["c", "b", "a"]); }); it("init skips children without init hook", { timeout: 5000 }, async () => { const noHook: Backend = { name: "no-hook", capabilities: { canRead: true, canReadMulti: false, canSave: false, canTrackModification: false, canWatch: false, }, async read() { return {}; }, }; const chain = new ChainedBackend({ backends: [noHook] }); await chain.init({ catalogName: "test", emitLoaded: () => {}, }); // No throw — pass. }); it("dispose skips children without dispose hook", { timeout: 5000, }, async () => { const noHook: Backend = { name: "no-hook", capabilities: { canRead: true, canReadMulti: false, canSave: false, canTrackModification: false, canWatch: false, }, async read() { return {}; }, }; const chain = new ChainedBackend({ backends: [noHook] }); await chain.dispose(); // No throw — pass. }); }); describe("ChainedBackend AbortSignal threading", () => { it("pre-aborted signal aborts read with BACKEND_TIMEOUT", { timeout: 5000, }, async () => { let childCalled = false; const stub: Backend = { name: "stub", capabilities: { canRead: true, canReadMulti: false, canSave: false, canTrackModification: false, canWatch: false, }, async read() { childCalled = true; return {}; }, }; const chain = new ChainedBackend({ backends: [stub] }); const ctrl = new AbortController(); ctrl.abort(); await expect( chain.read("en", "common", ctrl.signal) ).rejects.toMatchObject({ code: I18N_CODES.BACKEND_TIMEOUT }); expect(childCalled).toBe(false); }); it("pre-aborted signal aborts readMulti with BACKEND_TIMEOUT", { timeout: 5000, }, async () => { const chain = new ChainedBackend({ backends: [memoryBackend()], }); const ctrl = new AbortController(); ctrl.abort(); await expect( chain.readMulti("en", ["a"], ctrl.signal) ).rejects.toMatchObject({ code: I18N_CODES.BACKEND_TIMEOUT }); }); it("pre-aborted signal aborts save with BACKEND_TIMEOUT", { timeout: 5000, }, async () => { const chain = new ChainedBackend({ backends: [memoryBackend()], }); const ctrl = new AbortController(); ctrl.abort(); await expect( chain.save("en", "a", { x: "y" }, ctrl.signal) ).rejects.toMatchObject({ code: I18N_CODES.BACKEND_TIMEOUT }); }); it("signal threaded into child reads", { timeout: 5000 }, async () => { const recorder: { last: AbortSignal | undefined } = { last: undefined, }; const stub = makeStub({ name: "stub", response: { x: "y" }, recordSignal: recorder, }); const chain = new ChainedBackend({ backends: [stub] }); const ctrl = new AbortController(); await chain.read("en", "common", ctrl.signal); expect(recorder.last).toBe(ctrl.signal); }); it("signal threaded into child saves", { timeout: 5000 }, async () => { const seenSignals: Array = []; const stub: Backend = { name: "stub", capabilities: { canRead: true, canReadMulti: false, canSave: true, canTrackModification: false, canWatch: false, }, async read() { return {}; }, async save( _l: string, _n: string, _r: TranslationRecord, signal?: AbortSignal ) { seenSignals.push(signal); }, }; const chain = new ChainedBackend({ backends: [stub] }); const ctrl = new AbortController(); await chain.save("en", "a", { x: "y" }, ctrl.signal); expect(seenSignals[0]).toBe(ctrl.signal); }); it("signal threaded into child readMulti", { timeout: 5000 }, async () => { const seen: Array = []; const stub: Backend = { name: "stub", capabilities: { canRead: true, canReadMulti: true, canSave: false, canTrackModification: false, canWatch: false, }, async read() { return {}; }, async readMulti( _l: string, _ns: readonly string[], signal?: AbortSignal ) { seen.push(signal); return {}; }, }; const chain = new ChainedBackend({ backends: [stub] }); const ctrl = new AbortController(); await chain.readMulti("en", ["a"], ctrl.signal); expect(seen[0]).toBe(ctrl.signal); }); }); describe("ChainedBackend CHAINED_BACKEND_ALL_FAILED defence-in-depth", () => { it("runtime empty chain throws CHAINED_BACKEND_ALL_FAILED on read", { timeout: 5000, }, async () => { // The constructor's Zod schema rejects empty `backends` // arrays, so the only way to land in the runtime defensive // branch is to bypass the schema. Use vi.spyOn to make // safeParse accept an empty array for one constructor call. const spyTarget = ChainedBackendOptionsSchema as unknown as { safeParse: (input: unknown) => { success: boolean; data?: unknown; error?: unknown; }; }; const original = spyTarget.safeParse.bind(spyTarget); spyTarget.safeParse = ((_input: unknown) => ({ success: true, data: { backends: [] }, })) as typeof spyTarget.safeParse; try { const chain = new ChainedBackend({ backends: [] as unknown as readonly Backend[], }); await expect(chain.read("en", "common")).rejects.toMatchObject({ code: I18N_CODES.CHAINED_BACKEND_ALL_FAILED, }); } finally { spyTarget.safeParse = original as unknown as typeof spyTarget.safeParse; } }); it("constructor schema rejects backends: []", { timeout: 5000 }, () => { try { new ChainedBackend({ backends: [] as unknown as readonly Backend[], } as ChainedBackendOptions); expect.fail("expected throw"); } catch (err) { expect(err).toBeInstanceOf(I18nError); expect((err as I18nError).code).toBe(I18N_CODES.INVALID_OPTIONS); } }); });