diff --git a/tests/runtime-flow.test.ts b/tests/runtime-flow.test.ts index 0e6324d..3dc2a3e 100644 --- a/tests/runtime-flow.test.ts +++ b/tests/runtime-flow.test.ts @@ -479,4 +479,114 @@ describe("Yonexus.Client runtime flow", () => { expect(runtime.state.pendingPairing).toBeDefined(); expect(runtime.state.lastPairingFailure).toBe("invalid_code"); }); + + it("PF-10: restart during pending pairing resumes waiting for out-of-band code", async () => { + const now = 1_710_000_000; + const storeState = createMockStateStore({ + identifier: "client-a", + updatedAt: now + }); + + const firstTransportState = createMockTransport(); + const firstRuntime = createYonexusClientRuntime({ + config: { + mainHost: "ws://localhost:8787", + identifier: "client-a", + notifyBotToken: "stub-token", + adminUserId: "admin-user" + }, + transport: firstTransportState.transport, + stateStore: storeState.store, + now: () => now + }); + + await firstRuntime.start(); + firstRuntime.handleTransportStateChange("connected"); + await firstRuntime.handleMessage( + encodeBuiltin( + buildPairRequest( + { + identifier: "client-a", + expiresAt: now + 300, + ttlSeconds: 300, + adminNotification: "sent", + codeDelivery: "out_of_band" + }, + { requestId: "req-pair", timestamp: now } + ) + ) + ); + + expect(firstRuntime.state.phase).toBe("waiting_pair_confirm"); + expect(firstRuntime.state.pendingPairing).toMatchObject({ + expiresAt: now + 300, + ttlSeconds: 300, + adminNotification: "sent" + }); + + await firstRuntime.stop(); + + const secondTransportState = createMockTransport(); + const secondRuntime = createYonexusClientRuntime({ + config: { + mainHost: "ws://localhost:8787", + identifier: "client-a", + notifyBotToken: "stub-token", + adminUserId: "admin-user" + }, + transport: secondTransportState.transport, + stateStore: storeState.store, + now: () => now + 5 + }); + + await secondRuntime.start(); + secondRuntime.handleTransportStateChange("connected"); + + const hello = decodeBuiltin(secondTransportState.sent[0]); + expect(hello.type).toBe("hello"); + expect(hello.payload).toMatchObject({ + identifier: "client-a", + hasSecret: false, + hasKeyPair: true + }); + + await secondRuntime.handleMessage( + encodeBuiltin( + buildHelloAck( + { + identifier: "client-a", + nextAction: "waiting_pair_confirm" + }, + { requestId: "req-hello-resume", timestamp: now + 5 } + ) + ) + ); + + await secondRuntime.handleMessage( + encodeBuiltin( + buildPairRequest( + { + identifier: "client-a", + expiresAt: now + 300, + ttlSeconds: 295, + adminNotification: "sent", + codeDelivery: "out_of_band" + }, + { requestId: "req-pair-resume", timestamp: now + 5 } + ) + ) + ); + + expect(secondRuntime.state.phase).toBe("waiting_pair_confirm"); + expect(secondRuntime.state.pendingPairing).toMatchObject({ + expiresAt: now + 300, + ttlSeconds: 295, + adminNotification: "sent" + }); + expect(secondRuntime.submitPairingCode("PAIR-CODE-123", "req-pair-resume")).toBe(true); + + const pairConfirm = decodeBuiltin(secondTransportState.sent.at(-1)!); + expect(pairConfirm.type).toBe("pair_confirm"); + expect((pairConfirm.payload as PairConfirmPayload).pairingCode).toBe("PAIR-CODE-123"); + }); });