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";