/** * LS7-front — session-limit state in {@link useAgents} (ARCHITECTURE §21). * * Drives the hook behind the real {@link DIProvider} with an in-memory * {@link MockSystemGateway} (to emit the limit domain events) and a * {@link MockInputGateway} (to record `cancelResume`). Verifies that each of the * five limit events folds correctly into `limitByAgent`, plus the optimistic * `cancelResume` action and the backend verdict it returns. * * Cases: * - `agentRateLimited` → `{ limitedUntil, suspected: false }` * - `agentResumeScheduled` → arms `resumeFireAt` * - `agentResumeCancelled` → drops `resumeFireAt`, stays limité * - `agentResumed` → clears the entry entirely * - `agentRateLimitSuspected` (with & without `resetsAtMs`) → `suspected: true` * - realistic sequence rateLimited → scheduled → cancelResume (action) * - `cancelResume` action: optimistic drop + both backend verdicts */ import { describe, it, expect } from "vitest"; import { act, renderHook, waitFor } from "@testing-library/react"; import type { DomainEvent } from "@/domain"; import type { Gateways } from "@/ports"; import { MockInputGateway, MockSystemGateway } from "@/adapters/mock"; import { DIProvider } from "@/app/di"; import { useAgents } from "./useAgents"; const PROJECT_ID = "proj-limits-001"; const AGENT = "agent-A"; function setup() { const system = new MockSystemGateway(); const input = new MockInputGateway(); // useAgents calls agent.listAgents / profile.listProfiles on mount; provide // minimal stubs so the mount effect resolves without a backend. const agent = { listAgents: async () => [], listLiveAgents: async () => [], }; const profile = { listProfiles: async () => [] }; const gateways = { system, input, agent, profile } as unknown as Gateways; const wrapper = ({ children }: { children: React.ReactNode }) => ( {children} ); const view = renderHook(() => useAgents(PROJECT_ID), { wrapper }); return { system, input, view }; } /** Emits an event and flushes the async subscription/microtasks. */ async function emit(system: MockSystemGateway, event: DomainEvent) { await act(async () => { system.emit(event); await Promise.resolve(); }); } describe("useAgents — session-limit state (§21)", () => { it("agentRateLimited with a reset time → { limitedUntil, suspected:false }", async () => { const { system, view } = setup(); await emit(system, { type: "agentRateLimited", agentId: AGENT, resetsAtMs: 1_800_000_000_000, }); expect(view.result.current.limitByAgent[AGENT]).toEqual({ limitedUntil: 1_800_000_000_000, resumeFireAt: undefined, suspected: false, }); }); it("agentResumeScheduled arms resumeFireAt on top of the limited state", async () => { const { system, view } = setup(); await emit(system, { type: "agentRateLimited", agentId: AGENT, resetsAtMs: 1_800_000_000_000, }); await emit(system, { type: "agentResumeScheduled", agentId: AGENT, fireAtMs: 1_800_000_300_000, }); expect(view.result.current.limitByAgent[AGENT]).toMatchObject({ limitedUntil: 1_800_000_000_000, resumeFireAt: 1_800_000_300_000, suspected: false, }); }); it("agentResumeCancelled drops resumeFireAt but keeps the agent limité", async () => { const { system, view } = setup(); await emit(system, { type: "agentRateLimited", agentId: AGENT, resetsAtMs: 1_800_000_000_000, }); await emit(system, { type: "agentResumeScheduled", agentId: AGENT, fireAtMs: 1_800_000_300_000, }); await emit(system, { type: "agentResumeCancelled", agentId: AGENT }); const state = view.result.current.limitByAgent[AGENT]; expect(state).toBeDefined(); expect(state.resumeFireAt).toBeUndefined(); expect(state.limitedUntil).toBe(1_800_000_000_000); }); it("agentResumeCancelled on an unknown agent is a no-op (no entry created)", async () => { const { system, view } = setup(); await emit(system, { type: "agentResumeCancelled", agentId: AGENT }); expect(view.result.current.limitByAgent[AGENT]).toBeUndefined(); }); it("agentResumed clears the limit entry entirely", async () => { const { system, view } = setup(); await emit(system, { type: "agentRateLimited", agentId: AGENT, resetsAtMs: 1_800_000_000_000, }); await emit(system, { type: "agentResumed", agentId: AGENT }); expect(view.result.current.limitByAgent[AGENT]).toBeUndefined(); expect(AGENT in view.result.current.limitByAgent).toBe(false); }); it("agentRateLimitSuspected with a time → { limitedUntil, suspected:true }", async () => { const { system, view } = setup(); await emit(system, { type: "agentRateLimitSuspected", agentId: AGENT, resetsAtMs: 1_800_000_500_000, }); expect(view.result.current.limitByAgent[AGENT]).toMatchObject({ limitedUntil: 1_800_000_500_000, suspected: true, }); }); it("agentRateLimitSuspected WITHOUT a time → suspected:true, no limitedUntil", async () => { const { system, view } = setup(); await emit(system, { type: "agentRateLimitSuspected", agentId: AGENT }); const state = view.result.current.limitByAgent[AGENT]; expect(state.suspected).toBe(true); expect(state.limitedUntil).toBeUndefined(); }); it("realistic sequence: rateLimited → scheduled → cancelResume (action)", async () => { const { system, input, view } = setup(); await emit(system, { type: "agentRateLimited", agentId: AGENT, resetsAtMs: 1_800_000_000_000, }); await emit(system, { type: "agentResumeScheduled", agentId: AGENT, fireAtMs: 1_800_000_300_000, }); expect(view.result.current.limitByAgent[AGENT].resumeFireAt).toBe( 1_800_000_300_000, ); // The user clicks "Annuler la reprise": optimistic drop + port call. let verdict: boolean | undefined; await act(async () => { verdict = await view.result.current.cancelResume(AGENT); }); // Optimistic: the countdown is gone immediately; the agent stays limité. expect(view.result.current.limitByAgent[AGENT].resumeFireAt).toBeUndefined(); expect(view.result.current.limitByAgent[AGENT].limitedUntil).toBe( 1_800_000_000_000, ); // The action routed through the InputGateway and returned the verdict. expect(input.cancelledResumes).toEqual([AGENT]); expect(verdict).toBe(true); }); it("cancelResume relays a `false` backend verdict (already fired / none armed)", async () => { const { system, input, view } = setup(); input.cancelResumeResult = false; await emit(system, { type: "agentResumeScheduled", agentId: AGENT, fireAtMs: 1_800_000_300_000, }); let verdict: boolean | undefined; await act(async () => { verdict = await view.result.current.cancelResume(AGENT); }); expect(verdict).toBe(false); expect(input.cancelledResumes).toEqual([AGENT]); // Still optimistically dropped locally. expect(view.result.current.limitByAgent[AGENT]?.resumeFireAt).toBeUndefined(); }); it("cancelResume with no armed resume still calls the port (no-op locally)", async () => { const { input, view } = setup(); // No event emitted: the agent has no entry at all. await waitFor(() => expect(view.result.current).toBeTruthy()); let verdict: boolean | undefined; await act(async () => { verdict = await view.result.current.cancelResume(AGENT); }); expect(verdict).toBe(true); expect(input.cancelledResumes).toEqual([AGENT]); expect(view.result.current.limitByAgent[AGENT]).toBeUndefined(); }); it("folds limits for two agents independently", async () => { const { system, view } = setup(); await emit(system, { type: "agentRateLimited", agentId: "agent-A", resetsAtMs: 111, }); await emit(system, { type: "agentRateLimitSuspected", agentId: "agent-B", }); expect(view.result.current.limitByAgent["agent-A"]).toMatchObject({ limitedUntil: 111, suspected: false, }); expect(view.result.current.limitByAgent["agent-B"]).toMatchObject({ suspected: true, }); expect(view.result.current.limitByAgent["agent-B"].limitedUntil).toBeUndefined(); }); });