10 Commits
Author SHA1 Message Date
ksv741 c4d0b7edb0 feat(instruments): offer per-section retry after exhausted reads (WealthLedger/frontend#55)
Frontend verify / commits (pull_request) Successful in 31s
Frontend verify / security (pull_request) Successful in 10s
Frontend verify / verify (pull_request) Successful in 5m39s
2026-11-03 08:44:47 +03:00
ksv741 c84b633855 feat(events): offer retry after exhausted events reads (WealthLedger/frontend#55) 2026-11-03 08:42:28 +03:00
ksv741 7ecc57c547 feat(operations): offer retry after exhausted operations reads (WealthLedger/frontend#55) 2026-11-03 08:40:20 +03:00
ksv741 a2e9d63bfe feat(income): offer retry after exhausted income reads (WealthLedger/frontend#55) 2026-11-03 08:38:23 +03:00
ksv741 d3335a7a8d feat(accounts): offer retry after exhausted accounts reads (WealthLedger/frontend#55) 2026-11-03 08:36:56 +03:00
ksv741 3a01a3a5bc feat(account): offer retry after exhausted portfolio reads (WealthLedger/frontend#55) 2026-11-03 08:35:13 +03:00
ksv741 1a9199a92b feat(shared): add read-failure classifier and ReadRetry (WealthLedger/frontend#55) 2026-11-03 08:31:01 +03:00
codex-bot aaee9591d7 chore(api): update OpenAPI snapshot with UpcomingEventsDto.window (WealthLedger/frontend#134) (#135)
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 9s
Frontend verify / verify (push) Failing after 6m24s
2026-11-03 07:21:35 +03:00
codex-bot 9619dc9791 fix(account): show source metadata on empty account and income (WealthLedger/frontend#55) (#133)
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 10s
Frontend verify / verify (push) Failing after 6m34s
2026-11-03 06:44:13 +03:00
codex-bot 71fbe7774d refactor: replace repeated lazy page wrappers with lazyPage helper (#131) (#132)
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 8s
Frontend verify / verify (push) Failing after 6m34s
2026-11-02 07:31:25 +03:00
43 changed files with 1207 additions and 196 deletions
+28 -94
View File
@@ -1,4 +1,4 @@
import type { FC } from "react";
import type { ComponentType, FC } from "react";
import { lazy } from "react";
import { Navigate, Route, Routes } from "react-router";
@@ -6,101 +6,35 @@ import { Navigate, Route, Routes } from "react-router";
import { LazyPage } from "../RouteFallback";
import { Protected, Public } from "../RouteGuards";
const CardPage = lazy(async () => {
const { CardPage } = await import("@/pages/card");
// Pages use named exports; React.lazy needs a default export per dynamic import (one chunk each).
function lazyPage<Name extends string>(
load: () => Promise<Record<Name, ComponentType>>,
name: Name,
) {
return lazy(async () => {
const page = await load();
return { default: CardPage };
});
// eslint-disable-next-line security/detect-object-injection -- `name` is a compile-time literal checked against the module's exports.
return { default: page[name] };
});
}
const CardsPage = lazy(async () => {
const { CardsPage } = await import("@/pages/cards");
return { default: CardsPage };
});
const BondPage = lazy(async () => {
const { BondPage } = await import("@/pages/bond");
return { default: BondPage };
});
const BondsPage = lazy(async () => {
const { BondsPage } = await import("@/pages/bonds");
return { default: BondsPage };
});
const StockPage = lazy(async () => {
const { StockPage } = await import("@/pages/stock");
return { default: StockPage };
});
const StocksPage = lazy(async () => {
const { StocksPage } = await import("@/pages/stocks");
return { default: StocksPage };
});
const AccountPage = lazy(async () => {
const { AccountPage } = await import("@/pages/account");
return { default: AccountPage };
});
const AccountEventsPage = lazy(async () => {
const { AccountEventsPage } = await import("@/pages/account-events");
return { default: AccountEventsPage };
});
const AccountOperationsPage = lazy(async () => {
const { AccountOperationsPage } = await import("@/pages/account-operations");
return { default: AccountOperationsPage };
});
const AccountIncomePage = lazy(async () => {
const { AccountIncomePage } = await import("@/pages/account-income");
return { default: AccountIncomePage };
});
const AccountsPage = lazy(async () => {
const { AccountsPage } = await import("@/pages/accounts");
return { default: AccountsPage };
});
const LoginPage = lazy(async () => {
const { LoginPage } = await import("@/pages/login");
return { default: LoginPage };
});
const NotFoundPage = lazy(async () => {
const { NotFoundPage } = await import("@/pages/not-found");
return { default: NotFoundPage };
});
const RegisterPage = lazy(async () => {
const { RegisterPage } = await import("@/pages/register");
return { default: RegisterPage };
});
const SettingsPage = lazy(async () => {
const { SettingsPage } = await import("@/pages/settings");
return { default: SettingsPage };
});
const ProtectedLayout = lazy(async () => {
const { ProtectedLayout } = await import("../ProtectedLayout");
return { default: ProtectedLayout };
});
const CardPage = lazyPage(async () => import("@/pages/card"), "CardPage");
const CardsPage = lazyPage(async () => import("@/pages/cards"), "CardsPage");
const BondPage = lazyPage(async () => import("@/pages/bond"), "BondPage");
const BondsPage = lazyPage(async () => import("@/pages/bonds"), "BondsPage");
const StockPage = lazyPage(async () => import("@/pages/stock"), "StockPage");
const StocksPage = lazyPage(async () => import("@/pages/stocks"), "StocksPage");
const AccountPage = lazyPage(async () => import("@/pages/account"), "AccountPage");
const AccountEventsPage = lazyPage(async () => import("@/pages/account-events"), "AccountEventsPage");
const AccountOperationsPage = lazyPage(async () => import("@/pages/account-operations"), "AccountOperationsPage");
const AccountIncomePage = lazyPage(async () => import("@/pages/account-income"), "AccountIncomePage");
const AccountsPage = lazyPage(async () => import("@/pages/accounts"), "AccountsPage");
const LoginPage = lazyPage(async () => import("@/pages/login"), "LoginPage");
const NotFoundPage = lazyPage(async () => import("@/pages/not-found"), "NotFoundPage");
const RegisterPage = lazyPage(async () => import("@/pages/register"), "RegisterPage");
const SettingsPage = lazyPage(async () => import("@/pages/settings"), "SettingsPage");
const ProtectedLayout = lazyPage(async () => import("../ProtectedLayout"), "ProtectedLayout");
const AppRoutes: FC = () => (
<Routes>
@@ -1,4 +1,4 @@
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import { resource } from "@/test/resource";
@@ -76,4 +76,29 @@ describe("instrument source and transport presentation", () => {
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 = {
error: boolean;
hasData: boolean;
loading: boolean;
partial?: boolean;
readRetry?: ReturnType<typeof readRetry>;
};
function hasSectionData(data: unknown) {
@@ -13,8 +14,11 @@ function hasSectionData(data: unknown) {
function section(query: {
data: unknown;
error?: unknown;
isError: boolean;
isFetching?: boolean;
meta?: InvestmentResourceMeta;
refetch?: () => Promise<unknown>;
}): SectionState {
const isError = query.isError || query.meta?.lastAttempt.state === "failed";
const hasData = hasSectionData(query.data);
@@ -24,6 +28,17 @@ function section(query: {
hasData,
loading: !hasData && !isError,
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 { useTranslation } from "react-i18next";
import { ReadRetry } from "@/shared/ui";
import type { SectionState } from "../model/section";
import classes from "./InstrumentDetails.module.css";
@@ -81,6 +83,8 @@ const InstrumentSection: FC<InstrumentSectionProps> = (props) => {
</Alert>
)}
{!!props.state.readRetry && <ReadRetry {...props.state.readRetry} />}
{sectionContent}
</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);
});
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>
@@ -26,6 +26,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import type { IncomeAnalytics } from "@/entities/income";
import { configureAuthClient } from "@/shared/api";
import { formatSnapshotTime } from "@/shared/lib";
import { createTestQueryClient } from "@/test/query";
import { resource } from "@/test/resource";
import { server } from "@/test/server";
@@ -37,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",
@@ -403,21 +408,124 @@ describe("AccountIncomePage", () => {
expect(screen.queryByText(/Сравнение с/)).not.toBeInTheDocument();
});
it("shows a quiet realized error", async () => {
it.each([
["full", analytics],
["empty", { ...analytics, buckets: [], entries: [], instruments: [], totalsByCurrency: [] }],
])("shows the server source time and last attempt for a %s result", async (_name, data) => {
server.use(
http.get(
"*/api/v1/brokerage-accounts/account-1/income-analytics",
() => new HttpResponse(null, { status: 503 }),
http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () =>
HttpResponse.json(
resource(data, {
freshness: "stale",
lastAttempt: {
finishedAt: "2026-08-27T01:00:00Z",
problems: [],
startedAt: "2026-08-27T00:59:00Z",
state: "failed",
},
lastSuccessfulAt: "2026-08-26T12:00:00Z",
}),
),
),
);
renderPage();
expect(
await screen.findByText("Не удалось рассчитать пассивный доход", undefined, {
timeout: 4000,
}),
).toBeInTheDocument();
expect(await screen.findByText(/^Данные на /)).toHaveTextContent(
formatSnapshotTime("2026-08-26T12:00:00Z"),
);
expect(screen.getByText("Обновление не удалось")).toBeInTheDocument();
expect(screen.getByText("Данные требуют обновления")).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 = () => {
@@ -5,6 +5,7 @@ import { useParams, useSearchParams } from "react-router";
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 {
@@ -30,11 +31,14 @@ type AccountIncomePageViewModel
onReset: () => void;
onSubmit: (values: IncomeForm) => void;
partial?: boolean;
readRetry: ReturnType<typeof readRetry>;
source?: InvestmentResourceMeta;
state: "loaded";
totalVisible: boolean;
}
| {
period: IncomePeriod;
readRetry: ReturnType<typeof readRetry>;
state: "error";
}
| { state: "loading" };
@@ -84,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);
@@ -116,6 +122,8 @@ const useAccountIncomePageViewModel = (): AccountIncomePageViewModel => {
onReset,
onSubmit,
partial: analytics.meta?.completeness === "incomplete",
readRetry: retry,
source: analytics.meta,
state: "loaded",
totalVisible: analytics.meta?.completeness === "complete",
};
@@ -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<AccountIncomePageViewModel, { state: "error" }>;
const AccountIncomePageError: FC<ErrorProps> = () => {
const AccountIncomePageError: FC<ErrorProps> = (props) => {
const { t } = useTranslation();
const retry = props.readRetry.exhausted
? (
<ReadRetryButton
onRetry={props.readRetry.onRetry}
retrying={props.readRetry.retrying}
/>
)
: undefined;
return (
<StateView
action={retry}
error
text={t("income.errorText")}
title={t("income.errorTitle")}
@@ -9,6 +9,8 @@ import { StateView } from "@wealthledger/ui";
import { FormProvider } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { ReadRetry, SourceSyncStatus } from "@/shared/ui";
import type { AccountIncomePageViewModel } from "../AccountIncomePage.vm";
import classes from "../AccountIncomePage.module.css";
@@ -49,6 +51,14 @@ const AccountIncomePageLoaded: FC<LoadedProps> = (props) => {
/>
</FormProvider>
<SourceSyncStatus
lastAttempt={props.source?.lastAttempt}
lastSuccessfulAt={props.source?.lastSuccessfulAt}
stale={props.source?.freshness === "stale"}
/>
<ReadRetry {...props.readRetry} />
<AccountIncomePageLoadedSummary {...props} />
<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(
<QueryClientProvider client={createTestQueryClient()}>
<QueryClientProvider client={queryClient}>
<MantineProvider>
<MemoryRouter
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", () => {
it("marks cashless operations without rendering operation details", () => {
render(
@@ -545,20 +561,87 @@ describe("AccountOperationsPage", () => {
).toBeInTheDocument();
});
it("renders an error state", async () => {
configureTestAuth();
server.use(
http.get("*/api/v1/brokerage-accounts/:accountId/operations", () =>
HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 }),
),
);
renderPage();
expect(
await screen.findByText("Не удалось загрузить журнал", undefined, {
timeout: 4000,
}),
).toBeInTheDocument();
});
it(
"renders an error state with retry that recovers in place",
{ timeout: TEST_MS },
async () => {
configureTestAuth();
let isFailing = true;
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));
}),
);
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 () => {
configureTestAuth();
@@ -12,7 +12,12 @@ import {
useBrokerageOperationHistory,
} from "@/entities/broker-operation";
import { useScreenSync } from "@/features/tbank-connection";
import { ApiErrorCode, apiErrorCode, isApiErrorCode } from "@/shared/api";
import {
ApiErrorCode,
apiErrorCode,
isApiErrorCode,
readRetry,
} from "@/shared/api";
import {
defaultPeriod,
isValidPeriod,
@@ -42,11 +47,16 @@ type AccountOperationsPageViewModel
onPrevious: () => 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" };
const useAccountOperationsPageVm = (): AccountOperationsPageViewModel => {
@@ -107,6 +117,8 @@ const useAccountOperationsPageVm = (): AccountOperationsPageViewModel => {
isFetching: operations.isFetching,
});
const retry = readRetry([operations]);
if (availability === "loading" && operations.isLoading)
return { state: "loading" };
@@ -134,11 +146,11 @@ const useAccountOperationsPageVm = (): AccountOperationsPageViewModel => {
(isInvalidCursor || !operations.data)
&& (operations.isError || operations.meta?.lastAttempt.state === "failed")
)
return { onReset, state: "error" };
return { onReset, readRetry: retry, state: "error" };
if (!operations.data)
return availability === "unavailable"
? { onReset, state: "error" }
? { onReset, readRetry: retry, state: "error" }
: { state: "loading" };
const nextCursor = operations.data.nextCursor;
@@ -182,6 +194,7 @@ const useAccountOperationsPageVm = (): AccountOperationsPageViewModel => {
onPrevious: history.onPrevious,
onReset,
partial: operations.meta?.completeness === "incomplete",
readRetry: retry,
source: operations.meta,
state: "loaded",
totalVisible: operations.meta?.completeness === "complete",
@@ -1,9 +1,11 @@
import type { FC } from "react";
import { Button } from "@mantine/core";
import { Button, Group } from "@mantine/core";
import { StateView } from "@wealthledger/ui";
import { useTranslation } from "react-i18next";
import { ReadRetryButton } from "@/shared/ui";
import type { AccountOperationsPageViewModel } from "../AccountOperationsPage.vm";
type AccountOperationsPageErrorProps = Extract<
@@ -19,9 +21,18 @@ const AccountOperationsPageError: FC<AccountOperationsPageErrorProps> = (
return (
<StateView
action={(
<Button onClick={props.onReset} variant="default">
{t("ledger.reset")}
</Button>
<Group gap="sm" justify="center">
{props.readRetry.exhausted && (
<ReadRetryButton
onRetry={props.readRetry.onRetry}
retrying={props.readRetry.retrying}
/>
)}
<Button onClick={props.onReset} variant="default">
{t("ledger.reset")}
</Button>
</Group>
)}
text={t("ledger.errorText")}
title={t("ledger.errorTitle")}
@@ -19,7 +19,7 @@ import {
OperationGroup,
} from "@/entities/broker-operation";
import { groupItems } from "@/shared/lib";
import { SourceSyncStatus } from "@/shared/ui";
import { ReadRetry, SourceSyncStatus } from "@/shared/ui";
import type { AccountOperationsPageViewModel } from "../AccountOperationsPage.vm";
@@ -102,6 +102,8 @@ const AccountOperationsPageLoaded: FC<AccountOperationsPageLoadedProps> = (
stale={props.source?.freshness === "stale"}
/>
<ReadRetry {...props.readRetry} />
{!!props.partial && <Alert role="status">{t("investment.partial")}</Alert>}
{!!props.historyLoading && <Alert>{t("investment.history")}</Alert>}
@@ -18,6 +18,7 @@ import type { BrokerageEvent } from "@/entities/brokerage-event";
import { type Portfolio, portfolioQueryOptions } from "@/entities/portfolio";
import { configureAuthClient } from "@/shared/api";
import { formatSnapshotTime } from "@/shared/lib";
import { createTestQueryClient } from "@/test/query";
import { resource } from "@/test/resource";
import { server } from "@/test/server";
@@ -59,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();
@@ -231,6 +236,37 @@ describe("AccountPage states", () => {
.toHaveAccessibleDescription(/Акция: 100 ₽, 100 %/);
});
it("shows server source time and attempt for an empty account", async () => {
server.use(
http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () =>
HttpResponse.json(resource(
{
...portfolio,
cashBalances: [],
isStale: false,
positions: [],
totalsByCurrency: [],
},
{
lastAttempt: {
finishedAt: "2026-08-14T01:00:00Z",
problems: [],
startedAt: "2026-08-14T00:59:00Z",
state: "failed",
},
lastSuccessfulAt: "2026-08-13T12:00:00Z",
},
)),
),
);
renderAccountRoute(createTestQueryClient());
expect(await screen.findByText(/^Данные на /)).toHaveTextContent(
formatSnapshotTime("2026-08-13T12:00:00Z"),
);
expect(screen.getByText("Обновление не удалось")).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Повторить" })).not.toBeInTheDocument();
});
it("expands and resets other when switching slice", () => {
const positions = [400, 250, 150, 100, 50, 30, 20].map(
(value, index) => position({
@@ -874,7 +910,6 @@ describe("AccountPage states", () => {
).toBeInTheDocument();
expect(screen.queryByText(/T-Bank временно недоступен/))
.not.toBeInTheDocument();
expect(screen.queryByText(/Данные получены/)).not.toBeInTheDocument();
expect(screen.getByRole("radio", { name: "Акции" })).toBeInTheDocument();
expect(
screen.getByRole("radio", { name: "Облигации" }),
@@ -1224,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) => {
@@ -1276,7 +1415,7 @@ describe("AccountPage states", () => {
});
expect(structure).toHaveTextContent("10 ₽");
expect(screen.queryByText(/Данные получены/)).not.toBeInTheDocument();
expect(screen.getByText(/^Данные на /)).toBeInTheDocument();
void queryClient.invalidateQueries({
queryKey: portfolioQueryOptions("account-1").queryKey,
});
@@ -1320,7 +1459,7 @@ describe("AccountPage states", () => {
screen.getAllByRole("definition").map(node => node.textContent),
).toEqual(["-0,5 $"]),
);
expect(screen.queryByText(/Данные получены/)).not.toBeInTheDocument();
expect(screen.getByText(/^Данные на /)).toHaveTextContent("Данные на");
expect(structure).not.toHaveTextContent("10 ₽");
expect(structure).toHaveTextContent("Нет положительной стоимости");
expect(
@@ -1361,7 +1500,7 @@ describe("AccountPage states", () => {
expect(
screen.getAllByRole("definition").map(node => node.textContent),
).toEqual(["1 234,5 ₽"]);
expect(screen.queryByText(/Данные получены/)).not.toBeInTheDocument();
expect(screen.getByText(/^Данные на /)).toBeInTheDocument();
expect(
screen.queryByRole("status", { name: "Загрузка" }),
).not.toBeInTheDocument();
@@ -16,8 +16,10 @@ const AccountPage: FC = () => {
account={viewModel.account}
accountId={viewModel.accountId}
incomeSummary={viewModel.incomeSummary}
readRetry={viewModel.readRetry}
recentEvents={viewModel.recentEvents}
recentOperations={viewModel.recentOperations}
source={viewModel.source}
/>
);
@@ -34,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}
@@ -16,7 +16,7 @@ import {
import { useIncomeSummary } from "@/entities/income";
import { usePortfolio } from "@/entities/portfolio";
import { useScreenSync } from "@/features/tbank-connection";
import { ApiErrorCode, apiErrorCode, isApiErrorCode } from "@/shared/api";
import { apiErrorCode, isBrokerageAccessLoss, readRetry } from "@/shared/api";
import { useInitialAvailability } from "@/shared/lib";
import {
@@ -45,8 +45,10 @@ type AccountPageContent = {
accountId?: string;
incomeSummary: AccountPageIncomeSummary;
partial?: boolean;
readRetry: AccountPageReadRetry;
recentEvents: AccountPageRecentEvents;
recentOperations: AccountPageRecentOperations;
source?: InvestmentResourceMeta;
};
type AccountPageEmptyViewModel = AccountPageContent & {
@@ -62,24 +64,19 @@ type AccountPageLoadedViewModel = AccountPageContent & {
portfolio: Portfolio;
positionSections: AccountPagePositionSection[];
slice: Slice;
source?: InvestmentResourceMeta;
state: "loaded";
tab: string;
unrealizedReturns: Portfolio["totalsByCurrency"];
};
type AccountPageReadRetry = ReturnType<typeof readRetry>;
type AccountPageViewModel
= | AccountPageEmptyViewModel
| AccountPageLoadedViewModel
| { code?: string; state: "error" }
| { code?: string; readRetry: AccountPageReadRetry; state: "error" }
| { state: "loading" };
function isPortfolioAccessFailure(code?: string) {
return isApiErrorCode(code, ApiErrorCode.InvalidTbankToken)
|| isApiErrorCode(code, ApiErrorCode.NotFound)
|| isApiErrorCode(code, ApiErrorCode.TbankNotConnected);
}
function useAccountPageViewModel(): AccountPageViewModel {
const { accountId } = useParams();
const { t } = useTranslation();
@@ -119,7 +116,9 @@ function useAccountPageViewModel(): AccountPageViewModel {
]);
const code = apiErrorCode(portfolio.error);
const isAccessFailure = isPortfolioAccessFailure(code);
// The portfolio drives this screen; the retry repeats only its GET.
const retry = readRetry([portfolio]);
const isAccessFailure = isBrokerageAccessLoss(code);
const availability = useInitialAvailability({
hasContent: !!portfolio.data,
@@ -128,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);
@@ -150,8 +150,10 @@ function useAccountPageViewModel(): AccountPageViewModel {
account,
accountId,
incomeSummary,
readRetry: retry,
recentEvents,
recentOperations,
source: portfolio.meta,
state: "empty" as const,
};
@@ -186,6 +188,7 @@ function useAccountPageViewModel(): AccountPageViewModel {
partial: isPartial,
portfolio: portfolio.data,
positionSections: toAccountPagePositionSections(portfolio.data),
readRetry: retry,
recentEvents,
recentOperations,
slice: current.slice,
@@ -5,8 +5,10 @@ import { StateView } from "@wealthledger/ui";
import { useTranslation } from "react-i18next";
import type { BrokerageAccount } from "@/entities/brokerage-account";
import type { InvestmentResourceMeta } from "@/shared/api";
import { BrokerageEventsPreview } from "@/entities/brokerage-event";
import { ReadRetry, type ReadRetryProps, SourceSyncStatus } from "@/shared/ui";
import { PortfolioStructure } from "@/widgets/portfolio-structure";
import type {
@@ -23,8 +25,10 @@ type AccountPageEmptyProps = {
account?: BrokerageAccount;
accountId?: string;
incomeSummary?: AccountPageIncomeSummary;
readRetry?: ReadRetryProps;
recentEvents: AccountPageRecentEvents;
recentOperations: AccountPageRecentOperations;
source?: InvestmentResourceMeta;
};
const AccountPageEmpty: FC<AccountPageEmptyProps> = (props) => {
@@ -40,6 +44,14 @@ const AccountPageEmpty: FC<AccountPageEmptyProps> = (props) => {
<Stack>
<AccountHeading account={props.account} accountId={props.accountId} />
<SourceSyncStatus
lastAttempt={props.source?.lastAttempt}
lastSuccessfulAt={props.source?.lastSuccessfulAt}
stale={props.source?.freshness === "stale"}
/>
{!!props.readRetry && <ReadRetry {...props.readRetry} />}
<StateView text={text} title={t("portfolio.emptyTitle")} />
<PortfolioStructure
@@ -6,8 +6,12 @@ import { useTranslation } from "react-i18next";
import { Link } from "react-router";
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 { 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 (
<StateView
action={retry}
text={t("states.unavailableText")}
title={t("states.unavailableTitle")}
/>
@@ -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<AccountPageLoadedProps> = (props) => {
stale={props.source?.freshness === "stale"}
/>
{!!props.readRetry && <ReadRetry {...props.readRetry} />}
{props.partial
? tab !== "concentration" && (
<Alert role="status">{t("investment.partial")}</Alert>
@@ -1,6 +1,6 @@
import { MantineProvider } from "@mantine/core";
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 { MemoryRouter } from "react-router";
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)(
"keeps incomplete empty membership distinct from no accounts (attempt=%s)",
async (state) => {
@@ -16,6 +16,7 @@ const AccountsPage: FC = () => {
<AccountsPageLoaded
accounts={viewModel.accounts}
meta={viewModel.meta}
readRetry={viewModel.readRetry}
/>
);
};
@@ -4,14 +4,26 @@ import {
useBrokerageAccounts,
} from "@/entities/brokerage-account";
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";
type AccountsPageReadRetry = ReturnType<typeof readRetry>;
type AccountsPageViewModel
= | { accounts: BrokerageAccountCard[]; meta: NonNullable<ReturnType<typeof useBrokerageAccounts>["meta"]>; state: "loaded" }
| { code?: string; state: "error" }
| { state: "empty" }
| { state: "loading" };
= | {
accounts: BrokerageAccountCard[];
meta: NonNullable<ReturnType<typeof useBrokerageAccounts>["meta"]>;
readRetry: AccountsPageReadRetry;
state: "loaded";
}
| { code?: string; readRetry: AccountsPageReadRetry; state: "error" }
| { state: "empty" }
| { state: "loading" };
function isAccountsAccessFailure(code?: string) {
return isApiErrorCode(code, ApiErrorCode.InvalidTbankToken)
@@ -23,6 +35,7 @@ function useAccountsPageViewModel(): AccountsPageViewModel {
useScreenSync({ screen: "accounts" }, [accounts]);
const code = apiErrorCode(accounts.error);
const retry = readRetry([accounts]);
const isAccessFailure = isAccountsAccessFailure(code);
const hasResult = !!accounts.data
@@ -36,12 +49,13 @@ function useAccountsPageViewModel(): AccountsPageViewModel {
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)
return {
state: availability === "unavailable" ? "error" : "loading",
} as const;
return availability === "unavailable"
? { code, readRetry: retry, state: "error" as const }
: { state: "loading" as const };
if (accounts.data.length === 0)
return {
@@ -54,6 +68,7 @@ function useAccountsPageViewModel(): AccountsPageViewModel {
return {
accounts: brokerageAccountCards(accounts.data, meta.dependencies),
meta,
readRetry: retry,
state: "loaded" as const,
};
}
@@ -6,8 +6,12 @@ import { useTranslation } from "react-i18next";
import { Link } from "react-router";
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 { 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 (
<StateView
action={retry}
text={t("states.unavailableText")}
title={t("states.unavailableTitle")}
/>
@@ -11,7 +11,7 @@ import {
BrokerageAccountList,
} from "@/entities/brokerage-account";
import { formatMoney } from "@/shared/lib";
import { SourceSyncStatus } from "@/shared/ui";
import { ReadRetry, type ReadRetryProps, SourceSyncStatus } from "@/shared/ui";
const DECIMAL_BASE = 10n;
const DECIMAL_PART_COUNT = 2;
@@ -20,6 +20,7 @@ const ZERO = 0n;
type AccountsPageLoadedProps = {
accounts: BrokerageAccountCard[];
meta: InvestmentResourceMeta;
readRetry?: ReadRetryProps;
};
type Money = { currency: string; value: string };
@@ -132,6 +133,8 @@ const AccountsPageLoaded: FC<AccountsPageLoadedProps> = (props) => {
lastSuccessfulAt={props.meta.lastSuccessfulAt}
stale={props.meta.freshness === "stale"}
/>
{!!props.readRetry && <ReadRetry {...props.readRetry} />}
</Stack>
<Button
+16 -1
View File
@@ -347,7 +347,6 @@ describe("bond page", () => {
it("keeps market content and sold-position history when coupons fail", async () => {
setup({ failCoupons: true, noPosition: true });
await screen.findByText("98,125 %");
expect(screen.queryByRole("button", { name: "Повторить" })).not.toBeInTheDocument();
expect(
screen.queryByRole("heading", { name: "Моя позиция" }),
).not.toBeInTheDocument();
@@ -355,6 +354,22 @@ describe("bond page", () => {
screen.getByRole("heading", { name: "Моя история событий" }),
).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 () => {
const controls = setup({ path: `/bonds/${uid}?period=year&events=trades` });
await screen.findByText("98,125 %");
@@ -81,6 +81,7 @@ function setup(
candles?: NonNullable<StockPageVm["candles"]>["candles"];
cursorError?: string;
failCompany?: boolean;
failCompanyAfter?: { value: boolean };
failStock?: boolean;
metrics?: NonNullable<StockPageVm["company"]>["metrics"];
noPosition?: boolean;
@@ -133,7 +134,7 @@ function setup(
),
);
if (params.section === "company")
return options.failCompany
return (options.failCompanyAfter?.value ?? options.failCompany)
? HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 })
: HttpResponse.json(
@@ -396,7 +397,6 @@ describe("stock page", () => {
screen.queryByRole("heading", { name: "Моя позиция" }),
).not.toBeInTheDocument();
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 () => {
const { requests } = setup({ failCompany: true });
@@ -404,8 +404,39 @@ describe("stock page", () => {
const previousRequests = requests.length;
expect(screen.getByText("Зачисление дивидендов")).toBeVisible();
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 () => {
const { requests } = setup();
await screen.findByText("Зачисление дивидендов");
+8 -7
View File
@@ -3,7 +3,12 @@ import createQueryClient from "openapi-react-query";
import type { paths } from "./generated/schema";
import { ApiErrorCode, apiErrorCode, isApiErrorCode } from "./problem-details";
import {
ApiErrorCode,
apiErrorCode,
isApiErrorCode,
isBrokerageAccessLoss,
} from "./problem-details";
const HTTP_UNAUTHORIZED = 401;
const HTTP_REQUEST_TIMEOUT = 408;
@@ -165,12 +170,8 @@ function isNetworkFailure(error: unknown): boolean {
}
function isPermanentCode(code?: string): boolean {
return [
ApiErrorCode.InvalidTbankToken,
ApiErrorCode.NotFound,
ApiErrorCode.TbankNotConnected,
ApiErrorCode.Unauthorized,
].some(permanent => isApiErrorCode(code, permanent));
return isBrokerageAccessLoss(code)
|| isApiErrorCode(code, ApiErrorCode.Unauthorized);
}
function isTransientReadFailure(
+8
View File
@@ -1232,6 +1232,12 @@ export interface components {
data: components["schemas"]["IncomeAnalyticsDto"] | null;
meta: components["schemas"]["IncomeAnalyticsMetaDto"];
};
UpcomingEventsWindowDto: {
/** Format: date */
from: string;
/** Format: date */
to: string;
};
UpcomingEventDto: {
account?: components["schemas"]["AccountReferenceDto"];
/** @enum {string} */
@@ -1252,6 +1258,8 @@ export interface components {
accountId: string;
/** Format: date-time */
calculatedAt: string;
/** @description Inclusive UTC window the saved forecast was calculated for. Unchanged by the requested from/to filter. */
window: components["schemas"]["UpcomingEventsWindowDto"];
events: components["schemas"]["UpcomingEventDto"][];
incomeTotalsByCurrency: components["schemas"]["MoneyDto"][];
};
+7 -1
View File
@@ -7,7 +7,13 @@ export {
configureAuthClient,
} from "./client";
export type { paths } from "./generated/schema";
export { ApiErrorCode, apiErrorCode, isApiErrorCode } from "./problem-details";
export {
ApiErrorCode,
apiErrorCode,
isApiErrorCode,
isBrokerageAccessLoss,
} from "./problem-details";
export { isReadFailureExhausted, readRetry } from "./read-failure";
export {
type InvestmentResourceMeta,
+13 -1
View File
@@ -1,6 +1,11 @@
import { expect, it } from "vitest";
import { ApiErrorCode, apiErrorCode, isApiErrorCode } from "./problem-details";
import {
ApiErrorCode,
apiErrorCode,
isApiErrorCode,
isBrokerageAccessLoss,
} from "./problem-details";
it("matches known codes and preserves unknown wire codes for fallback handling", () => {
expect(
@@ -16,3 +21,10 @@ it("matches known codes and preserves unknown wire codes for fallback handling",
expect(apiErrorCode(null)).toBeUndefined();
expect(apiErrorCode({})).toBeUndefined();
});
it("separates brokerage access loss from UNAUTHORIZED, temporary and unknown codes", () => {
for (const code of ["INVALID_TBANK_TOKEN", "NOT_FOUND", "TBANK_NOT_CONNECTED"])
expect(isBrokerageAccessLoss(code)).toBe(true);
for (const code of ["UNAUTHORIZED", "TBANK_UNAVAILABLE", "FUTURE_PROVIDER_ERROR", undefined])
expect(isBrokerageAccessLoss(code)).toBe(false);
});
+11 -1
View File
@@ -41,4 +41,14 @@ function isApiErrorCode(
return value === expected;
}
export { ApiErrorCode, apiErrorCode, isApiErrorCode };
// Confirmed loss of access to the brokerage data. UNAUTHORIZED is a session
// failure with its own refresh/sign-out path, so it is deliberately excluded.
function isBrokerageAccessLoss(code?: string): boolean {
return [
ApiErrorCode.InvalidTbankToken,
ApiErrorCode.NotFound,
ApiErrorCode.TbankNotConnected,
].some(loss => isApiErrorCode(code, loss));
}
export { ApiErrorCode, apiErrorCode, isApiErrorCode, isBrokerageAccessLoss };
+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 -10
View File
@@ -10,7 +10,7 @@ import type { components, paths } from "./generated/schema";
import { useAuthenticatedQuery } from "./authenticated-query";
import { getAuthenticatedSessionSnapshot } from "./client";
import { ApiErrorCode, apiErrorCode, isApiErrorCode } from "./problem-details";
import { apiErrorCode, isBrokerageAccessLoss } from "./problem-details";
type InvestmentResourceMeta = components["schemas"]["InvestmentResourceMetaDto"];
@@ -39,15 +39,7 @@ function invalidateResourceAccess(
group: string,
error: unknown,
) {
const errorCode = apiErrorCode(error);
if (
[
ApiErrorCode.InvalidTbankToken,
ApiErrorCode.NotFound,
ApiErrorCode.TbankNotConnected,
].every(code => !isApiErrorCode(errorCode, code))
)
return;
if (!isBrokerageAccessLoss(apiErrorCode(error))) return;
const affected = {
predicate: (cached: { meta?: Record<string, unknown> }) =>
+26
View File
@@ -7243,6 +7243,23 @@
"meta"
]
},
"UpcomingEventsWindowDto": {
"type": "object",
"properties": {
"from": {
"type": "string",
"format": "date"
},
"to": {
"type": "string",
"format": "date"
}
},
"required": [
"from",
"to"
]
},
"UpcomingEventDto": {
"type": "object",
"properties": {
@@ -7324,6 +7341,14 @@
"type": "string",
"format": "date-time"
},
"window": {
"description": "Inclusive UTC window the saved forecast was calculated for. Unchanged by the requested from/to filter.",
"allOf": [
{
"$ref": "#/components/schemas/UpcomingEventsWindowDto"
}
]
},
"events": {
"type": "array",
"items": {
@@ -7340,6 +7365,7 @@
"required": [
"accountId",
"calculatedAt",
"window",
"events",
"incomeTotalsByCurrency"
]
+2
View File
@@ -421,7 +421,9 @@ const ru = {
},
history: "История догружается",
partial: "Период загружен не полностью",
readFailed: "Не удалось обновить данные",
refresh: "Обновить",
retry: "Повторить",
sourceTime: "Данные на {{date}}",
stale: "Данные требуют обновления",
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,
} from "./EntryGroup/EntryGroup";
export { PayoutCard } from "./PayoutCard/PayoutCard";
export {
ReadRetry,
ReadRetryButton,
type ReadRetryProps,
} from "./ReadRetry/ReadRetry";
export { SourceSyncStatus } from "./SourceSyncStatus/SourceSyncStatus";