test(server): two-player consent flow (T3)

Seven scenarios covering the propose/consent state machine:
propose -> proposal-pending + queued ack; approve -> consent-
received + T2 queue; reject -> rejected(rejected); 60s timeout
with vi.useFakeTimers -> rejected(timeout); self-consent blocked;
supersession preserves wire ordering
(rejected(superseded) before new proposal-pending); solo-mode
propose directs caller back to update.
This commit is contained in:
Joey Yakimowich-Payne 2026-04-19 09:24:59 -06:00
commit 2a04ae513c
No known key found for this signature in database

View file

@ -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<ClientData> {
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<string, unknown> } {
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<string, unknown> };
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<string, unknown>,
version: number,
): Record<string, unknown> {
return { roomCode, candidate, version };
}
function consentPayload(
roomCode: string,
decision: "approve" | "reject",
): Record<string, unknown> {
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);
});
});