Files
suaveplan/packages/foundation/data/serialize/src/codec/wire/wire.schemas.test.ts
2026-05-27 23:47:53 +01:00

284 lines
9.7 KiB
TypeScript

/**
* Spec-conformance tests for SZ-10 — Zod schema validation at the public
* boundary. Pins:
* - WireNodeSchema accepts every valid wire shape produced by serialize.
* - WireNodeSchema rejects malformed envelopes (non-string __t, extra keys).
* - deserialize rejects malformed JSON wire input with MalformedWireError.
* - decodeBinary rejects malformed msgpack wire input with MalformedWireError.
* - serialize / deserialize / decodeBinary reject invalid options with
* InvalidOptionsError (typed by stable error code).
*/
import { describe, expect, expectTypeOf, it } from "@suaveplan/testing/runner";
import {
InvalidOptionsError,
MalformedWireError,
} from "../../errors/serialize-error.js";
import { registerType, unregisterType } from "../../registry/registry.js";
import { deserialize } from "../json/json-decode.js";
import { serialize } from "../json/json-encode.js";
import { decodeBinary } from "../msgpack/msgpack-decode.js";
import { encodeBinary } from "../msgpack/msgpack-encode.js";
import {
TaggedEnvelopeSchema,
validateWireNode,
WireNodeSchema,
} from "./wire.schemas.js";
describe("SZ-10 — WireNodeSchema acceptance", () => {
it("accepts JSON primitives", { timeout: 5000 }, () => {
for (const v of [null, true, false, 1, "x"]) {
expect(WireNodeSchema.safeParse(v).success).toBe(true);
}
});
it("accepts arrays of nodes", { timeout: 5000 }, () => {
expect(WireNodeSchema.safeParse([1, "x", null]).success).toBe(true);
});
it("accepts plain records", { timeout: 5000 }, () => {
expect(WireNodeSchema.safeParse({ a: 1, b: "x" }).success).toBe(true);
});
it("accepts a tagged envelope", { timeout: 5000 }, () => {
expect(
WireNodeSchema.safeParse({ __t: "__Date", v: "2024-01-01" }).success
).toBe(true);
});
});
describe("SZ-10 — WireNodeSchema rejection", () => {
it("rejects undefined", { timeout: 5000 }, () => {
expect(WireNodeSchema.safeParse(undefined).success).toBe(false);
});
it("rejects symbols and functions", { timeout: 5000 }, () => {
expect(WireNodeSchema.safeParse(Symbol("x")).success).toBe(false);
expect(WireNodeSchema.safeParse(() => 0).success).toBe(false);
});
it("strict envelope rejects extra keys", { timeout: 5000 }, () => {
const r = TaggedEnvelopeSchema.safeParse({
__t: "__Date",
v: "x",
extra: 1,
});
expect(r.success).toBe(false);
});
it("validateWireNode returns ZodError on rejection", {
timeout: 5000,
}, () => {
const r = validateWireNode(undefined);
expect(r.ok).toBe(false);
if (!r.ok) {
expect(r.error.issues.length).toBeGreaterThan(0);
}
});
});
describe("SZ-10 — deserialize boundary validation", () => {
it("invalid JSON throws MalformedWireError", { timeout: 5000 }, () => {
expect(() => deserialize("{not json")).toThrowError(MalformedWireError);
});
it("malformed envelope (non-string __t) throws MalformedWireError", {
timeout: 5000,
}, () => {
const bad = JSON.stringify({ __t: 42, v: null });
expect(() => deserialize(bad)).toThrowError(MalformedWireError);
});
it("MalformedWireError carries CODEC.MALFORMED_WIRE code", {
timeout: 5000,
}, () => {
try {
deserialize("{not json");
} catch (e) {
expect((e as MalformedWireError).code).toBe(
"SERIALIZE.CODEC.MALFORMED_WIRE"
);
return;
}
throw new Error("expected throw");
});
});
describe("SZ-10 — decodeBinary boundary validation", () => {
it("invalid msgpack bytes throw MalformedWireError", {
timeout: 5000,
}, () => {
// 0xc1 is reserved/never-used in msgpack and triggers a decode error.
const bad = new Uint8Array([0xc1, 0xc1, 0xc1]);
expect(() => decodeBinary(bad)).toThrowError(MalformedWireError);
});
it("round-trips valid msgpack input", { timeout: 5000 }, () => {
const bytes = encodeBinary({ a: 1, b: "two" });
const back = decodeBinary(bytes);
expect(back).toEqual({ a: 1, b: "two" });
});
});
describe("SZ-10 — invalid options rejection", () => {
it("serialize throws InvalidOptionsError for non-positive maxDepth", {
timeout: 5000,
}, () => {
expect(() => serialize({}, { maxDepth: -1 } as never)).toThrowError(
InvalidOptionsError
);
});
it("serialize throws InvalidOptionsError for non-positive maxSize", {
timeout: 5000,
}, () => {
expect(() => serialize({}, { maxSize: 0 } as never)).toThrowError(
InvalidOptionsError
);
});
it("serialize throws InvalidOptionsError for unknown option keys", {
timeout: 5000,
}, () => {
expect(() => serialize({}, { unknown: 1 } as never)).toThrowError(
InvalidOptionsError
);
});
it("deserialize throws InvalidOptionsError for non-AbortSignal signal", {
timeout: 5000,
}, () => {
expect(() =>
deserialize("null", { signal: "nope" as unknown as AbortSignal })
).toThrowError(InvalidOptionsError);
});
it("encodeBinary throws InvalidOptionsError for non-positive maxSize", {
timeout: 5000,
}, () => {
expect(() => encodeBinary({}, { maxSize: -10 } as never)).toThrowError(
InvalidOptionsError
);
});
it("decodeBinary throws InvalidOptionsError for unknown option keys", {
timeout: 5000,
}, () => {
const bytes = encodeBinary({ a: 1 });
expect(() => decodeBinary(bytes, { unknown: 1 } as never)).toThrowError(
InvalidOptionsError
);
});
});
describe("SZ-10 — error class branch coverage", () => {
it("MalformedWireError works without a path argument", {
timeout: 5000,
}, () => {
const err = new MalformedWireError("no-path");
expect(err.code).toBe("SERIALIZE.CODEC.MALFORMED_WIRE");
expect(err.context?.["tag"]).toBe("no-path");
expect(err.context?.["path"]).toBeUndefined();
});
it("InvalidOptionsError works with cause", { timeout: 5000 }, () => {
const cause = new Error("inner");
const err = new InvalidOptionsError("invalid", cause);
expect(err.code).toBe("SERIALIZE.OPTIONS.INVALID");
expect(err.cause).toBe(cause);
});
});
describe("SZ-10 — msgpack wire-schema rejection", () => {
it("decodeBinary rejects msgpack-encoded malformed envelopes", {
timeout: 5000,
}, () => {
// Build a real msgpack buffer for { __t: 1, v: 1 } using @msgpack/msgpack
// directly — bypassing our encoder so the wire-schema rejection path
// can be exercised independently of JSON.parse.
// 0x82 = fixmap of 2; 0xa3 "__t" = fixstr; 0x01 = 1; 0xa1 "v" = fixstr; 0x01 = 1.
const bytes = new Uint8Array([
0x82, 0xa3, 0x5f, 0x5f, 0x74, 0x01, 0xa1, 0x76, 0x01,
]);
expect(() => decodeBinary(bytes)).toThrowError(MalformedWireError);
});
});
describe("SZ-10 — encode/decode error label fallback", () => {
it("encode error path tags non-Error throws as SERIALIZE.CODEC.UNKNOWN", {
timeout: 5000,
}, () => {
// Register a custom encode function that throws a non-Error value to
// exercise the `: "SERIALIZE.CODEC.UNKNOWN"` branch in both codecs.
const tag = "__SerializeEncodeBranchProbe";
const ctor = class EncodeBranchProbe {};
registerType({
tag,
ctor,
encode: () => {
throw "raw-string-throw";
},
decode: (v) => v,
});
try {
const probe = new ctor();
expect(() => serialize(probe)).toThrow();
expect(() => encodeBinary(probe)).toThrow();
} finally {
unregisterType(tag);
}
});
it("decode error path tags non-Error throws as SERIALIZE.CODEC.UNKNOWN", {
timeout: 5000,
}, () => {
// Register a custom decode function that throws a non-Error value to
// exercise the `: "SERIALIZE.CODEC.UNKNOWN"` branch on the decode side.
const tag = "__SerializeDecodeBranchProbe";
const ctor = class DecodeBranchProbe {};
registerType({
tag,
ctor,
encode: () => null,
decode: () => {
throw "raw-string-throw";
},
});
try {
const probe = new ctor();
const json = serialize(probe);
expect(() => deserialize(json)).toThrow();
const bytes = encodeBinary(probe);
expect(() => decodeBinary(bytes)).toThrow();
} finally {
unregisterType(tag);
}
});
});
describe("SZ-10 — generic threading", () => {
it("deserialize<T>() returns T at the type level", {
timeout: 5000,
}, () => {
const json = serialize({ name: "ada", age: 36 });
const result = deserialize<{ name: string; age: number }>(json);
expectTypeOf(result).toEqualTypeOf<{ name: string; age: number }>();
expect(result.name).toBe("ada");
});
it("decodeBinary<T>() returns T at the type level", {
timeout: 5000,
}, () => {
const bytes = encodeBinary([1, 2, 3]);
const result = decodeBinary<number[]>(bytes);
expectTypeOf(result).toEqualTypeOf<number[]>();
expect(result).toEqual([1, 2, 3]);
});
it("deserialize() defaults to unknown when T is omitted", {
timeout: 5000,
}, () => {
const result = deserialize(serialize({ a: 1 }));
expectTypeOf(result).toEqualTypeOf<unknown>();
});
});