import { MantineProvider } from "@mantine/core"; import { type QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import { http, HttpResponse } from "msw"; import { MemoryRouter } from "react-router"; import { describe, expect, it, vi } from "vitest"; import { brokerageAccountsQueryKey } from "@/entities/brokerage-account"; import { configureAuthClient } from "@/shared/api"; import { createTestQueryClient } from "@/test/query"; import { resource } from "@/test/resource"; import { server } from "@/test/server"; import { AccountsPage } from "./AccountsPage"; import { AccountsPageEmpty } from "./components/AccountsPageEmpty.view"; import { AccountsPageError } from "./components/AccountsPageError.view"; import { AccountsPageLoading } from "./components/AccountsPageLoading.view"; const account = { id: "account-1", name: "Основной", openedOn: "2026-01-01", type: "BROKERAGE", } as const; function configureTestAuth() { configureAuthClient({ accessToken: () => "test-token", clear: vi.fn(), refresh: () => Promise.resolve(true), }); } function portfolioSummary(totals: { currency: string; value: string }) { return { bondPositionCount: 1, cash: { currency: "RUB", value: "0" }, fundPositionCount: 0, stockPositionCount: 0, structure: { categories: [], hasNegativeValues: false, totalValue: "0", unvaluedCategories: [], }, totalPositionCount: 1, totals, unrealizedReturn: { absolute: "0", costBasis: "0", partial: false, percent: null, }, }; } function renderPage(queryClient: QueryClient) { configureTestAuth(); return render( , ); } describe("AccountsPage", () => { it("summarizes account totals and reports excluded accounts", async () => { server.use(http.get("*/api/v1/brokerage-accounts", () => HttpResponse.json(resource([ { ...account, portfolioSummary: portfolioSummary({ currency: "RUB", value: "100.10" }), }, { ...account, id: "account-2", name: "Второй", portfolioSummary: portfolioSummary({ currency: "RUB", value: "50.20" }), }, { ...account, id: "account-3", name: "Без сводки", portfolioSummary: null, }, { ...account, id: "account-4", name: "Только деньги", portfolioSummary: { ...portfolioSummary({ currency: "RUB", value: "25" }), bondPositionCount: 0, cash: { currency: "RUB", value: "25" }, totalPositionCount: 0, }, }, { ...account, id: "account-5", name: "Без сумм", portfolioSummary: { ...portfolioSummary({ currency: "RUB", value: "0" }), totalPositionCount: 0, }, }, ])), )); const client = createTestQueryClient(); renderPage(client); expect(await screen.findByText("175,30 ₽")).toBeInTheDocument(); expect(screen.queryByText("Ваши инвестиции по счетам")) .not.toBeInTheDocument(); expect(screen.getByText("5 счетов")).toBeInTheDocument(); expect(screen.getByText("Нет текущих сумм")).toBeInTheDocument(); expect(screen.getByText( "Ещё 1 счёт не включён: нет актуального портфеля", )).toBeInTheDocument(); }); it("loads accounts after retrying an initial HTTP failure", async () => { const modes: (null | string)[] = []; server.use( http.get("*/api/v1/brokerage-accounts", ({ request }) => { modes.push(new URL(request.url).searchParams.get("sync")); return modes.length === 1 ? HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 }) : HttpResponse.json(resource([account])); }), ); renderPage(createTestQueryClient()); expect(await screen.findByText(account.name, undefined, { timeout: 4000, })).toBeVisible(); expect(modes).toEqual([null, null]); }); it.each([ ["loading", AccountsPageLoading, "status"], ["empty", AccountsPageEmpty, undefined], ] as const)("renders only the %s state", (_name, State, role) => { render( , ); // eslint-disable-next-line vitest/no-conditional-expect -- The fixed table case selects the expected outcome; every case asserts its own branch. if (role) expect(screen.getByRole(role)).toBeInTheDocument(); else // eslint-disable-next-line vitest/no-conditional-expect -- The fixed table case selects the expected outcome; every case asserts its own branch. expect( screen.getByRole("heading", { name: "Нет инвестиционных счетов" }), ).toBeInTheDocument(); }); it("routes connection errors to settings without technical retry", () => { render( , ); expect(screen.queryByRole("button", { name: "Повторить" })) .not.toBeInTheDocument(); expect( screen.getByRole("link", { name: "Открыть настройки T-Bank" }), ).toHaveAttribute("href", "/settings"); }); it("keeps the cached account list visible during a background refresh", async () => { let requestCount = 0; let resolveRefresh: ((response: Response) => void) | undefined; server.use( http.get("*/api/v1/brokerage-accounts", () => { requestCount += 1; if (requestCount === 1) return HttpResponse.json(resource([account])); { const deferred = Promise.withResolvers(); resolveRefresh = deferred.resolve; return deferred.promise; } }), ); const queryClient = createTestQueryClient(); renderPage(queryClient); await screen.findByText(account.name); void queryClient.invalidateQueries({ queryKey: brokerageAccountsQueryKey, }); await waitFor(() => expect(requestCount).toBe(2)); expect(screen.getByText(account.name)).toBeInTheDocument(); expect(screen.queryByRole("status", { name: "Обновляем данные" })) .not.toBeInTheDocument(); resolveRefresh?.(HttpResponse.json(resource([account]))); }); it("keeps the cached account list quietly after a refresh error", async () => { let requestCount = 0; server.use( http.get("*/api/v1/brokerage-accounts", () => { requestCount += 1; if (requestCount === 1) return HttpResponse.json(resource([account])); return requestCount === 2 ? HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 }) : HttpResponse.json(resource([account])); }), ); const queryClient = createTestQueryClient(); renderPage(queryClient); await screen.findByText(account.name); void queryClient.invalidateQueries({ queryKey: brokerageAccountsQueryKey, }); await waitFor(() => expect(requestCount).toBe(2)); expect( screen.queryByText( "Не удалось обновить данные. Показаны последние полученные.", ), ).not.toBeInTheDocument(); expect(screen.queryByRole("button", { name: "Повторить" })) .not.toBeInTheDocument(); expect(screen.getByText(account.name)).toBeInTheDocument(); }); it("keeps a cached empty account list visible after a refresh error", async () => { let requestCount = 0; server.use( http.get("*/api/v1/brokerage-accounts", () => { requestCount += 1; return requestCount === 1 ? HttpResponse.json(resource([])) : HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 }); }), ); const queryClient = createTestQueryClient(); renderPage(queryClient); await screen.findByText("Нет инвестиционных счетов"); void queryClient.invalidateQueries({ queryKey: brokerageAccountsQueryKey, }); await waitFor(() => expect(requestCount).toBe(2)); expect( screen.queryByText( "Не удалось обновить данные. Показаны последние полученные.", ), ).not.toBeInTheDocument(); expect(screen.getByText("Нет инвестиционных счетов")).toBeInTheDocument(); }); }); // Automatic read retries wait 1s + 2s before the failure is final. const RETRIES_EXHAUSTED_MS = 6000; const TEST_MS = 10_000; describe("exhausted read retries", () => { it( "keeps the cached list, warns and retries the same accounts GET", { timeout: TEST_MS }, async () => { let isFailing = false; let requests = 0; server.use( http.get("*/api/v1/brokerage-accounts", () => { requests += 1; if (isFailing) return HttpResponse.json( { code: "TBANK_UNAVAILABLE" }, { status: 503 }, ); return HttpResponse.json(resource([account])); }), ); const queryClient = createTestQueryClient(); renderPage(queryClient); await screen.findByText("Основной"); expect(screen.queryByRole("button", { name: "Повторить" })) .not.toBeInTheDocument(); isFailing = true; void queryClient.invalidateQueries({ queryKey: brokerageAccountsQueryKey, }); const retry = await screen.findByRole( "button", { name: "Повторить" }, { timeout: RETRIES_EXHAUSTED_MS }, ); expect(screen.getByText("Не удалось обновить данные")).toBeInTheDocument(); expect(screen.getByText("Основной")).toBeInTheDocument(); expect(requests).toBe(4); isFailing = false; fireEvent.click(retry); await waitFor(() => expect(screen.queryByText("Не удалось обновить данные")) .not.toBeInTheDocument()); expect(requests).toBe(5); expect(screen.getByText("Основной")).toBeInTheDocument(); }, ); it( "offers retry on the unavailable state after an initial failure", { timeout: TEST_MS }, async () => { let isFailing = true; server.use( http.get("*/api/v1/brokerage-accounts", () => isFailing ? HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 }) : HttpResponse.json(resource([account]))), ); renderPage(createTestQueryClient()); const retry = await screen.findByRole( "button", { name: "Повторить" }, { timeout: RETRIES_EXHAUSTED_MS }, ); isFailing = false; fireEvent.click(retry); expect(await screen.findByText("Основной")).toBeInTheDocument(); }, ); it("does not offer a plain retry when the connection is lost", async () => { server.use( http.get("*/api/v1/brokerage-accounts", () => HttpResponse.json({ code: "TBANK_NOT_CONNECTED" }, { status: 409 })), ); renderPage(createTestQueryClient()); expect(await screen.findByRole("link", { name: "Открыть настройки T-Bank" })) .toBeInTheDocument(); expect(screen.queryByRole("button", { name: "Повторить" })) .not.toBeInTheDocument(); }); }); it.each(["none", "failed"] as const)( "keeps incomplete empty membership distinct from no accounts (attempt=%s)", async (state) => { server.use( http.get("*/api/v1/brokerage-accounts", () => HttpResponse.json(resource([], { completeness: "incomplete", lastAttempt: { finishedAt: state === "failed" ? "2026-08-14T00:01:00Z" : null, problems: [], startedAt: state === "none" ? null : "2026-08-14T00:00:00Z", state, }, })), ), ); const client = createTestQueryClient(); renderPage(client); await waitFor(() => expect(client.getQueryData(brokerageAccountsQueryKey)).toMatchObject({ data: [], meta: { completeness: "incomplete" }, }), ); expect(screen.queryByText("Нет инвестиционных счетов")) .not.toBeInTheDocument(); if (state === "failed") // eslint-disable-next-line vitest/no-conditional-expect -- Each fixed state has a different observable recovery contract. expect(await screen.findByRole("heading", { name: "Пока недоступно" })) .toBeVisible(); else // eslint-disable-next-line vitest/no-conditional-expect -- Each fixed state has a different observable recovery contract. expect(screen.getByRole("status")).toBeVisible(); }, ); it("keeps unknown membership loading instead of declaring no accounts", async () => { server.use( http.get("*/api/v1/brokerage-accounts", () => HttpResponse.json(resource(null)), ), ); const client = createTestQueryClient(); renderPage(client); await waitFor(() => expect(client.getQueryData(brokerageAccountsQueryKey)).toMatchObject( { data: null, }, ), ); expect( screen.queryByText("Нет инвестиционных счетов"), ).not.toBeInTheDocument(); expect(screen.getByRole("status")).toBeVisible(); });