Files
0pt/tests/p0/schema-evolution.test.ts
T

150 lines
6.9 KiB
TypeScript

/**
* 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");
});
});