feat(accounts): offer retry after exhausted accounts reads (WealthLedger/frontend#55)
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
import { MantineProvider } from "@mantine/core";
|
import { MantineProvider } from "@mantine/core";
|
||||||
import { type QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
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 { http, HttpResponse } from "msw";
|
||||||
import { MemoryRouter } from "react-router";
|
import { MemoryRouter } from "react-router";
|
||||||
import { describe, expect, it, vi } from "vitest";
|
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)(
|
it.each(["none", "failed"] as const)(
|
||||||
"keeps incomplete empty membership distinct from no accounts (attempt=%s)",
|
"keeps incomplete empty membership distinct from no accounts (attempt=%s)",
|
||||||
async (state) => {
|
async (state) => {
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ const AccountsPage: FC = () => {
|
|||||||
<AccountsPageLoaded
|
<AccountsPageLoaded
|
||||||
accounts={viewModel.accounts}
|
accounts={viewModel.accounts}
|
||||||
meta={viewModel.meta}
|
meta={viewModel.meta}
|
||||||
|
readRetry={viewModel.readRetry}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,14 +4,26 @@ import {
|
|||||||
useBrokerageAccounts,
|
useBrokerageAccounts,
|
||||||
} from "@/entities/brokerage-account";
|
} from "@/entities/brokerage-account";
|
||||||
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 { useInitialAvailability } from "@/shared/lib";
|
import { useInitialAvailability } from "@/shared/lib";
|
||||||
|
|
||||||
|
type AccountsPageReadRetry = ReturnType<typeof readRetry>;
|
||||||
|
|
||||||
type AccountsPageViewModel
|
type AccountsPageViewModel
|
||||||
= | { accounts: BrokerageAccountCard[]; meta: NonNullable<ReturnType<typeof useBrokerageAccounts>["meta"]>; state: "loaded" }
|
= | {
|
||||||
| { code?: string; state: "error" }
|
accounts: BrokerageAccountCard[];
|
||||||
| { state: "empty" }
|
meta: NonNullable<ReturnType<typeof useBrokerageAccounts>["meta"]>;
|
||||||
| { state: "loading" };
|
readRetry: AccountsPageReadRetry;
|
||||||
|
state: "loaded";
|
||||||
|
}
|
||||||
|
| { code?: string; readRetry: AccountsPageReadRetry; state: "error" }
|
||||||
|
| { state: "empty" }
|
||||||
|
| { state: "loading" };
|
||||||
|
|
||||||
function isAccountsAccessFailure(code?: string) {
|
function isAccountsAccessFailure(code?: string) {
|
||||||
return isApiErrorCode(code, ApiErrorCode.InvalidTbankToken)
|
return isApiErrorCode(code, ApiErrorCode.InvalidTbankToken)
|
||||||
@@ -23,6 +35,7 @@ function useAccountsPageViewModel(): AccountsPageViewModel {
|
|||||||
useScreenSync({ screen: "accounts" }, [accounts]);
|
useScreenSync({ screen: "accounts" }, [accounts]);
|
||||||
|
|
||||||
const code = apiErrorCode(accounts.error);
|
const code = apiErrorCode(accounts.error);
|
||||||
|
const retry = readRetry([accounts]);
|
||||||
const isAccessFailure = isAccountsAccessFailure(code);
|
const isAccessFailure = isAccountsAccessFailure(code);
|
||||||
|
|
||||||
const hasResult = !!accounts.data
|
const hasResult = !!accounts.data
|
||||||
@@ -36,12 +49,13 @@ function useAccountsPageViewModel(): AccountsPageViewModel {
|
|||||||
isFetching: accounts.isFetching,
|
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)
|
if (!hasResult || !accounts.data)
|
||||||
return {
|
return availability === "unavailable"
|
||||||
state: availability === "unavailable" ? "error" : "loading",
|
? { code, readRetry: retry, state: "error" as const }
|
||||||
} as const;
|
: { state: "loading" as const };
|
||||||
|
|
||||||
if (accounts.data.length === 0)
|
if (accounts.data.length === 0)
|
||||||
return {
|
return {
|
||||||
@@ -54,6 +68,7 @@ function useAccountsPageViewModel(): AccountsPageViewModel {
|
|||||||
return {
|
return {
|
||||||
accounts: brokerageAccountCards(accounts.data, meta.dependencies),
|
accounts: brokerageAccountCards(accounts.data, meta.dependencies),
|
||||||
meta,
|
meta,
|
||||||
|
readRetry: retry,
|
||||||
state: "loaded" as const,
|
state: "loaded" as const,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,8 +6,12 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { Link } from "react-router";
|
import { Link } from "react-router";
|
||||||
|
|
||||||
import { ApiErrorCode, isApiErrorCode } from "@/shared/api";
|
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 AccountsPageError: FC<AccountsPageErrorProps> = (props) => {
|
||||||
const { code } = 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 (
|
return (
|
||||||
<StateView
|
<StateView
|
||||||
|
action={retry}
|
||||||
text={t("states.unavailableText")}
|
text={t("states.unavailableText")}
|
||||||
title={t("states.unavailableTitle")}
|
title={t("states.unavailableTitle")}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
BrokerageAccountList,
|
BrokerageAccountList,
|
||||||
} from "@/entities/brokerage-account";
|
} from "@/entities/brokerage-account";
|
||||||
import { formatMoney } from "@/shared/lib";
|
import { formatMoney } from "@/shared/lib";
|
||||||
import { SourceSyncStatus } from "@/shared/ui";
|
import { ReadRetry, type ReadRetryProps, SourceSyncStatus } from "@/shared/ui";
|
||||||
|
|
||||||
const DECIMAL_BASE = 10n;
|
const DECIMAL_BASE = 10n;
|
||||||
const DECIMAL_PART_COUNT = 2;
|
const DECIMAL_PART_COUNT = 2;
|
||||||
@@ -20,6 +20,7 @@ const ZERO = 0n;
|
|||||||
type AccountsPageLoadedProps = {
|
type AccountsPageLoadedProps = {
|
||||||
accounts: BrokerageAccountCard[];
|
accounts: BrokerageAccountCard[];
|
||||||
meta: InvestmentResourceMeta;
|
meta: InvestmentResourceMeta;
|
||||||
|
readRetry?: ReadRetryProps;
|
||||||
};
|
};
|
||||||
|
|
||||||
type Money = { currency: string; value: string };
|
type Money = { currency: string; value: string };
|
||||||
@@ -132,6 +133,8 @@ const AccountsPageLoaded: FC<AccountsPageLoadedProps> = (props) => {
|
|||||||
lastSuccessfulAt={props.meta.lastSuccessfulAt}
|
lastSuccessfulAt={props.meta.lastSuccessfulAt}
|
||||||
stale={props.meta.freshness === "stale"}
|
stale={props.meta.freshness === "stale"}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{!!props.readRetry && <ReadRetry {...props.readRetry} />}
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
Reference in New Issue
Block a user