feat(instruments): offer per-section retry after exhausted reads (WealthLedger/frontend#55)
Frontend verify / commits (pull_request) Successful in 31s
Frontend verify / security (pull_request) Successful in 10s
Frontend verify / verify (pull_request) Successful in 5m39s

This commit is contained in:
2026-11-03 08:44:47 +03:00
parent c84b633855
commit c4d0b7edb0
5 changed files with 96 additions and 6 deletions
@@ -1,4 +1,4 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { resource } from "@/test/resource"; import { resource } from "@/test/resource";
@@ -76,4 +76,29 @@ describe("instrument source and transport presentation", () => {
loading: false, loading: false,
}); });
}); });
it("carries the retry of an exhausted read and only of that read", () => {
const refetch = vi.fn(() => Promise.resolve());
const state = section({
data: { rows: [] },
error: { code: "TBANK_UNAVAILABLE", status: 503 },
isError: true,
isFetching: false,
refetch,
});
expect(state.readRetry?.exhausted).toBe(true);
state.readRetry?.onRetry();
expect(refetch).toHaveBeenCalledOnce();
});
it("offers no retry for access loss or a healthy read", () => {
const base = { data: undefined, isFetching: false, refetch: vi.fn() };
expect(section({ ...base, error: { code: "NOT_FOUND" }, isError: true })
.readRetry?.exhausted).toBe(false);
expect(section({ ...base, error: null, isError: false })
.readRetry?.exhausted).toBe(false);
});
}); });
@@ -1,10 +1,11 @@
import type { InvestmentResourceMeta } from "@/shared/api"; import { type InvestmentResourceMeta, readRetry } from "@/shared/api";
type SectionState = { type SectionState = {
error: boolean; error: boolean;
hasData: boolean; hasData: boolean;
loading: boolean; loading: boolean;
partial?: boolean; partial?: boolean;
readRetry?: ReturnType<typeof readRetry>;
}; };
function hasSectionData(data: unknown) { function hasSectionData(data: unknown) {
@@ -13,8 +14,11 @@ function hasSectionData(data: unknown) {
function section(query: { function section(query: {
data: unknown; data: unknown;
error?: unknown;
isError: boolean; isError: boolean;
isFetching?: boolean;
meta?: InvestmentResourceMeta; meta?: InvestmentResourceMeta;
refetch?: () => Promise<unknown>;
}): SectionState { }): SectionState {
const isError = query.isError || query.meta?.lastAttempt.state === "failed"; const isError = query.isError || query.meta?.lastAttempt.state === "failed";
const hasData = hasSectionData(query.data); const hasData = hasSectionData(query.data);
@@ -24,6 +28,17 @@ function section(query: {
hasData, hasData,
loading: !hasData && !isError, loading: !hasData && !isError,
partial: query.meta?.completeness === "incomplete", partial: query.meta?.completeness === "incomplete",
// Each section retries only its own GET, so sections recover independently.
readRetry: query.refetch
? readRetry([
{
error: query.error,
isError: query.isError,
isFetching: query.isFetching ?? false,
refetch: query.refetch,
},
])
: undefined,
}; };
} }
@@ -12,6 +12,8 @@ import {
import { LoadingSkeleton } from "@wealthledger/ui"; import { LoadingSkeleton } from "@wealthledger/ui";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { ReadRetry } from "@/shared/ui";
import type { SectionState } from "../model/section"; import type { SectionState } from "../model/section";
import classes from "./InstrumentDetails.module.css"; import classes from "./InstrumentDetails.module.css";
@@ -81,6 +83,8 @@ const InstrumentSection: FC<InstrumentSectionProps> = (props) => {
</Alert> </Alert>
)} )}
{!!props.state.readRetry && <ReadRetry {...props.state.readRetry} />}
{sectionContent} {sectionContent}
</Paper> </Paper>
); );
+16 -1
View File
@@ -347,7 +347,6 @@ describe("bond page", () => {
it("keeps market content and sold-position history when coupons fail", async () => { it("keeps market content and sold-position history when coupons fail", async () => {
setup({ failCoupons: true, noPosition: true }); setup({ failCoupons: true, noPosition: true });
await screen.findByText("98,125 %"); await screen.findByText("98,125 %");
expect(screen.queryByRole("button", { name: "Повторить" })).not.toBeInTheDocument();
expect( expect(
screen.queryByRole("heading", { name: "Моя позиция" }), screen.queryByRole("heading", { name: "Моя позиция" }),
).not.toBeInTheDocument(); ).not.toBeInTheDocument();
@@ -355,6 +354,22 @@ describe("bond page", () => {
screen.getByRole("heading", { name: "Моя история событий" }), screen.getByRole("heading", { name: "Моя история событий" }),
).toBeVisible(); ).toBeVisible();
}); });
it(
"offers a local retry for the failed coupons section only",
{ timeout: 10_000 },
async () => {
setup({ failCoupons: true });
await screen.findByText("98,125 %");
// Automatic read retries wait 1s + 2s before the failure is final.
await screen.findByRole(
"button",
{ name: "Повторить" },
{ timeout: 6000 },
);
expect(screen.getAllByRole("button", { name: "Повторить" })).toHaveLength(1);
expect(screen.getByText("98,125 %")).toBeVisible();
},
);
it("preserves cached quote without manual refresh", async () => { it("preserves cached quote without manual refresh", async () => {
const controls = setup({ path: `/bonds/${uid}?period=year&events=trades` }); const controls = setup({ path: `/bonds/${uid}?period=year&events=trades` });
await screen.findByText("98,125 %"); await screen.findByText("98,125 %");
@@ -81,6 +81,7 @@ function setup(
candles?: NonNullable<StockPageVm["candles"]>["candles"]; candles?: NonNullable<StockPageVm["candles"]>["candles"];
cursorError?: string; cursorError?: string;
failCompany?: boolean; failCompany?: boolean;
failCompanyAfter?: { value: boolean };
failStock?: boolean; failStock?: boolean;
metrics?: NonNullable<StockPageVm["company"]>["metrics"]; metrics?: NonNullable<StockPageVm["company"]>["metrics"];
noPosition?: boolean; noPosition?: boolean;
@@ -133,7 +134,7 @@ function setup(
), ),
); );
if (params.section === "company") if (params.section === "company")
return options.failCompany return (options.failCompanyAfter?.value ?? options.failCompany)
? HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 }) ? HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 })
: HttpResponse.json( : HttpResponse.json(
@@ -396,7 +397,6 @@ describe("stock page", () => {
screen.queryByRole("heading", { name: "Моя позиция" }), screen.queryByRole("heading", { name: "Моя позиция" }),
).not.toBeInTheDocument(); ).not.toBeInTheDocument();
expect(screen.getByRole("heading", { name: "График цены" })).toBeVisible(); expect(screen.getByRole("heading", { name: "График цены" })).toBeVisible();
expect(screen.queryByRole("button", { name: "Повторить" })).not.toBeInTheDocument();
}); });
it("keeps successful sections visible when company data is initially missing", async () => { it("keeps successful sections visible when company data is initially missing", async () => {
const { requests } = setup({ failCompany: true }); const { requests } = setup({ failCompany: true });
@@ -404,8 +404,39 @@ describe("stock page", () => {
const previousRequests = requests.length; const previousRequests = requests.length;
expect(screen.getByText("Зачисление дивидендов")).toBeVisible(); expect(screen.getByText("Зачисление дивидендов")).toBeVisible();
expect(requests).toHaveLength(previousRequests); expect(requests).toHaveLength(previousRequests);
expect(screen.queryByRole("button", { name: "Повторить" })).not.toBeInTheDocument();
}); });
it(
"retries only the failed company section and recovers it in place",
{ timeout: 10_000 },
async () => {
const failing = { value: true };
const { requests } = setup({ failCompanyAfter: failing });
await screen.findByText("Зачисление дивидендов");
// Automatic read retries wait 1s + 2s before the failure is final.
const retry = await screen.findByRole(
"button",
{ name: "Повторить" },
{ timeout: 6000 },
);
expect(screen.getByRole("heading", { name: "График цены" })).toBeVisible();
expect(screen.getAllByRole("button", { name: "Повторить" })).toHaveLength(1);
const before = requests.length;
const companyBefore = requests.filter(url => url.pathname.endsWith("/company")).length;
failing.value = false;
fireEvent.click(retry);
await waitFor(() =>
expect(screen.queryByRole("button", { name: "Повторить" }))
.not.toBeInTheDocument());
expect(requests.filter(url => url.pathname.endsWith("/company")))
.toHaveLength(companyBefore + 1);
expect(requests.length - before).toBe(1);
expect(screen.getByText("Описание компании")).toBeVisible();
},
);
it("sends instrument-filtered continuation, restores it on returning to the filter and changes chart period", async () => { it("sends instrument-filtered continuation, restores it on returning to the filter and changes chart period", async () => {
const { requests } = setup(); const { requests } = setup();
await screen.findByText("Зачисление дивидендов"); await screen.findByText("Зачисление дивидендов");