From 1a9199a92b550af0a6eff1f476c46bb0c9d61cb2 Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Tue, 3 Nov 2026 08:29:43 +0300 Subject: [PATCH 1/7] feat(shared): add read-failure classifier and ReadRetry (WealthLedger/frontend#55) --- src/shared/api/index.ts | 1 + src/shared/api/read-failure.test.ts | 80 ++++++++++++++++++++++ src/shared/api/read-failure.ts | 37 ++++++++++ src/shared/i18n/locales/ru.ts | 2 + src/shared/ui/ReadRetry/ReadRetry.test.tsx | 37 ++++++++++ src/shared/ui/ReadRetry/ReadRetry.tsx | 42 ++++++++++++ src/shared/ui/index.ts | 1 + 7 files changed, 200 insertions(+) create mode 100644 src/shared/api/read-failure.test.ts create mode 100644 src/shared/api/read-failure.ts create mode 100644 src/shared/ui/ReadRetry/ReadRetry.test.tsx create mode 100644 src/shared/ui/ReadRetry/ReadRetry.tsx diff --git a/src/shared/api/index.ts b/src/shared/api/index.ts index bbd54da..60bc969 100644 --- a/src/shared/api/index.ts +++ b/src/shared/api/index.ts @@ -13,6 +13,7 @@ export { isApiErrorCode, isBrokerageAccessLoss, } from "./problem-details"; +export { isReadFailureExhausted, readRetry } from "./read-failure"; export { type InvestmentResourceMeta, diff --git a/src/shared/api/read-failure.test.ts b/src/shared/api/read-failure.test.ts new file mode 100644 index 0000000..0824687 --- /dev/null +++ b/src/shared/api/read-failure.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it, vi } from "vitest"; + +import { isReadFailureExhausted, readRetry } from "./read-failure"; + +const transient = { code: "TBANK_UNAVAILABLE", status: 503 }; +const settled = { isError: true, isFetching: false }; + +describe("isReadFailureExhausted", () => { + it("is true for a settled transient failure, even without a code", () => { + expect(isReadFailureExhausted({ ...settled, error: transient })).toBe(true); + expect( + isReadFailureExhausted({ + ...settled, + error: new TypeError("Network request failed"), + }), + ).toBe(true); + }); + + it("stays true while a manual retry runs and false without a failure", () => { + expect( + isReadFailureExhausted({ + error: transient, + isError: true, + isFetching: true, + }), + ).toBe(true); + expect( + isReadFailureExhausted({ + error: null, + isError: false, + isFetching: false, + }), + ).toBe(false); + }); + + it.each([ + "INVALID_TBANK_TOKEN", + "NOT_FOUND", + "TBANK_NOT_CONNECTED", + "UNAUTHORIZED", + ])("is false for %s, which has its own action", (code) => { + expect(isReadFailureExhausted({ ...settled, error: { code } })).toBe(false); + }); +}); + +describe("readRetry", () => { + it("refetches only the failed reads and reports a running retry", () => { + const failedRefetch = vi.fn(() => Promise.resolve()); + const healthyRefetch = vi.fn(() => Promise.resolve()); + + const retry = readRetry([ + { ...settled, error: transient, refetch: failedRefetch }, + { + error: null, + isError: false, + isFetching: true, + refetch: healthyRefetch, + }, + { ...settled, error: { code: "NOT_FOUND" }, refetch: healthyRefetch }, + ]); + + expect(retry).toMatchObject({ exhausted: true, retrying: false }); + retry.onRetry(); + expect(failedRefetch).toHaveBeenCalledOnce(); + expect(healthyRefetch).not.toHaveBeenCalled(); + }); + + it("is not exhausted while every read is healthy", () => { + expect( + readRetry([ + { + error: null, + isError: false, + isFetching: false, + refetch: vi.fn(() => Promise.resolve()), + }, + ]).exhausted, + ).toBe(false); + }); +}); diff --git a/src/shared/api/read-failure.ts b/src/shared/api/read-failure.ts new file mode 100644 index 0000000..99ad715 --- /dev/null +++ b/src/shared/api/read-failure.ts @@ -0,0 +1,37 @@ +import { + ApiErrorCode, + apiErrorCode, + isApiErrorCode, + isBrokerageAccessLoss, +} from "./problem-details"; + +type ReadState = { error: unknown; isError: boolean; isFetching: boolean }; + +type RetryableRead = ReadState & { refetch: () => Promise }; + +// Automatic retries run inside one fetchAppRead call and Query's own retry is +// off, so a query in the error state has exhausted them. The state stays +// "error" while a manual retry runs, which keeps the warning in place. Access +// loss and a lost session keep their own actions and never offer a plain retry. +function isReadFailureExhausted(state: ReadState): boolean { + if (!state.isError) return false; + const code = apiErrorCode(state.error); + + return !isBrokerageAccessLoss(code) + && !isApiErrorCode(code, ApiErrorCode.Unauthorized); +} + +// Retry repeats only the GET of the failed queries, never a source sync. +function readRetry(reads: RetryableRead[]) { + const failed = reads.filter(read => isReadFailureExhausted(read)); + + return { + exhausted: failed.length > 0, + onRetry: () => { + void Promise.allSettled(failed.map(read => read.refetch())); + }, + retrying: failed.some(read => read.isFetching), + }; +} + +export { isReadFailureExhausted, readRetry }; diff --git a/src/shared/i18n/locales/ru.ts b/src/shared/i18n/locales/ru.ts index 1e32cb3..d6fcc35 100644 --- a/src/shared/i18n/locales/ru.ts +++ b/src/shared/i18n/locales/ru.ts @@ -421,7 +421,9 @@ const ru = { }, history: "История догружается", partial: "Период загружен не полностью", + readFailed: "Не удалось обновить данные", refresh: "Обновить", + retry: "Повторить", sourceTime: "Данные на {{date}}", stale: "Данные требуют обновления", syncFailed: diff --git a/src/shared/ui/ReadRetry/ReadRetry.test.tsx b/src/shared/ui/ReadRetry/ReadRetry.test.tsx new file mode 100644 index 0000000..86f0b97 --- /dev/null +++ b/src/shared/ui/ReadRetry/ReadRetry.test.tsx @@ -0,0 +1,37 @@ +import { MantineProvider } from "@mantine/core"; +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +import { ReadRetry } from "./ReadRetry"; + +function renderRetry(props: Parameters[0]) { + return render( + + + , + ); +} + +describe("ReadRetry", () => { + it("renders nothing until the automatic retries are exhausted", () => { + renderRetry({ exhausted: false, onRetry: vi.fn() }); + + expect(screen.queryByRole("status")).not.toBeInTheDocument(); + expect(screen.queryByRole("button")).not.toBeInTheDocument(); + }); + + it("announces the failure politely and retries on activation", () => { + const onRetry = vi.fn(); + renderRetry({ exhausted: true, onRetry }); + + expect(screen.getByRole("status")).toHaveTextContent("Не удалось обновить данные"); + fireEvent.click(screen.getByRole("button", { name: "Повторить" })); + expect(onRetry).toHaveBeenCalledOnce(); + }); + + it("disables the action while the retry runs", () => { + renderRetry({ exhausted: true, onRetry: vi.fn(), retrying: true }); + + expect(screen.getByRole("button", { name: "Повторить" })).toBeDisabled(); + }); +}); diff --git a/src/shared/ui/ReadRetry/ReadRetry.tsx b/src/shared/ui/ReadRetry/ReadRetry.tsx new file mode 100644 index 0000000..eafa933 --- /dev/null +++ b/src/shared/ui/ReadRetry/ReadRetry.tsx @@ -0,0 +1,42 @@ +import type { FC } from "react"; + +import { Alert, Button, Group, Text } from "@mantine/core"; +import { useTranslation } from "react-i18next"; + +// Shown only after the automatic read retries are exhausted. The action repeats +// the failed GET of the affected query; it never starts a source sync. +type ReadRetryProps = { + exhausted: boolean; + onRetry: () => void; + retrying?: boolean; +}; + +const ReadRetryButton: FC> = ( + props, +) => { + const { t } = useTranslation(); + + return ( + + ); +}; + +const ReadRetry: FC = (props) => { + const { t } = useTranslation(); + + if (!props.exhausted) return null; + + return ( + + + {t("investment.readFailed")} + + + + + ); +}; + +export { ReadRetry, ReadRetryButton, type ReadRetryProps }; diff --git a/src/shared/ui/index.ts b/src/shared/ui/index.ts index 88a1681..495ee4f 100644 --- a/src/shared/ui/index.ts +++ b/src/shared/ui/index.ts @@ -5,4 +5,5 @@ export { EntryGroupTotal, } from "./EntryGroup/EntryGroup"; export { PayoutCard } from "./PayoutCard/PayoutCard"; +export { ReadRetry, ReadRetryButton } from "./ReadRetry/ReadRetry"; export { SourceSyncStatus } from "./SourceSyncStatus/SourceSyncStatus"; -- 2.54.0 From 3a01a3a5bc025a80f75ce35b654471f46e2a9fed Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Tue, 3 Nov 2026 08:35:13 +0300 Subject: [PATCH 2/7] feat(account): offer retry after exhausted portfolio reads (WealthLedger/frontend#55) --- .../ui/AccountPage/AccountPage.test.tsx | 108 ++++++++++++++++++ .../account/ui/AccountPage/AccountPage.tsx | 2 + .../account/ui/AccountPage/AccountPage.vm.ts | 20 +++- .../components/AccountPageEmpty.view.tsx | 5 +- .../components/AccountPageError.view.tsx | 16 ++- .../components/AccountPageLoaded.view.tsx | 5 +- src/shared/ui/index.ts | 6 +- 7 files changed, 152 insertions(+), 10 deletions(-) diff --git a/src/pages/account/ui/AccountPage/AccountPage.test.tsx b/src/pages/account/ui/AccountPage/AccountPage.test.tsx index 37cbacf..9ab30b9 100644 --- a/src/pages/account/ui/AccountPage/AccountPage.test.tsx +++ b/src/pages/account/ui/AccountPage/AccountPage.test.tsx @@ -60,6 +60,10 @@ function configureTestAuth() { }); } +// Automatic read retries wait 1s + 2s before the failure is final. +const RETRIES_EXHAUSTED_MS = 6000; +const TEST_MS = 10_000; + function renderAccountRoute(queryClient: QueryClient, accountId = "account-1") { configureTestAuth(); @@ -1255,6 +1259,110 @@ describe("AccountPage states", () => { expect(screen.getByText("Вторая")).toBeInTheDocument(); }); + it( + "keeps cached totals, then warns and retries the same portfolio GET", + { timeout: TEST_MS }, + async () => { + let isFailing = false; + let requests = 0; + server.use( + http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () => { + requests += 1; + if (isFailing) + return HttpResponse.json( + { code: "TBANK_UNAVAILABLE" }, + { status: 503 }, + ); + + return HttpResponse.json(resource( + requests === 1 + ? portfolio + : { ...portfolio, totalsByCurrency: [{ currency: "RUB", value: "999" }] }, + { resourceKey: "portfolio" }, + )); + }), + ); + const queryClient = createTestQueryClient(); + renderAccountRoute(queryClient); + await screen.findByText("Вторая"); + expect(screen.queryByRole("button", { name: "Повторить" })) + .not.toBeInTheDocument(); + + isFailing = true; + void queryClient.invalidateQueries({ + queryKey: portfolioQueryOptions("account-1").queryKey, + }); + + const retry = await screen.findByRole( + "button", + { name: "Повторить" }, + { timeout: RETRIES_EXHAUSTED_MS }, + ); + + expect(screen.getByText("Не удалось обновить данные")).toBeInTheDocument(); + expect(screen.getByText("Вторая")).toBeInTheDocument(); + expect(screen.getByText("1 234,5 ₽")).toBeInTheDocument(); + // One cached read plus the three automatic attempts. + expect(requests).toBe(4); + + isFailing = false; + fireEvent.click(retry); + + expect(await screen.findByText("999 ₽")).toBeInTheDocument(); + expect(requests).toBe(5); + expect(screen.queryByText("Не удалось обновить данные")) + .not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Повторить" })) + .not.toBeInTheDocument(); + }, + ); + + it( + "offers retry after an initial temporary failure and recovers in place", + { timeout: TEST_MS }, + async () => { + let isFailing = true; + let requests = 0; + server.use( + http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () => { + requests += 1; + + return isFailing + ? HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 }) + : HttpResponse.json(resource(portfolio, { resourceKey: "portfolio" })); + }), + ); + renderAccountRoute(createTestQueryClient()); + + const retry = await screen.findByRole( + "button", + { name: "Повторить" }, + { timeout: RETRIES_EXHAUSTED_MS }, + ); + + expect(requests).toBe(3); + isFailing = false; + fireEvent.click(retry); + + expect(await screen.findByText("Вторая")).toBeInTheDocument(); + expect(requests).toBe(4); + }, + ); + + it.each(["NOT_FOUND", "UNAUTHORIZED"])( + "does not offer a plain retry for %s after a cached portfolio", + async (code) => { + server.use( + http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () => + HttpResponse.json({ code }, { status: code === "NOT_FOUND" ? 404 : 401 })), + ); + renderAccountRoute(createTestQueryClient()); + await waitFor(() => + expect(screen.queryByRole("heading", { name: "Загрузка" })).not.toBeInTheDocument()); + expect(screen.queryByRole("button", { name: "Повторить" })).not.toBeInTheDocument(); + }, + ); + it.each([503])( "preserves totals quietly through refresh error %i and success", async (status) => { diff --git a/src/pages/account/ui/AccountPage/AccountPage.tsx b/src/pages/account/ui/AccountPage/AccountPage.tsx index f9809bc..db138bc 100644 --- a/src/pages/account/ui/AccountPage/AccountPage.tsx +++ b/src/pages/account/ui/AccountPage/AccountPage.tsx @@ -16,6 +16,7 @@ const AccountPage: FC = () => { account={viewModel.account} accountId={viewModel.accountId} incomeSummary={viewModel.incomeSummary} + readRetry={viewModel.readRetry} recentEvents={viewModel.recentEvents} recentOperations={viewModel.recentOperations} source={viewModel.source} @@ -35,6 +36,7 @@ const AccountPage: FC = () => { partial={viewModel.partial} portfolio={viewModel.portfolio} positionSections={viewModel.positionSections} + readRetry={viewModel.readRetry} recentEvents={viewModel.recentEvents} recentOperations={viewModel.recentOperations} slice={viewModel.slice} diff --git a/src/pages/account/ui/AccountPage/AccountPage.vm.ts b/src/pages/account/ui/AccountPage/AccountPage.vm.ts index 622071a..77814d0 100644 --- a/src/pages/account/ui/AccountPage/AccountPage.vm.ts +++ b/src/pages/account/ui/AccountPage/AccountPage.vm.ts @@ -16,7 +16,7 @@ import { import { useIncomeSummary } from "@/entities/income"; import { usePortfolio } from "@/entities/portfolio"; import { useScreenSync } from "@/features/tbank-connection"; -import { apiErrorCode, isBrokerageAccessLoss } from "@/shared/api"; +import { apiErrorCode, isBrokerageAccessLoss, readRetry } from "@/shared/api"; import { useInitialAvailability } from "@/shared/lib"; import { @@ -45,6 +45,7 @@ type AccountPageContent = { accountId?: string; incomeSummary: AccountPageIncomeSummary; partial?: boolean; + readRetry: AccountPageReadRetry; recentEvents: AccountPageRecentEvents; recentOperations: AccountPageRecentOperations; source?: InvestmentResourceMeta; @@ -68,10 +69,12 @@ type AccountPageLoadedViewModel = AccountPageContent & { unrealizedReturns: Portfolio["totalsByCurrency"]; }; +type AccountPageReadRetry = ReturnType; + type AccountPageViewModel = | AccountPageEmptyViewModel | AccountPageLoadedViewModel - | { code?: string; state: "error" } + | { code?: string; readRetry: AccountPageReadRetry; state: "error" } | { state: "loading" }; function useAccountPageViewModel(): AccountPageViewModel { @@ -113,6 +116,8 @@ function useAccountPageViewModel(): AccountPageViewModel { ]); const code = apiErrorCode(portfolio.error); + // The portfolio drives this screen; the retry repeats only its GET. + const retry = readRetry([portfolio]); const isAccessFailure = isBrokerageAccessLoss(code); const availability = useInitialAvailability({ @@ -122,12 +127,13 @@ function useAccountPageViewModel(): AccountPageViewModel { isFetching: portfolio.isFetching, }); - if (isAccessFailure) return { code, state: "error" as const }; + if (isAccessFailure) + return { code, readRetry: retry, state: "error" as const }; if (!portfolio.data) - return { - state: availability === "unavailable" ? "error" : "loading", - } as const; + return availability === "unavailable" + ? { code, readRetry: retry, state: "error" as const } + : { state: "loading" as const }; const account = accounts.data?.find(item => item.id === accountId); const recentOperations = toRecentOperations(accountId, operations); @@ -144,6 +150,7 @@ function useAccountPageViewModel(): AccountPageViewModel { account, accountId, incomeSummary, + readRetry: retry, recentEvents, recentOperations, source: portfolio.meta, @@ -181,6 +188,7 @@ function useAccountPageViewModel(): AccountPageViewModel { partial: isPartial, portfolio: portfolio.data, positionSections: toAccountPagePositionSections(portfolio.data), + readRetry: retry, recentEvents, recentOperations, slice: current.slice, diff --git a/src/pages/account/ui/AccountPage/components/AccountPageEmpty.view.tsx b/src/pages/account/ui/AccountPage/components/AccountPageEmpty.view.tsx index 510c88c..8fe13e3 100644 --- a/src/pages/account/ui/AccountPage/components/AccountPageEmpty.view.tsx +++ b/src/pages/account/ui/AccountPage/components/AccountPageEmpty.view.tsx @@ -8,7 +8,7 @@ import type { BrokerageAccount } from "@/entities/brokerage-account"; import type { InvestmentResourceMeta } from "@/shared/api"; import { BrokerageEventsPreview } from "@/entities/brokerage-event"; -import { SourceSyncStatus } from "@/shared/ui"; +import { ReadRetry, type ReadRetryProps, SourceSyncStatus } from "@/shared/ui"; import { PortfolioStructure } from "@/widgets/portfolio-structure"; import type { @@ -25,6 +25,7 @@ type AccountPageEmptyProps = { account?: BrokerageAccount; accountId?: string; incomeSummary?: AccountPageIncomeSummary; + readRetry?: ReadRetryProps; recentEvents: AccountPageRecentEvents; recentOperations: AccountPageRecentOperations; source?: InvestmentResourceMeta; @@ -49,6 +50,8 @@ const AccountPageEmpty: FC = (props) => { stale={props.source?.freshness === "stale"} /> + {!!props.readRetry && } + = (props) => { const { code } = props; @@ -43,8 +47,18 @@ const AccountPageError: FC = (props) => { /> ); + const retry = props.readRetry?.exhausted + ? ( + + ) + : undefined; + return ( diff --git a/src/pages/account/ui/AccountPage/components/AccountPageLoaded.view.tsx b/src/pages/account/ui/AccountPage/components/AccountPageLoaded.view.tsx index 8348501..86bbe73 100644 --- a/src/pages/account/ui/AccountPage/components/AccountPageLoaded.view.tsx +++ b/src/pages/account/ui/AccountPage/components/AccountPageLoaded.view.tsx @@ -15,7 +15,7 @@ import type { InvestmentResourceMeta } from "@/shared/api"; import { BrokerageEventsPreview } from "@/entities/brokerage-event"; import { formatMoney } from "@/shared/lib"; -import { SourceSyncStatus } from "@/shared/ui"; +import { ReadRetry, type ReadRetryProps, SourceSyncStatus } from "@/shared/ui"; import { PortfolioStructure } from "@/widgets/portfolio-structure"; import type { Concentration as ConcentrationModel, Slice } from "../../../model/concentration"; @@ -46,6 +46,7 @@ type AccountPageLoadedProps = { partial?: boolean; portfolio: Portfolio; positionSections: AccountPagePositionSection[]; + readRetry?: ReadRetryProps; recentEvents: AccountPageRecentEvents; recentOperations: AccountPageRecentOperations; slice: Slice; @@ -86,6 +87,8 @@ const AccountPageLoaded: FC = (props) => { stale={props.source?.freshness === "stale"} /> + {!!props.readRetry && } + {props.partial ? tab !== "concentration" && ( {t("investment.partial")} diff --git a/src/shared/ui/index.ts b/src/shared/ui/index.ts index 495ee4f..598d3ed 100644 --- a/src/shared/ui/index.ts +++ b/src/shared/ui/index.ts @@ -5,5 +5,9 @@ export { EntryGroupTotal, } from "./EntryGroup/EntryGroup"; export { PayoutCard } from "./PayoutCard/PayoutCard"; -export { ReadRetry, ReadRetryButton } from "./ReadRetry/ReadRetry"; +export { + ReadRetry, + ReadRetryButton, + type ReadRetryProps, +} from "./ReadRetry/ReadRetry"; export { SourceSyncStatus } from "./SourceSyncStatus/SourceSyncStatus"; -- 2.54.0 From d3335a7a8d2965e23f53f61f08d7a9cbf37dbaaa Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Tue, 3 Nov 2026 08:36:56 +0300 Subject: [PATCH 3/7] feat(accounts): offer retry after exhausted accounts reads (WealthLedger/frontend#55) --- .../ui/AccountsPage/AccountsPage.test.tsx | 97 ++++++++++++++++++- .../accounts/ui/AccountsPage/AccountsPage.tsx | 1 + .../ui/AccountsPage/AccountsPage.vm.ts | 33 +++++-- .../components/AccountsPageError.view.tsx | 16 ++- .../components/AccountsPageLoaded.view.tsx | 5 +- 5 files changed, 140 insertions(+), 12 deletions(-) diff --git a/src/pages/accounts/ui/AccountsPage/AccountsPage.test.tsx b/src/pages/accounts/ui/AccountsPage/AccountsPage.test.tsx index 6da65ee..9fc2416 100644 --- a/src/pages/accounts/ui/AccountsPage/AccountsPage.test.tsx +++ b/src/pages/accounts/ui/AccountsPage/AccountsPage.test.tsx @@ -1,6 +1,6 @@ import { MantineProvider } from "@mantine/core"; import { type QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { render, screen, waitFor } from "@testing-library/react"; +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"; @@ -261,6 +261,101 @@ describe("AccountsPage", () => { }); }); +// 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) => { diff --git a/src/pages/accounts/ui/AccountsPage/AccountsPage.tsx b/src/pages/accounts/ui/AccountsPage/AccountsPage.tsx index 8e02cdc..e141610 100644 --- a/src/pages/accounts/ui/AccountsPage/AccountsPage.tsx +++ b/src/pages/accounts/ui/AccountsPage/AccountsPage.tsx @@ -16,6 +16,7 @@ const AccountsPage: FC = () => { ); }; diff --git a/src/pages/accounts/ui/AccountsPage/AccountsPage.vm.ts b/src/pages/accounts/ui/AccountsPage/AccountsPage.vm.ts index 4f3252c..e9c1fef 100644 --- a/src/pages/accounts/ui/AccountsPage/AccountsPage.vm.ts +++ b/src/pages/accounts/ui/AccountsPage/AccountsPage.vm.ts @@ -4,14 +4,26 @@ import { useBrokerageAccounts, } from "@/entities/brokerage-account"; import { useScreenSync } from "@/features/tbank-connection"; -import { ApiErrorCode, apiErrorCode, isApiErrorCode } from "@/shared/api"; +import { + ApiErrorCode, + apiErrorCode, + isApiErrorCode, + readRetry, +} from "@/shared/api"; import { useInitialAvailability } from "@/shared/lib"; +type AccountsPageReadRetry = ReturnType; + type AccountsPageViewModel - = | { accounts: BrokerageAccountCard[]; meta: NonNullable["meta"]>; state: "loaded" } - | { code?: string; state: "error" } - | { state: "empty" } - | { state: "loading" }; + = | { + accounts: BrokerageAccountCard[]; + meta: NonNullable["meta"]>; + readRetry: AccountsPageReadRetry; + state: "loaded"; + } + | { code?: string; readRetry: AccountsPageReadRetry; state: "error" } + | { state: "empty" } + | { state: "loading" }; function isAccountsAccessFailure(code?: string) { return isApiErrorCode(code, ApiErrorCode.InvalidTbankToken) @@ -23,6 +35,7 @@ function useAccountsPageViewModel(): AccountsPageViewModel { useScreenSync({ screen: "accounts" }, [accounts]); const code = apiErrorCode(accounts.error); + const retry = readRetry([accounts]); const isAccessFailure = isAccountsAccessFailure(code); const hasResult = !!accounts.data @@ -36,12 +49,13 @@ function useAccountsPageViewModel(): AccountsPageViewModel { isFetching: accounts.isFetching, }); - if (isAccessFailure) return { code, state: "error" as const }; + if (isAccessFailure) + return { code, readRetry: retry, state: "error" as const }; if (!hasResult || !accounts.data) - return { - state: availability === "unavailable" ? "error" : "loading", - } as const; + return availability === "unavailable" + ? { code, readRetry: retry, state: "error" as const } + : { state: "loading" as const }; if (accounts.data.length === 0) return { @@ -54,6 +68,7 @@ function useAccountsPageViewModel(): AccountsPageViewModel { return { accounts: brokerageAccountCards(accounts.data, meta.dependencies), meta, + readRetry: retry, state: "loaded" as const, }; } diff --git a/src/pages/accounts/ui/AccountsPage/components/AccountsPageError.view.tsx b/src/pages/accounts/ui/AccountsPage/components/AccountsPageError.view.tsx index 7ad5c98..9d4cf6e 100644 --- a/src/pages/accounts/ui/AccountsPage/components/AccountsPageError.view.tsx +++ b/src/pages/accounts/ui/AccountsPage/components/AccountsPageError.view.tsx @@ -6,8 +6,12 @@ import { useTranslation } from "react-i18next"; import { Link } from "react-router"; import { ApiErrorCode, isApiErrorCode } from "@/shared/api"; +import { ReadRetryButton, type ReadRetryProps } from "@/shared/ui"; -type AccountsPageErrorProps = { code?: string }; +type AccountsPageErrorProps = { + code?: string; + readRetry?: ReadRetryProps; +}; const AccountsPageError: FC = (props) => { const { code } = props; @@ -38,8 +42,18 @@ const AccountsPageError: FC = (props) => { /> ); + const retry = props.readRetry?.exhausted + ? ( + + ) + : undefined; + return ( diff --git a/src/pages/accounts/ui/AccountsPage/components/AccountsPageLoaded.view.tsx b/src/pages/accounts/ui/AccountsPage/components/AccountsPageLoaded.view.tsx index 96d5d88..ef5e51e 100644 --- a/src/pages/accounts/ui/AccountsPage/components/AccountsPageLoaded.view.tsx +++ b/src/pages/accounts/ui/AccountsPage/components/AccountsPageLoaded.view.tsx @@ -11,7 +11,7 @@ import { BrokerageAccountList, } from "@/entities/brokerage-account"; import { formatMoney } from "@/shared/lib"; -import { SourceSyncStatus } from "@/shared/ui"; +import { ReadRetry, type ReadRetryProps, SourceSyncStatus } from "@/shared/ui"; const DECIMAL_BASE = 10n; const DECIMAL_PART_COUNT = 2; @@ -20,6 +20,7 @@ const ZERO = 0n; type AccountsPageLoadedProps = { accounts: BrokerageAccountCard[]; meta: InvestmentResourceMeta; + readRetry?: ReadRetryProps; }; type Money = { currency: string; value: string }; @@ -132,6 +133,8 @@ const AccountsPageLoaded: FC = (props) => { lastSuccessfulAt={props.meta.lastSuccessfulAt} stale={props.meta.freshness === "stale"} /> + + {!!props.readRetry && } + + {props.readRetry.exhausted && ( + + )} + + + )} text={t("ledger.errorText")} title={t("ledger.errorTitle")} diff --git a/src/pages/account-operations/ui/AccountOperationsPage/components/AccountOperationsPageLoaded.view.tsx b/src/pages/account-operations/ui/AccountOperationsPage/components/AccountOperationsPageLoaded.view.tsx index afe7301..447205a 100644 --- a/src/pages/account-operations/ui/AccountOperationsPage/components/AccountOperationsPageLoaded.view.tsx +++ b/src/pages/account-operations/ui/AccountOperationsPage/components/AccountOperationsPageLoaded.view.tsx @@ -19,7 +19,7 @@ import { OperationGroup, } from "@/entities/broker-operation"; import { groupItems } from "@/shared/lib"; -import { SourceSyncStatus } from "@/shared/ui"; +import { ReadRetry, SourceSyncStatus } from "@/shared/ui"; import type { AccountOperationsPageViewModel } from "../AccountOperationsPage.vm"; @@ -102,6 +102,8 @@ const AccountOperationsPageLoaded: FC = ( stale={props.source?.freshness === "stale"} /> + + {!!props.partial && {t("investment.partial")}} {!!props.historyLoading && {t("investment.history")}} -- 2.54.0 From c84b63385513e83d0c8b12c059197c775b8ea6ce Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Tue, 3 Nov 2026 08:42:28 +0300 Subject: [PATCH 6/7] feat(events): offer retry after exhausted events reads (WealthLedger/frontend#55) --- .../AccountEventsPage.retry.test.tsx | 155 ++++++++++++++++++ .../AccountEventsPage.test.tsx | 7 +- .../AccountEventsPage/AccountEventsPage.vm.ts | 16 +- .../AccountEventsPageError.view.tsx | 14 +- .../AccountEventsPageLoaded.view.tsx | 4 +- 5 files changed, 188 insertions(+), 8 deletions(-) create mode 100644 src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.retry.test.tsx diff --git a/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.retry.test.tsx b/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.retry.test.tsx new file mode 100644 index 0000000..db2644f --- /dev/null +++ b/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.retry.test.tsx @@ -0,0 +1,155 @@ +import { MantineProvider } from "@mantine/core"; +import { QueryClientProvider } from "@tanstack/react-query"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { http, HttpResponse } from "msw"; +import { MemoryRouter, Route, Routes } from "react-router"; +import { describe, expect, it, vi } from "vitest"; + +import { configureAuthClient } from "@/shared/api"; +import { createTestQueryClient } from "@/test/query"; +import { resource } from "@/test/resource"; +import { server } from "@/test/server"; + +import { AccountEventsPage } from "./AccountEventsPage"; + +// Automatic read retries wait 1s + 2s before the failure is final. +const RETRIES_EXHAUSTED_MS = 6000; +const TEST_MS = 10_000; + +const snapshot = resource({ + events: [ + { + date: "2026-10-01", + dateKind: "event", + displayName: "Synthetic coupon", + figi: null, + instrumentUid: null, + isin: null, + logoName: null, + projectedAmount: { currency: "RUB", value: "123.45" }, + ticker: "SYN", + type: "coupon", + }, + ], + incomeTotalsByCurrency: [{ currency: "RUB", value: "123.45" }], +}); + +function renderEvents() { + configureAuthClient({ + accessToken: () => "token", + clear: vi.fn(), + refresh: () => Promise.resolve(true), + }); + const queryClient = createTestQueryClient(); + + render( + + + + + } + path="/accounts/:accountId/events" + /> + + + + , + ); + + return { queryClient }; +} + +describe("AccountEventsPage read retry", () => { + it( + "offers retry after an initial failure and recovers in place", + { timeout: TEST_MS }, + async () => { + let isFailing = true; + let requests = 0; + server.use( + http.get("*/api/v1/brokerage-accounts/:accountId/events", () => { + requests += 1; + + return isFailing + ? HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 }) + : HttpResponse.json(snapshot); + }), + ); + renderEvents(); + + const retry = await screen.findByRole( + "button", + { name: "Повторить" }, + { timeout: RETRIES_EXHAUSTED_MS }, + ); + + expect(requests).toBe(3); + + isFailing = false; + fireEvent.click(retry); + + expect(await screen.findByText("Купон · Synthetic coupon")).toBeInTheDocument(); + expect(requests).toBe(4); + }, + ); + + it( + "keeps cached events, warns and retries the same GET", + { timeout: TEST_MS }, + async () => { + let isFailing = false; + let requests = 0; + server.use( + http.get("*/api/v1/brokerage-accounts/:accountId/events", () => { + requests += 1; + + return isFailing + ? HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 }) + : HttpResponse.json(snapshot); + }), + ); + const { queryClient } = renderEvents(); + await screen.findByText("Купон · Synthetic coupon"); + expect(screen.queryByRole("button", { name: "Повторить" })) + .not.toBeInTheDocument(); + + isFailing = true; + void queryClient.invalidateQueries({ + queryKey: ["get", "/api/v1/brokerage-accounts/{accountId}/events"], + }); + + const retry = await screen.findByRole( + "button", + { name: "Повторить" }, + { timeout: RETRIES_EXHAUSTED_MS }, + ); + + expect(screen.getByText("Не удалось обновить данные")).toBeInTheDocument(); + expect(screen.getByText("Купон · Synthetic coupon")).toBeInTheDocument(); + expect(requests).toBe(4); + + isFailing = false; + fireEvent.click(retry); + + await waitFor(() => + expect(screen.queryByText("Не удалось обновить данные")) + .not.toBeInTheDocument()); + expect(requests).toBe(5); + }, + ); + + it("does not offer a plain retry when the account is gone", async () => { + server.use( + http.get("*/api/v1/brokerage-accounts/:accountId/events", () => + HttpResponse.json({ code: "NOT_FOUND" }, { status: 404 })), + ); + renderEvents(); + + await waitFor(() => + expect(screen.queryByRole("heading", { name: "Загрузка" })) + .not.toBeInTheDocument()); + expect(screen.queryByRole("button", { name: "Повторить" })) + .not.toBeInTheDocument(); + }); +}); diff --git a/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.test.tsx b/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.test.tsx index 15994eb..7a3db60 100644 --- a/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.test.tsx +++ b/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.test.tsx @@ -169,15 +169,18 @@ describe("AccountEventsPage", () => { expect(screen.getAllByText("O")).toHaveLength(2); }); - it("renders empty and quiet error states", async () => { + it("renders empty and error states with retry", async () => { const { rerender } = renderPage(); expect( screen.getByText("За выбранный период событий не найдено"), ).toBeInTheDocument(); query.mockReturnValue({ data: undefined, + error: { code: "TBANK_UNAVAILABLE", status: 503 }, isError: true, + isFetching: false, isLoading: false, + refetch: vi.fn(), } as never); rerender( @@ -187,7 +190,7 @@ describe("AccountEventsPage", () => { , ); expect(await screen.findByRole("button", { name: "Сбросить" })).toBeVisible(); - expect(screen.queryByRole("button", { name: "Повторить" })).not.toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Повторить" })).toBeVisible(); }); it("shows a structural busy state before events are available", () => { diff --git a/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.vm.ts b/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.vm.ts index 92ef995..3190415 100644 --- a/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.vm.ts +++ b/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.vm.ts @@ -1,13 +1,12 @@ import { useState } from "react"; import { useParams } from "react-router"; -import type { InvestmentResourceMeta } from "@/shared/api"; - import { type BrokerageEvent, useBrokerageEvents, } from "@/entities/brokerage-event"; import { useScreenSync } from "@/features/tbank-connection"; +import { type InvestmentResourceMeta, readRetry } from "@/shared/api"; import { defaultPeriod, isValidPeriod, @@ -28,11 +27,16 @@ type AccountEventsPageViewModel onPeriodPopoverOpen: () => void; onReset: () => void; partial?: boolean; + readRetry: ReturnType; source?: InvestmentResourceMeta; state: "loaded"; totalVisible: boolean; } - | { onReset: () => void; state: "error" } + | { + onReset: () => void; + readRetry: ReturnType; + state: "error"; + } | { state: "loading" }; function useAccountEventsPageViewModel(): AccountEventsPageViewModel { @@ -83,6 +87,8 @@ function useAccountEventsPageViewModel(): AccountEventsPageViewModel { isFetching: events.isFetching, }); + const retry = readRetry([events]); + if (availability === "loading" && events.isLoading) return { state: "loading" }; @@ -94,11 +100,12 @@ function useAccountEventsPageViewModel(): AccountEventsPageViewModel { if ((events.isError || events.meta?.lastAttempt.state === "failed") && !events.data) return { onReset, + readRetry: retry, state: "error", }; if (!events.data) return availability === "unavailable" - ? { onReset, state: "error" } + ? { onReset, readRetry: retry, state: "error" } : { state: "loading" }; return { @@ -122,6 +129,7 @@ function useAccountEventsPageViewModel(): AccountEventsPageViewModel { onPeriodPopoverOpen: () => setMinDate(moscowDate(new Date())), onReset, partial: events.meta?.completeness === "incomplete", + readRetry: retry, source: events.meta, state: "loaded", totalVisible: events.meta?.completeness === "complete", diff --git a/src/pages/account-events/ui/AccountEventsPage/components/AccountEventsPageError.view.tsx b/src/pages/account-events/ui/AccountEventsPage/components/AccountEventsPageError.view.tsx index 86666b5..b36b3d2 100644 --- a/src/pages/account-events/ui/AccountEventsPage/components/AccountEventsPageError.view.tsx +++ b/src/pages/account-events/ui/AccountEventsPage/components/AccountEventsPageError.view.tsx @@ -4,7 +4,12 @@ import { Button, Group } from "@mantine/core"; import { StateView } from "@wealthledger/ui"; import { useTranslation } from "react-i18next"; -type AccountEventsPageErrorProps = { onReset: () => void }; +import { ReadRetryButton, type ReadRetryProps } from "@/shared/ui"; + +type AccountEventsPageErrorProps = { + onReset: () => void; + readRetry: ReadRetryProps; +}; const AccountEventsPageError: FC = (props) => { const { t } = useTranslation(); @@ -13,6 +18,13 @@ const AccountEventsPageError: FC = (props) => { + {props.readRetry.exhausted && ( + + )} + diff --git a/src/pages/account-events/ui/AccountEventsPage/components/AccountEventsPageLoaded.view.tsx b/src/pages/account-events/ui/AccountEventsPage/components/AccountEventsPageLoaded.view.tsx index 4fbd2f7..514d839 100644 --- a/src/pages/account-events/ui/AccountEventsPage/components/AccountEventsPageLoaded.view.tsx +++ b/src/pages/account-events/ui/AccountEventsPage/components/AccountEventsPageLoaded.view.tsx @@ -18,7 +18,7 @@ import { brokerageEventKey, } from "@/entities/brokerage-event"; import { groupItems } from "@/shared/lib"; -import { SourceSyncStatus } from "@/shared/ui"; +import { ReadRetry, SourceSyncStatus } from "@/shared/ui"; import type { AccountEventsPageViewModel } from "../AccountEventsPage.vm"; @@ -83,6 +83,8 @@ const AccountEventsPageLoaded: FC = (props) => { stale={props.source?.freshness === "stale"} /> + + {!!props.partial && {t("investment.partial")}} {t("events.hint")} -- 2.54.0 From c4d0b7edb0617248a5c905e8fb65c5aa7d6138ef Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Tue, 3 Nov 2026 08:44:47 +0300 Subject: [PATCH 7/7] feat(instruments): offer per-section retry after exhausted reads (WealthLedger/frontend#55) --- .../instrument-details/model/section.test.ts | 27 +++++++++++++- .../instrument-details/model/section.ts | 17 ++++++++- .../ui/InstrumentSection.view.tsx | 4 ++ src/pages/bond/ui/BondPage/BondPage.test.tsx | 17 ++++++++- .../stock/ui/StockPage/StockPage.test.tsx | 37 +++++++++++++++++-- 5 files changed, 96 insertions(+), 6 deletions(-) diff --git a/src/features/instrument-details/model/section.test.ts b/src/features/instrument-details/model/section.test.ts index c4642c2..eb44779 100644 --- a/src/features/instrument-details/model/section.test.ts +++ b/src/features/instrument-details/model/section.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { resource } from "@/test/resource"; @@ -76,4 +76,29 @@ describe("instrument source and transport presentation", () => { loading: false, }); }); + + it("carries the retry of an exhausted read and only of that read", () => { + const refetch = vi.fn(() => Promise.resolve()); + + const state = section({ + data: { rows: [] }, + error: { code: "TBANK_UNAVAILABLE", status: 503 }, + isError: true, + isFetching: false, + refetch, + }); + + expect(state.readRetry?.exhausted).toBe(true); + state.readRetry?.onRetry(); + expect(refetch).toHaveBeenCalledOnce(); + }); + + it("offers no retry for access loss or a healthy read", () => { + const base = { data: undefined, isFetching: false, refetch: vi.fn() }; + + expect(section({ ...base, error: { code: "NOT_FOUND" }, isError: true }) + .readRetry?.exhausted).toBe(false); + expect(section({ ...base, error: null, isError: false }) + .readRetry?.exhausted).toBe(false); + }); }); diff --git a/src/features/instrument-details/model/section.ts b/src/features/instrument-details/model/section.ts index a18b264..7c62f68 100644 --- a/src/features/instrument-details/model/section.ts +++ b/src/features/instrument-details/model/section.ts @@ -1,10 +1,11 @@ -import type { InvestmentResourceMeta } from "@/shared/api"; +import { type InvestmentResourceMeta, readRetry } from "@/shared/api"; type SectionState = { error: boolean; hasData: boolean; loading: boolean; partial?: boolean; + readRetry?: ReturnType; }; function hasSectionData(data: unknown) { @@ -13,8 +14,11 @@ function hasSectionData(data: unknown) { function section(query: { data: unknown; + error?: unknown; isError: boolean; + isFetching?: boolean; meta?: InvestmentResourceMeta; + refetch?: () => Promise; }): SectionState { const isError = query.isError || query.meta?.lastAttempt.state === "failed"; const hasData = hasSectionData(query.data); @@ -24,6 +28,17 @@ function section(query: { hasData, loading: !hasData && !isError, partial: query.meta?.completeness === "incomplete", + // Each section retries only its own GET, so sections recover independently. + readRetry: query.refetch + ? readRetry([ + { + error: query.error, + isError: query.isError, + isFetching: query.isFetching ?? false, + refetch: query.refetch, + }, + ]) + : undefined, }; } diff --git a/src/features/instrument-details/ui/InstrumentSection.view.tsx b/src/features/instrument-details/ui/InstrumentSection.view.tsx index 5c37ba1..f9c32c6 100644 --- a/src/features/instrument-details/ui/InstrumentSection.view.tsx +++ b/src/features/instrument-details/ui/InstrumentSection.view.tsx @@ -12,6 +12,8 @@ import { import { LoadingSkeleton } from "@wealthledger/ui"; import { useTranslation } from "react-i18next"; +import { ReadRetry } from "@/shared/ui"; + import type { SectionState } from "../model/section"; import classes from "./InstrumentDetails.module.css"; @@ -81,6 +83,8 @@ const InstrumentSection: FC = (props) => { )} + {!!props.state.readRetry && } + {sectionContent} ); diff --git a/src/pages/bond/ui/BondPage/BondPage.test.tsx b/src/pages/bond/ui/BondPage/BondPage.test.tsx index 3c5fa52..caf8345 100644 --- a/src/pages/bond/ui/BondPage/BondPage.test.tsx +++ b/src/pages/bond/ui/BondPage/BondPage.test.tsx @@ -347,7 +347,6 @@ describe("bond page", () => { it("keeps market content and sold-position history when coupons fail", async () => { setup({ failCoupons: true, noPosition: true }); await screen.findByText("98,125 %"); - expect(screen.queryByRole("button", { name: "Повторить" })).not.toBeInTheDocument(); expect( screen.queryByRole("heading", { name: "Моя позиция" }), ).not.toBeInTheDocument(); @@ -355,6 +354,22 @@ describe("bond page", () => { screen.getByRole("heading", { name: "Моя история событий" }), ).toBeVisible(); }); + it( + "offers a local retry for the failed coupons section only", + { timeout: 10_000 }, + async () => { + setup({ failCoupons: true }); + await screen.findByText("98,125 %"); + // Automatic read retries wait 1s + 2s before the failure is final. + await screen.findByRole( + "button", + { name: "Повторить" }, + { timeout: 6000 }, + ); + expect(screen.getAllByRole("button", { name: "Повторить" })).toHaveLength(1); + expect(screen.getByText("98,125 %")).toBeVisible(); + }, + ); it("preserves cached quote without manual refresh", async () => { const controls = setup({ path: `/bonds/${uid}?period=year&events=trades` }); await screen.findByText("98,125 %"); diff --git a/src/pages/stock/ui/StockPage/StockPage.test.tsx b/src/pages/stock/ui/StockPage/StockPage.test.tsx index 129c1db..e66a4a3 100644 --- a/src/pages/stock/ui/StockPage/StockPage.test.tsx +++ b/src/pages/stock/ui/StockPage/StockPage.test.tsx @@ -81,6 +81,7 @@ function setup( candles?: NonNullable["candles"]; cursorError?: string; failCompany?: boolean; + failCompanyAfter?: { value: boolean }; failStock?: boolean; metrics?: NonNullable["metrics"]; noPosition?: boolean; @@ -133,7 +134,7 @@ function setup( ), ); if (params.section === "company") - return options.failCompany + return (options.failCompanyAfter?.value ?? options.failCompany) ? HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 }) : HttpResponse.json( @@ -396,7 +397,6 @@ describe("stock page", () => { screen.queryByRole("heading", { name: "Моя позиция" }), ).not.toBeInTheDocument(); expect(screen.getByRole("heading", { name: "График цены" })).toBeVisible(); - expect(screen.queryByRole("button", { name: "Повторить" })).not.toBeInTheDocument(); }); it("keeps successful sections visible when company data is initially missing", async () => { const { requests } = setup({ failCompany: true }); @@ -404,8 +404,39 @@ describe("stock page", () => { const previousRequests = requests.length; expect(screen.getByText("Зачисление дивидендов")).toBeVisible(); expect(requests).toHaveLength(previousRequests); - expect(screen.queryByRole("button", { name: "Повторить" })).not.toBeInTheDocument(); }); + it( + "retries only the failed company section and recovers it in place", + { timeout: 10_000 }, + async () => { + const failing = { value: true }; + const { requests } = setup({ failCompanyAfter: failing }); + await screen.findByText("Зачисление дивидендов"); + + // Automatic read retries wait 1s + 2s before the failure is final. + const retry = await screen.findByRole( + "button", + { name: "Повторить" }, + { timeout: 6000 }, + ); + + expect(screen.getByRole("heading", { name: "График цены" })).toBeVisible(); + expect(screen.getAllByRole("button", { name: "Повторить" })).toHaveLength(1); + const before = requests.length; + const companyBefore = requests.filter(url => url.pathname.endsWith("/company")).length; + + failing.value = false; + fireEvent.click(retry); + + await waitFor(() => + expect(screen.queryByRole("button", { name: "Повторить" })) + .not.toBeInTheDocument()); + expect(requests.filter(url => url.pathname.endsWith("/company"))) + .toHaveLength(companyBefore + 1); + expect(requests.length - before).toBe(1); + expect(screen.getByText("Описание компании")).toBeVisible(); + }, + ); it("sends instrument-filtered continuation, restores it on returning to the filter and changes chart period", async () => { const { requests } = setup(); await screen.findByText("Зачисление дивидендов"); -- 2.54.0