feat(operations): offer retry after exhausted operations reads (WealthLedger/frontend#55)

This commit is contained in:
2026-11-03 08:40:20 +03:00
parent a2e9d63bfe
commit 7ecc57c547
4 changed files with 134 additions and 25 deletions
@@ -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>}