244 lines
7.0 KiB
TypeScript
244 lines
7.0 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
|
|
import {
|
|
compareRealtimeSequences,
|
|
isCanonicalRealtimeSequence,
|
|
isRealtimeResumeCursor,
|
|
isStrictRealtimeTimestamp,
|
|
nextRealtimeSequence,
|
|
REALTIME_MAX_SEQUENCE,
|
|
} from "../../../src/contracts/realtime-events.ts";
|
|
import {
|
|
isValidatedRealtimeEventDto,
|
|
} from "../../../src/adapters/realtime/event-codec.ts";
|
|
import {
|
|
TEST_LIMITS,
|
|
createTestRealtimeCodec,
|
|
createTestRealtimeRegistry,
|
|
realtimeEventJson,
|
|
realtimeEventValue,
|
|
} from "./fixture.ts";
|
|
|
|
describe("REALTIME_EVENT_V1 codec", () => {
|
|
it("accepts an exact registered envelope and snapshots schema output", () => {
|
|
const codec = createTestRealtimeCodec();
|
|
const result = codec.decode(realtimeEventJson());
|
|
|
|
expect(result).toMatchObject({
|
|
ok: true,
|
|
value: {
|
|
envelope: {
|
|
protocol: "REALTIME_EVENT_V1",
|
|
sequence: "1",
|
|
recoveryMode: "CURSOR",
|
|
resumeCursor: "cursor-00000001",
|
|
payload: { value: "changed" },
|
|
},
|
|
},
|
|
});
|
|
if (!result.ok) return;
|
|
expect(isValidatedRealtimeEventDto(result.value)).toBe(true);
|
|
expect(Object.isFrozen(result.value)).toBe(true);
|
|
expect(Object.isFrozen(result.value.envelope)).toBe(true);
|
|
expect(Object.isFrozen(result.value.envelope.payload)).toBe(true);
|
|
expect(result.value.wireBytes).toBeGreaterThan(0);
|
|
expect(result.value.fingerprintBytes).toBeGreaterThan(0);
|
|
});
|
|
|
|
it("rejects extra keys, unknown registrations and schema failures", () => {
|
|
const codec = createTestRealtimeCodec();
|
|
|
|
expect(
|
|
codec.decode(
|
|
JSON.stringify({
|
|
...realtimeEventValue(),
|
|
arbitrary: "override",
|
|
}),
|
|
),
|
|
).toMatchObject({
|
|
ok: false,
|
|
error: { kind: "MALFORMED_EVENT", operation: "DECODE" },
|
|
});
|
|
expect(
|
|
codec.decode(
|
|
realtimeEventJson({ streamId: "UNKNOWN_STREAM" }),
|
|
),
|
|
).toMatchObject({
|
|
ok: false,
|
|
error: { kind: "MALFORMED_EVENT" },
|
|
});
|
|
expect(
|
|
codec.decode(
|
|
realtimeEventJson({ eventType: "UNKNOWN_EVENT" }),
|
|
),
|
|
).toMatchObject({
|
|
ok: false,
|
|
error: { kind: "MALFORMED_EVENT" },
|
|
});
|
|
expect(
|
|
codec.decode(realtimeEventJson({ payload: { value: 42 } })),
|
|
).toMatchObject({
|
|
ok: false,
|
|
error: { kind: "MALFORMED_EVENT" },
|
|
});
|
|
|
|
const duplicateEnvelopeMember =
|
|
`{"eventId":"shadowed",${JSON.stringify(
|
|
realtimeEventValue(),
|
|
).slice(1)}`;
|
|
expect(codec.decode(duplicateEnvelopeMember)).toMatchObject({
|
|
ok: false,
|
|
error: { kind: "MALFORMED_EVENT", operation: "DECODE" },
|
|
});
|
|
const duplicatePayloadMember = realtimeEventJson().replace(
|
|
'"payload":{"value":"changed"}',
|
|
'"payload":{"value":"first","\\u0076alue":"changed"}',
|
|
);
|
|
expect(codec.decode(duplicatePayloadMember)).toMatchObject({
|
|
ok: false,
|
|
error: { kind: "MALFORMED_EVENT", operation: "DECODE" },
|
|
});
|
|
});
|
|
|
|
it("fails closed on version, sequence, timestamp, scope and cursor syntax", () => {
|
|
const codec = createTestRealtimeCodec();
|
|
|
|
expect(
|
|
codec.decode(realtimeEventJson({ protocol: "REALTIME_EVENT_V2" })),
|
|
).toMatchObject({
|
|
ok: false,
|
|
error: { kind: "PROTOCOL_MISMATCH" },
|
|
});
|
|
for (const overrides of [
|
|
{ sequence: "01" },
|
|
{ sequence: "18446744073709551616" },
|
|
{ occurredAt: "2026-02-30T01:02:03Z" },
|
|
{ occurredAt: "2026-07-28 01:02:03Z" },
|
|
{ scopeBinding: "scope\r\ninjected" },
|
|
{ resumeCursor: "" },
|
|
{ resumeCursor: "cursor\ninjected" },
|
|
{ recoveryMode: "SNAPSHOT_ONLY", resumeCursor: null },
|
|
]) {
|
|
expect(codec.decode(realtimeEventJson(overrides))).toMatchObject({
|
|
ok: false,
|
|
error: { kind: "MALFORMED_EVENT" },
|
|
});
|
|
}
|
|
});
|
|
|
|
it("enforces global, per-stream and payload structure ceilings", () => {
|
|
const strictCodec = createTestRealtimeCodec(
|
|
createTestRealtimeRegistry({
|
|
limits: {
|
|
...TEST_LIMITS,
|
|
maxEventBytes: 512,
|
|
maxPayloadNodes: 1,
|
|
},
|
|
}),
|
|
);
|
|
expect(
|
|
strictCodec.decode(realtimeEventJson()),
|
|
).toMatchObject({
|
|
ok: false,
|
|
error: { kind: "MALFORMED_EVENT" },
|
|
});
|
|
|
|
const byteBoundCodec = createTestRealtimeCodec(
|
|
createTestRealtimeRegistry({
|
|
limits: {
|
|
...TEST_LIMITS,
|
|
maxEventBytes: 512,
|
|
},
|
|
}),
|
|
);
|
|
expect(
|
|
byteBoundCodec.decode(
|
|
realtimeEventJson({ payload: { value: "x".repeat(600) } }),
|
|
),
|
|
).toMatchObject({
|
|
ok: false,
|
|
error: { kind: "EVENT_TOO_LARGE" },
|
|
});
|
|
|
|
expect(
|
|
createTestRealtimeCodec().decode(
|
|
"x".repeat(64 * 1024 + 1),
|
|
),
|
|
).toMatchObject({
|
|
ok: false,
|
|
error: { kind: "EVENT_TOO_LARGE" },
|
|
});
|
|
});
|
|
|
|
it("supports the exact null-cursor SESSION_REBUILD discriminant", () => {
|
|
const registry = createTestRealtimeRegistry({
|
|
recovery: {
|
|
mode: "SESSION_REBUILD",
|
|
rebuildInputId: "referenceRealtimeRebuild",
|
|
},
|
|
delivery: "EPHEMERAL",
|
|
stateBearing: false,
|
|
});
|
|
const codec = createTestRealtimeCodec(registry);
|
|
|
|
expect(
|
|
codec.decode(
|
|
realtimeEventJson({
|
|
recoveryMode: "SESSION_REBUILD",
|
|
resumeCursor: null,
|
|
}),
|
|
),
|
|
).toMatchObject({
|
|
ok: true,
|
|
value: {
|
|
envelope: {
|
|
recoveryMode: "SESSION_REBUILD",
|
|
resumeCursor: null,
|
|
},
|
|
},
|
|
});
|
|
expect(
|
|
codec.decode(
|
|
realtimeEventJson({
|
|
recoveryMode: "SESSION_REBUILD",
|
|
resumeCursor: "synthetic",
|
|
}),
|
|
),
|
|
).toMatchObject({
|
|
ok: false,
|
|
error: { kind: "MALFORMED_EVENT" },
|
|
});
|
|
});
|
|
|
|
it("normalizes semantic identity independently of JSON key order", () => {
|
|
const codec = createTestRealtimeCodec();
|
|
const value = realtimeEventValue();
|
|
const reversed = Object.fromEntries(
|
|
Object.entries(value).reverse(),
|
|
);
|
|
const first = codec.decode(JSON.stringify(value));
|
|
const second = codec.decode(JSON.stringify(reversed));
|
|
|
|
expect(first.ok).toBe(true);
|
|
expect(second.ok).toBe(true);
|
|
if (!first.ok || !second.ok) return;
|
|
expect(first.value.semanticFingerprint).toBe(
|
|
second.value.semanticFingerprint,
|
|
);
|
|
});
|
|
|
|
it("uses bounded uint64 sequence and header-safe cursor helpers", () => {
|
|
expect(isCanonicalRealtimeSequence("0")).toBe(true);
|
|
expect(isCanonicalRealtimeSequence(REALTIME_MAX_SEQUENCE)).toBe(true);
|
|
expect(isCanonicalRealtimeSequence("00")).toBe(false);
|
|
expect(isCanonicalRealtimeSequence("18446744073709551616")).toBe(false);
|
|
expect(compareRealtimeSequences("9", "10")).toBe(-1);
|
|
expect(nextRealtimeSequence("9")).toBe("10");
|
|
expect(nextRealtimeSequence(REALTIME_MAX_SEQUENCE)).toBeNull();
|
|
expect(isRealtimeResumeCursor("cursor:/+=._~-")).toBe(true);
|
|
expect(isRealtimeResumeCursor("cursor\nunsafe")).toBe(false);
|
|
expect(isStrictRealtimeTimestamp("2024-02-29T23:59:59Z")).toBe(true);
|
|
expect(isStrictRealtimeTimestamp("2023-02-29T23:59:59Z")).toBe(false);
|
|
});
|
|
});
|