feat(investments): warn and offer retry after exhausted read retries (WealthLedger/frontend#55) (#136)
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 9s
Frontend verify / verify (push) Failing after 7m5s

This commit was merged in pull request #136.
This commit is contained in:
2026-11-03 09:49:08 +03:00
parent aaee9591d7
commit 8ce1e8e444
36 changed files with 1025 additions and 80 deletions
@@ -1,4 +1,4 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { resource } from "@/test/resource"; import { resource } from "@/test/resource";
@@ -76,4 +76,29 @@ describe("instrument source and transport presentation", () => {
loading: false, 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);
});
}); });
@@ -1,10 +1,11 @@
import type { InvestmentResourceMeta } from "@/shared/api"; import { type InvestmentResourceMeta, readRetry } from "@/shared/api";
type SectionState = { type SectionState = {
error: boolean; error: boolean;
hasData: boolean; hasData: boolean;
loading: boolean; loading: boolean;
partial?: boolean; partial?: boolean;
readRetry?: ReturnType<typeof readRetry>;
}; };
function hasSectionData(data: unknown) { function hasSectionData(data: unknown) {
@@ -13,8 +14,11 @@ function hasSectionData(data: unknown) {
function section(query: { function section(query: {
data: unknown; data: unknown;
error?: unknown;
isError: boolean; isError: boolean;
isFetching?: boolean;
meta?: InvestmentResourceMeta; meta?: InvestmentResourceMeta;
refetch?: () => Promise<unknown>;
}): SectionState { }): SectionState {
const isError = query.isError || query.meta?.lastAttempt.state === "failed"; const isError = query.isError || query.meta?.lastAttempt.state === "failed";
const hasData = hasSectionData(query.data); const hasData = hasSectionData(query.data);
@@ -24,6 +28,17 @@ function section(query: {
hasData, hasData,
loading: !hasData && !isError, loading: !hasData && !isError,
partial: query.meta?.completeness === "incomplete", 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,
}; };
} }
@@ -12,6 +12,8 @@ import {
import { LoadingSkeleton } from "@wealthledger/ui"; import { LoadingSkeleton } from "@wealthledger/ui";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { ReadRetry } from "@/shared/ui";
import type { SectionState } from "../model/section"; import type { SectionState } from "../model/section";
import classes from "./InstrumentDetails.module.css"; import classes from "./InstrumentDetails.module.css";
@@ -81,6 +83,8 @@ const InstrumentSection: FC<InstrumentSectionProps> = (props) => {
</Alert> </Alert>
)} )}
{!!props.state.readRetry && <ReadRetry {...props.state.readRetry} />}
{sectionContent} {sectionContent}
</Paper> </Paper>
); );
@@ -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); 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(); const { rerender } = renderPage();
expect( expect(
screen.getByText("За выбранный период событий не найдено"), screen.getByText("За выбранный период событий не найдено"),
).toBeInTheDocument(); ).toBeInTheDocument();
query.mockReturnValue({ query.mockReturnValue({
data: undefined, data: undefined,
error: { code: "TBANK_UNAVAILABLE", status: 503 },
isError: true, isError: true,
isFetching: false,
isLoading: false, isLoading: false,
refetch: vi.fn(),
} as never); } as never);
rerender( rerender(
<MantineProvider> <MantineProvider>
@@ -187,7 +190,7 @@ describe("AccountEventsPage", () => {
</MantineProvider>, </MantineProvider>,
); );
expect(await screen.findByRole("button", { name: "Сбросить" })).toBeVisible(); 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", () => { it("shows a structural busy state before events are available", () => {
@@ -1,13 +1,12 @@
import { useState } from "react"; import { useState } from "react";
import { useParams } from "react-router"; import { useParams } from "react-router";
import type { InvestmentResourceMeta } from "@/shared/api";
import { import {
type BrokerageEvent, type BrokerageEvent,
useBrokerageEvents, useBrokerageEvents,
} from "@/entities/brokerage-event"; } from "@/entities/brokerage-event";
import { useScreenSync } from "@/features/tbank-connection"; import { useScreenSync } from "@/features/tbank-connection";
import { type InvestmentResourceMeta, readRetry } from "@/shared/api";
import { import {
defaultPeriod, defaultPeriod,
isValidPeriod, isValidPeriod,
@@ -28,11 +27,16 @@ type AccountEventsPageViewModel
onPeriodPopoverOpen: () => void; onPeriodPopoverOpen: () => void;
onReset: () => void; onReset: () => void;
partial?: boolean; partial?: boolean;
readRetry: ReturnType<typeof readRetry>;
source?: InvestmentResourceMeta; source?: InvestmentResourceMeta;
state: "loaded"; state: "loaded";
totalVisible: boolean; totalVisible: boolean;
} }
| { onReset: () => void; state: "error" } | {
onReset: () => void;
readRetry: ReturnType<typeof readRetry>;
state: "error";
}
| { state: "loading" }; | { state: "loading" };
function useAccountEventsPageViewModel(): AccountEventsPageViewModel { function useAccountEventsPageViewModel(): AccountEventsPageViewModel {
@@ -83,6 +87,8 @@ function useAccountEventsPageViewModel(): AccountEventsPageViewModel {
isFetching: events.isFetching, isFetching: events.isFetching,
}); });
const retry = readRetry([events]);
if (availability === "loading" && events.isLoading) if (availability === "loading" && events.isLoading)
return { state: "loading" }; return { state: "loading" };
@@ -94,11 +100,12 @@ function useAccountEventsPageViewModel(): AccountEventsPageViewModel {
if ((events.isError || events.meta?.lastAttempt.state === "failed") && !events.data) if ((events.isError || events.meta?.lastAttempt.state === "failed") && !events.data)
return { return {
onReset, onReset,
readRetry: retry,
state: "error", state: "error",
}; };
if (!events.data) if (!events.data)
return availability === "unavailable" return availability === "unavailable"
? { onReset, state: "error" } ? { onReset, readRetry: retry, state: "error" }
: { state: "loading" }; : { state: "loading" };
return { return {
@@ -122,6 +129,7 @@ function useAccountEventsPageViewModel(): AccountEventsPageViewModel {
onPeriodPopoverOpen: () => setMinDate(moscowDate(new Date())), onPeriodPopoverOpen: () => setMinDate(moscowDate(new Date())),
onReset, onReset,
partial: events.meta?.completeness === "incomplete", partial: events.meta?.completeness === "incomplete",
readRetry: retry,
source: events.meta, source: events.meta,
state: "loaded", state: "loaded",
totalVisible: events.meta?.completeness === "complete", totalVisible: events.meta?.completeness === "complete",
@@ -4,7 +4,12 @@ import { Button, Group } from "@mantine/core";
import { StateView } from "@wealthledger/ui"; import { StateView } from "@wealthledger/ui";
import { useTranslation } from "react-i18next"; 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 AccountEventsPageError: FC<AccountEventsPageErrorProps> = (props) => {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -13,6 +18,13 @@ const AccountEventsPageError: FC<AccountEventsPageErrorProps> = (props) => {
<StateView <StateView
action={( action={(
<Group> <Group>
{props.readRetry.exhausted && (
<ReadRetryButton
onRetry={props.readRetry.onRetry}
retrying={props.readRetry.retrying}
/>
)}
<Button onClick={props.onReset} variant="default"> <Button onClick={props.onReset} variant="default">
{t("ledger.reset")} {t("ledger.reset")}
</Button> </Button>
@@ -18,7 +18,7 @@ import {
brokerageEventKey, brokerageEventKey,
} from "@/entities/brokerage-event"; } from "@/entities/brokerage-event";
import { groupItems } from "@/shared/lib"; import { groupItems } from "@/shared/lib";
import { SourceSyncStatus } from "@/shared/ui"; import { ReadRetry, SourceSyncStatus } from "@/shared/ui";
import type { AccountEventsPageViewModel } from "../AccountEventsPage.vm"; import type { AccountEventsPageViewModel } from "../AccountEventsPage.vm";
@@ -83,6 +83,8 @@ const AccountEventsPageLoaded: FC<AccountEventsPageLoadedProps> = (props) => {
stale={props.source?.freshness === "stale"} stale={props.source?.freshness === "stale"}
/> />
<ReadRetry {...props.readRetry} />
{!!props.partial && <Alert role="status">{t("investment.partial")}</Alert>} {!!props.partial && <Alert role="status">{t("investment.partial")}</Alert>}
<Text c="dimmed">{t("events.hint")}</Text> <Text c="dimmed">{t("events.hint")}</Text>
@@ -38,6 +38,10 @@ import {
} from "./AccountIncomePage.vm"; } from "./AccountIncomePage.vm";
import { IncomePayments } from "./components/IncomePayments.view"; 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 const analytics
= { = {
accountId: "account-1", accountId: "account-1",
@@ -433,21 +437,95 @@ describe("AccountIncomePage", () => {
expect(screen.queryByRole("button", { name: "Повторить" })).not.toBeInTheDocument(); expect(screen.queryByRole("button", { name: "Повторить" })).not.toBeInTheDocument();
}); });
it("shows a quiet realized error", async () => { it(
server.use( "offers retry after an initial failure and recovers in place",
http.get( { timeout: TEST_MS },
"*/api/v1/brokerage-accounts/account-1/income-analytics", async () => {
() => new HttpResponse(null, { status: 503 }), let isFailing = true;
), let requests = 0;
); server.use(
renderPage(); http.get(
expect( "*/api/v1/brokerage-accounts/account-1/income-analytics",
await screen.findByText("Не удалось рассчитать пассивный доход", undefined, { () => {
timeout: 4000, requests += 1;
}),
).toBeInTheDocument(); return isFailing
expect(screen.queryByRole("button", { name: "Повторить" })).not.toBeInTheDocument(); ? 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 = () => { const LocationProbe: FC = () => {
@@ -3,10 +3,9 @@ import { useEffect, useMemo, useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { useParams, useSearchParams } from "react-router"; import { useParams, useSearchParams } from "react-router";
import type { InvestmentResourceMeta } from "@/shared/api";
import { type IncomeAnalytics, useIncomeAnalytics } from "@/entities/income"; import { type IncomeAnalytics, useIncomeAnalytics } from "@/entities/income";
import { useScreenSync } from "@/features/tbank-connection"; import { useScreenSync } from "@/features/tbank-connection";
import { type InvestmentResourceMeta, readRetry } from "@/shared/api";
import { isValidPeriod, moscowDate, useInitialAvailability } from "@/shared/lib"; import { isValidPeriod, moscowDate, useInitialAvailability } from "@/shared/lib";
import { import {
@@ -32,12 +31,14 @@ type AccountIncomePageViewModel
onReset: () => void; onReset: () => void;
onSubmit: (values: IncomeForm) => void; onSubmit: (values: IncomeForm) => void;
partial?: boolean; partial?: boolean;
readRetry: ReturnType<typeof readRetry>;
source?: InvestmentResourceMeta; source?: InvestmentResourceMeta;
state: "loaded"; state: "loaded";
totalVisible: boolean; totalVisible: boolean;
} }
| { | {
period: IncomePeriod; period: IncomePeriod;
readRetry: ReturnType<typeof readRetry>;
state: "error"; state: "error";
} }
| { state: "loading" }; | { state: "loading" };
@@ -87,12 +88,14 @@ const useAccountIncomePageViewModel = (): AccountIncomePageViewModel => {
isFetching: analytics.isFetching, isFetching: analytics.isFetching,
}); });
const retry = readRetry([analytics]);
if (availability === "loading" && analytics.isLoading) if (availability === "loading" && analytics.isLoading)
return { state: "loading" }; return { state: "loading" };
if (!analytics.data) if (!analytics.data)
return availability === "unavailable" return availability === "unavailable"
? { period, state: "error" } ? { period, readRetry: retry, state: "error" }
: { state: "loading" }; : { state: "loading" };
const display = toIncomeDisplayModel(analytics.data); const display = toIncomeDisplayModel(analytics.data);
@@ -119,6 +122,7 @@ const useAccountIncomePageViewModel = (): AccountIncomePageViewModel => {
onReset, onReset,
onSubmit, onSubmit,
partial: analytics.meta?.completeness === "incomplete", partial: analytics.meta?.completeness === "incomplete",
readRetry: retry,
source: analytics.meta, source: analytics.meta,
state: "loaded", state: "loaded",
totalVisible: analytics.meta?.completeness === "complete", totalVisible: analytics.meta?.completeness === "complete",
@@ -3,15 +3,27 @@ import type { FC } from "react";
import { StateView } from "@wealthledger/ui"; import { StateView } from "@wealthledger/ui";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { ReadRetryButton } from "@/shared/ui";
import type { AccountIncomePageViewModel } from "../AccountIncomePage.vm"; import type { AccountIncomePageViewModel } from "../AccountIncomePage.vm";
type ErrorProps = Extract<AccountIncomePageViewModel, { state: "error" }>; type ErrorProps = Extract<AccountIncomePageViewModel, { state: "error" }>;
const AccountIncomePageError: FC<ErrorProps> = () => { const AccountIncomePageError: FC<ErrorProps> = (props) => {
const { t } = useTranslation(); const { t } = useTranslation();
const retry = props.readRetry.exhausted
? (
<ReadRetryButton
onRetry={props.readRetry.onRetry}
retrying={props.readRetry.retrying}
/>
)
: undefined;
return ( return (
<StateView <StateView
action={retry}
error error
text={t("income.errorText")} text={t("income.errorText")}
title={t("income.errorTitle")} title={t("income.errorTitle")}
@@ -9,7 +9,7 @@ import { StateView } from "@wealthledger/ui";
import { FormProvider } from "react-hook-form"; import { FormProvider } from "react-hook-form";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { SourceSyncStatus } from "@/shared/ui"; import { ReadRetry, SourceSyncStatus } from "@/shared/ui";
import type { AccountIncomePageViewModel } from "../AccountIncomePage.vm"; import type { AccountIncomePageViewModel } from "../AccountIncomePage.vm";
@@ -57,6 +57,8 @@ const AccountIncomePageLoaded: FC<LoadedProps> = (props) => {
stale={props.source?.freshness === "stale"} stale={props.source?.freshness === "stale"}
/> />
<ReadRetry {...props.readRetry} />
<AccountIncomePageLoadedSummary {...props} /> <AccountIncomePageLoadedSummary {...props} />
<Alert color="blue">{t("income.taxBoundary")}</Alert> <Alert color="blue">{t("income.taxBoundary")}</Alert>
@@ -90,9 +90,13 @@ function configureTestAuth() {
}); });
} }
function renderPage(search = "", accountId = "account-1") { function renderPage(
search = "",
accountId = "account-1",
queryClient = createTestQueryClient(),
) {
return render( return render(
<QueryClientProvider client={createTestQueryClient()}> <QueryClientProvider client={queryClient}>
<MantineProvider> <MantineProvider>
<MemoryRouter <MemoryRouter
initialEntries={[`/accounts/${accountId}/operations${search}`]} initialEntries={[`/accounts/${accountId}/operations${search}`]}
@@ -109,6 +113,18 @@ function renderPage(search = "", accountId = "account-1") {
); );
} }
// Automatic read retries wait 1s + 2s before the failure is final.
const RETRIES_EXHAUSTED_MS = 6000;
const TEST_MS = 10_000;
const loadedOperations = {
accountId: "account-1",
hasNext: false,
items: [operation],
nextCursor: null,
observedAt: "2026-08-14T00:00:00Z",
};
describe("LedgerEntryRow", () => { describe("LedgerEntryRow", () => {
it("marks cashless operations without rendering operation details", () => { it("marks cashless operations without rendering operation details", () => {
render( render(
@@ -545,20 +561,87 @@ describe("AccountOperationsPage", () => {
).toBeInTheDocument(); ).toBeInTheDocument();
}); });
it("renders an error state", async () => { it(
configureTestAuth(); "renders an error state with retry that recovers in place",
server.use( { timeout: TEST_MS },
http.get("*/api/v1/brokerage-accounts/:accountId/operations", () => async () => {
HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 }), configureTestAuth();
), let isFailing = true;
); let requests = 0;
renderPage(); server.use(
expect( http.get("*/api/v1/brokerage-accounts/:accountId/operations", () => {
await screen.findByText("Не удалось загрузить журнал", undefined, { requests += 1;
timeout: 4000,
}), return isFailing
).toBeInTheDocument(); ? HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 })
}); : HttpResponse.json(resource(loadedOperations));
}),
);
renderPage();
expect(
await screen.findByText("Не удалось загрузить журнал", undefined, {
timeout: RETRIES_EXHAUSTED_MS,
}),
).toBeInTheDocument();
expect(requests).toBe(3);
isFailing = false;
fireEvent.click(screen.getByRole("button", { name: "Повторить" }));
expect(await screen.findByText("Покупка акций")).toBeInTheDocument();
expect(requests).toBe(4);
},
);
it(
"keeps cached operations, warns and retries the same GET",
{ timeout: TEST_MS },
async () => {
configureTestAuth();
let isFailing = false;
let requests = 0;
server.use(
http.get("*/api/v1/brokerage-accounts/:accountId/operations", () => {
requests += 1;
return isFailing
? HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 })
: HttpResponse.json(resource(loadedOperations));
}),
);
const queryClient = createTestQueryClient();
renderPage("", "account-1", queryClient);
await screen.findByText("Покупка акций");
expect(screen.queryByRole("button", { name: "Повторить" }))
.not.toBeInTheDocument();
isFailing = true;
void queryClient.invalidateQueries({
queryKey: [
"get",
"/api/v1/brokerage-accounts/{accountId}/operations",
],
});
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);
},
);
it("keeps a null operations envelope loading instead of a confirmed empty period", async () => { it("keeps a null operations envelope loading instead of a confirmed empty period", async () => {
configureTestAuth(); configureTestAuth();
@@ -12,7 +12,12 @@ import {
useBrokerageOperationHistory, useBrokerageOperationHistory,
} from "@/entities/broker-operation"; } from "@/entities/broker-operation";
import { useScreenSync } from "@/features/tbank-connection"; import { useScreenSync } from "@/features/tbank-connection";
import { ApiErrorCode, apiErrorCode, isApiErrorCode } from "@/shared/api"; import {
ApiErrorCode,
apiErrorCode,
isApiErrorCode,
readRetry,
} from "@/shared/api";
import { import {
defaultPeriod, defaultPeriod,
isValidPeriod, isValidPeriod,
@@ -42,11 +47,16 @@ type AccountOperationsPageViewModel
onPrevious: () => void; onPrevious: () => void;
onReset: () => void; onReset: () => void;
partial?: boolean; partial?: boolean;
readRetry: ReturnType<typeof readRetry>;
source?: InvestmentResourceMeta; source?: InvestmentResourceMeta;
state: "loaded"; state: "loaded";
totalVisible: boolean; totalVisible: boolean;
} }
| { onReset: () => void; state: "error" } | {
onReset: () => void;
readRetry: ReturnType<typeof readRetry>;
state: "error";
}
| { state: "loading" }; | { state: "loading" };
const useAccountOperationsPageVm = (): AccountOperationsPageViewModel => { const useAccountOperationsPageVm = (): AccountOperationsPageViewModel => {
@@ -107,6 +117,8 @@ const useAccountOperationsPageVm = (): AccountOperationsPageViewModel => {
isFetching: operations.isFetching, isFetching: operations.isFetching,
}); });
const retry = readRetry([operations]);
if (availability === "loading" && operations.isLoading) if (availability === "loading" && operations.isLoading)
return { state: "loading" }; return { state: "loading" };
@@ -134,11 +146,11 @@ const useAccountOperationsPageVm = (): AccountOperationsPageViewModel => {
(isInvalidCursor || !operations.data) (isInvalidCursor || !operations.data)
&& (operations.isError || operations.meta?.lastAttempt.state === "failed") && (operations.isError || operations.meta?.lastAttempt.state === "failed")
) )
return { onReset, state: "error" }; return { onReset, readRetry: retry, state: "error" };
if (!operations.data) if (!operations.data)
return availability === "unavailable" return availability === "unavailable"
? { onReset, state: "error" } ? { onReset, readRetry: retry, state: "error" }
: { state: "loading" }; : { state: "loading" };
const nextCursor = operations.data.nextCursor; const nextCursor = operations.data.nextCursor;
@@ -182,6 +194,7 @@ const useAccountOperationsPageVm = (): AccountOperationsPageViewModel => {
onPrevious: history.onPrevious, onPrevious: history.onPrevious,
onReset, onReset,
partial: operations.meta?.completeness === "incomplete", partial: operations.meta?.completeness === "incomplete",
readRetry: retry,
source: operations.meta, source: operations.meta,
state: "loaded", state: "loaded",
totalVisible: operations.meta?.completeness === "complete", totalVisible: operations.meta?.completeness === "complete",
@@ -1,9 +1,11 @@
import type { FC } from "react"; import type { FC } from "react";
import { Button } from "@mantine/core"; import { Button, Group } from "@mantine/core";
import { StateView } from "@wealthledger/ui"; import { StateView } from "@wealthledger/ui";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { ReadRetryButton } from "@/shared/ui";
import type { AccountOperationsPageViewModel } from "../AccountOperationsPage.vm"; import type { AccountOperationsPageViewModel } from "../AccountOperationsPage.vm";
type AccountOperationsPageErrorProps = Extract< type AccountOperationsPageErrorProps = Extract<
@@ -19,9 +21,18 @@ const AccountOperationsPageError: FC<AccountOperationsPageErrorProps> = (
return ( return (
<StateView <StateView
action={( action={(
<Button onClick={props.onReset} variant="default"> <Group gap="sm" justify="center">
{t("ledger.reset")} {props.readRetry.exhausted && (
</Button> <ReadRetryButton
onRetry={props.readRetry.onRetry}
retrying={props.readRetry.retrying}
/>
)}
<Button onClick={props.onReset} variant="default">
{t("ledger.reset")}
</Button>
</Group>
)} )}
text={t("ledger.errorText")} text={t("ledger.errorText")}
title={t("ledger.errorTitle")} title={t("ledger.errorTitle")}
@@ -19,7 +19,7 @@ import {
OperationGroup, OperationGroup,
} from "@/entities/broker-operation"; } from "@/entities/broker-operation";
import { groupItems } from "@/shared/lib"; import { groupItems } from "@/shared/lib";
import { SourceSyncStatus } from "@/shared/ui"; import { ReadRetry, SourceSyncStatus } from "@/shared/ui";
import type { AccountOperationsPageViewModel } from "../AccountOperationsPage.vm"; import type { AccountOperationsPageViewModel } from "../AccountOperationsPage.vm";
@@ -102,6 +102,8 @@ const AccountOperationsPageLoaded: FC<AccountOperationsPageLoadedProps> = (
stale={props.source?.freshness === "stale"} stale={props.source?.freshness === "stale"}
/> />
<ReadRetry {...props.readRetry} />
{!!props.partial && <Alert role="status">{t("investment.partial")}</Alert>} {!!props.partial && <Alert role="status">{t("investment.partial")}</Alert>}
{!!props.historyLoading && <Alert>{t("investment.history")}</Alert>} {!!props.historyLoading && <Alert>{t("investment.history")}</Alert>}
@@ -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") { function renderAccountRoute(queryClient: QueryClient, accountId = "account-1") {
configureTestAuth(); configureTestAuth();
@@ -1255,6 +1259,110 @@ describe("AccountPage states", () => {
expect(screen.getByText("Вторая")).toBeInTheDocument(); 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])( it.each([503])(
"preserves totals quietly through refresh error %i and success", "preserves totals quietly through refresh error %i and success",
async (status) => { async (status) => {
@@ -16,6 +16,7 @@ const AccountPage: FC = () => {
account={viewModel.account} account={viewModel.account}
accountId={viewModel.accountId} accountId={viewModel.accountId}
incomeSummary={viewModel.incomeSummary} incomeSummary={viewModel.incomeSummary}
readRetry={viewModel.readRetry}
recentEvents={viewModel.recentEvents} recentEvents={viewModel.recentEvents}
recentOperations={viewModel.recentOperations} recentOperations={viewModel.recentOperations}
source={viewModel.source} source={viewModel.source}
@@ -35,6 +36,7 @@ const AccountPage: FC = () => {
partial={viewModel.partial} partial={viewModel.partial}
portfolio={viewModel.portfolio} portfolio={viewModel.portfolio}
positionSections={viewModel.positionSections} positionSections={viewModel.positionSections}
readRetry={viewModel.readRetry}
recentEvents={viewModel.recentEvents} recentEvents={viewModel.recentEvents}
recentOperations={viewModel.recentOperations} recentOperations={viewModel.recentOperations}
slice={viewModel.slice} slice={viewModel.slice}
@@ -16,7 +16,7 @@ import {
import { useIncomeSummary } from "@/entities/income"; import { useIncomeSummary } from "@/entities/income";
import { usePortfolio } from "@/entities/portfolio"; import { usePortfolio } from "@/entities/portfolio";
import { useScreenSync } from "@/features/tbank-connection"; 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 { useInitialAvailability } from "@/shared/lib";
import { import {
@@ -45,6 +45,7 @@ type AccountPageContent = {
accountId?: string; accountId?: string;
incomeSummary: AccountPageIncomeSummary; incomeSummary: AccountPageIncomeSummary;
partial?: boolean; partial?: boolean;
readRetry: AccountPageReadRetry;
recentEvents: AccountPageRecentEvents; recentEvents: AccountPageRecentEvents;
recentOperations: AccountPageRecentOperations; recentOperations: AccountPageRecentOperations;
source?: InvestmentResourceMeta; source?: InvestmentResourceMeta;
@@ -68,10 +69,12 @@ type AccountPageLoadedViewModel = AccountPageContent & {
unrealizedReturns: Portfolio["totalsByCurrency"]; unrealizedReturns: Portfolio["totalsByCurrency"];
}; };
type AccountPageReadRetry = ReturnType<typeof readRetry>;
type AccountPageViewModel type AccountPageViewModel
= | AccountPageEmptyViewModel = | AccountPageEmptyViewModel
| AccountPageLoadedViewModel | AccountPageLoadedViewModel
| { code?: string; state: "error" } | { code?: string; readRetry: AccountPageReadRetry; state: "error" }
| { state: "loading" }; | { state: "loading" };
function useAccountPageViewModel(): AccountPageViewModel { function useAccountPageViewModel(): AccountPageViewModel {
@@ -113,6 +116,8 @@ function useAccountPageViewModel(): AccountPageViewModel {
]); ]);
const code = apiErrorCode(portfolio.error); 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 isAccessFailure = isBrokerageAccessLoss(code);
const availability = useInitialAvailability({ const availability = useInitialAvailability({
@@ -122,12 +127,13 @@ function useAccountPageViewModel(): AccountPageViewModel {
isFetching: portfolio.isFetching, isFetching: portfolio.isFetching,
}); });
if (isAccessFailure) return { code, state: "error" as const }; if (isAccessFailure)
return { code, readRetry: retry, state: "error" as const };
if (!portfolio.data) if (!portfolio.data)
return { return availability === "unavailable"
state: availability === "unavailable" ? "error" : "loading", ? { code, readRetry: retry, state: "error" as const }
} as const; : { state: "loading" as const };
const account = accounts.data?.find(item => item.id === accountId); const account = accounts.data?.find(item => item.id === accountId);
const recentOperations = toRecentOperations(accountId, operations); const recentOperations = toRecentOperations(accountId, operations);
@@ -144,6 +150,7 @@ function useAccountPageViewModel(): AccountPageViewModel {
account, account,
accountId, accountId,
incomeSummary, incomeSummary,
readRetry: retry,
recentEvents, recentEvents,
recentOperations, recentOperations,
source: portfolio.meta, source: portfolio.meta,
@@ -181,6 +188,7 @@ function useAccountPageViewModel(): AccountPageViewModel {
partial: isPartial, partial: isPartial,
portfolio: portfolio.data, portfolio: portfolio.data,
positionSections: toAccountPagePositionSections(portfolio.data), positionSections: toAccountPagePositionSections(portfolio.data),
readRetry: retry,
recentEvents, recentEvents,
recentOperations, recentOperations,
slice: current.slice, slice: current.slice,
@@ -8,7 +8,7 @@ import type { BrokerageAccount } from "@/entities/brokerage-account";
import type { InvestmentResourceMeta } from "@/shared/api"; import type { InvestmentResourceMeta } from "@/shared/api";
import { BrokerageEventsPreview } from "@/entities/brokerage-event"; 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 { PortfolioStructure } from "@/widgets/portfolio-structure";
import type { import type {
@@ -25,6 +25,7 @@ type AccountPageEmptyProps = {
account?: BrokerageAccount; account?: BrokerageAccount;
accountId?: string; accountId?: string;
incomeSummary?: AccountPageIncomeSummary; incomeSummary?: AccountPageIncomeSummary;
readRetry?: ReadRetryProps;
recentEvents: AccountPageRecentEvents; recentEvents: AccountPageRecentEvents;
recentOperations: AccountPageRecentOperations; recentOperations: AccountPageRecentOperations;
source?: InvestmentResourceMeta; source?: InvestmentResourceMeta;
@@ -49,6 +50,8 @@ const AccountPageEmpty: FC<AccountPageEmptyProps> = (props) => {
stale={props.source?.freshness === "stale"} stale={props.source?.freshness === "stale"}
/> />
{!!props.readRetry && <ReadRetry {...props.readRetry} />}
<StateView text={text} title={t("portfolio.emptyTitle")} /> <StateView text={text} title={t("portfolio.emptyTitle")} />
<PortfolioStructure <PortfolioStructure
@@ -6,8 +6,12 @@ import { useTranslation } from "react-i18next";
import { Link } from "react-router"; import { Link } from "react-router";
import { ApiErrorCode, isApiErrorCode } from "@/shared/api"; import { ApiErrorCode, isApiErrorCode } from "@/shared/api";
import { ReadRetryButton, type ReadRetryProps } from "@/shared/ui";
type AccountPageErrorProps = { code?: string }; type AccountPageErrorProps = {
code?: string;
readRetry?: ReadRetryProps;
};
const AccountPageError: FC<AccountPageErrorProps> = (props) => { const AccountPageError: FC<AccountPageErrorProps> = (props) => {
const { code } = props; const { code } = props;
@@ -43,8 +47,18 @@ const AccountPageError: FC<AccountPageErrorProps> = (props) => {
/> />
); );
const retry = props.readRetry?.exhausted
? (
<ReadRetryButton
onRetry={props.readRetry.onRetry}
retrying={props.readRetry.retrying}
/>
)
: undefined;
return ( return (
<StateView <StateView
action={retry}
text={t("states.unavailableText")} text={t("states.unavailableText")}
title={t("states.unavailableTitle")} title={t("states.unavailableTitle")}
/> />
@@ -15,7 +15,7 @@ import type { InvestmentResourceMeta } from "@/shared/api";
import { BrokerageEventsPreview } from "@/entities/brokerage-event"; import { BrokerageEventsPreview } from "@/entities/brokerage-event";
import { formatMoney } from "@/shared/lib"; 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 { PortfolioStructure } from "@/widgets/portfolio-structure";
import type { Concentration as ConcentrationModel, Slice } from "../../../model/concentration"; import type { Concentration as ConcentrationModel, Slice } from "../../../model/concentration";
@@ -46,6 +46,7 @@ type AccountPageLoadedProps = {
partial?: boolean; partial?: boolean;
portfolio: Portfolio; portfolio: Portfolio;
positionSections: AccountPagePositionSection[]; positionSections: AccountPagePositionSection[];
readRetry?: ReadRetryProps;
recentEvents: AccountPageRecentEvents; recentEvents: AccountPageRecentEvents;
recentOperations: AccountPageRecentOperations; recentOperations: AccountPageRecentOperations;
slice: Slice; slice: Slice;
@@ -86,6 +87,8 @@ const AccountPageLoaded: FC<AccountPageLoadedProps> = (props) => {
stale={props.source?.freshness === "stale"} stale={props.source?.freshness === "stale"}
/> />
{!!props.readRetry && <ReadRetry {...props.readRetry} />}
{props.partial {props.partial
? tab !== "concentration" && ( ? tab !== "concentration" && (
<Alert role="status">{t("investment.partial")}</Alert> <Alert role="status">{t("investment.partial")}</Alert>
@@ -1,6 +1,6 @@
import { MantineProvider } from "@mantine/core"; import { MantineProvider } from "@mantine/core";
import { type QueryClient, QueryClientProvider } from "@tanstack/react-query"; 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 { http, HttpResponse } from "msw";
import { MemoryRouter } from "react-router"; import { MemoryRouter } from "react-router";
import { describe, expect, it, vi } from "vitest"; 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)( it.each(["none", "failed"] as const)(
"keeps incomplete empty membership distinct from no accounts (attempt=%s)", "keeps incomplete empty membership distinct from no accounts (attempt=%s)",
async (state) => { async (state) => {
@@ -16,6 +16,7 @@ const AccountsPage: FC = () => {
<AccountsPageLoaded <AccountsPageLoaded
accounts={viewModel.accounts} accounts={viewModel.accounts}
meta={viewModel.meta} meta={viewModel.meta}
readRetry={viewModel.readRetry}
/> />
); );
}; };
@@ -4,14 +4,26 @@ import {
useBrokerageAccounts, useBrokerageAccounts,
} from "@/entities/brokerage-account"; } from "@/entities/brokerage-account";
import { useScreenSync } from "@/features/tbank-connection"; 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"; import { useInitialAvailability } from "@/shared/lib";
type AccountsPageReadRetry = ReturnType<typeof readRetry>;
type AccountsPageViewModel type AccountsPageViewModel
= | { accounts: BrokerageAccountCard[]; meta: NonNullable<ReturnType<typeof useBrokerageAccounts>["meta"]>; state: "loaded" } = | {
| { code?: string; state: "error" } accounts: BrokerageAccountCard[];
| { state: "empty" } meta: NonNullable<ReturnType<typeof useBrokerageAccounts>["meta"]>;
| { state: "loading" }; readRetry: AccountsPageReadRetry;
state: "loaded";
}
| { code?: string; readRetry: AccountsPageReadRetry; state: "error" }
| { state: "empty" }
| { state: "loading" };
function isAccountsAccessFailure(code?: string) { function isAccountsAccessFailure(code?: string) {
return isApiErrorCode(code, ApiErrorCode.InvalidTbankToken) return isApiErrorCode(code, ApiErrorCode.InvalidTbankToken)
@@ -23,6 +35,7 @@ function useAccountsPageViewModel(): AccountsPageViewModel {
useScreenSync({ screen: "accounts" }, [accounts]); useScreenSync({ screen: "accounts" }, [accounts]);
const code = apiErrorCode(accounts.error); const code = apiErrorCode(accounts.error);
const retry = readRetry([accounts]);
const isAccessFailure = isAccountsAccessFailure(code); const isAccessFailure = isAccountsAccessFailure(code);
const hasResult = !!accounts.data const hasResult = !!accounts.data
@@ -36,12 +49,13 @@ function useAccountsPageViewModel(): AccountsPageViewModel {
isFetching: accounts.isFetching, 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) if (!hasResult || !accounts.data)
return { return availability === "unavailable"
state: availability === "unavailable" ? "error" : "loading", ? { code, readRetry: retry, state: "error" as const }
} as const; : { state: "loading" as const };
if (accounts.data.length === 0) if (accounts.data.length === 0)
return { return {
@@ -54,6 +68,7 @@ function useAccountsPageViewModel(): AccountsPageViewModel {
return { return {
accounts: brokerageAccountCards(accounts.data, meta.dependencies), accounts: brokerageAccountCards(accounts.data, meta.dependencies),
meta, meta,
readRetry: retry,
state: "loaded" as const, state: "loaded" as const,
}; };
} }
@@ -6,8 +6,12 @@ import { useTranslation } from "react-i18next";
import { Link } from "react-router"; import { Link } from "react-router";
import { ApiErrorCode, isApiErrorCode } from "@/shared/api"; 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<AccountsPageErrorProps> = (props) => { const AccountsPageError: FC<AccountsPageErrorProps> = (props) => {
const { code } = props; const { code } = props;
@@ -38,8 +42,18 @@ const AccountsPageError: FC<AccountsPageErrorProps> = (props) => {
/> />
); );
const retry = props.readRetry?.exhausted
? (
<ReadRetryButton
onRetry={props.readRetry.onRetry}
retrying={props.readRetry.retrying}
/>
)
: undefined;
return ( return (
<StateView <StateView
action={retry}
text={t("states.unavailableText")} text={t("states.unavailableText")}
title={t("states.unavailableTitle")} title={t("states.unavailableTitle")}
/> />
@@ -11,7 +11,7 @@ import {
BrokerageAccountList, BrokerageAccountList,
} from "@/entities/brokerage-account"; } from "@/entities/brokerage-account";
import { formatMoney } from "@/shared/lib"; import { formatMoney } from "@/shared/lib";
import { SourceSyncStatus } from "@/shared/ui"; import { ReadRetry, type ReadRetryProps, SourceSyncStatus } from "@/shared/ui";
const DECIMAL_BASE = 10n; const DECIMAL_BASE = 10n;
const DECIMAL_PART_COUNT = 2; const DECIMAL_PART_COUNT = 2;
@@ -20,6 +20,7 @@ const ZERO = 0n;
type AccountsPageLoadedProps = { type AccountsPageLoadedProps = {
accounts: BrokerageAccountCard[]; accounts: BrokerageAccountCard[];
meta: InvestmentResourceMeta; meta: InvestmentResourceMeta;
readRetry?: ReadRetryProps;
}; };
type Money = { currency: string; value: string }; type Money = { currency: string; value: string };
@@ -132,6 +133,8 @@ const AccountsPageLoaded: FC<AccountsPageLoadedProps> = (props) => {
lastSuccessfulAt={props.meta.lastSuccessfulAt} lastSuccessfulAt={props.meta.lastSuccessfulAt}
stale={props.meta.freshness === "stale"} stale={props.meta.freshness === "stale"}
/> />
{!!props.readRetry && <ReadRetry {...props.readRetry} />}
</Stack> </Stack>
<Button <Button
+16 -1
View File
@@ -347,7 +347,6 @@ describe("bond page", () => {
it("keeps market content and sold-position history when coupons fail", async () => { it("keeps market content and sold-position history when coupons fail", async () => {
setup({ failCoupons: true, noPosition: true }); setup({ failCoupons: true, noPosition: true });
await screen.findByText("98,125 %"); await screen.findByText("98,125 %");
expect(screen.queryByRole("button", { name: "Повторить" })).not.toBeInTheDocument();
expect( expect(
screen.queryByRole("heading", { name: "Моя позиция" }), screen.queryByRole("heading", { name: "Моя позиция" }),
).not.toBeInTheDocument(); ).not.toBeInTheDocument();
@@ -355,6 +354,22 @@ describe("bond page", () => {
screen.getByRole("heading", { name: "Моя история событий" }), screen.getByRole("heading", { name: "Моя история событий" }),
).toBeVisible(); ).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 () => { it("preserves cached quote without manual refresh", async () => {
const controls = setup({ path: `/bonds/${uid}?period=year&events=trades` }); const controls = setup({ path: `/bonds/${uid}?period=year&events=trades` });
await screen.findByText("98,125 %"); await screen.findByText("98,125 %");
@@ -81,6 +81,7 @@ function setup(
candles?: NonNullable<StockPageVm["candles"]>["candles"]; candles?: NonNullable<StockPageVm["candles"]>["candles"];
cursorError?: string; cursorError?: string;
failCompany?: boolean; failCompany?: boolean;
failCompanyAfter?: { value: boolean };
failStock?: boolean; failStock?: boolean;
metrics?: NonNullable<StockPageVm["company"]>["metrics"]; metrics?: NonNullable<StockPageVm["company"]>["metrics"];
noPosition?: boolean; noPosition?: boolean;
@@ -133,7 +134,7 @@ function setup(
), ),
); );
if (params.section === "company") if (params.section === "company")
return options.failCompany return (options.failCompanyAfter?.value ?? options.failCompany)
? HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 }) ? HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 })
: HttpResponse.json( : HttpResponse.json(
@@ -396,7 +397,6 @@ describe("stock page", () => {
screen.queryByRole("heading", { name: "Моя позиция" }), screen.queryByRole("heading", { name: "Моя позиция" }),
).not.toBeInTheDocument(); ).not.toBeInTheDocument();
expect(screen.getByRole("heading", { name: "График цены" })).toBeVisible(); 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 () => { it("keeps successful sections visible when company data is initially missing", async () => {
const { requests } = setup({ failCompany: true }); const { requests } = setup({ failCompany: true });
@@ -404,8 +404,39 @@ describe("stock page", () => {
const previousRequests = requests.length; const previousRequests = requests.length;
expect(screen.getByText("Зачисление дивидендов")).toBeVisible(); expect(screen.getByText("Зачисление дивидендов")).toBeVisible();
expect(requests).toHaveLength(previousRequests); 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 () => { it("sends instrument-filtered continuation, restores it on returning to the filter and changes chart period", async () => {
const { requests } = setup(); const { requests } = setup();
await screen.findByText("Зачисление дивидендов"); await screen.findByText("Зачисление дивидендов");
+1
View File
@@ -13,6 +13,7 @@ export {
isApiErrorCode, isApiErrorCode,
isBrokerageAccessLoss, isBrokerageAccessLoss,
} from "./problem-details"; } from "./problem-details";
export { isReadFailureExhausted, readRetry } from "./read-failure";
export { export {
type InvestmentResourceMeta, type InvestmentResourceMeta,
+80
View File
@@ -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);
});
});
+37
View File
@@ -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<unknown> };
// 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 };
+2
View File
@@ -421,7 +421,9 @@ const ru = {
}, },
history: "История догружается", history: "История догружается",
partial: "Период загружен не полностью", partial: "Период загружен не полностью",
readFailed: "Не удалось обновить данные",
refresh: "Обновить", refresh: "Обновить",
retry: "Повторить",
sourceTime: "Данные на {{date}}", sourceTime: "Данные на {{date}}",
stale: "Данные требуют обновления", stale: "Данные требуют обновления",
syncFailed: syncFailed:
@@ -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<typeof ReadRetry>[0]) {
return render(
<MantineProvider>
<ReadRetry {...props} />
</MantineProvider>,
);
}
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();
});
});
+42
View File
@@ -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<Pick<ReadRetryProps, "onRetry" | "retrying">> = (
props,
) => {
const { t } = useTranslation();
return (
<Button disabled={props.retrying} onClick={props.onRetry} variant="light">
{t("investment.retry")}
</Button>
);
};
const ReadRetry: FC<ReadRetryProps> = (props) => {
const { t } = useTranslation();
if (!props.exhausted) return null;
return (
<Alert aria-live="polite" color="orange" role="status">
<Group gap="sm" justify="space-between">
<Text>{t("investment.readFailed")}</Text>
<ReadRetryButton onRetry={props.onRetry} retrying={props.retrying} />
</Group>
</Alert>
);
};
export { ReadRetry, ReadRetryButton, type ReadRetryProps };
+5
View File
@@ -5,4 +5,9 @@ export {
EntryGroupTotal, EntryGroupTotal,
} from "./EntryGroup/EntryGroup"; } from "./EntryGroup/EntryGroup";
export { PayoutCard } from "./PayoutCard/PayoutCard"; export { PayoutCard } from "./PayoutCard/PayoutCard";
export {
ReadRetry,
ReadRetryButton,
type ReadRetryProps,
} from "./ReadRetry/ReadRetry";
export { SourceSyncStatus } from "./SourceSyncStatus/SourceSyncStatus"; export { SourceSyncStatus } from "./SourceSyncStatus/SourceSyncStatus";