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