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:
parent
929ee6da81
commit
2a04ae513c
1 changed files with 475 additions and 0 deletions
475
packages/server/src/ws.modifier-profile-consent.test.ts
Normal file
475
packages/server/src/ws.modifier-profile-consent.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Add table
Add a link
Reference in a new issue