From c84b63385513e83d0c8b12c059197c775b8ea6ce Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Tue, 3 Nov 2026 08:42:28 +0300 Subject: [PATCH] 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")}