diff --git a/src/pages/accounts/ui/AccountsPage/AccountsPage.test.tsx b/src/pages/accounts/ui/AccountsPage/AccountsPage.test.tsx
index 6da65ee..9fc2416 100644
--- a/src/pages/accounts/ui/AccountsPage/AccountsPage.test.tsx
+++ b/src/pages/accounts/ui/AccountsPage/AccountsPage.test.tsx
@@ -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) => {
diff --git a/src/pages/accounts/ui/AccountsPage/AccountsPage.tsx b/src/pages/accounts/ui/AccountsPage/AccountsPage.tsx
index 8e02cdc..e141610 100644
--- a/src/pages/accounts/ui/AccountsPage/AccountsPage.tsx
+++ b/src/pages/accounts/ui/AccountsPage/AccountsPage.tsx
@@ -16,6 +16,7 @@ const AccountsPage: FC = () => {
);
};
diff --git a/src/pages/accounts/ui/AccountsPage/AccountsPage.vm.ts b/src/pages/accounts/ui/AccountsPage/AccountsPage.vm.ts
index 4f3252c..e9c1fef 100644
--- a/src/pages/accounts/ui/AccountsPage/AccountsPage.vm.ts
+++ b/src/pages/accounts/ui/AccountsPage/AccountsPage.vm.ts
@@ -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;
+
type AccountsPageViewModel
- = | { accounts: BrokerageAccountCard[]; meta: NonNullable["meta"]>; state: "loaded" }
- | { code?: string; state: "error" }
- | { state: "empty" }
- | { state: "loading" };
+ = | {
+ accounts: BrokerageAccountCard[];
+ meta: NonNullable["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,
};
}
diff --git a/src/pages/accounts/ui/AccountsPage/components/AccountsPageError.view.tsx b/src/pages/accounts/ui/AccountsPage/components/AccountsPageError.view.tsx
index 7ad5c98..9d4cf6e 100644
--- a/src/pages/accounts/ui/AccountsPage/components/AccountsPageError.view.tsx
+++ b/src/pages/accounts/ui/AccountsPage/components/AccountsPageError.view.tsx
@@ -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 = (props) => {
const { code } = props;
@@ -38,8 +42,18 @@ const AccountsPageError: FC = (props) => {
/>
);
+ const retry = props.readRetry?.exhausted
+ ? (
+
+ )
+ : undefined;
+
return (
diff --git a/src/pages/accounts/ui/AccountsPage/components/AccountsPageLoaded.view.tsx b/src/pages/accounts/ui/AccountsPage/components/AccountsPageLoaded.view.tsx
index 96d5d88..ef5e51e 100644
--- a/src/pages/accounts/ui/AccountsPage/components/AccountsPageLoaded.view.tsx
+++ b/src/pages/accounts/ui/AccountsPage/components/AccountsPageLoaded.view.tsx
@@ -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 = (props) => {
lastSuccessfulAt={props.meta.lastSuccessfulAt}
stale={props.meta.freshness === "stale"}
/>
+
+ {!!props.readRetry && }