T3 audit gap 1 (BLOCKER). The server's ModifierKindIdSchema was still z.enum([built-ins]) even after the client-side mirror was widened during T29. Effect: any multiplayer room.create or modifier-profile.update carrying a profile whose perType[i].kind is a user-authored CustomModifierId (e.g. 'custom:shield') was rejected server-side as INVALID_MESSAGE. Symmetric fix: accept any non-empty string, defer kind validity to the engine's registry-dispatch fallback at apply time (MODIFIER_REGISTRY → engine.customModifiers → warn-and-skip). The built-in id list is preserved as a doc comment + void-referenced constant so it can't silently drift. Test updated: 'rejects a profile with unknown modifier kind' inverted to 'accepts a profile with arbitrary modifier kind (T3 widening)' + new 'rejects empty-string modifier kind' to preserve the min(1) guard.
885 lines
23 KiB
TypeScript
885 lines
23 KiB
TypeScript
import { describe, it, expect } from "vitest";
|
|
import {
|
|
validateMessage,
|
|
validateMessageString,
|
|
PROTOCOL_VERSION,
|
|
ClientMessageSchema,
|
|
ServerMessageSchema,
|
|
ModifierProfileSchema,
|
|
ModifierProfileUpdatePayloadSchema,
|
|
RoomCreatePayloadSchema,
|
|
MODIFIER_PROFILE_INVALID,
|
|
MODIFIER_PROFILE_NO_KING,
|
|
MODIFIER_PROFILE_INVULN_KING,
|
|
MODIFIER_PROFILE_DEADLOCK,
|
|
type AnyMessage,
|
|
type ClientMessage,
|
|
type ServerMessage,
|
|
} from "./protocol.js";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Fixtures — one minimally-valid example per message type.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const UUID = "550e8400-e29b-41d4-a716-446655440000";
|
|
const UUID2 = "661f9500-f30c-42e5-b827-557766550111";
|
|
|
|
const envelope = { v: PROTOCOL_VERSION, seq: 0, ts: 1 } as const;
|
|
|
|
const fixtures: Record<string, AnyMessage> = {
|
|
"room.create": {
|
|
...envelope,
|
|
type: "room.create",
|
|
payload: { rulesetIds: ["pawns-move-backward"] },
|
|
},
|
|
"room.create (empty payload)": {
|
|
...envelope,
|
|
type: "room.create",
|
|
payload: {},
|
|
},
|
|
"room.join": {
|
|
...envelope,
|
|
type: "room.join",
|
|
payload: { code: "ABC123" },
|
|
},
|
|
"room.leave": {
|
|
...envelope,
|
|
type: "room.leave",
|
|
payload: {},
|
|
},
|
|
"game.move": {
|
|
...envelope,
|
|
type: "game.move",
|
|
token: UUID,
|
|
payload: { from: "e2", to: "e4" },
|
|
},
|
|
"game.move (promotion)": {
|
|
...envelope,
|
|
type: "game.move",
|
|
token: UUID,
|
|
payload: { from: "a7", to: "a8", promoteTo: "queen" },
|
|
},
|
|
"room.created": {
|
|
...envelope,
|
|
seq: 1,
|
|
type: "room.created",
|
|
payload: { code: "ABC123", token: UUID, color: "white" },
|
|
},
|
|
"room.joined": {
|
|
...envelope,
|
|
seq: 1,
|
|
type: "room.joined",
|
|
payload: {
|
|
code: "ABC123",
|
|
token: UUID2,
|
|
color: "black",
|
|
activeRules: ["pawns-move-backward"],
|
|
},
|
|
},
|
|
"game.state": {
|
|
...envelope,
|
|
seq: 2,
|
|
type: "game.state",
|
|
payload: {
|
|
facts: [{ id: 1, attr: "PieceType", value: "pawn" }],
|
|
turn: "white",
|
|
lastSeq: 42,
|
|
moveHistory: ["e2-e4"],
|
|
activeRules: [],
|
|
fen: "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1",
|
|
},
|
|
},
|
|
"game.delta (ongoing)": {
|
|
...envelope,
|
|
seq: 3,
|
|
type: "game.delta",
|
|
payload: {
|
|
inserted: [{ id: 1, attr: "Position", value: 28 }],
|
|
retracted: [{ id: 1, attr: "Position", value: 12 }],
|
|
moveNotation: "e2e4",
|
|
turn: "black",
|
|
gameOver: null,
|
|
},
|
|
},
|
|
"game.delta (game over)": {
|
|
...envelope,
|
|
seq: 99,
|
|
type: "game.delta",
|
|
payload: {
|
|
inserted: [],
|
|
retracted: [],
|
|
moveNotation: "Qh4#",
|
|
turn: "black",
|
|
gameOver: { winner: "black", reason: "checkmate" },
|
|
},
|
|
},
|
|
"game.end": {
|
|
...envelope,
|
|
seq: 100,
|
|
type: "game.end",
|
|
payload: {
|
|
winner: "white",
|
|
reason: "checkmate",
|
|
finalFen: "rnb1kbnr/pppp1ppp/8/4p3/6Pq/5P2/PPPPP2P/RNBQKBNR w KQkq - 0 3",
|
|
},
|
|
},
|
|
"error (non-fatal)": {
|
|
...envelope,
|
|
seq: 5,
|
|
type: "error",
|
|
payload: {
|
|
code: "ILLEGAL_MOVE",
|
|
message: "Move e2-e5 is not legal",
|
|
fatal: false,
|
|
},
|
|
},
|
|
"error (fatal)": {
|
|
...envelope,
|
|
seq: 6,
|
|
type: "error",
|
|
payload: {
|
|
code: "VERSION_MISMATCH",
|
|
message: "expected v=1",
|
|
fatal: true,
|
|
},
|
|
},
|
|
};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Round-trip: every fixture must JSON-serialise and re-validate identically.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("validateMessage — round-trip", () => {
|
|
for (const [name, message] of Object.entries(fixtures)) {
|
|
it(`round-trips ${name}`, () => {
|
|
const encoded = JSON.stringify(message);
|
|
const decoded = JSON.parse(encoded) as unknown;
|
|
const result = validateMessage(decoded);
|
|
|
|
expect(result.ok).toBe(true);
|
|
if (result.ok) {
|
|
expect(result.data).toEqual(message);
|
|
expect(result.data.type).toBe(message.type);
|
|
}
|
|
});
|
|
}
|
|
|
|
it("round-trips with an optional token present", () => {
|
|
const msg = {
|
|
...envelope,
|
|
token: UUID,
|
|
type: "room.leave" as const,
|
|
payload: {},
|
|
};
|
|
const result = validateMessage(JSON.parse(JSON.stringify(msg)));
|
|
expect(result.ok).toBe(true);
|
|
if (result.ok) expect(result.data.token).toBe(UUID);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Rejections
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("validateMessage — rejections", () => {
|
|
it("rejects non-object input (null)", () => {
|
|
const r = validateMessage(null);
|
|
expect(r.ok).toBe(false);
|
|
if (!r.ok) expect(r.error).toMatch(/INVALID_MESSAGE/);
|
|
});
|
|
|
|
it("rejects non-object input (array)", () => {
|
|
const r = validateMessage([1, 2, 3]);
|
|
expect(r.ok).toBe(false);
|
|
if (!r.ok) expect(r.error).toMatch(/INVALID_MESSAGE/);
|
|
});
|
|
|
|
it("rejects non-object input (string)", () => {
|
|
const r = validateMessage("hello");
|
|
expect(r.ok).toBe(false);
|
|
if (!r.ok) expect(r.error).toMatch(/INVALID_MESSAGE/);
|
|
});
|
|
|
|
it("rejects v=2 with VERSION_MISMATCH", () => {
|
|
const r = validateMessage({
|
|
v: 2,
|
|
seq: 0,
|
|
ts: 1,
|
|
type: "room.create",
|
|
payload: {},
|
|
});
|
|
expect(r.ok).toBe(false);
|
|
if (!r.ok) {
|
|
expect(r.error).toMatch(/VERSION_MISMATCH/);
|
|
expect(r.error).toContain("expected v=1");
|
|
}
|
|
});
|
|
|
|
it("rejects missing v with VERSION_MISMATCH (v is undefined)", () => {
|
|
const r = validateMessage({
|
|
seq: 0,
|
|
ts: 1,
|
|
type: "room.create",
|
|
payload: {},
|
|
});
|
|
expect(r.ok).toBe(false);
|
|
if (!r.ok) expect(r.error).toMatch(/VERSION_MISMATCH/);
|
|
});
|
|
|
|
it("rejects missing type", () => {
|
|
const r = validateMessage({ v: 1, seq: 0, ts: 1, payload: {} });
|
|
expect(r.ok).toBe(false);
|
|
if (!r.ok) expect(r.error).toMatch(/INVALID_MESSAGE/);
|
|
});
|
|
|
|
it("rejects non-string type", () => {
|
|
const r = validateMessage({
|
|
v: 1,
|
|
seq: 0,
|
|
ts: 1,
|
|
type: 42,
|
|
payload: {},
|
|
});
|
|
expect(r.ok).toBe(false);
|
|
if (!r.ok) expect(r.error).toMatch(/non-string `type`/);
|
|
});
|
|
|
|
it("rejects unknown message type", () => {
|
|
const r = validateMessage({
|
|
v: 1,
|
|
seq: 0,
|
|
ts: 1,
|
|
type: "nope.nope",
|
|
payload: {},
|
|
});
|
|
expect(r.ok).toBe(false);
|
|
if (!r.ok) expect(r.error).toMatch(/unknown message type "nope.nope"/);
|
|
});
|
|
|
|
it("rejects game.move with bad `from` format", () => {
|
|
const r = validateMessage({
|
|
...envelope,
|
|
type: "game.move",
|
|
payload: { from: "e22", to: "e4" },
|
|
});
|
|
expect(r.ok).toBe(false);
|
|
if (!r.ok) expect(r.error).toMatch(/from/);
|
|
});
|
|
|
|
it("rejects game.move with bad `to` file", () => {
|
|
const r = validateMessage({
|
|
...envelope,
|
|
type: "game.move",
|
|
payload: { from: "e2", to: "z9" },
|
|
});
|
|
expect(r.ok).toBe(false);
|
|
});
|
|
|
|
it("rejects game.move with bad promotion piece", () => {
|
|
const r = validateMessage({
|
|
...envelope,
|
|
type: "game.move",
|
|
payload: { from: "a7", to: "a8", promoteTo: "king" },
|
|
});
|
|
expect(r.ok).toBe(false);
|
|
if (!r.ok) expect(r.error).toMatch(/promoteTo/);
|
|
});
|
|
|
|
it("rejects room.join with 5-char code", () => {
|
|
const r = validateMessage({
|
|
...envelope,
|
|
type: "room.join",
|
|
payload: { code: "ABC12" },
|
|
});
|
|
expect(r.ok).toBe(false);
|
|
});
|
|
|
|
it("rejects room.join with lowercase code", () => {
|
|
const r = validateMessage({
|
|
...envelope,
|
|
type: "room.join",
|
|
payload: { code: "abc123" },
|
|
});
|
|
expect(r.ok).toBe(false);
|
|
});
|
|
|
|
it("rejects negative seq", () => {
|
|
const r = validateMessage({
|
|
v: 1,
|
|
seq: -1,
|
|
ts: 1,
|
|
type: "room.leave",
|
|
payload: {},
|
|
});
|
|
expect(r.ok).toBe(false);
|
|
});
|
|
|
|
it("rejects non-integer seq", () => {
|
|
const r = validateMessage({
|
|
v: 1,
|
|
seq: 1.5,
|
|
ts: 1,
|
|
type: "room.leave",
|
|
payload: {},
|
|
});
|
|
expect(r.ok).toBe(false);
|
|
});
|
|
|
|
it("rejects non-positive ts", () => {
|
|
const r = validateMessage({
|
|
v: 1,
|
|
seq: 0,
|
|
ts: 0,
|
|
type: "room.leave",
|
|
payload: {},
|
|
});
|
|
expect(r.ok).toBe(false);
|
|
});
|
|
|
|
it("rejects token that is not a UUID", () => {
|
|
const r = validateMessage({
|
|
...envelope,
|
|
token: "not-a-uuid",
|
|
type: "room.leave",
|
|
payload: {},
|
|
});
|
|
expect(r.ok).toBe(false);
|
|
});
|
|
|
|
it("rejects room.created with non-UUID token", () => {
|
|
const r = validateMessage({
|
|
...envelope,
|
|
type: "room.created",
|
|
payload: { code: "ABC123", token: "nope", color: "white" },
|
|
});
|
|
expect(r.ok).toBe(false);
|
|
});
|
|
|
|
it("rejects room.created with invalid color", () => {
|
|
const r = validateMessage({
|
|
...envelope,
|
|
type: "room.created",
|
|
payload: { code: "ABC123", token: UUID, color: "green" },
|
|
});
|
|
expect(r.ok).toBe(false);
|
|
});
|
|
|
|
it("rejects game.state missing required field", () => {
|
|
const r = validateMessage({
|
|
...envelope,
|
|
type: "game.state",
|
|
payload: {
|
|
facts: [],
|
|
turn: "white",
|
|
lastSeq: 0,
|
|
moveHistory: [],
|
|
activeRules: [],
|
|
// fen missing
|
|
},
|
|
});
|
|
expect(r.ok).toBe(false);
|
|
if (!r.ok) expect(r.error).toMatch(/fen/);
|
|
});
|
|
|
|
it("rejects error payload with unknown error code", () => {
|
|
const r = validateMessage({
|
|
...envelope,
|
|
type: "error",
|
|
payload: { code: "UNKNOWN_CODE", message: "x", fatal: false },
|
|
});
|
|
expect(r.ok).toBe(false);
|
|
});
|
|
|
|
it("rejects game.delta gameOver with invalid reason", () => {
|
|
const r = validateMessage({
|
|
...envelope,
|
|
type: "game.delta",
|
|
payload: {
|
|
inserted: [],
|
|
retracted: [],
|
|
moveNotation: "a",
|
|
turn: "white",
|
|
gameOver: { winner: "white", reason: "???" },
|
|
},
|
|
});
|
|
expect(r.ok).toBe(false);
|
|
});
|
|
|
|
it("rejects game.end with invalid winner", () => {
|
|
const r = validateMessage({
|
|
...envelope,
|
|
type: "game.end",
|
|
payload: { winner: "nobody", reason: "x", finalFen: "x" },
|
|
});
|
|
expect(r.ok).toBe(false);
|
|
});
|
|
|
|
it("rejects room.leave with extra payload keys (strict)", () => {
|
|
const r = validateMessage({
|
|
...envelope,
|
|
type: "room.leave",
|
|
payload: { extra: 1 },
|
|
});
|
|
expect(r.ok).toBe(false);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// validateMessageString — raw-string entry point
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("validateMessageString", () => {
|
|
it("parses a valid JSON string frame", () => {
|
|
const msg: ClientMessage = {
|
|
...envelope,
|
|
type: "room.create",
|
|
payload: {},
|
|
};
|
|
const r = validateMessageString(JSON.stringify(msg));
|
|
expect(r.ok).toBe(true);
|
|
});
|
|
|
|
it("reports malformed JSON as INVALID_MESSAGE", () => {
|
|
const r = validateMessageString("{ this is not json");
|
|
expect(r.ok).toBe(false);
|
|
if (!r.ok) {
|
|
expect(r.error).toMatch(/INVALID_MESSAGE/);
|
|
expect(r.error).toMatch(/malformed JSON/);
|
|
}
|
|
});
|
|
|
|
it("propagates version mismatch through the string entry point", () => {
|
|
const r = validateMessageString(
|
|
JSON.stringify({ v: 99, seq: 0, ts: 1, type: "room.leave", payload: {} }),
|
|
);
|
|
expect(r.ok).toBe(false);
|
|
if (!r.ok) expect(r.error).toMatch(/VERSION_MISMATCH/);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Type-narrowing smoke test — ensures discriminated unions are usable.
|
|
// (Executed at runtime, but the value comes from the union's narrowing.)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("schema unions", () => {
|
|
it("ClientMessageSchema parses client-originating types only", () => {
|
|
const ok = ClientMessageSchema.safeParse({
|
|
...envelope,
|
|
type: "game.move",
|
|
payload: { from: "e2", to: "e4" },
|
|
});
|
|
expect(ok.success).toBe(true);
|
|
|
|
const bad = ClientMessageSchema.safeParse({
|
|
...envelope,
|
|
type: "game.end",
|
|
payload: { winner: "white", reason: "x", finalFen: "x" },
|
|
});
|
|
expect(bad.success).toBe(false);
|
|
});
|
|
|
|
it("ServerMessageSchema parses server-originating types only", () => {
|
|
const okMsg: ServerMessage = {
|
|
...envelope,
|
|
type: "error",
|
|
payload: { code: "RATE_LIMIT", message: "too fast", fatal: true },
|
|
};
|
|
const good = ServerMessageSchema.safeParse(okMsg);
|
|
expect(good.success).toBe(true);
|
|
|
|
const bad = ServerMessageSchema.safeParse({
|
|
...envelope,
|
|
type: "room.create",
|
|
payload: {},
|
|
});
|
|
expect(bad.success).toBe(false);
|
|
});
|
|
|
|
it("narrows AnyMessage by `type` discriminant", () => {
|
|
const r = validateMessage({
|
|
...envelope,
|
|
type: "game.move",
|
|
payload: { from: "e2", to: "e4" },
|
|
});
|
|
expect(r.ok).toBe(true);
|
|
if (r.ok && r.data.type === "game.move") {
|
|
// TS narrowing should let us read these fields without casts.
|
|
expect(r.data.payload.from).toBe("e2");
|
|
expect(r.data.payload.to).toBe("e4");
|
|
}
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Layout field on room.create — discriminated union of premade / FEN / custom
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("room.create layout payload", () => {
|
|
it("accepts { kind: 'premade', id } layout selection", () => {
|
|
const r = validateMessage({
|
|
...envelope,
|
|
type: "room.create",
|
|
payload: {
|
|
layout: { kind: "premade", id: "dunsany" },
|
|
},
|
|
});
|
|
expect(r.ok).toBe(true);
|
|
});
|
|
|
|
it("accepts { kind: 'fen', fen } layout selection", () => {
|
|
const r = validateMessage({
|
|
...envelope,
|
|
type: "room.create",
|
|
payload: {
|
|
layout: {
|
|
kind: "fen",
|
|
fen: "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR",
|
|
name: "Pasted FEN",
|
|
},
|
|
},
|
|
});
|
|
expect(r.ok).toBe(true);
|
|
});
|
|
|
|
it("accepts { kind: 'custom', pieces } layout selection", () => {
|
|
const r = validateMessage({
|
|
...envelope,
|
|
type: "room.create",
|
|
payload: {
|
|
layout: {
|
|
kind: "custom",
|
|
pieces: [
|
|
{ type: "king", color: "white", square: 4 },
|
|
{ type: "king", color: "black", square: 60 },
|
|
],
|
|
},
|
|
},
|
|
});
|
|
expect(r.ok).toBe(true);
|
|
});
|
|
|
|
it("rejects unknown layout kind", () => {
|
|
const r = validateMessage({
|
|
...envelope,
|
|
type: "room.create",
|
|
payload: { layout: { kind: "nonsense", id: "foo" } },
|
|
});
|
|
expect(r.ok).toBe(false);
|
|
});
|
|
|
|
it("rejects custom layout with out-of-range square", () => {
|
|
const r = validateMessage({
|
|
...envelope,
|
|
type: "room.create",
|
|
payload: {
|
|
layout: {
|
|
kind: "custom",
|
|
pieces: [{ type: "king", color: "white", square: 999 }],
|
|
},
|
|
},
|
|
});
|
|
expect(r.ok).toBe(false);
|
|
});
|
|
|
|
it("rejects custom layout with > 128 pieces (DoS cap)", () => {
|
|
const pieces = Array.from({ length: 129 }, (_, i) => ({
|
|
type: "pawn" as const,
|
|
color: "white" as const,
|
|
square: i % 64,
|
|
}));
|
|
const r = validateMessage({
|
|
...envelope,
|
|
type: "room.create",
|
|
payload: { layout: { kind: "custom", pieces } },
|
|
});
|
|
expect(r.ok).toBe(false);
|
|
});
|
|
|
|
it("room.create without layout still parses (backward compat)", () => {
|
|
const r = validateMessage({
|
|
...envelope,
|
|
type: "room.create",
|
|
payload: {},
|
|
});
|
|
expect(r.ok).toBe(true);
|
|
});
|
|
|
|
it("room.created echoes resolved layout", () => {
|
|
const r = validateMessage({
|
|
...envelope,
|
|
seq: 1,
|
|
type: "room.created",
|
|
payload: {
|
|
code: "ABC123",
|
|
token: UUID,
|
|
color: "white",
|
|
layout: {
|
|
id: "dunsany",
|
|
name: "Dunsany's Chess",
|
|
pieces: [
|
|
{ type: "king", color: "white", square: 4 },
|
|
{ type: "king", color: "black", square: 60 },
|
|
],
|
|
},
|
|
},
|
|
});
|
|
expect(r.ok).toBe(true);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Modifier profile — schemas, RoomCreate integration, error codes
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* A minimally-valid ModifierProfile fixture. Uses one perType entry
|
|
* (a +1 HP bonus on white pawns) and one perInstance entry so both
|
|
* arrays are exercised by parsing.
|
|
*/
|
|
const validProfile = {
|
|
id: "test-profile",
|
|
name: "Test Profile",
|
|
description: "For protocol.test coverage.",
|
|
layoutId: "classic",
|
|
perType: [
|
|
{
|
|
kind: "hp-bonus",
|
|
pieceType: "pawn",
|
|
color: "white",
|
|
value: 1,
|
|
},
|
|
],
|
|
perInstance: [
|
|
{
|
|
kind: "range-bonus",
|
|
square: "d1",
|
|
value: 2,
|
|
},
|
|
],
|
|
version: 1,
|
|
source: "custom",
|
|
} as const;
|
|
|
|
describe("ModifierProfileSchema", () => {
|
|
it("parses a valid profile", () => {
|
|
const r = ModifierProfileSchema.safeParse(validProfile);
|
|
expect(r.success).toBe(true);
|
|
});
|
|
|
|
it("parses a profile with empty perType / perInstance", () => {
|
|
const r = ModifierProfileSchema.safeParse({
|
|
...validProfile,
|
|
perType: [],
|
|
perInstance: [],
|
|
});
|
|
expect(r.success).toBe(true);
|
|
});
|
|
|
|
it("rejects a profile with wrong version literal", () => {
|
|
const r = ModifierProfileSchema.safeParse({ ...validProfile, version: 2 });
|
|
expect(r.success).toBe(false);
|
|
});
|
|
|
|
it("accepts a profile with arbitrary modifier kind (T3 widening for custom ids)", () => {
|
|
// Pre-T3 the wire enum rejected unknown kinds. The widening was
|
|
// forced by user-authored CustomModifierIds (arbitrary strings like
|
|
// "custom:shield"); kind validity is now enforced by the engine's
|
|
// registry-dispatch fallback at apply time, not at the wire.
|
|
const r = ModifierProfileSchema.safeParse({
|
|
...validProfile,
|
|
perType: [
|
|
{
|
|
kind: "custom:teleport",
|
|
pieceType: "pawn",
|
|
color: "white",
|
|
value: 1,
|
|
},
|
|
],
|
|
});
|
|
expect(r.success).toBe(true);
|
|
});
|
|
|
|
it("rejects a profile with empty-string modifier kind", () => {
|
|
const r = ModifierProfileSchema.safeParse({
|
|
...validProfile,
|
|
perType: [
|
|
{
|
|
kind: "",
|
|
pieceType: "pawn",
|
|
color: "white",
|
|
value: 1,
|
|
},
|
|
],
|
|
});
|
|
expect(r.success).toBe(false);
|
|
});
|
|
|
|
it("rejects a perInstance entry with non-algebraic square", () => {
|
|
const r = ModifierProfileSchema.safeParse({
|
|
...validProfile,
|
|
perInstance: [{ kind: "hp-bonus", square: "d9", value: 1 }],
|
|
});
|
|
expect(r.success).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("RoomCreatePayloadSchema — profile field (T17)", () => {
|
|
it("accepts a room.create with a valid inline profile", () => {
|
|
const r = validateMessage({
|
|
...envelope,
|
|
type: "room.create",
|
|
payload: { profile: validProfile },
|
|
});
|
|
expect(r.ok).toBe(true);
|
|
});
|
|
|
|
it("accepts a room.create WITHOUT a profile (backward compat)", () => {
|
|
const r = validateMessage({
|
|
...envelope,
|
|
type: "room.create",
|
|
payload: {},
|
|
});
|
|
expect(r.ok).toBe(true);
|
|
});
|
|
|
|
it("accepts room.create with layout + profile + rulesetIds together", () => {
|
|
const r = validateMessage({
|
|
...envelope,
|
|
type: "room.create",
|
|
payload: {
|
|
rulesetIds: ["piece-hp"],
|
|
layout: { kind: "premade", id: "classic" },
|
|
profile: validProfile,
|
|
},
|
|
});
|
|
expect(r.ok).toBe(true);
|
|
});
|
|
|
|
it("parses through the schema directly (not just the envelope path)", () => {
|
|
const r = RoomCreatePayloadSchema.safeParse({ profile: validProfile });
|
|
expect(r.success).toBe(true);
|
|
if (r.success) expect(r.data.profile?.id).toBe("test-profile");
|
|
});
|
|
|
|
it("rejects a room.create with a malformed profile", () => {
|
|
const r = validateMessage({
|
|
...envelope,
|
|
type: "room.create",
|
|
payload: {
|
|
profile: { ...validProfile, version: 99 },
|
|
},
|
|
});
|
|
// The profile field is optional, but when present it must validate.
|
|
expect(r.ok).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("ModifierProfileUpdatePayloadSchema", () => {
|
|
it("parses a well-formed update payload", () => {
|
|
const r = ModifierProfileUpdatePayloadSchema.safeParse({
|
|
type: "modifier-profile.update",
|
|
roomCode: "ABC123",
|
|
newProfile: validProfile,
|
|
version: 3,
|
|
});
|
|
expect(r.success).toBe(true);
|
|
});
|
|
|
|
it("parses with version = 0 (initial pre-update state)", () => {
|
|
const r = ModifierProfileUpdatePayloadSchema.safeParse({
|
|
type: "modifier-profile.update",
|
|
roomCode: "ABC123",
|
|
newProfile: validProfile,
|
|
version: 0,
|
|
});
|
|
expect(r.success).toBe(true);
|
|
});
|
|
|
|
it("rejects missing `version`", () => {
|
|
const r = ModifierProfileUpdatePayloadSchema.safeParse({
|
|
type: "modifier-profile.update",
|
|
roomCode: "ABC123",
|
|
newProfile: validProfile,
|
|
});
|
|
expect(r.success).toBe(false);
|
|
});
|
|
|
|
it("rejects negative `version`", () => {
|
|
const r = ModifierProfileUpdatePayloadSchema.safeParse({
|
|
type: "modifier-profile.update",
|
|
roomCode: "ABC123",
|
|
newProfile: validProfile,
|
|
version: -1,
|
|
});
|
|
expect(r.success).toBe(false);
|
|
});
|
|
|
|
it("rejects non-integer `version`", () => {
|
|
const r = ModifierProfileUpdatePayloadSchema.safeParse({
|
|
type: "modifier-profile.update",
|
|
roomCode: "ABC123",
|
|
newProfile: validProfile,
|
|
version: 1.5,
|
|
});
|
|
expect(r.success).toBe(false);
|
|
});
|
|
|
|
it("rejects bad roomCode format", () => {
|
|
const r = ModifierProfileUpdatePayloadSchema.safeParse({
|
|
type: "modifier-profile.update",
|
|
roomCode: "abc123", // lowercase rejected
|
|
newProfile: validProfile,
|
|
version: 1,
|
|
});
|
|
expect(r.success).toBe(false);
|
|
});
|
|
|
|
it("rejects wrong literal `type`", () => {
|
|
const r = ModifierProfileUpdatePayloadSchema.safeParse({
|
|
type: "modifier-profile.nope",
|
|
roomCode: "ABC123",
|
|
newProfile: validProfile,
|
|
version: 1,
|
|
});
|
|
expect(r.success).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("Modifier profile error codes", () => {
|
|
it("const exports equal their string literal values", () => {
|
|
expect(MODIFIER_PROFILE_INVALID).toBe("MODIFIER_PROFILE_INVALID");
|
|
expect(MODIFIER_PROFILE_NO_KING).toBe("MODIFIER_PROFILE_NO_KING");
|
|
expect(MODIFIER_PROFILE_INVULN_KING).toBe("MODIFIER_PROFILE_INVULN_KING");
|
|
expect(MODIFIER_PROFILE_DEADLOCK).toBe("MODIFIER_PROFILE_DEADLOCK");
|
|
});
|
|
|
|
it("all four are accepted by the ErrorCodeSchema via an error payload", () => {
|
|
for (const code of [
|
|
MODIFIER_PROFILE_INVALID,
|
|
MODIFIER_PROFILE_NO_KING,
|
|
MODIFIER_PROFILE_INVULN_KING,
|
|
MODIFIER_PROFILE_DEADLOCK,
|
|
]) {
|
|
const r = validateMessage({
|
|
...envelope,
|
|
type: "error",
|
|
payload: { code, message: `reason for ${code}`, fatal: false },
|
|
});
|
|
expect(r.ok).toBe(true);
|
|
}
|
|
});
|
|
|
|
it("rejects a similar-but-unregistered modifier profile code", () => {
|
|
const r = validateMessage({
|
|
...envelope,
|
|
type: "error",
|
|
payload: {
|
|
code: "MODIFIER_PROFILE_UNKNOWN",
|
|
message: "x",
|
|
fatal: false,
|
|
},
|
|
});
|
|
expect(r.ok).toBe(false);
|
|
});
|
|
});
|