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,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