Initial Commit
This commit is contained in:
@@ -0,0 +1,872 @@
|
||||
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<BackendCapabilities>;
|
||||
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<void>;
|
||||
} = {
|
||||
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<MergeStrategy>().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<AbortSignal | undefined> = [];
|
||||
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<AbortSignal | undefined> = [];
|
||||
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);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user