import { MantineProvider } from "@mantine/core"; import { type QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { fireEvent, render, screen, waitFor, within, } from "@testing-library/react"; import { http, HttpResponse } from "msw"; import { describe, expect, it, vi } from "vitest"; import { apiQueryClient, configureAuthClient } from "@/shared/api"; import { createTestQueryClient } from "@/test/query"; import { server } from "@/test/server"; import { tbankConnectionQueryOptions } from "../../model/connection-boundary"; import { TbankConnectionSection } from "./TbankConnectionSection"; const brokerageAccountsQueryKey = apiQueryClient.queryOptions( "get", "/api/v1/brokerage-accounts", ).queryKey; const portfolioQueryKey = apiQueryClient.queryOptions( "get", "/api/v1/brokerage-accounts/{accountId}/portfolio", { params: { path: { accountId: "account-1" } } }, ).queryKey; const operationsQueryKey = apiQueryClient.queryOptions( "get", "/api/v1/brokerage-accounts/{accountId}/operations", { params: { path: { accountId: "account-1" } } }, ).queryKey; const stockQueryKey = apiQueryClient.queryOptions( "get", "/api/v1/stocks/{uid}/position", { params: { path: { uid: "e6123145-9665-43e0-8413-cd61b8aa9b13" } } }, ).queryKey; const bondQueryKey = apiQueryClient.queryOptions( "get", "/api/v1/bonds/{uid}/position", { params: { path: { uid: "e6123145-9665-43e0-8413-cd61b8aa9b13" } } }, ).queryKey; const bankCardsQueryKey = apiQueryClient.queryOptions( "get", "/api/v1/bank-cards", { params: { query: { includeArchived: "false" } }, }, ).queryKey; const unrelatedQueryKey = apiQueryClient.queryOptions( "get", "/api/v1/auth/me", ).queryKey; function renderSection(client: QueryClient) { configureAuthClient({ accessToken: () => "test-token", clear: vi.fn(), refresh: () => Promise.resolve(true), }); return render( , ); } function seededClient() { const client = createTestQueryClient(); client.setQueryData(brokerageAccountsQueryKey, [ { id: "account-1", name: "Основной счет", openedOn: null, type: "BROKERAGE", }, ]); client.setQueryData(portfolioQueryKey, { source: "previous-connection" }); client.setQueryData(stockQueryKey, { source: "previous-connection" }); client.setQueryData(bondQueryKey, { source: "previous-connection" }); client.setQueryData(unrelatedQueryKey, { id: "user-1" }); client.setQueryData(bankCardsQueryKey, [{ id: "bank-card-1" }]); return client; } describe("TbankConnectionSection", () => { it("preserves cached status and a draft entered during a delayed failed refetch", async () => { const status = { connected: true, connectedAt: "2026-08-14T00:00:00Z" }; const { promise: response, resolve: finishRefetch } = Promise.withResolvers(); let reads = 0; server.use( http.get("*/api/v1/tbank-connection", async () => { reads += 1; if (reads === 1) { await response; return HttpResponse.json( { code: "TBANK_UNAVAILABLE" }, { status: 503 }, ); } return HttpResponse.json(status); }), ); const client = seededClient(); client.setQueryData(tbankConnectionQueryOptions().queryKey, status); renderSection(client); await waitFor(() => expect(reads).toBe(1)); const token = screen.getByLabelText("Token T-Bank"); fireEvent.change(token, { target: { value: "replacement-draft" } }); finishRefetch?.(); expect( await screen.findByText(/Не удалось обновить статус подключения/), ).toBeVisible(); expect(screen.getByLabelText("Token T-Bank")).toBe(token); expect(token).toHaveValue("replacement-draft"); expect(screen.getByText("Подключено с 14.08.2026")).toBeVisible(); expect(client.getQueryData(tbankConnectionQueryOptions().queryKey)).toEqual( status, ); expect(client.getQueryData(portfolioQueryKey)).toEqual({ source: "previous-connection", }); fireEvent.click(screen.getByRole("button", { name: "Повторить" })); await waitFor(() => expect(reads).toBe(2)); await waitFor(() => expect( screen.queryByText(/Не удалось обновить статус подключения/), ).not.toBeInTheDocument(), ); expect(token).toHaveValue("replacement-draft"); }); it("shows a cold-start error without a form and recovers by retrying status", async () => { let reads = 0; server.use( http.get("*/api/v1/tbank-connection", () => { reads += 1; return reads === 1 ? HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 }) : HttpResponse.json( { connected: false, connectedAt: null }, ); }), ); renderSection(createTestQueryClient()); expect( await screen.findByText(/Не удалось загрузить статус подключения/), ).toBeVisible(); expect(screen.queryByLabelText("Token T-Bank")).not.toBeInTheDocument(); fireEvent.click(screen.getByRole("button", { name: "Повторить" })); expect(await screen.findByLabelText("Token T-Bank")).toBeVisible(); expect(reads).toBe(2); }); it("commits connection status and resets the complete brokerage query subtree after connecting", async () => { const client = seededClient(); renderSection(client); await screen.findByText("Не подключено"); fireEvent.change(screen.getByLabelText("Token T-Bank"), { target: { value: "replacement-token" }, }); fireEvent.click(screen.getByRole("button", { name: "Подключить" })); expect( await screen.findByRole("button", { name: "Заменить token" }), ).toBeInTheDocument(); expect(client.getQueryData(brokerageAccountsQueryKey)).toBeUndefined(); expect(client.getQueryData(bankCardsQueryKey)).toEqual([ { id: "bank-card-1" }, ]); expect(client.getQueryState(bankCardsQueryKey)?.isInvalidated).toBe(false); expect(client.getQueryData(portfolioQueryKey)).toBeUndefined(); expect(client.getQueryData(stockQueryKey)).toBeUndefined(); expect(client.getQueryData(bondQueryKey)).toBeUndefined(); expect(client.getQueryData(unrelatedQueryKey)).toEqual({ id: "user-1" }); }); it("creates the same financial boundary when an existing connection is replaced", async () => { server.use( http.get("*/api/v1/tbank-connection", () => HttpResponse.json({ connected: true, connectedAt: "2026-08-13T00:00:00Z", }), ), ); const client = seededClient(); renderSection(client); await screen.findByRole("button", { name: "Заменить token" }); fireEvent.change(screen.getByLabelText("Token T-Bank"), { target: { value: "replacement-token" }, }); fireEvent.click(screen.getByRole("button", { name: "Заменить token" })); await waitFor(() => expect(client.getQueryData(portfolioQueryKey)).toBeUndefined(), ); expect(client.getQueryData(brokerageAccountsQueryKey)).toBeUndefined(); expect(client.getQueryData(bankCardsQueryKey)).toEqual([ { id: "bank-card-1" }, ]); expect(client.getQueryState(bankCardsQueryKey)?.isInvalidated).toBe(false); }); it("commits disconnected status and resets financial queries after deleting", async () => { server.use( http.get("*/api/v1/tbank-connection", () => HttpResponse.json({ connected: true, connectedAt: "2026-08-14T00:00:00Z", }), ), ); const client = seededClient(); renderSection(client); fireEvent.click( await screen.findByRole("button", { name: "Удалить подключение" }), ); fireEvent.click(await screen.findByRole("button", { name: /^Удалить$/ })); expect( await screen.findByRole("button", { name: "Подключить" }), ).toBeInTheDocument(); expect(client.getQueryData(brokerageAccountsQueryKey)).toBeUndefined(); expect(client.getQueryData(bankCardsQueryKey)).toEqual([ { id: "bank-card-1" }, ]); expect(client.getQueryState(bankCardsQueryKey)?.isInvalidated).toBe(false); expect(client.getQueryData(portfolioQueryKey)).toBeUndefined(); expect(client.getQueryData(stockQueryKey)).toBeUndefined(); expect(client.getQueryData(bondQueryKey)).toBeUndefined(); expect(client.getQueryData(unrelatedQueryKey)).toEqual({ id: "user-1" }); }); it("cancels an in-flight previous-boundary query and ignores its late response", async () => { const client = seededClient(); let isAborted = false; const { promise: previousResponse, resolve: finishPrevious } = Promise.withResolvers<{ source: string }>(); const previousRequest = client .query({ queryFn: ({ signal }) => { signal.addEventListener("abort", () => { isAborted = true; }); return previousResponse; }, queryKey: operationsQueryKey, }) // eslint-disable-next-line unicorn/prefer-await -- Attach the handler immediately and retain the pending promise for queue/race ordering. .catch(() => {}); renderSection(client); await screen.findByText("Не подключено"); fireEvent.change(screen.getByLabelText("Token T-Bank"), { target: { value: "replacement-token" }, }); fireEvent.click(screen.getByRole("button", { name: "Подключить" })); await waitFor(() => expect(isAborted).toBe(true)); await previousRequest; finishPrevious?.({ source: "previous-connection" }); await Promise.resolve(); expect(client.getQueryData(operationsQueryKey)).toBeUndefined(); }); it.each([ ["INVALID_TBANK_TOKEN", "Проверьте token и его права."], ["TBANK_UNAVAILABLE", "T-Bank временно недоступен. Повторите попытку позже."], [undefined, "Не удалось выполнить запрос. Попробуйте ещё раз."], ])("preserves the draft and cache after %s, then retries successfully", async (code, message) => { const { promise: response, resolve: finishUpdate } = Promise.withResolvers(); const update = vi.fn() .mockImplementationOnce(() => HttpResponse.json({ code }, { status: 503 }), ) .mockImplementationOnce(() => response); server.use(http.put("*/api/v1/tbank-connection", update)); const client = seededClient(); renderSection(client); await screen.findByText("Не подключено"); const token = screen.getByLabelText("Token T-Bank"); fireEvent.change(token, { target: { value: "replacement-token" } }); fireEvent.click(screen.getByRole("button", { name: "Подключить" })); await screen.findByText(message); expect(token).toHaveValue("replacement-token"); expect(screen.getByText("Не подключено")).toBeVisible(); expect(client.getQueryData(brokerageAccountsQueryKey)).toBeDefined(); expect(client.getQueryData(portfolioQueryKey)).toEqual({ source: "previous-connection", }); fireEvent.click(screen.getByRole("button", { name: "Подключить" })); await waitFor(() => expect(update).toHaveBeenCalledTimes(2)); expect(screen.queryByText(message)).not.toBeInTheDocument(); expect(token).toBeDisabled(); expect(token).toHaveValue("replacement-token"); finishUpdate(HttpResponse.json({ connected: true, connectedAt: "2026-08-14T00:00:00Z", })); expect(await screen.findByText("Подключено с 14.08.2026")).toBeVisible(); expect(token).toHaveValue(""); expect(screen.queryByText(message)).not.toBeInTheDocument(); }); it("keeps DELETE recovery inside the dialog and preserves cache until successful retry", async () => { let deletes = 0; const { promise: response, resolve: finishDelete } = Promise.withResolvers(); server.use( http.get("*/api/v1/tbank-connection", () => HttpResponse.json({ connected: true, connectedAt: "2026-08-14T00:00:00Z", }), ), http.delete("*/api/v1/tbank-connection", async () => { deletes += 1; if (deletes === 1) await response; return deletes === 1 ? HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 }) : new HttpResponse(null, { status: 204 }); }), ); const client = seededClient(); renderSection(client); fireEvent.click( await screen.findByRole("button", { name: "Удалить подключение" }), ); const dialog = await screen.findByRole("dialog", { name: "Удалить подключение?", }); await waitFor(() => // eslint-disable-next-line testing-library/no-node-access -- This assertion checks DOM structure, decoration or focus behavior that semantic queries cannot establish. expect(dialog.contains(document.activeElement)).toBe(true), ); const confirm = within(dialog).getByRole("button", { name: /^Удалить$/ }); confirm.focus(); fireEvent.click(confirm); await waitFor(() => expect(confirm).toBeDisabled()); expect( within(dialog).getByRole("button", { name: "Закрыть" }), ).toBeDisabled(); // JSDOM does not move focus when a focused button becomes disabled. document.body.tabIndex = -1; document.body.focus(); document.body.removeAttribute("tabindex"); expect(document.body).toHaveFocus(); finishDelete?.(); expect(await within(dialog).findByRole("alert")).toHaveTextContent( "Не удалось выполнить запрос. Попробуйте ещё раз.", ); expect(confirm).toHaveFocus(); expect( screen.getByRole("button", { name: "Заменить token" }), ).toBeInTheDocument(); expect(client.getQueryData(brokerageAccountsQueryKey)).toBeDefined(); expect(client.getQueryData(portfolioQueryKey)).toEqual({ source: "previous-connection", }); fireEvent.click(within(dialog).getByRole("button", { name: /^Удалить$/ })); await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument(), ); expect(screen.getByText("Не подключено")).toBeVisible(); expect(client.getQueryData(portfolioQueryKey)).toBeUndefined(); expect(deletes).toBe(2); }); });