feat(investments): warn and offer retry after exhausted read retries (WealthLedger/frontend#55) #136

Merged
codex-bot merged 7 commits from fix/55-read-failure-retry into develop 2026-11-03 09:49:08 +03:00
5 changed files with 188 additions and 8 deletions
Showing only changes of commit c84b633855 - Show all commits
@@ -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(
<QueryClientProvider client={queryClient}>
<MantineProvider env="test">
<MemoryRouter initialEntries={["/accounts/account-1/events"]}>
<Routes>
<Route
element={<AccountEventsPage />}
path="/accounts/:accountId/events"
/>
</Routes>
</MemoryRouter>
</MantineProvider>
</QueryClientProvider>,
);
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();
});
});
@@ -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(
<MantineProvider>
@@ -187,7 +190,7 @@ describe("AccountEventsPage", () => {
</MantineProvider>,
);
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", () => {
@@ -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<typeof readRetry>;
source?: InvestmentResourceMeta;
state: "loaded";
totalVisible: boolean;
}
| { onReset: () => void; state: "error" }
| {
onReset: () => void;
readRetry: ReturnType<typeof readRetry>;
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",
@@ -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<AccountEventsPageErrorProps> = (props) => {
const { t } = useTranslation();
@@ -13,6 +18,13 @@ const AccountEventsPageError: FC<AccountEventsPageErrorProps> = (props) => {
<StateView
action={(
<Group>
{props.readRetry.exhausted && (
<ReadRetryButton
onRetry={props.readRetry.onRetry}
retrying={props.readRetry.retrying}
/>
)}
<Button onClick={props.onReset} variant="default">
{t("ledger.reset")}
</Button>
@@ -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<AccountEventsPageLoadedProps> = (props) => {
stale={props.source?.freshness === "stale"}
/>
<ReadRetry {...props.readRetry} />
{!!props.partial && <Alert role="status">{t("investment.partial")}</Alert>}
<Text c="dimmed">{t("events.hint")}</Text>