Skip to content
272 changes: 262 additions & 10 deletions client/src/adapter/__tests__/draftPodAdapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ const mockGuestSubmitPick = vi.fn(async () => {});
const mockGuestSubmitPickWithDraftEffect = vi.fn(async () => {});
const mockGuestSubmitDeck = vi.fn(async () => {});
const mockGuestLeave = vi.fn(async () => {});
const mockGuestDispose = vi.fn();

vi.mock("../p2p-draft-guest", () => ({
P2PDraftGuest: vi.fn().mockImplementation(function () {
Expand All @@ -98,6 +99,7 @@ vi.mock("../p2p-draft-guest", () => ({
submitPickWithDraftEffect: mockGuestSubmitPickWithDraftEffect,
submitDeck: mockGuestSubmitDeck,
leave: mockGuestLeave,
dispose: mockGuestDispose,
view: null,
seat: null,
token: null,
Expand Down Expand Up @@ -178,6 +180,7 @@ describe("DraftPodHostAdapter", () => {
});

afterEach(async () => {
vi.useRealTimers();
await adapter.dispose();
});

Expand Down Expand Up @@ -288,6 +291,154 @@ describe("DraftPodHostAdapter", () => {
expect(events).toContainEqual({ type: "viewUpdated", view: restoredView });
});

it("destroys a post-hostRoom host when its restore is aborted", async () => {
const { hostRoom } = await import("../../network/connection");
const hostResult = mockHostResult();
(hostRoom as ReturnType<typeof vi.fn>).mockResolvedValue(hostResult);
vi.mocked(loadDraftHostSession).mockResolvedValue({
persistenceId: "draft-1",
roomCode: "ABCDE",
kind: "Premier",
podSize: 8,
hostDisplayName: "Host",
tournamentFormat: "Swiss",
podPolicy: "Competitive",
seatTokens: { 0: "host" },
seatNames: { 0: "Host" },
kickedTokens: [],
draftStarted: true,
draftCode: "draft-1",
draftSessionJson: "{}",
poolInput: { type: "Set", data: { set_pool_json: "{}" } },
});
let resolveRestore!: (view: DraftPlayerView | null) => void;
mockHostRestoreFromPersisted.mockImplementationOnce(() => new Promise((resolve) => {
resolveRestore = resolve;
}));
const controller = new AbortController();
const initializing = adapter.initialize({
poolInput: { type: "Set", data: { set_pool_json: "{}" } },
kind: "Premier",
podSize: 8,
hostDisplayName: "Host",
tournamentFormat: "Swiss",
podPolicy: "Competitive",
persistenceId: "draft-1",
signal: controller.signal,
});

await Promise.resolve();
await Promise.resolve();
controller.abort();
resolveRestore(mockView("Drafting"));

await expect(initializing).rejects.toThrow("initialization aborted");
expect(mockHostDispose).toHaveBeenCalledOnce();
expect(hostResult.destroy).toHaveBeenCalledOnce();
expect(mockHostInitialize).not.toHaveBeenCalled();
expect(adapter.roomCode).toBeNull();
});

it("cleans a pending local host when the adapter is disposed during restore", async () => {
const { hostRoom } = await import("../../network/connection");
const hostResult = mockHostResult();
(hostRoom as ReturnType<typeof vi.fn>).mockResolvedValue(hostResult);
let resolveSession!: (session: null) => void;
vi.mocked(loadDraftHostSession).mockImplementationOnce(() => new Promise<null>((resolve) => {
resolveSession = resolve;
}));
const initializing = adapter.initialize({
poolInput: { type: "Set", data: { set_pool_json: "{}" } },
kind: "Premier",
podSize: 8,
hostDisplayName: "Host",
tournamentFormat: "Swiss",
podPolicy: "Competitive",
persistenceId: "draft-1",
});

await Promise.resolve();
await Promise.resolve();
const disposing = adapter.dispose({ preserveSession: true });
resolveSession(null);

await disposing;
await expect(initializing).rejects.toThrow("initialization aborted");
expect(hostResult.destroy).toHaveBeenCalledOnce();
expect(mockHostDispose).toHaveBeenCalledOnce();
expect(mockHostInitialize).not.toHaveBeenCalled();
});

it("destroys a late hostRoom result before the same room code is rehosted", async () => {
const { hostRoom } = await import("../../network/connection");
const staleHostResult = mockHostResult();
let resolveHostRoom!: (result: ReturnType<typeof mockHostResult>) => void;
(hostRoom as ReturnType<typeof vi.fn>).mockImplementationOnce(
() => new Promise<ReturnType<typeof mockHostResult>>((resolve) => {
resolveHostRoom = resolve;
}),
);
const initializing = adapter.initialize({
poolInput: { type: "Set", data: { set_pool_json: "{}" } },
kind: "Premier",
podSize: 8,
hostDisplayName: "Host",
tournamentFormat: "Swiss",
podPolicy: "Competitive",
preferredRoomCode: "ABCDE",
});

await Promise.resolve();
const disposing = adapter.dispose({ preserveSession: true });
resolveHostRoom(staleHostResult);

await disposing;
await expect(initializing).rejects.toThrow("initialization aborted");
expect(staleHostResult.destroy).toHaveBeenCalledOnce();

const replacement = new DraftPodHostAdapter();
const replacementResult = mockHostResult();
(hostRoom as ReturnType<typeof vi.fn>).mockResolvedValueOnce(replacementResult);
await replacement.initialize({
poolInput: { type: "Set", data: { set_pool_json: "{}" } },
kind: "Premier",
podSize: 8,
hostDisplayName: "Host",
tournamentFormat: "Swiss",
podPolicy: "Competitive",
preferredRoomCode: "ABCDE",
});

expect(replacement.roomCode).toBe("ABCDE");
await replacement.dispose({ preserveSession: true });
});

it("does not publish a host if cancellation wins its local initialize race", async () => {
let resolveInitialize!: () => void;
mockHostInitialize.mockImplementationOnce(() => new Promise<void>((resolve) => {
resolveInitialize = resolve;
}));
const controller = new AbortController();
const initializing = adapter.initialize({
poolInput: { type: "Set", data: { set_pool_json: "{}" } },
kind: "Premier",
podSize: 8,
hostDisplayName: "Host",
tournamentFormat: "Swiss",
podPolicy: "Competitive",
signal: controller.signal,
});

await Promise.resolve();
await Promise.resolve();
controller.abort();
resolveInitialize();

await expect(initializing).rejects.toThrow("initialization aborted");
expect(mockHostDispose).toHaveBeenCalledOnce();
await expect(adapter.startDraft()).rejects.toThrow("Host not initialized");
});

it("delegates submitPick and returns view", async () => {
await adapter.initialize({
poolInput: { type: "Set", data: { set_pool_json: "{}" } },
Expand Down Expand Up @@ -467,6 +618,7 @@ describe("DraftPodGuestAdapter", () => {

afterEach(async () => {
await adapter.dispose();
vi.useRealTimers();
});

it("starts in idle status", () => {
Expand All @@ -478,6 +630,7 @@ describe("DraftPodGuestAdapter", () => {

it("transitions to lobby after initialization", async () => {
await adapter.initialize({
kind: "new",
roomCode: "ABCDE",
displayName: "Alice",
});
Expand All @@ -489,15 +642,104 @@ describe("DraftPodGuestAdapter", () => {
expect(statusEvents).toContainEqual({ type: "statusChanged", status: "lobby" });
});

it("looks up reconnect tokens by host peer id", async () => {
it("does not look up a reconnect token for a new join", async () => {
const { loadDraftGuestSession } = await import("../../services/draftPersistence");
await adapter.initialize({
kind: "new",
roomCode: "ABCDE",
displayName: "Alice",
});

expect(loadDraftGuestSession).not.toHaveBeenCalled();
});

it("refuses to send a reconnect capability to a different host peer", async () => {
const { joinRoom } = await import("../../network/connection");
const mismatched = {
...mockJoinResult(),
conn: { peer: "phase2-OTHER" },
};
(joinRoom as ReturnType<typeof vi.fn>).mockResolvedValueOnce(mismatched);

await expect(adapter.initialize({
kind: "reconnect",
roomCode: "ABCDE",
displayName: "Alice",
hostPeerId: "phase2-ABCDE",
draftToken: "opaque-token",
})).rejects.toThrow("host changed");
expect(mockGuestInitialize).not.toHaveBeenCalled();
expect(mismatched.destroyPeer).toHaveBeenCalledOnce();
});

it("retries only credentialed reconnect room joins within a bounded budget", async () => {
vi.useFakeTimers();
const { joinRoom } = await import("../../network/connection");
(joinRoom as ReturnType<typeof vi.fn>)
.mockRejectedValueOnce(new Error("first transport failure"))
.mockRejectedValueOnce(new Error("second transport failure"))
.mockResolvedValueOnce(mockJoinResult());

const reconnecting = adapter.initialize({
kind: "reconnect",
roomCode: "ABCDE",
displayName: "Alice",
hostPeerId: "phase2-ABCDE",
draftToken: "opaque-token",
});
await vi.runAllTimersAsync();
await expect(reconnecting).resolves.toBeUndefined();
expect(joinRoom).toHaveBeenCalledTimes(3);
});

it("aborts a credentialed reconnect join without starting another attempt", async () => {
vi.useFakeTimers();
const { joinRoom } = await import("../../network/connection");
(joinRoom as ReturnType<typeof vi.fn>).mockRejectedValue(new Error("transport failure"));
const controller = new AbortController();

const reconnecting = adapter.initialize({
kind: "reconnect",
roomCode: "ABCDE",
displayName: "Alice",
hostPeerId: "phase2-ABCDE",
draftToken: "opaque-token",
signal: controller.signal,
});
await Promise.resolve();
controller.abort();
await expect(reconnecting).rejects.toMatchObject({ name: "AbortError" });
expect(joinRoom).toHaveBeenCalledTimes(1);
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.

it("passes a reconnect config through without a join fallback", async () => {
await adapter.initialize({
kind: "reconnect",
roomCode: "ABCDE",
displayName: "Alice",
hostPeerId: "phase2-ABCDE",
draftToken: "opaque-token",
});

expect(loadDraftGuestSession).toHaveBeenCalledWith("phase2-ABCDE");
const { P2PDraftGuest } = await import("../p2p-draft-guest");
expect(P2PDraftGuest).toHaveBeenLastCalledWith(
expect.anything(),
"phase2-ABCDE",
expect.anything(),
expect.objectContaining({ kind: "reconnect", draftToken: "opaque-token" }),
);
});

it("preserves recovery credentials for lifecycle disposal but clears them on explicit leave", async () => {
await adapter.initialize({ kind: "new", roomCode: "ABCDE", displayName: "Alice" });
await adapter.dispose();
expect(mockGuestDispose).toHaveBeenCalled();
expect(mockGuestLeave).not.toHaveBeenCalled();

adapter = new DraftPodGuestAdapter();
await adapter.initialize({ kind: "new", roomCode: "ABCDE", displayName: "Alice" });
await adapter.dispose({ preserveRecovery: false });
expect(mockGuestLeave).toHaveBeenCalled();
});

it("emits error on connection failure", async () => {
Expand All @@ -507,7 +749,7 @@ describe("DraftPodGuestAdapter", () => {
);

await expect(
adapter.initialize({ roomCode: "ZZZZZ", displayName: "Bob" }),
adapter.initialize({ kind: "new", roomCode: "ZZZZZ", displayName: "Bob" }),
).rejects.toThrow("Connection timed out");

expect(adapter.status).toBe("error");
Expand All @@ -518,21 +760,21 @@ describe("DraftPodGuestAdapter", () => {
});

it("delegates submitPick to P2PDraftGuest", async () => {
await adapter.initialize({ roomCode: "ABCDE", displayName: "Alice" });
await adapter.initialize({ kind: "new", roomCode: "ABCDE", displayName: "Alice" });

await adapter.submitPick("card-456");
expect(mockGuestSubmitPick).toHaveBeenCalledWith("card-456");
});

it("delegates draft-effect picks to P2PDraftGuest", async () => {
await adapter.initialize({ roomCode: "ABCDE", displayName: "Alice" });
await adapter.initialize({ kind: "new", roomCode: "ABCDE", displayName: "Alice" });

await adapter.submitPickWithDraftEffect("cogwork-1", ["card-1", "card-2"]);
expect(mockGuestSubmitPickWithDraftEffect).toHaveBeenCalledWith("cogwork-1", ["card-1", "card-2"]);
});

it("delegates submitDeck to P2PDraftGuest", async () => {
await adapter.initialize({ roomCode: "ABCDE", displayName: "Alice" });
await adapter.initialize({ kind: "new", roomCode: "ABCDE", displayName: "Alice" });

await adapter.submitDeck(["Swamp", "Mountain"]);
expect(mockGuestSubmitDeck).toHaveBeenCalledWith(["Swamp", "Mountain"]);
Expand All @@ -544,7 +786,7 @@ describe("DraftPodGuestAdapter", () => {
});

it("maps P2PDraftGuest events to DraftPodGuestEvents", async () => {
await adapter.initialize({ roomCode: "ABCDE", displayName: "Alice" });
await adapter.initialize({ kind: "new", roomCode: "ABCDE", displayName: "Alice" });

const guestEventHandler = mockGuestOnEvent.mock.calls[0][0];

Expand Down Expand Up @@ -575,6 +817,15 @@ describe("DraftPodGuestAdapter", () => {
guestEventHandler({ type: "draftResumed" });
expect(events).toContainEqual({ type: "draftResumed" });

guestEventHandler({
type: "reconnectFailed",
failure: { kind: "retryable", message: "Host is restarting" },
});
expect(events).toContainEqual({
type: "reconnectFailed",
failure: { kind: "retryable", message: "Host is restarting" },
});

// Simulate kicked
guestEventHandler({ type: "kicked", reason: "Host kicked you" });
expect(adapter.status).toBe("kicked");
Expand All @@ -599,7 +850,7 @@ describe("DraftPodGuestAdapter", () => {
});

it("updates status based on DraftPlayerView status", async () => {
await adapter.initialize({ roomCode: "ABCDE", displayName: "Alice" });
await adapter.initialize({ kind: "new", roomCode: "ABCDE", displayName: "Alice" });
const guestEventHandler = mockGuestOnEvent.mock.calls[0][0];

guestEventHandler({ type: "viewUpdated", view: mockView("Drafting") });
Expand All @@ -613,10 +864,11 @@ describe("DraftPodGuestAdapter", () => {
});

it("cleans up on dispose", async () => {
await adapter.initialize({ roomCode: "ABCDE", displayName: "Alice" });
await adapter.initialize({ kind: "new", roomCode: "ABCDE", displayName: "Alice" });

await adapter.dispose();
expect(mockGuestLeave).toHaveBeenCalledOnce();
expect(mockGuestDispose).toHaveBeenCalledOnce();
expect(mockGuestLeave).not.toHaveBeenCalled();
expect(adapter.status).toBe("idle");
expect(adapter.currentView).toBeNull();
expect(adapter.seatIndex).toBeNull();
Expand Down
2 changes: 1 addition & 1 deletion client/src/adapter/__tests__/p2pDraftEffectPick.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ describe("P2P draft-effect picks", () => {
{} as never,
"host-peer",
{} as never,
"Alice",
{ kind: "new", roomCode: "ABCDE", displayName: "Alice" },
);
const send = vi.fn(async () => {});
(guest as unknown as { session: { send: typeof send } }).session = { send };
Expand Down
Loading
Loading