/** * 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() 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() returns T at the type level", { timeout: 5000, }, () => { const bytes = encodeBinary([1, 2, 3]); const result = decodeBinary(bytes); expectTypeOf(result).toEqualTypeOf(); 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(); }); });