/** * P063 DoD: "a breaking schema is detected BEFORE consumer corruption." * * Two levels, both tested here: * - the pure comparison (checkCompatibility) per policy direction, and * - the registry refusing to record a version that breaks the PREVIOUS * version's declared policy — including the bypass where a producer tries * to widen its own policy to "none" on the breaking version. * * The end-to-end "no consumer write happened" proof lives in * tests/p0/ingestion-runner.test.ts, which asserts the sink was never called. */ import { describe, expect, it } from "vitest"; import { checkCompatibility, conformanceErrors } from "@/src/schema/compatibility"; import { SchemaRegistry } from "@/src/schema/schema-registry"; import { InMemoryIngestionRepository } from "@/src/repositories/in-memory-ingestion-repository"; import { SiaxError } from "@/src/platform/errors"; import type { RecordSchemaFields } from "@/src/schema/record-schema"; const V1: RecordSchemaFields = { org_number: { type: "string", required: true }, name: { type: "string", required: true }, employees: { type: "number", required: false }, }; describe("P063 compatibility rules", () => { it("adding an OPTIONAL field is backward-compatible", () => { const next = { ...V1, sni_code: { type: "string" as const, required: false } }; expect(checkCompatibility(V1, next, "backward").compatible).toBe(true); }); it("adding a REQUIRED field breaks backward compatibility", () => { const next = { ...V1, sni_code: { type: "string" as const, required: true } }; const verdict = checkCompatibility(V1, next, "backward"); expect(verdict.compatible).toBe(false); expect(verdict.breaks[0].field).toBe("sni_code"); expect(verdict.breaks[0].detail).toContain("absent from every record written under the previous version"); }); it("adding a required field is FORWARD-safe — direction matters", () => { const next = { ...V1, sni_code: { type: "string" as const, required: true } }; expect(checkCompatibility(V1, next, "forward").compatible).toBe(true); expect(checkCompatibility(V1, next, "full").compatible).toBe(false); }); it("removing a required field breaks BOTH directions", () => { const next: RecordSchemaFields = { org_number: V1.org_number, employees: V1.employees }; expect(checkCompatibility(V1, next, "backward").compatible).toBe(false); expect(checkCompatibility(V1, next, "forward").compatible).toBe(false); }); it("removing an OPTIONAL field is backward-safe but not forward-safe", () => { const next: RecordSchemaFields = { org_number: V1.org_number, name: V1.name }; expect(checkCompatibility(V1, next, "backward").compatible).toBe(true); expect(checkCompatibility(V1, next, "forward").compatible).toBe(true); }); it("a type change breaks every policy except an explicit 'none'", () => { const next: RecordSchemaFields = { ...V1, employees: { type: "string", required: false } }; expect(checkCompatibility(V1, next, "backward").compatible).toBe(false); expect(checkCompatibility(V1, next, "forward").compatible).toBe(false); expect(checkCompatibility(V1, next, "full").compatible).toBe(false); expect(checkCompatibility(V1, next, "none").compatible).toBe(true); }); it("conformanceErrors catches a record that silently stopped matching its schema", () => { expect(conformanceErrors(V1, { org_number: "556001-0001", name: "Alfa AB" })).toEqual([]); expect(conformanceErrors(V1, { org_number: "556001-0001" })).toContain('required field "name" is missing'); expect(conformanceErrors(V1, { org_number: "556001-0001", name: "Alfa", employees: "ten" })).toContain( 'field "employees" is string, expected number', ); expect(conformanceErrors(V1, { org_number: "1", name: "A", surprise: true })).toContain( 'unregistered field "surprise" appeared in the payload', ); }); }); describe("P063 schema registry — versioning and refusal", () => { function registry() { return new SchemaRegistry(new InMemoryIngestionRepository()); } it("the first registration is v1; an identical re-registration does not create v2", async () => { const reg = registry(); const first = await reg.register({ source_id: "src_a", tenant_id: "t", fields: V1, compatibility: "backward" }); expect(first.schema.schema_version).toBe(1); const again = await reg.register({ source_id: "src_a", tenant_id: "t", fields: V1, compatibility: "backward" }); expect(again.unchanged).toBe(true); expect((await reg.list("src_a")).length).toBe(1); }); it("a compatible change becomes v2", async () => { const reg = registry(); await reg.register({ source_id: "src_b", tenant_id: "t", fields: V1, compatibility: "backward" }); const v2 = await reg.register({ source_id: "src_b", tenant_id: "t", fields: { ...V1, sni_code: { type: "string", required: false } }, compatibility: "backward", }); expect(v2.schema.schema_version).toBe(2); expect((await reg.getLatest("src_b"))?.schema_version).toBe(2); }); it("a BREAKING change is refused and nothing is persisted", async () => { const reg = registry(); await reg.register({ source_id: "src_c", tenant_id: "t", fields: V1, compatibility: "backward" }); const err = await reg .register({ source_id: "src_c", tenant_id: "t", fields: { ...V1, sni_code: { type: "string", required: true } }, compatibility: "backward", }) .catch((e: unknown) => e); expect(err).toBeInstanceOf(SiaxError); expect((err as SiaxError).code).toBe("CONFLICT"); expect((err as SiaxError).message).toContain("breaks the declared"); // Still only v1 on record — a refused registration writes nothing. expect((await reg.list("src_c")).map((s) => s.schema_version)).toEqual([1]); }); it("a producer cannot widen its OWN policy to 'none' on the breaking version", async () => { const reg = registry(); await reg.register({ source_id: "src_d", tenant_id: "t", fields: V1, compatibility: "full" }); // The PREVIOUS version's policy governs — declaring "none" here must not help. await expect( reg.register({ source_id: "src_d", tenant_id: "t", fields: { ...V1, employees: { type: "string", required: false } }, compatibility: "none", }), ).rejects.toThrow(/breaks the declared "full" compatibility policy/); }); it("a source that explicitly declared 'none' opts out — recorded, never assumed", async () => { const reg = registry(); await reg.register({ source_id: "src_e", tenant_id: "t", fields: V1, compatibility: "none" }); const v2 = await reg.register({ source_id: "src_e", tenant_id: "t", fields: { ...V1, employees: { type: "string", required: false } }, compatibility: "none", }); expect(v2.schema.schema_version).toBe(2); expect(v2.schema.compatibility).toBe("none"); }); });