From a2e9d63bfe74d1ffb7cfc7671c719dbd40302f8d Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Tue, 3 Nov 2026 08:38:23 +0300 Subject: [PATCH] feat(income): offer retry after exhausted income reads (WealthLedger/frontend#55) --- .../AccountIncomePage.test.tsx | 108 +++++++++++++++--- .../AccountIncomePage/AccountIncomePage.vm.ts | 10 +- .../AccountIncomePageError.view.tsx | 14 ++- .../AccountIncomePageLoaded.view.tsx | 4 +- 4 files changed, 116 insertions(+), 20 deletions(-) diff --git a/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.test.tsx b/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.test.tsx index bcd6ce8..2e6def7 100644 --- a/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.test.tsx +++ b/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.test.tsx @@ -38,6 +38,10 @@ import { } from "./AccountIncomePage.vm"; import { IncomePayments } from "./components/IncomePayments.view"; +// Automatic read retries wait 1s + 2s before the failure is final. +const RETRIES_EXHAUSTED_MS = 6000; +const TEST_MS = 10_000; + const analytics = { accountId: "account-1", @@ -433,21 +437,95 @@ describe("AccountIncomePage", () => { expect(screen.queryByRole("button", { name: "Повторить" })).not.toBeInTheDocument(); }); - it("shows a quiet realized error", async () => { - server.use( - http.get( - "*/api/v1/brokerage-accounts/account-1/income-analytics", - () => new HttpResponse(null, { status: 503 }), - ), - ); - renderPage(); - expect( - await screen.findByText("Не удалось рассчитать пассивный доход", undefined, { - timeout: 4000, - }), - ).toBeInTheDocument(); - expect(screen.queryByRole("button", { name: "Повторить" })).not.toBeInTheDocument(); - }); + 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/account-1/income-analytics", + () => { + requests += 1; + + return isFailing + ? new HttpResponse(null, { status: 503 }) + : HttpResponse.json(resource(analytics)); + }, + ), + ); + renderPage(); + expect( + await screen.findByText( + "Не удалось рассчитать пассивный доход", + undefined, + { timeout: RETRIES_EXHAUSTED_MS }, + ), + ).toBeInTheDocument(); + const retry = screen.getByRole("button", { name: "Повторить" }); + expect(requests).toBe(3); + + isFailing = false; + fireEvent.click(retry); + + expect( + await screen.findByRole("region", { name: "Выплаты" }), + ).toBeInTheDocument(); + expect(requests).toBe(4); + }, + ); + + it( + "keeps cached income, warns and retries the same GET", + { timeout: TEST_MS }, + async () => { + let isFailing = false; + let requests = 0; + server.use( + http.get( + "*/api/v1/brokerage-accounts/account-1/income-analytics", + () => { + requests += 1; + + return isFailing + ? HttpResponse.json( + { code: "TBANK_UNAVAILABLE" }, + { status: 503 }, + ) + : HttpResponse.json(resource(analytics)); + }, + ), + ); + const { queryClient } = renderPage(); + await screen.findByRole("region", { name: "Выплаты" }); + expect(screen.queryByRole("button", { name: "Повторить" })) + .not.toBeInTheDocument(); + + isFailing = true; + void queryClient.invalidateQueries({ + queryKey: ["get", "/api/v1/brokerage-accounts/{accountId}/income-analytics"], + }); + + const retry = await screen.findByRole( + "button", + { name: "Повторить" }, + { timeout: RETRIES_EXHAUSTED_MS }, + ); + + expect(screen.getByText("Не удалось обновить данные")).toBeInTheDocument(); + expect(screen.getByRole("region", { name: "Выплаты" })).toBeInTheDocument(); + expect(requests).toBe(4); + + isFailing = false; + fireEvent.click(retry); + + await waitFor(() => + expect(screen.queryByText("Не удалось обновить данные")) + .not.toBeInTheDocument()); + expect(requests).toBe(5); + }, + ); }); const LocationProbe: FC = () => { diff --git a/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.vm.ts b/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.vm.ts index f22540d..069c915 100644 --- a/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.vm.ts +++ b/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.vm.ts @@ -3,10 +3,9 @@ import { useEffect, useMemo, useState } from "react"; import { useForm } from "react-hook-form"; import { useParams, useSearchParams } from "react-router"; -import type { InvestmentResourceMeta } from "@/shared/api"; - import { type IncomeAnalytics, useIncomeAnalytics } from "@/entities/income"; import { useScreenSync } from "@/features/tbank-connection"; +import { type InvestmentResourceMeta, readRetry } from "@/shared/api"; import { isValidPeriod, moscowDate, useInitialAvailability } from "@/shared/lib"; import { @@ -32,12 +31,14 @@ type AccountIncomePageViewModel onReset: () => void; onSubmit: (values: IncomeForm) => void; partial?: boolean; + readRetry: ReturnType; source?: InvestmentResourceMeta; state: "loaded"; totalVisible: boolean; } | { period: IncomePeriod; + readRetry: ReturnType; state: "error"; } | { state: "loading" }; @@ -87,12 +88,14 @@ const useAccountIncomePageViewModel = (): AccountIncomePageViewModel => { isFetching: analytics.isFetching, }); + const retry = readRetry([analytics]); + if (availability === "loading" && analytics.isLoading) return { state: "loading" }; if (!analytics.data) return availability === "unavailable" - ? { period, state: "error" } + ? { period, readRetry: retry, state: "error" } : { state: "loading" }; const display = toIncomeDisplayModel(analytics.data); @@ -119,6 +122,7 @@ const useAccountIncomePageViewModel = (): AccountIncomePageViewModel => { onReset, onSubmit, partial: analytics.meta?.completeness === "incomplete", + readRetry: retry, source: analytics.meta, state: "loaded", totalVisible: analytics.meta?.completeness === "complete", diff --git a/src/pages/account-income/ui/AccountIncomePage/components/AccountIncomePageError.view.tsx b/src/pages/account-income/ui/AccountIncomePage/components/AccountIncomePageError.view.tsx index bdaf3bd..742ab88 100644 --- a/src/pages/account-income/ui/AccountIncomePage/components/AccountIncomePageError.view.tsx +++ b/src/pages/account-income/ui/AccountIncomePage/components/AccountIncomePageError.view.tsx @@ -3,15 +3,27 @@ import type { FC } from "react"; import { StateView } from "@wealthledger/ui"; import { useTranslation } from "react-i18next"; +import { ReadRetryButton } from "@/shared/ui"; + import type { AccountIncomePageViewModel } from "../AccountIncomePage.vm"; type ErrorProps = Extract; -const AccountIncomePageError: FC = () => { +const AccountIncomePageError: FC = (props) => { const { t } = useTranslation(); + const retry = props.readRetry.exhausted + ? ( + + ) + : undefined; + return ( = (props) => { stale={props.source?.freshness === "stale"} /> + + {t("income.taxBoundary")}