test(protocol): add codec coverage
This commit is contained in:
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
coverage/
|
||||||
|
*.log
|
||||||
@@ -1,237 +1,252 @@
|
|||||||
:
|
import { describe, expect, it } from "vitest";
|
||||||
import { describe, it, expect, beforeEach } from "vitest";
|
|
||||||
import {
|
import {
|
||||||
encodeBuiltin,
|
buildAuthFailed,
|
||||||
|
buildAuthRequest,
|
||||||
|
buildAuthSuccess,
|
||||||
|
buildDisconnectNotice,
|
||||||
|
buildError,
|
||||||
|
buildHeartbeat,
|
||||||
|
buildHeartbeatAck,
|
||||||
|
buildHello,
|
||||||
|
buildHelloAck,
|
||||||
|
buildPairConfirm,
|
||||||
|
buildPairFailed,
|
||||||
|
buildPairRequest,
|
||||||
|
buildPairSuccess,
|
||||||
|
buildRePairRequired,
|
||||||
|
buildStatusUpdate,
|
||||||
|
CodecError,
|
||||||
decodeBuiltin,
|
decodeBuiltin,
|
||||||
parseRuleMessage,
|
encodeBuiltin,
|
||||||
encodeRuleMessage,
|
|
||||||
parseRewrittenRuleMessage,
|
|
||||||
encodeRewrittenRuleMessage,
|
encodeRewrittenRuleMessage,
|
||||||
|
encodeRuleMessage,
|
||||||
isBuiltinMessage,
|
isBuiltinMessage,
|
||||||
CodecError
|
parseRewrittenRuleMessage,
|
||||||
} from "../src/codec.js";
|
parseRuleMessage
|
||||||
import type { BuiltinEnvelope, HelloPayload, HelloAckPayload } from "../src/types.js";
|
} from "../src/index.js";
|
||||||
|
|
||||||
describe("Protocol Codec", () => {
|
describe("Protocol Codec - encodeBuiltin / decodeBuiltin", () => {
|
||||||
describe("encodeBuiltin / decodeBuiltin", () => {
|
it("encodes and decodes a hello envelope", () => {
|
||||||
it("should encode and decode a hello message", () => {
|
const envelope = buildHello(
|
||||||
const envelope: BuiltinEnvelope<"hello", HelloPayload> = {
|
{
|
||||||
type: "hello",
|
identifier: "client-a",
|
||||||
requestId: "req_001",
|
hasSecret: true,
|
||||||
timestamp: 1711886400,
|
hasKeyPair: true,
|
||||||
|
publicKey: "pk-test",
|
||||||
|
protocolVersion: "1"
|
||||||
|
},
|
||||||
|
{ requestId: "req-001" }
|
||||||
|
);
|
||||||
|
|
||||||
|
const encoded = encodeBuiltin(envelope);
|
||||||
|
expect(encoded.startsWith("builtin::")).toBe(true);
|
||||||
|
|
||||||
|
const decoded = decodeBuiltin(encoded);
|
||||||
|
expect(decoded.type).toBe("hello");
|
||||||
|
expect(decoded.requestId).toBe("req-001");
|
||||||
|
expect(decoded.payload).toMatchObject({
|
||||||
|
identifier: "client-a",
|
||||||
|
hasSecret: true,
|
||||||
|
hasKeyPair: true,
|
||||||
|
publicKey: "pk-test",
|
||||||
|
protocolVersion: "1"
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("encodes and decodes all builtin message types", () => {
|
||||||
|
const testCases = [
|
||||||
|
{
|
||||||
|
builder: buildHelloAck,
|
||||||
|
payload: { identifier: "client-a", nextAction: "pair_required" as const }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
builder: buildPairRequest,
|
||||||
payload: {
|
payload: {
|
||||||
identifier: "client-a",
|
identifier: "client-a",
|
||||||
hasSecret: true,
|
expiresAt: 1_710_000_000,
|
||||||
hasKeyPair: true,
|
ttlSeconds: 300,
|
||||||
publicKey: "pk_test",
|
adminNotification: "sent" as const,
|
||||||
protocolVersion: "1"
|
codeDelivery: "out_of_band" as const
|
||||||
}
|
}
|
||||||
};
|
},
|
||||||
|
{
|
||||||
const encoded = encodeBuiltin(envelope);
|
builder: buildPairConfirm,
|
||||||
expect(encoded).toBe(
|
payload: { identifier: "client-a", pairingCode: "ABCD-1234-XYZ" }
|
||||||
'builtin::{"type":"hello","requestId":"req_001","timestamp":1711886400,"payload":{"identifier":"client-a","hasSecret":true,"hasKeyPair":true,"publicKey":"pk_test","protocolVersion":"1"}}'
|
},
|
||||||
);
|
{
|
||||||
|
builder: buildPairSuccess,
|
||||||
const decoded = decodeBuiltin(encoded);
|
payload: { identifier: "client-a", secret: "secret-xyz", pairedAt: 1_710_000_000 }
|
||||||
expect(decoded).toEqual(envelope);
|
},
|
||||||
});
|
{
|
||||||
|
builder: buildPairFailed,
|
||||||
it("should encode and decode a hello_ack message", () => {
|
payload: { identifier: "client-a", reason: "expired" as const }
|
||||||
const envelope: BuiltinEnvelope<"hello_ack", HelloAckPayload> = {
|
},
|
||||||
type: "hello_ack",
|
{
|
||||||
requestId: "req_001",
|
builder: buildAuthRequest,
|
||||||
timestamp: 1711886401,
|
|
||||||
payload: {
|
payload: {
|
||||||
identifier: "client-a",
|
identifier: "client-a",
|
||||||
nextAction: "pair_required"
|
nonce: "RANDOM24CHARACTERSTRINGX",
|
||||||
|
proofTimestamp: 1_710_000_000,
|
||||||
|
signature: "sig-base64",
|
||||||
|
publicKey: "pk-test"
|
||||||
}
|
}
|
||||||
};
|
},
|
||||||
|
{
|
||||||
|
builder: buildAuthSuccess,
|
||||||
|
payload: { identifier: "client-a", authenticatedAt: 1_710_000_000, status: "online" as const }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
builder: buildAuthFailed,
|
||||||
|
payload: { identifier: "client-a", reason: "invalid_signature" as const }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
builder: buildRePairRequired,
|
||||||
|
payload: { identifier: "client-a", reason: "nonce_collision" as const }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
builder: buildHeartbeat,
|
||||||
|
payload: { identifier: "client-a", status: "alive" as const }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
builder: buildHeartbeatAck,
|
||||||
|
payload: { identifier: "client-a", status: "online" as const }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
builder: buildStatusUpdate,
|
||||||
|
payload: { identifier: "client-a", status: "unstable" as const, reason: "heartbeat_timeout_7m" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
builder: buildDisconnectNotice,
|
||||||
|
payload: { identifier: "client-a", reason: "heartbeat_timeout_11m" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
builder: buildError,
|
||||||
|
payload: { code: "MALFORMED_MESSAGE" as const, message: "Invalid JSON" }
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const { builder, payload } of testCases) {
|
||||||
|
const envelope = builder(payload as never, { requestId: "test-req" });
|
||||||
const encoded = encodeBuiltin(envelope);
|
const encoded = encodeBuiltin(envelope);
|
||||||
const decoded = decodeBuiltin(encoded);
|
const decoded = decodeBuiltin(encoded);
|
||||||
expect(decoded).toEqual(envelope);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle envelope without optional fields", () => {
|
expect(decoded.type).toBe(envelope.type);
|
||||||
const envelope: BuiltinEnvelope = {
|
expect(decoded.requestId).toBe("test-req");
|
||||||
type: "heartbeat"
|
expect(decoded.payload).toMatchObject(payload);
|
||||||
};
|
}
|
||||||
|
|
||||||
const encoded = encodeBuiltin(envelope);
|
|
||||||
expect(encoded).toBe('builtin::{"type":"heartbeat"}');
|
|
||||||
|
|
||||||
const decoded = decodeBuiltin(encoded);
|
|
||||||
expect(decoded).toEqual(envelope);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should throw CodecError for malformed builtin message", () => {
|
|
||||||
expect(() => decodeBuiltin("invalid")).toThrow(CodecError);
|
|
||||||
expect(() => decodeBuiltin("builtin::")).toThrow(CodecError);
|
|
||||||
expect(() => decodeBuiltin("builtin::invalid json")).toThrow(CodecError);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should throw CodecError for non-builtin message prefix", () => {
|
|
||||||
expect(() => decodeBuiltin("hello::{\"type\":\"test\"}")).toThrow(CodecError);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("isBuiltinMessage", () => {
|
it("throws CodecError for malformed messages", () => {
|
||||||
it("should return true for builtin messages", () => {
|
expect(() => decodeBuiltin("not-builtin::{")).toThrow(CodecError);
|
||||||
expect(isBuiltinMessage('builtin::{"type":"hello"}')).toBe(true);
|
expect(() => decodeBuiltin("builtin::not-json")).toThrow(CodecError);
|
||||||
expect(isBuiltinMessage("builtin::{}")).toBe(true);
|
expect(() => decodeBuiltin("builtin::{}")).toThrow(CodecError); // missing type
|
||||||
});
|
expect(() => decodeBuiltin("no-delimiter")).toThrow(CodecError);
|
||||||
|
expect(() => decodeBuiltin("")).toThrow(CodecError);
|
||||||
it("should return false for non-builtin messages", () => {
|
|
||||||
expect(isBuiltinMessage("hello::world")).toBe(false);
|
|
||||||
expect(isBuiltinMessage("rule::content")).toBe(false);
|
|
||||||
expect(isBuiltinMessage("")).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("parseRuleMessage", () => {
|
it("throws CodecError for invalid envelope input", () => {
|
||||||
it("should parse simple rule message", () => {
|
expect(() => encodeBuiltin(null as never)).toThrow(CodecError);
|
||||||
const result = parseRuleMessage("chat_sync::{\"body\":\"hello\"}");
|
expect(() => encodeBuiltin({} as never)).toThrow(CodecError);
|
||||||
expect(result).toEqual({
|
expect(() => encodeBuiltin({ type: 123 } as never)).toThrow(CodecError);
|
||||||
rule: "chat_sync",
|
|
||||||
content: '{"body":"hello"}'
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should parse rule message with content containing ::", () => {
|
|
||||||
const result = parseRuleMessage("chat_sync::sender::extra::{\"body\":\"hello::world\"}");
|
|
||||||
expect(result).toEqual({
|
|
||||||
rule: "chat_sync",
|
|
||||||
content: 'sender::extra::{"body":"hello::world"}'
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should throw CodecError for malformed rule message", () => {
|
|
||||||
expect(() => parseRuleMessage("invalid")).toThrow(CodecError);
|
|
||||||
expect(() => parseRuleMessage("")).toThrow(CodecError);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should reject builtin as rule identifier", () => {
|
|
||||||
expect(() => parseRuleMessage('builtin::{"type":"hello"}')).toThrow(CodecError);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("encodeRuleMessage", () => {
|
|
||||||
it("should encode rule message", () => {
|
|
||||||
const encoded = encodeRuleMessage("chat_sync", '{"body":"hello"}');
|
|
||||||
expect(encoded).toBe('chat_sync::{"body":"hello"}');
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should reject builtin as rule", () => {
|
|
||||||
expect(() => encodeRuleMessage("builtin", "content")).toThrow(CodecError);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("parseRewrittenRuleMessage", () => {
|
|
||||||
it("should parse rewritten server-side message", () => {
|
|
||||||
const result = parseRewrittenRuleMessage("chat_sync::client-a::{\"body\":\"hello\"}");
|
|
||||||
expect(result).toEqual({
|
|
||||||
rule: "chat_sync",
|
|
||||||
sender: "client-a",
|
|
||||||
content: '{"body":"hello"}'
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle content with :: delimiters", () => {
|
|
||||||
const result = parseRewrittenRuleMessage("chat_sync::client-a::extra::{\"body\":\"hello::world\"}");
|
|
||||||
expect(result).toEqual({
|
|
||||||
rule: "chat_sync",
|
|
||||||
sender: "client-a",
|
|
||||||
content: 'extra::{"body":"hello::world"}'
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should throw CodecError for malformed rewritten message", () => {
|
|
||||||
expect(() => parseRewrittenRuleMessage("rule::content")).toThrow(CodecError);
|
|
||||||
expect(() => parseRewrittenRuleMessage("invalid")).toThrow(CodecError);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("encodeRewrittenRuleMessage", () => {
|
|
||||||
it("should encode rewritten message", () => {
|
|
||||||
const encoded = encodeRewrittenRuleMessage("chat_sync", "client-a", '{"body":"hello"}');
|
|
||||||
expect(encoded).toBe('chat_sync::client-a::{"body":"hello"}');
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should reject builtin as rule", () => {
|
|
||||||
expect(() => encodeRewrittenRuleMessage("builtin", "sender", "content")).toThrow(CodecError);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("Protocol Message Examples", () => {
|
describe("Protocol Codec - Rule Message Parsing", () => {
|
||||||
describe("Hello Flow", () => {
|
it("parses rule messages correctly", () => {
|
||||||
it("should demonstrate complete hello flow", () => {
|
const parsed = parseRuleMessage("chat_sync::{\"body\":\"hello\"}");
|
||||||
// Client sends hello
|
expect(parsed.ruleIdentifier).toBe("chat_sync");
|
||||||
const hello: BuiltinEnvelope<"hello", HelloPayload> = {
|
expect(parsed.content).toBe('{"body":"hello"}');
|
||||||
type: "hello",
|
|
||||||
requestId: "req_001",
|
|
||||||
timestamp: Math.floor(Date.now() / 1000),
|
|
||||||
payload: {
|
|
||||||
identifier: "client-a",
|
|
||||||
hasSecret: false,
|
|
||||||
hasKeyPair: true,
|
|
||||||
publicKey: "ed25519_pk_test123",
|
|
||||||
protocolVersion: "1"
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const helloWire = encodeBuiltin(hello);
|
|
||||||
expect(isBuiltinMessage(helloWire)).toBe(true);
|
|
||||||
|
|
||||||
// Server parses hello
|
|
||||||
const parsedHello = decodeBuiltin(helloWire);
|
|
||||||
expect(parsedHello.type).toBe("hello");
|
|
||||||
expect((parsedHello.payload as HelloPayload).identifier).toBe("client-a");
|
|
||||||
|
|
||||||
// Server responds with hello_ack
|
|
||||||
const helloAck: BuiltinEnvelope<"hello_ack", HelloAckPayload> = {
|
|
||||||
type: "hello_ack",
|
|
||||||
requestId: hello.requestId,
|
|
||||||
timestamp: Math.floor(Date.now() / 1000),
|
|
||||||
payload: {
|
|
||||||
identifier: "client-a",
|
|
||||||
nextAction: "pair_required"
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const ackWire = encodeBuiltin(helloAck);
|
|
||||||
const parsedAck = decodeBuiltin(ackWire);
|
|
||||||
expect(parsedAck.type).toBe("hello_ack");
|
|
||||||
expect((parsedAck.payload as HelloAckPayload).nextAction).toBe("pair_required");
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("Rule Message Flow", () => {
|
it("handles content containing :: delimiters", () => {
|
||||||
it("should demonstrate client to server message", () => {
|
const parsed = parseRuleMessage("rule::a::b::c");
|
||||||
const message = encodeRuleMessage("chat_sync", '{"conversationId":"abc","body":"hello"}');
|
expect(parsed.ruleIdentifier).toBe("rule");
|
||||||
expect(message).toBe('chat_sync::{"conversationId":"abc","body":"hello"}');
|
expect(parsed.content).toBe("a::b::c");
|
||||||
|
});
|
||||||
// Server parses
|
|
||||||
const parsed = parseRuleMessage(message);
|
|
||||||
expect(parsed.rule).toBe("chat_sync");
|
|
||||||
expect(parsed.content).toBe('{"conversationId":"abc","body":"hello"}');
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should demonstrate server-side rewriting", () => {
|
it("throws CodecError for invalid rule messages", () => {
|
||||||
// Server rewrites incoming message
|
expect(() => parseRuleMessage("no-delimiter")).toThrow(CodecError);
|
||||||
const rewritten = encodeRewrittenRuleMessage(
|
expect(() => parseRuleMessage(":")).toThrow(CodecError);
|
||||||
"chat_sync",
|
expect(() => parseRuleMessage("")).toThrow(CodecError);
|
||||||
"client-a",
|
expect(() => parseRuleMessage("::content")).toThrow(CodecError); // empty identifier
|
||||||
'{"conversationId":"abc","body":"hello"}'
|
});
|
||||||
);
|
|
||||||
expect(rewritten).toBe('chat_sync::client-a::{"conversationId":"abc","body":"hello"}');
|
|
||||||
|
|
||||||
// Server-side handler parses
|
it("throws CodecError for reserved builtin identifier", () => {
|
||||||
const parsed = parseRewrittenRuleMessage(rewritten);
|
expect(() => parseRuleMessage("builtin::something")).toThrow(
|
||||||
expect(parsed.rule).toBe("chat_sync");
|
/reserved/
|
||||||
expect(parsed.sender).toBe("client-a");
|
);
|
||||||
expect(parsed.content).toBe('{"conversationId":"abc","body":"hello"}');
|
});
|
||||||
});
|
|
||||||
|
it("throws CodecError for invalid rule identifier characters", () => {
|
||||||
|
expect(() => parseRuleMessage("rule with spaces::content")).toThrow(CodecError);
|
||||||
|
// Note: special chars in content are allowed - only the rule identifier is validated
|
||||||
|
expect(() => parseRuleMessage("rule::with::special!::chars")).not.toThrow(); // content can contain special chars
|
||||||
|
});
|
||||||
|
|
||||||
|
it("isBuiltinMessage identifies builtin messages", () => {
|
||||||
|
expect(isBuiltinMessage("builtin::{\"type\":\"hello\"}")).toBe(true);
|
||||||
|
expect(isBuiltinMessage("rule::content")).toBe(false);
|
||||||
|
expect(isBuiltinMessage("not-builtin::content")).toBe(false);
|
||||||
|
expect(isBuiltinMessage("")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Protocol Codec - Server Rewritten Messages", () => {
|
||||||
|
it("parses rewritten messages with sender identifier", () => {
|
||||||
|
const parsed = parseRewrittenRuleMessage("chat_sync::client-a::{\"body\":\"hello\"}");
|
||||||
|
expect(parsed.ruleIdentifier).toBe("chat_sync");
|
||||||
|
expect(parsed.senderIdentifier).toBe("client-a");
|
||||||
|
expect(parsed.messageContent).toBe('{"body":"hello"}');
|
||||||
|
expect(parsed.content).toBe("client-a::{\"body\":\"hello\"}");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles complex content with multiple delimiters", () => {
|
||||||
|
const parsed = parseRewrittenRuleMessage("rule::sender::a::b::c");
|
||||||
|
expect(parsed.ruleIdentifier).toBe("rule");
|
||||||
|
expect(parsed.senderIdentifier).toBe("sender");
|
||||||
|
expect(parsed.messageContent).toBe("a::b::c");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws CodecError for malformed rewritten messages", () => {
|
||||||
|
expect(() => parseRewrittenRuleMessage("no-delimiters")).toThrow(CodecError);
|
||||||
|
expect(() => parseRewrittenRuleMessage("rule::only-one")).toThrow(/sender/);
|
||||||
|
expect(() => parseRewrittenRuleMessage("::sender::content")).toThrow(/Empty/);
|
||||||
|
// Note: "rule:::content" has empty sender which is caught by "missing sender identifier delimiter" check
|
||||||
|
expect(() => parseRewrittenRuleMessage("rule:::content")).toThrow(/sender/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Protocol Codec - encodeRuleMessage", () => {
|
||||||
|
it("encodes rule messages correctly", () => {
|
||||||
|
const encoded = encodeRuleMessage("chat_sync", '{"body":"hello"}');
|
||||||
|
expect(encoded).toBe('chat_sync::{"body":"hello"}');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws CodecError for invalid rule identifiers", () => {
|
||||||
|
expect(() => encodeRuleMessage("", "content")).toThrow(CodecError);
|
||||||
|
expect(() => encodeRuleMessage("builtin", "content")).toThrow(/reserved/);
|
||||||
|
expect(() => encodeRuleMessage("rule with spaces", "content")).toThrow(CodecError);
|
||||||
|
expect(() => encodeRuleMessage("rule!", "content")).toThrow(CodecError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws CodecError for non-string content", () => {
|
||||||
|
expect(() => encodeRuleMessage("rule", null as never)).toThrow(CodecError);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Protocol Codec - encodeRewrittenRuleMessage", () => {
|
||||||
|
it("encodes rewritten messages correctly", () => {
|
||||||
|
const encoded = encodeRewrittenRuleMessage("chat_sync", "client-a", '{"body":"hello"}');
|
||||||
|
expect(encoded).toBe('chat_sync::client-a::{"body":"hello"}');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws CodecError for reserved rule identifier", () => {
|
||||||
|
expect(() => encodeRewrittenRuleMessage("builtin", "sender", "content")).toThrow(/reserved/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws CodecError for empty identifiers", () => {
|
||||||
|
expect(() => encodeRewrittenRuleMessage("", "sender", "content")).toThrow(CodecError);
|
||||||
|
expect(() => encodeRewrittenRuleMessage("rule", "", "content")).toThrow(CodecError);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user