150 lines
6.9 KiB
TypeScript
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");
|
|
});
|
|
});
|