feat(investments): warn and offer retry after exhausted read retries (WealthLedger/frontend#55) #136
@@ -38,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",
|
||||
@@ -433,21 +437,95 @@ describe("AccountIncomePage", () => {
|
||||
expect(screen.queryByRole("button", { name: "Повторить" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows a quiet realized error", async () => {
|
||||
server.use(
|
||||
http.get(
|
||||
"*/api/v1/brokerage-accounts/account-1/income-analytics",
|
||||
() => new HttpResponse(null, { status: 503 }),
|
||||
),
|
||||
);
|
||||
renderPage();
|
||||
expect(
|
||||
await screen.findByText("Не удалось рассчитать пассивный доход", undefined, {
|
||||
timeout: 4000,
|
||||
}),
|
||||
).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 = () => {
|
||||
|
||||
@@ -3,10 +3,9 @@ import { useEffect, useMemo, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useParams, useSearchParams } from "react-router";
|
||||
|
||||
import type { InvestmentResourceMeta } from "@/shared/api";
|
||||
|
||||
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 {
|
||||
@@ -32,12 +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" };
|
||||
@@ -87,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);
|
||||
@@ -119,6 +122,7 @@ const useAccountIncomePageViewModel = (): AccountIncomePageViewModel => {
|
||||
onReset,
|
||||
onSubmit,
|
||||
partial: analytics.meta?.completeness === "incomplete",
|
||||
readRetry: retry,
|
||||
source: analytics.meta,
|
||||
state: "loaded",
|
||||
totalVisible: analytics.meta?.completeness === "complete",
|
||||
|
||||
+13
-1
@@ -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")}
|
||||
|
||||
+3
-1
@@ -9,7 +9,7 @@ import { StateView } from "@wealthledger/ui";
|
||||
import { FormProvider } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { SourceSyncStatus } from "@/shared/ui";
|
||||
import { ReadRetry, SourceSyncStatus } from "@/shared/ui";
|
||||
|
||||
import type { AccountIncomePageViewModel } from "../AccountIncomePage.vm";
|
||||
|
||||
@@ -57,6 +57,8 @@ const AccountIncomePageLoaded: FC<LoadedProps> = (props) => {
|
||||
stale={props.source?.freshness === "stale"}
|
||||
/>
|
||||
|
||||
<ReadRetry {...props.readRetry} />
|
||||
|
||||
<AccountIncomePageLoadedSummary {...props} />
|
||||
|
||||
<Alert color="blue">{t("income.taxBoundary")}</Alert>
|
||||
|
||||
Reference in New Issue
Block a user