import { act, renderHook, waitFor } from "@testing-library/react"; import { http, HttpResponse } from "msw"; import { describe, expect, it, vi } from "vitest"; import { useBrokerageAccounts } from "@/entities/brokerage-account"; import { apiQueryClient, configureAuthClient } from "@/shared/api"; import { createQueryWrapper } from "@/test/query"; import { resource } from "@/test/resource"; import { server } from "@/test/server"; import { useScreenSync } from "../index"; import { commitTbankConnectionBoundary } from "./connection-boundary"; import { type InvestmentScreen, useTbankSync } from "./sync"; describe("sync connection and session boundaries", () => { it.each(["connection", "session"])( "discards pending status and mutation after %s changes", async (boundary) => { let generation = 0; configureAuthClient({ accessToken: () => "synthetic", clear: vi.fn(), generation: () => generation, refresh: () => Promise.resolve(true), }); const { promise: blocked, resolve: release } = Promise.withResolvers(); const accepted = { id: "work", now: "2026-09-25T12:00:00Z", scope: { screen: "accounts" }, }; let started = 0; let isChanged = false; server.use( http.get("*/api/v1/brokerage-accounts", () => HttpResponse.json(resource([], { sync: { errorCode: null, id: "work", state: "running" }, })), ), http.post("*/api/v1/tbank-sync", async () => { started += 1; await blocked; return HttpResponse.json(accepted, { status: 202 }); }), http.get("*/api/v1/tbank-sync/:id", async () => { const now = isChanged ? "2026-09-25T12:01:00Z" : accepted.now; started += 1; await blocked; return HttpResponse.json( { ...accepted, errorCode: null, now, progress: { completed: 1, discovering: false, total: 1 }, resources: [], state: "succeeded", }, ); }), ); const { queryClient, wrapper } = createQueryWrapper(); const published: string[] = []; const unsubscribe = queryClient.getQueryCache().subscribe((event) => { if ( (event.query.queryKey as readonly unknown[])[1] !== "/api/v1/tbank-sync/{id}" ) { return; } const data = event.query.state.data as undefined | { now?: string }; if (data?.now) published.push(data.now); }); const { result } = renderHook( () => { const accounts = useBrokerageAccounts(); useScreenSync({ screen: "accounts" }, [accounts]); return { mutation: useTbankSync() }; }, { wrapper }, ); const screen: InvestmentScreen = { screen: "accounts" }; let mutation!: Promise; act(() => { mutation = result.current.mutation.mutateAsync({ body: screen }); }); // eslint-disable-next-line vitest/valid-expect -- Attach rejection handling before releasing the request; the assertion is awaited below. const discarded = expect(mutation).rejects.toMatchObject({ name: "AbortError", }); await waitFor(() => expect(started).toBe(2)); await act(async () => { isChanged = true; if (boundary === "connection") commitTbankConnectionBoundary( queryClient, { connected: false, connectedAt: null, }, ); else { generation += 1; queryClient.clear(); } release(); await blocked; }); await discarded; expect(result.current.mutation.data).toBeUndefined(); const key = apiQueryClient.queryOptions( "get", "/api/v1/tbank-sync/{id}", { params: { path: { id: "work" } } }, ).queryKey; // A new request may start after a successful connection boundary; an old result cannot win it. if (boundary === "session") // eslint-disable-next-line vitest/no-conditional-expect -- The fixed table case selects the expected outcome; every case asserts its own branch. expect(queryClient.getQueryData(key)).toBeUndefined(); else await waitFor(() => // eslint-disable-next-line vitest/no-conditional-expect -- The fixed table case selects the expected outcome; every case asserts its own branch. expect(queryClient.getQueryData(key)).toMatchObject({ now: "2026-09-25T12:01:00Z", }), ); expect(published).not.toContain(accepted.now); unsubscribe(); }, ); });