import { act, renderHook, waitFor } from "@testing-library/react"; import { http, HttpResponse } from "msw"; import { describe, expect, it, vi } from "vitest"; import { apiQueryClient, configureAuthClient } from "@/shared/api"; import { createQueryWrapper } from "@/test/query"; import { server } from "@/test/server"; import { type InvestmentScreen, useTbankSync, useTbankSyncStatus, } from "../index"; import { commitTbankConnectionBoundary } from "./connection-boundary"; 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.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((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ { ...accepted, errorCode: null, now, progress: { completed: 1, discovering: false, total: 1 }, resources: [], state: "succeeded", } /* eslint-enable unicorn/no-null */)); }), ); 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( () => ({ mutation: useTbankSync(), status: useTbankSyncStatus("work"), }), { 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, (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ { connected: false, connectedAt: null, } /* eslint-enable unicorn/no-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(); }, ); });