diff --git a/packages/server/src/ws.modifier-profile-consent.test.ts b/packages/server/src/ws.modifier-profile-consent.test.ts new file mode 100644 index 0000000..41a8d7b --- /dev/null +++ b/packages/server/src/ws.modifier-profile-consent.test.ts @@ -0,0 +1,475 @@ +/** + * T3 integration tests: two-player consent flow + * (`modifier-profile.propose` / `modifier-profile.consent`) per + * T2-ADR-2. + * + * Covers: + * 1. Host proposes -> opponent receives `proposal-pending`; + * proposer receives `modifier-profile.queued` ack only. + * 2. Opponent approves -> proposer gets `consent-received`, + * profile promoted to Room.pendingProfile (T2 queue). + * 3. Opponent rejects -> both get + * `modifier-profile.rejected` reason `"rejected"`. + * 4. Timeout (vi.useFakeTimers) -> both get `rejected` reason + * `"timeout"`. + * 5. Self-consent -> proposer's `consent` attempt returns + * `INVALID_MESSAGE`; proposal remains active. + * 6. Supersession -> a second `propose` broadcasts + * `rejected` reason `"superseded"` for the first BEFORE + * sending `proposal-pending` for the second. + * 7. Solo mode (1 player) rejects `propose` with guidance. + * + * Harness mirrors `ws.modifier-profile-update.test.ts`. + */ +import type { ServerWebSocket } from "bun"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { + handleMessage, + registerConnection, + roomRegistry, + sessionRegistry, + type ClientData, +} from "./broadcast.js"; +import { PROTOCOL_VERSION, type ClientMessage } from "./protocol.js"; + +// --- Mock ServerWebSocket harness ------------------------------------ + +interface MockWs extends ServerWebSocket { + readonly sent: unknown[]; + readonly closed: boolean; +} + +function makeMockWs(clientId: string): MockWs { + const sent: unknown[] = []; + const closedFlag = { value: false }; + const ws = { + data: { clientId } as ClientData, + sent, + get closed(): boolean { + return closedFlag.value; + }, + send(msg: string | Buffer): number { + const str = typeof msg === "string" ? msg : msg.toString("utf8"); + sent.push(JSON.parse(str)); + return str.length; + }, + close(): void { + closedFlag.value = true; + }, + } as unknown as MockWs; + return ws; +} + +function nextMsgOfType( + ws: MockWs, + type: string, +): { payload: Record } { + const idx = ws.sent.findIndex( + (m) => + typeof m === "object" && + m !== null && + (m as { type: unknown }).type === type, + ); + if (idx < 0) { + const types = ws.sent.map((m) => (m as { type?: string }).type); + throw new Error( + `no message of type "${type}" in inbox (got ${JSON.stringify(types)})`, + ); + } + const msg = ws.sent[idx] as { payload: Record }; + ws.sent.splice(idx, 1); + return msg; +} + +function hasMsgOfType(ws: MockWs, type: string): boolean { + return ws.sent.some( + (m) => + typeof m === "object" && + m !== null && + (m as { type?: string }).type === type, + ); +} + +function sendClient( + ws: MockWs, + type: ClientMessage["type"], + payload: unknown, + seq = 1, +): void { + handleMessage( + ws, + JSON.stringify({ + v: PROTOCOL_VERSION, + seq, + ts: Date.now(), + type, + payload, + }), + ); +} + +// --- Profile fixtures ------------------------------------------------ + +const initialProfile = { + id: "t3-initial", + name: "Initial", + description: "White pawns +1 HP.", + layoutId: "classic", + perType: [ + { + kind: "hp-bonus" as const, + pieceType: "pawn" as const, + color: "white" as const, + value: 1, + }, + ], + perInstance: [], + version: 1 as const, + source: "custom" as const, +}; + +const candidateA = { + id: "t3-candidate-a", + name: "Candidate A", + description: "Rooks +1 range.", + layoutId: "classic", + perType: [ + { + kind: "range-bonus" as const, + pieceType: "rook" as const, + color: "both" as const, + value: 1, + }, + ], + perInstance: [], + version: 1 as const, + source: "custom" as const, +}; + +const candidateB = { + id: "t3-candidate-b", + name: "Candidate B", + description: "Bishops +1 range.", + layoutId: "classic", + perType: [ + { + kind: "range-bonus" as const, + pieceType: "bishop" as const, + color: "both" as const, + value: 1, + }, + ], + perInstance: [], + version: 1 as const, + source: "custom" as const, +}; + +function proposePayload( + roomCode: string, + candidate: Record, + version: number, +): Record { + return { roomCode, candidate, version }; +} + +function consentPayload( + roomCode: string, + decision: "approve" | "reject", +): Record { + return { roomCode, decision }; +} + +// --- Setup helpers --------------------------------------------------- + +/** Multiplayer room (2 filled slots) with an initial profile at v1. */ +function setupMultiplayerRoom(): { + white: MockWs; + black: MockWs; + code: string; +} { + const white = makeMockWs(`T3-W-${crypto.randomUUID()}`); + const black = makeMockWs(`T3-B-${crypto.randomUUID()}`); + registerConnection(white); + registerConnection(black); + + sendClient(white, "room.create", { profile: initialProfile }); + const created = nextMsgOfType(white, "room.created"); + const code = created.payload["code"] as string; + + sendClient(black, "room.join", { code }); + nextMsgOfType(black, "room.joined"); + if (hasMsgOfType(white, "game.state")) nextMsgOfType(white, "game.state"); + if (hasMsgOfType(black, "game.state")) nextMsgOfType(black, "game.state"); + + return { white, black, code }; +} + +/** Solo room (1 filled slot). */ +function setupSoloRoom(): { white: MockWs; code: string } { + const white = makeMockWs(`T3-SOLO-${crypto.randomUUID()}`); + registerConnection(white); + sendClient(white, "room.create", { profile: initialProfile }); + const created = nextMsgOfType(white, "room.created"); + const code = created.payload["code"] as string; + return { white, code }; +} + +// --- Tests ----------------------------------------------------------- + +describe("modifier-profile.propose/consent (T3)", () => { + afterEach(() => { + // Clear any dangling proposalState that would leak into the + // next test's room via the module-scoped registry. Each test + // creates a fresh room code so registry leakage is cosmetic + // rather than functional, but cleaning up keeps timer-based + // tests deterministic. + vi.useRealTimers(); + }); + + it("propose -> opponent gets proposal-pending; proposer gets queued ack only", () => { + const { white, black, code } = setupMultiplayerRoom(); + + sendClient( + white, + "modifier-profile.propose", + proposePayload(code, candidateA, 1), + ); + + // Proposer ack (T2 queued shape reused). + const ack = nextMsgOfType(white, "modifier-profile.queued"); + expect(ack.payload["roomCode"]).toBe(code); + expect(ack.payload["pendingVersion"]).toBe(2); + + // Proposer does NOT get their own proposal-pending. + expect(hasMsgOfType(white, "modifier-profile.proposal-pending")).toBe( + false, + ); + + // Opponent receives proposal-pending. + const pending = nextMsgOfType(black, "modifier-profile.proposal-pending"); + expect(pending.payload["roomCode"]).toBe(code); + expect(pending.payload["proposer"]).toBe("white"); + expect((pending.payload["profile"] as { id: string }).id).toBe( + candidateA.id, + ); + expect(typeof pending.payload["expiresAt"]).toBe("number"); + + // Room has proposalState. + const room = roomRegistry.getRoom(code)!; + expect(room.proposalState?.profile.id).toBe(candidateA.id); + expect(room.proposalState?.proposedBy).toBe("white"); + + // No pendingProfile yet (only installed on approve). + expect(room.pendingProfile).toBeUndefined(); + }); + + it("approve -> proposer gets consent-received; profile enters T2 pending queue", () => { + const { white, black, code } = setupMultiplayerRoom(); + + sendClient( + white, + "modifier-profile.propose", + proposePayload(code, candidateA, 1), + ); + nextMsgOfType(white, "modifier-profile.queued"); + nextMsgOfType(black, "modifier-profile.proposal-pending"); + + sendClient( + black, + "modifier-profile.consent", + consentPayload(code, "approve"), + ); + + // Proposer receives consent-received. + const approved = nextMsgOfType(white, "modifier-profile.consent-received"); + expect(approved.payload["roomCode"]).toBe(code); + + // Opponent (who approved) doesn't hear a consent-received — + // they already know they approved. + expect(hasMsgOfType(black, "modifier-profile.consent-received")).toBe( + false, + ); + + // No rejected broadcast on approve. + expect(hasMsgOfType(white, "modifier-profile.rejected")).toBe(false); + expect(hasMsgOfType(black, "modifier-profile.rejected")).toBe(false); + + // proposalState drained, pending slot populated with the + // proposer's token for apply-time NACK routing. + const room = roomRegistry.getRoom(code)!; + expect(room.proposalState).toBeUndefined(); + expect(room.pendingProfile?.id).toBe(candidateA.id); + // Version hasn't bumped yet (only on apply at turn boundary). + expect(sessionRegistry.get(code)?.getProfileVersion()).toBe(1); + + // Next move drains the queue and broadcasts updated to both. + sendClient(white, "game.move", { from: "e2", to: "e4" }); + const wUpd = nextMsgOfType(white, "modifier-profile.updated"); + const bUpd = nextMsgOfType(black, "modifier-profile.updated"); + expect(wUpd.payload["version"]).toBe(2); + expect(bUpd.payload["version"]).toBe(2); + expect((wUpd.payload["profile"] as { id: string }).id).toBe(candidateA.id); + }); + + it("reject -> both get modifier-profile.rejected reason=rejected; no queue entry", () => { + const { white, black, code } = setupMultiplayerRoom(); + + sendClient( + white, + "modifier-profile.propose", + proposePayload(code, candidateA, 1), + ); + nextMsgOfType(white, "modifier-profile.queued"); + nextMsgOfType(black, "modifier-profile.proposal-pending"); + + sendClient( + black, + "modifier-profile.consent", + consentPayload(code, "reject"), + ); + + const wRej = nextMsgOfType(white, "modifier-profile.rejected"); + const bRej = nextMsgOfType(black, "modifier-profile.rejected"); + expect(wRej.payload["reason"]).toBe("rejected"); + expect(bRej.payload["reason"]).toBe("rejected"); + + // No consent-received on reject. + expect(hasMsgOfType(white, "modifier-profile.consent-received")).toBe( + false, + ); + + const room = roomRegistry.getRoom(code)!; + expect(room.proposalState).toBeUndefined(); + expect(room.pendingProfile).toBeUndefined(); + }); + + it("60s timeout -> both get modifier-profile.rejected reason=timeout", () => { + vi.useFakeTimers(); + const { white, black, code } = setupMultiplayerRoom(); + + sendClient( + white, + "modifier-profile.propose", + proposePayload(code, candidateA, 1), + ); + nextMsgOfType(white, "modifier-profile.queued"); + nextMsgOfType(black, "modifier-profile.proposal-pending"); + + // Before the timeout nothing fires. + vi.advanceTimersByTime(59_999); + expect(hasMsgOfType(white, "modifier-profile.rejected")).toBe(false); + expect(hasMsgOfType(black, "modifier-profile.rejected")).toBe(false); + + // Cross the 60s boundary. + vi.advanceTimersByTime(2); + + const wRej = nextMsgOfType(white, "modifier-profile.rejected"); + const bRej = nextMsgOfType(black, "modifier-profile.rejected"); + expect(wRej.payload["reason"]).toBe("timeout"); + expect(bRej.payload["reason"]).toBe("timeout"); + + const room = roomRegistry.getRoom(code)!; + expect(room.proposalState).toBeUndefined(); + expect(room.pendingProfile).toBeUndefined(); + }); + + it("self-consent -> proposer rejected with INVALID_MESSAGE; proposal unchanged", () => { + const { white, black, code } = setupMultiplayerRoom(); + + sendClient( + white, + "modifier-profile.propose", + proposePayload(code, candidateA, 1), + ); + nextMsgOfType(white, "modifier-profile.queued"); + nextMsgOfType(black, "modifier-profile.proposal-pending"); + + // Proposer tries to approve their own proposal. + sendClient( + white, + "modifier-profile.consent", + consentPayload(code, "approve"), + ); + + const err = nextMsgOfType(white, "error"); + expect(err.payload["code"]).toBe("INVALID_MESSAGE"); + expect(String(err.payload["message"])).toMatch(/self-consent/i); + + // Proposal still active; no broadcasts fired. + const room = roomRegistry.getRoom(code)!; + expect(room.proposalState?.profile.id).toBe(candidateA.id); + expect(hasMsgOfType(white, "modifier-profile.consent-received")).toBe( + false, + ); + expect(hasMsgOfType(white, "modifier-profile.rejected")).toBe(false); + expect(hasMsgOfType(black, "modifier-profile.rejected")).toBe(false); + }); + + it("supersession -> second propose broadcasts rejected(superseded) for first, THEN sends new proposal-pending", () => { + const { white, black, code } = setupMultiplayerRoom(); + + // First propose (candidateA). + sendClient( + white, + "modifier-profile.propose", + proposePayload(code, candidateA, 1), + ); + nextMsgOfType(white, "modifier-profile.queued"); + nextMsgOfType(black, "modifier-profile.proposal-pending"); + + // Drain the proposer-ack from the second so we can inspect + // the wire ordering on both sockets cleanly. + sendClient( + white, + "modifier-profile.propose", + proposePayload(code, candidateB, 1), + ); + + // White should now have: rejected(superseded) followed by + // queued ack for the NEW proposal. Order matters: the spec + // requires superseded to arrive before the new proposal's + // receipt confirmation. + const wRej = nextMsgOfType(white, "modifier-profile.rejected"); + expect(wRej.payload["reason"]).toBe("superseded"); + const wQueued = nextMsgOfType(white, "modifier-profile.queued"); + expect(wQueued.payload["pendingVersion"]).toBe(2); + + // Black also sees rejected(superseded) first, then the new + // proposal-pending. + const bRej = nextMsgOfType(black, "modifier-profile.rejected"); + expect(bRej.payload["reason"]).toBe("superseded"); + const bNewPending = nextMsgOfType( + black, + "modifier-profile.proposal-pending", + ); + expect((bNewPending.payload["profile"] as { id: string }).id).toBe( + candidateB.id, + ); + + // Room now has the second proposal only. + const room = roomRegistry.getRoom(code)!; + expect(room.proposalState?.profile.id).toBe(candidateB.id); + }); + + it("solo mode -> propose rejected with guidance to use modifier-profile.update", () => { + const { white, code } = setupSoloRoom(); + + sendClient( + white, + "modifier-profile.propose", + proposePayload(code, candidateA, 1), + ); + + const err = nextMsgOfType(white, "error"); + expect(err.payload["code"]).toBe("INVALID_MESSAGE"); + expect(String(err.payload["message"])).toMatch( + /2 players|modifier-profile\.update/i, + ); + + // No proposalState installed, no queued ack fired. + const room = roomRegistry.getRoom(code)!; + expect(room.proposalState).toBeUndefined(); + expect(hasMsgOfType(white, "modifier-profile.queued")).toBe(false); + }); +});