import { act, renderHook, waitFor } from "@testing-library/react"; import { HttpResponse, http } from "msw"; import { describe, expect, it, vi } from "vitest"; import { useBrokerageAccounts } from "@/entities/brokerage-account"; import { configureAuthClient } from "@/shared/api"; import { createQueryWrapper } from "@/test/query"; import { resource } from "@/test/resource"; import { server } from "@/test/server"; import { useScreenSync } from "../index"; function auth() { configureAuthClient({ accessToken: () => "synthetic", refresh: async () => true, clear: vi.fn(), }); } describe("page-owned screen synchronization", () => { it("posts only its scope once and observes completion without importing again", async () => { auth(); const modes: (string | null)[] = []; const bodies: unknown[] = []; let completed = false; let polls = 0; const meta = () => resource([], { stateVersion: completed ? "2" : "1" }).meta; server.use( http.get("*/api/v1/brokerage-accounts", ({ request }) => { modes.push(new URL(request.url).searchParams.get("sync")); return HttpResponse.json(resource([], meta())); }), http.post("*/api/v1/tbank-sync", async ({ request }) => { bodies.push(await request.json()); completed = true; return HttpResponse.json( { id: "screen", scope: { screen: "accounts" }, now: "2026-09-25T00:00:00Z", }, { status: 202 }, ); }), http.get("*/api/v1/tbank-sync/:id", () => { polls++; return HttpResponse.json({ id: "screen", scope: { screen: "accounts" }, now: "2026-09-25T00:00:00Z", state: "succeeded", errorCode: null, progress: { total: 1, completed: 1, discovering: false }, resources: [meta()], }); }), ); const { result, unmount } = renderHook( () => { const accounts = useBrokerageAccounts(); return { accounts, sync: useScreenSync({ screen: "accounts" }, [accounts]), }; }, { wrapper: createQueryWrapper().wrapper }, ); await waitFor(() => expect(result.current.accounts.data).toEqual([])); await act(async () => { await Promise.all([ result.current.sync.refresh(), result.current.sync.refresh(), ]); }); await waitFor(() => expect(result.current.accounts.meta?.stateVersion).toBe("2"), ); expect(bodies).toEqual([{ screen: "accounts" }]); expect(modes).toEqual([null, "observe"]); vi.useFakeTimers(); try { await act(() => vi.advanceTimersByTimeAsync(120000)); expect(polls).toBe(1); expect(modes).toEqual([null, "observe"]); unmount(); await vi.advanceTimersByTimeAsync(120000); expect(polls).toBe(1); } finally { vi.useRealTimers(); } }); it.each(["leave", "logout"])( "publishes completed empty coverage while history runs, then stops on %s", async (boundary) => { vi.useFakeTimers({ toFake: ["Date"] }); try { auth(); let completed = false; let polls = 0; const modes: (string | null)[] = []; const snapshot = () => resource(completed ? [] : null, { stateVersion: completed ? "covered" : "initial", sync: { id: "history", state: "running", errorCode: null }, }); server.use( http.get("*/api/v1/brokerage-accounts", ({ request }) => { modes.push(new URL(request.url).searchParams.get("sync")); return HttpResponse.json(snapshot()); }), http.get("*/api/v1/tbank-sync/:id", () => { polls++; return HttpResponse.json({ id: "history", scope: null, now: "2026-09-25T00:00:00Z", state: "running", errorCode: null, progress: { total: 2, completed: completed ? 1 : 0, discovering: true, }, resources: [snapshot().meta], }); }), ); const { result, unmount } = renderHook( () => { const accounts = useBrokerageAccounts(); return { accounts, sync: useScreenSync({ screen: "accounts" }, [accounts]), }; }, { wrapper: createQueryWrapper().wrapper }, ); await waitFor(() => expect(result.current.accounts.meta?.sync.id).toBe("history"), ); await waitFor(() => expect(polls).toBeGreaterThan(0)); expect(result.current.sync.refreshing).toBe(true); expect(result.current.accounts.data).toBeUndefined(); completed = true; vi.setSystemTime(Date.now() + 61000); await waitFor(() => expect(result.current.accounts.data).toEqual([]), { timeout: 3000, }); expect(result.current.sync.refreshing).toBe(true); await act(async () => { await new Promise((resolve) => setTimeout(resolve, 1100)); }); expect(modes).toEqual([null, "observe"]); if (boundary === "leave") unmount(); else await act(() => configureAuthClient(null)); const stopped = polls; await act(async () => { await new Promise((resolve) => setTimeout(resolve, 1100)); }); expect(polls).toBe(stopped); } finally { vi.useRealTimers(); } }, ); it("clears private snapshots when status confirms access loss", async () => { auth(); server.use( http.get("*/api/v1/brokerage-accounts", () => HttpResponse.json( resource([], { sync: { id: "revoked", state: "running", errorCode: null }, }), ), ), http.get("*/api/v1/tbank-sync/:id", () => HttpResponse.json({ code: "INVALID_TBANK_TOKEN" }, { status: 400 }), ), ); const { result } = renderHook( () => { const accounts = useBrokerageAccounts(); return { accounts, sync: useScreenSync({ screen: "accounts" }, [accounts]), }; }, { wrapper: createQueryWrapper().wrapper }, ); await waitFor(() => expect(result.current.accounts.isError).toBe(true)); expect(result.current.accounts.data).toBeUndefined(); }); it("observes a projection when only its dependency reports new coverage", async () => { auth(); let observed = false; const modes: (string | null)[] = []; const dependency = resource([], { resourceKey: "operations", stateVersion: observed ? "covered" : "initial", sync: { id: "dependency", state: observed ? "idle" : "running", errorCode: null, }, }).meta; server.use( http.get("*/api/v1/brokerage-accounts", ({ request }) => { const mode = new URL(request.url).searchParams.get("sync"); modes.push(mode); if (mode) observed = true; return HttpResponse.json( resource(observed ? [] : null, { resourceKey: "projection", dependencies: [ { ...dependency, stateVersion: observed ? "covered" : "initial" }, ], }), ); }), http.get("*/api/v1/tbank-sync/:id", () => HttpResponse.json({ id: "dependency", scope: null, now: "2026-09-25T00:00:00Z", state: "succeeded", errorCode: null, progress: { total: 1, completed: 1, discovering: false }, resources: [{ ...dependency, stateVersion: "covered" }], }), ), ); const { result } = renderHook( () => { const accounts = useBrokerageAccounts(); useScreenSync({ screen: "accounts" }, [accounts]); return accounts; }, { wrapper: createQueryWrapper().wrapper }, ); await waitFor(() => expect(result.current.data).toEqual([])); expect(modes).toEqual([null, "observe"]); }); it("preserves a fresh snapshot and timestamp after a failed force", async () => { auth(); const saved = resource([], { lastSuccessfulAt: "2026-09-25T01:00:00Z" }); server.use( http.get("*/api/v1/brokerage-accounts", () => HttpResponse.json(saved)), http.post("*/api/v1/tbank-sync", () => HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 }), ), ); const { result } = renderHook( () => { const accounts = useBrokerageAccounts(); return { accounts, sync: useScreenSync({ screen: "accounts" }, [accounts]), }; }, { wrapper: createQueryWrapper().wrapper }, ); await waitFor(() => expect(result.current.accounts.data).toEqual([])); await act(() => result.current.sync.refresh()); await waitFor(() => expect(result.current.sync.transportError).toBe(true)); expect(result.current.accounts.meta).toEqual(saved.meta); }); });