fix: resolve frontend audit findings
Frontend security / security (push) Successful in 10s
Frontend verify / verify (push) Successful in 4m57s
Conventional commits / commits (pull_request) Successful in 28s
Frontend security / security (pull_request) Successful in 10s
Frontend verify / verify (pull_request) Successful in 5m10s

Repair investment recovery, draft preservation, date validation,
currency presentation, accessibility, and route error recovery.
Correct Docker and dependency checks and consume explicit income roles.
This commit is contained in:
2026-09-27 11:38:20 +03:00
parent b34c1c860a
commit f6db28defa
50 changed files with 2017 additions and 215 deletions
@@ -1,6 +1,7 @@
import { MantineProvider } from "@mantine/core";
import { QueryClientProvider } from "@tanstack/react-query";
import {
act,
fireEvent,
render,
renderHook,
@@ -33,11 +34,210 @@ const Navigation: FC = () => {
<button type="button" onClick={() => navigate(-1)}>
History back
</button>
<button type="button" onClick={() => navigate(1)}>
History forward
</button>
</>
);
};
describe("Upcoming event date filters", () => {
it("keeps reads and sync disabled without an account scope", async () => {
configureAuthClient({
accessToken: () => "token",
refresh: async () => true,
clear: vi.fn(),
});
const requests: string[] = [];
server.use(
http.all("*/api/v1/*", ({ request }) => {
requests.push(request.url);
return new HttpResponse(null, { status: 503 });
}),
);
const { wrapper: QueryWrapper, queryClient } = createQueryWrapper();
const { result } = renderHook(() => useAccountEventsPageViewModel(), {
wrapper: ({ children }) => (
<QueryWrapper>
<MemoryRouter initialEntries={["?from=2099-02-01"]}>
{children}
</MemoryRouter>
</QueryWrapper>
),
});
await act(async () => {});
expect(result.current).toEqual({ state: "loading" });
expect(queryClient.isFetching()).toBe(0);
expect(requests).toEqual([]);
});
it.each([
"from=2099-02-01&to=2099-02-28",
"from=2099-02-01",
"to=2099-02-28",
])(
"keeps event calendar dates in GET and source sync requests: %s",
async (search) => {
configureAuthClient({
accessToken: () => "token",
refresh: async () => true,
clear: vi.fn(),
});
const requested: string[] = [];
const syncRequests: unknown[] = [];
server.use(
http.get("*/api/v1/brokerage-accounts/all/events", ({ request }) => {
requested.push(new URL(request.url).search);
return HttpResponse.json(
resource(null, {
sync: {
id: null,
state: "failed",
errorCode: "TBANK_UNAVAILABLE",
},
}),
);
}),
http.post("*/api/v1/tbank-sync", async ({ request }) => {
syncRequests.push(await request.json());
return HttpResponse.json(
{ code: "TBANK_UNAVAILABLE" },
{ status: 503 },
);
}),
);
const period = Object.fromEntries(new URLSearchParams(search));
const { wrapper: QueryWrapper } = createQueryWrapper();
const { result } = renderHook(
() => useAccountEventsPageViewModel("all"),
{
wrapper: ({ children }) => (
<QueryWrapper>
<MemoryRouter initialEntries={[`?${search}`]}>
{children}
</MemoryRouter>
</QueryWrapper>
),
},
);
await waitFor(() => expect(result.current.state).toBe("error"));
expect(requested).toEqual([`?${search}`]);
expect(syncRequests).toEqual([]);
act(() => {
if (result.current.state === "error") result.current.retry();
});
await waitFor(() =>
expect(syncRequests).toEqual([
{ screen: "events", accountId: "all", ...period },
]),
);
},
);
it.each(["account-1", "all"])(
"normalizes invalid links and history while preserving one-sided future dates for %s",
async (accountId) => {
configureAuthClient({
accessToken: () => "token",
refresh: async () => true,
clear: vi.fn(),
});
const searches: string[] = [];
server.use(
http.get(
"*/api/v1/brokerage-accounts/:accountId/events",
({ request }) => {
searches.push(new URL(request.url).search);
return HttpResponse.json(resource({ events: [] }));
},
),
);
render(
<QueryClientProvider client={createTestQueryClient()}>
<MantineProvider env="test">
<MemoryRouter
initialIndex={0}
initialEntries={[
"?from=bad&to=2099-02-28",
"?from=2099-02-30",
"?from=2099-03-01&to=2099-02-28",
"?from=2099-02-01",
"?to=2099-02-28",
].map((search) => `/accounts/${accountId}/events${search}`)}
>
<Navigation />
<Routes>
<Route
path="/accounts/:accountId/events"
element={<AccountEventsPage />}
/>
</Routes>
</MemoryRouter>
</MantineProvider>
</QueryClientProvider>,
);
await screen.findByLabelText("Период");
for (const expected of ["", "", "?from=2099-02-01", "?to=2099-02-28"]) {
fireEvent.click(
screen.getByRole("button", { name: "History forward" }),
);
await waitFor(() =>
expect(screen.getByLabelText("URL").textContent).toBe(expected),
);
await screen.findByLabelText("Период");
}
fireEvent.click(screen.getByRole("button", { name: "History back" }));
await waitFor(() =>
expect(screen.getByLabelText("URL")).toHaveTextContent(
"?from=2099-02-01",
),
);
expect(screen.getByLabelText("Период")).toHaveTextContent("01.02.2099");
expect(searches).toEqual(["", "?from=2099-02-01", "?to=2099-02-28"]);
},
);
it.each(["account-1", "all"])(
"lets users reset filters after a request failure for %s",
async (accountId) => {
configureAuthClient({
accessToken: () => "token",
refresh: async () => true,
clear: vi.fn(),
});
server.use(
http.get(
"*/api/v1/brokerage-accounts/:accountId/events",
({ request }) =>
new URL(request.url).searchParams.has("from")
? HttpResponse.json({ code: "VALIDATION_ERROR" }, { status: 400 })
: HttpResponse.json(resource({ events: [] })),
),
);
render(
<QueryClientProvider client={createTestQueryClient()}>
<MantineProvider env="test">
<MemoryRouter
initialEntries={[`/accounts/${accountId}/events?from=2099-02-01`]}
>
<Navigation />
<Routes>
<Route
path="/accounts/:accountId/events"
element={<AccountEventsPage />}
/>
</Routes>
</MemoryRouter>
</MantineProvider>
</QueryClientProvider>,
);
await screen.findByRole("button", { name: "Повторить" });
fireEvent.click(screen.getByRole("button", { name: "Сбросить" }));
await screen.findByLabelText("Период");
expect(screen.getByLabelText("URL").textContent).toBe("");
},
);
it.each(["account-1", "all"])(
"applies dates only on submit, restores URL dates, and resets from empty results for %s",
async (accountId) => {
@@ -9,6 +9,7 @@ const { refresh } = vi.hoisted(() => ({ refresh: vi.fn() }));
vi.mock("@/features/tbank-connection", () => ({
useScreenSync: () => ({
refresh,
retry: refresh,
refreshing: false,
sourceError: false,
transportError: false,
@@ -78,7 +79,7 @@ describe("AccountEventsPage", () => {
refetch: vi.fn(),
} as never);
renderPage();
expect(query).toHaveBeenCalledWith(undefined, {});
expect(query).toHaveBeenCalledWith("", {});
expect(screen.getByRole("status", { name: "Загрузка" })).toHaveAttribute(
"aria-busy",
"true",
@@ -10,7 +10,7 @@ const AccountEventsPage: FC<AccountEventsPageProps> = (props) => {
const viewModel = useAccountEventsPageViewModel(props.accountId);
if (viewModel.state === "loading") return <AccountEventsPageLoading />;
if (viewModel.state === "error")
return <AccountEventsPageError retry={viewModel.retry} />;
return <AccountEventsPageError {...viewModel} />;
return <AccountEventsPageLoaded {...viewModel} />;
};
@@ -1,5 +1,6 @@
import { useEffect, useState } from "react";
import { useParams, useSearchParams } from "react-router";
import { z } from "zod";
import {
type BrokerageEvent,
useBrokerageEvents,
@@ -8,7 +9,7 @@ import { useScreenSync } from "@/features/tbank-connection";
type AccountEventsPageViewModel =
| { state: "loading" }
| { state: "error"; retry: () => void }
| { state: "error"; retry: () => void; onReset: () => void }
| {
state: "loaded";
events: BrokerageEvent[];
@@ -31,12 +32,22 @@ function useAccountEventsPageViewModel(
scopeId?: string,
): AccountEventsPageViewModel {
const { accountId: routeAccountId } = useParams();
const accountId = scopeId ?? routeAccountId;
const accountId = scopeId ?? routeAccountId ?? "";
const [searchParams, setSearchParams] = useSearchParams();
const appliedFrom = searchParams.get("from");
const appliedTo = searchParams.get("to");
const urlFrom = searchParams.get("from");
const urlTo = searchParams.get("to");
const validUrlPeriod = isValidPeriod(urlFrom, urlTo);
const appliedFrom = validUrlPeriod ? urlFrom : null;
const appliedTo = validUrlPeriod ? urlTo : null;
const [draftFrom, setDraftFrom] = useState<string | null>(appliedFrom);
const [draftTo, setDraftTo] = useState<string | null>(appliedTo);
useEffect(() => {
if (validUrlPeriod) return;
const normalized = new URLSearchParams(searchParams);
normalized.delete("from");
normalized.delete("to");
setSearchParams(normalized, { replace: true });
}, [validUrlPeriod, searchParams, setSearchParams]);
useEffect(() => {
setDraftFrom(appliedFrom);
setDraftTo(appliedTo);
@@ -54,12 +65,18 @@ function useAccountEventsPageViewModel(
},
[events],
);
const onReset = () => {
setDraftFrom(null);
setDraftTo(null);
setSearchParams({});
};
if (events.isLoading) return { state: "loading" };
if ((events.isError || events.meta?.sync.state === "failed") && !events.data)
return {
state: "error",
onReset,
retry: () => {
void sync.refresh();
void sync.retry();
},
};
if (!events.data) return { state: "loading" };
@@ -72,26 +89,34 @@ function useAccountEventsPageViewModel(
refreshing: events.isFetching || sync.refreshing,
refreshError: events.isError || sync.sourceError || sync.transportError,
retry: () => {
void sync.refresh();
void sync.retry();
},
draftFrom,
draftTo,
canApply:
(draftFrom !== appliedFrom || draftTo !== appliedTo) &&
(!draftFrom || !draftTo || draftFrom <= draftTo),
isValidPeriod(draftFrom, draftTo),
onDraftFromChange: setDraftFrom,
onDraftToChange: setDraftTo,
onApply: () =>
onApply: () => {
if (!isValidPeriod(draftFrom, draftTo)) return;
setSearchParams({
...(draftFrom ? { from: draftFrom } : {}),
...(draftTo ? { to: draftTo } : {}),
}),
onReset: () => {
setDraftFrom(null);
setDraftTo(null);
setSearchParams({});
});
},
onReset,
};
}
const calendarDate = z.string().date();
function isValidPeriod(from: string | null, to: string | null) {
return (
[from, to].every(
(date) => date === null || calendarDate.safeParse(date).success,
) &&
(!from || !to || from <= to)
);
}
export { type AccountEventsPageViewModel, useAccountEventsPageViewModel };
@@ -1,9 +1,9 @@
import { Button } from "@mantine/core";
import { Button, Group } from "@mantine/core";
import { StateView } from "@wealthledger/ui";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
type AccountEventsPageErrorProps = { retry: () => void };
type AccountEventsPageErrorProps = { retry: () => void; onReset: () => void };
const AccountEventsPageError: FC<AccountEventsPageErrorProps> = (props) => {
const { t } = useTranslation();
@@ -12,7 +12,14 @@ const AccountEventsPageError: FC<AccountEventsPageErrorProps> = (props) => {
error
title={t("events.errorTitle")}
text={t("events.errorText")}
action={<Button onClick={props.retry}>{t("events.retry")}</Button>}
action={
<Group>
<Button onClick={props.retry}>{t("events.retry")}</Button>
<Button variant="default" onClick={props.onReset}>
{t("ledger.reset")}
</Button>
</Group>
}
/>
);
};
@@ -6,6 +6,7 @@ import {
act,
fireEvent,
render,
renderHook,
screen,
waitFor,
within,
@@ -26,7 +27,10 @@ import { createQueryWrapper } from "@/test/query";
import { server } from "@/test/server";
import { isDate } from "../../model/period";
import { AccountIncomePage } from "./AccountIncomePage";
import { toIncomeDisplayModel } from "./AccountIncomePage.vm";
import {
toIncomeDisplayModel,
useAccountIncomePageViewModel,
} from "./AccountIncomePage.vm";
import { IncomePayments } from "./components/IncomePayments.view";
const analytics = {
@@ -148,6 +152,79 @@ function renderPage(search = "from=2026-08-01&to=2026-08-27") {
}
describe("AccountIncomePage", () => {
it("keeps reads and sync disabled without an account scope", async () => {
configureAuthClient({
accessToken: () => "token",
refresh: async () => true,
clear: vi.fn(),
});
const requests: string[] = [];
server.use(
http.all("*/api/v1/*", ({ request }) => {
requests.push(request.url);
return new HttpResponse(null, { status: 503 });
}),
);
const { wrapper: QueryWrapper, queryClient } = createQueryWrapper();
const { result } = renderHook(() => useAccountIncomePageViewModel(), {
wrapper: ({ children }) => (
<QueryWrapper>
<MemoryRouter initialEntries={["?from=2024-02-29&to=2024-03-01"]}>
{children}
</MemoryRouter>
</QueryWrapper>
),
});
await act(async () => {});
expect(result.current).toEqual({ state: "loading" });
expect(queryClient.isFetching()).toBe(0);
expect(requests).toEqual([]);
});
it("keeps income calendar dates in GET and source sync requests", async () => {
const requested: string[] = [];
const syncRequests: unknown[] = [];
server.use(
http.get(
"*/api/v1/brokerage-accounts/:accountId/income-analytics",
({ request }) => {
requested.push(new URL(request.url).search);
return HttpResponse.json(
resource(null, {
sync: {
id: null,
state: "failed",
errorCode: "TBANK_UNAVAILABLE",
},
}),
);
},
),
http.post("*/api/v1/tbank-sync", async ({ request }) => {
syncRequests.push(await request.json());
return HttpResponse.json(
{ code: "TBANK_UNAVAILABLE" },
{ status: 503 },
);
}),
);
renderPage("from=2024-02-29&to=2024-03-01");
const retry = await screen.findByRole("button", { name: "Повторить" });
expect(requested).toEqual(["?from=2024-02-29&to=2024-03-01"]);
expect(syncRequests).toEqual([]);
fireEvent.click(retry);
await waitFor(() =>
expect(syncRequests).toEqual([
{
screen: "income",
accountId: "account-1",
from: "2024-02-29",
to: "2024-03-01",
},
]),
);
});
it("requests a multi-year period from the URL without resetting it", async () => {
const requested: string[] = [];
server.use(
@@ -431,20 +508,18 @@ describe("AccountIncomePage", () => {
});
it("shows the unavailable year state without falling back to the previous period", async () => {
const dependencies = [
{ ...resource(analytics).meta, role: "selected" as const },
{ ...resource(analytics).meta, role: "previous" as const },
{
...resource(null).meta,
role: "year" as const,
unavailableReason: "CALENDAR_OR_ACCOUNT_HISTORY",
},
];
server.use(
http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () =>
HttpResponse.json(
resource(analytics, {
dependencies: [
resource(analytics).meta,
resource(analytics).meta,
{
...resource(null).meta,
unavailableReason: "CALENDAR_OR_ACCOUNT_HISTORY",
},
],
}),
),
HttpResponse.json(resource(analytics, { dependencies })),
),
);
renderPage();
@@ -829,11 +904,87 @@ describe("income period and snapshot boundary", () => {
});
});
it("maps comparison notices by role regardless of dependency order or resource key", () => {
const selected = { ...resource(analytics).meta, role: "selected" as const };
const previous = {
...resource(null).meta,
resourceKey: "opaque/year-looking-key",
role: "previous" as const,
sync: {
id: null,
state: "failed" as const,
errorCode: "TBANK_UNAVAILABLE",
},
};
const year = {
...resource(null).meta,
resourceKey: "opaque/previous-looking-key",
role: "year" as const,
unavailableReason: "CALENDAR_OR_ACCOUNT_HISTORY",
};
for (const dependencies of [
[selected, previous, year],
[year, selected, previous],
]) {
const display = toIncomeDisplayModel(analytics, {
...resource(analytics).meta,
dependencies,
});
expect(display?.previousNotice).toBe("comparisonFailed");
expect(display?.yearNotice).toBe("yearUnavailable");
expect(display?.previousComparison).not.toBeNull();
}
});
it.each([false, true])(
"shows unavailable comparisons for absent roles without positional fallback (legacy=%s)",
async (legacy) => {
server.use(
http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () =>
HttpResponse.json(
resource(
{ ...analytics, previous: null, yearComparison: null },
{
dependencies: legacy
? [
resource(null).meta,
resource(null).meta,
resource(null).meta,
]
: [],
},
),
),
),
);
renderPage();
expect(
await screen.findByText("Сравнение с предыдущим периодом недоступно."),
).toBeVisible();
expect(
screen.getByText("Сравнение с годом ранее недоступно."),
).toBeVisible();
expect(
screen.queryByText("Период сравнения ещё не загружен полностью."),
).not.toBeInTheDocument();
expect(screen.getByRole("region", { name: "Выплаты" })).toBeVisible();
},
);
it("shows selected income before comparison coverage without inventing a zero baseline", async () => {
const dependencies = (["year", "selected", "previous"] as const).map(
(role) => ({
...resource(role === "selected" ? analytics : null).meta,
role,
}),
);
server.use(
http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () =>
HttpResponse.json(
resource({ ...analytics, previous: null, yearComparison: null }),
resource(
{ ...analytics, previous: null, yearComparison: null },
{ dependencies },
),
),
),
);
@@ -856,24 +1007,22 @@ it("shows selected income before comparison coverage without inventing a zero ba
});
it("keeps complete previous comparison while year source failed without inventing its amounts", async () => {
const dependencies = [
{ ...resource(analytics).meta, role: "previous" as const },
{
...resource(null).meta,
role: "year" as const,
sync: {
id: null,
state: "failed" as const,
errorCode: "TBANK_UNAVAILABLE",
},
},
{ ...resource(analytics).meta, role: "selected" as const },
];
server.use(
http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () =>
HttpResponse.json(
resource(analytics, {
dependencies: [
resource(analytics).meta,
resource(analytics).meta,
{
...resource(null).meta,
sync: {
id: null,
state: "failed",
errorCode: "TBANK_UNAVAILABLE",
},
},
],
}),
),
HttpResponse.json(resource(analytics, { dependencies })),
),
);
renderPage();
@@ -22,6 +22,7 @@ type IncomeMeta = ReturnType<typeof useIncomeAnalytics>["meta"];
type ComparisonNotice =
| "comparisonPending"
| "comparisonFailed"
| "previousUnavailable"
| "yearUnavailable";
type IncomeDisplayModel = {
@@ -84,7 +85,7 @@ const useAccountIncomePageViewModel = (
scopeId?: string,
): AccountIncomePageViewModel => {
const { accountId: routeAccountId } = useParams();
const accountId = scopeId ?? routeAccountId;
const accountId = scopeId ?? routeAccountId ?? "";
const [searchParams, setSearchParams] = useSearchParams();
const requested = useMemo(
() => ({
@@ -130,7 +131,7 @@ const useAccountIncomePageViewModel = (
};
if (analytics.isLoading || !analytics.data) {
if (analytics.isError || analytics.meta?.sync.state === "failed")
return { state: "error", retry: sync.refresh, period };
return { state: "error", retry: sync.retry, period };
return { state: "loading" };
}
const display = toIncomeDisplayModel(analytics.data, analytics.meta);
@@ -152,7 +153,7 @@ const useAccountIncomePageViewModel = (
partial: analytics.meta?.availability !== "complete",
refreshing: analytics.isFetching || sync.refreshing,
refreshError: analytics.isError || sync.sourceError || sync.transportError,
retry: sync.refresh,
retry: sync.retry,
form,
onSubmit,
onReset,
@@ -236,14 +237,14 @@ function toIncomeDisplayModel(
if (instrument.includedCount !== instrument.receiptCount) return null;
}
// The income endpoint returns selected, previous and year dependencies in order.
const [, previous, year] = meta?.dependencies ?? [];
const previous = meta?.dependencies?.find(({ role }) => role === "previous");
const year = meta?.dependencies?.find(({ role }) => role === "year");
return {
entries,
previousComparison: comparisonDisplay(analytics.period, analytics.previous),
comparison: comparisonDisplay(analytics.period, analytics.yearComparison),
previousNotice: comparisonNotice(previous),
yearNotice: comparisonNotice(year),
previousNotice: comparisonNotice(previous, "previousUnavailable"),
yearNotice: comparisonNotice(year, "yearUnavailable"),
};
}
@@ -251,9 +252,10 @@ function comparisonNotice(
dependency:
| NonNullable<NonNullable<IncomeMeta>["dependencies"]>[number]
| undefined,
unavailable: "previousUnavailable" | "yearUnavailable",
): ComparisonNotice {
if (dependency?.unavailableReason) return "yearUnavailable";
return dependency?.sync.state === "failed"
if (!dependency || dependency.unavailableReason) return unavailable;
return dependency.sync.state === "failed"
? "comparisonFailed"
: "comparisonPending";
}
@@ -2,7 +2,14 @@ import { MantineProvider } from "@mantine/core";
import { QueryClientProvider } from "@tanstack/react-query";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { HttpResponse, http } from "msw";
import { MemoryRouter, Route, Routes } from "react-router";
import type { FC } from "react";
import {
MemoryRouter,
Route,
Routes,
useLocation,
useNavigate,
} from "react-router";
import { describe, expect, it, vi } from "vitest";
import {
type BrokerageOperation,
@@ -32,6 +39,22 @@ const operation: BrokerageOperation = {
commission: { currency: "RUB", value: "1" },
};
const Navigation: FC = () => {
const location = useLocation();
const navigate = useNavigate();
return (
<>
<output aria-label="URL">{location.search}</output>
<button type="button" onClick={() => navigate(-1)}>
History back
</button>
<button type="button" onClick={() => navigate(1)}>
History forward
</button>
</>
);
};
function configureTestAuth() {
configureAuthClient({
accessToken: () => "token",
@@ -187,6 +210,133 @@ describe("LedgerEntryRow", () => {
});
describe("AccountOperationsPage", () => {
it.each(["account-1", "all"])(
"normalizes invalid periods and history, preserves one-sided UTC dates and resets cursors for %s",
async (accountId) => {
configureTestAuth();
const searches: URLSearchParams[] = [];
server.use(
http.get(
"*/api/v1/brokerage-accounts/:accountId/operations",
({ request }) => {
searches.push(new URL(request.url).searchParams);
return HttpResponse.json(
resource({
items: [operation],
nextCursor: "next",
hasNext: true,
}),
);
},
),
);
render(
<QueryClientProvider client={createTestQueryClient()}>
<MantineProvider env="test">
<MemoryRouter
initialIndex={0}
initialEntries={[
"?from=bad&to=2026-02-28&categories=trade",
"?from=2026-02-30&categories=trade",
"?from=2026-03-01&to=2026-02-28&categories=trade",
"?from=2026-02-01&categories=trade",
"?to=2026-02-28&categories=trade",
].map((search) => `/accounts/${accountId}/operations${search}`)}
>
<Navigation />
<Routes>
<Route
path="/accounts/:accountId/operations"
element={<AccountOperationsPage />}
/>
</Routes>
</MemoryRouter>
</MantineProvider>
</QueryClientProvider>,
);
await screen.findByLabelText("Период");
expect(searches[0].get("from")).toBeNull();
expect(searches[0].get("to")).toBeNull();
for (const expected of [
"?categories=trade",
"?categories=trade",
"?from=2026-02-01&categories=trade",
]) {
fireEvent.click(
screen.getByRole("button", { name: "History forward" }),
);
await waitFor(() =>
expect(screen.getByLabelText("URL").textContent).toBe(expected),
);
await screen.findByLabelText("Период");
}
expect(searches.at(-1)?.get("from")).toBe("2026-02-01T00:00:00.000Z");
expect(searches.at(-1)?.get("to")).toBeNull();
fireEvent.click(screen.getByRole("button", { name: "Далее" }));
await waitFor(() => expect(searches.at(-1)?.get("cursor")).toBe("next"));
await screen.findByLabelText("Период");
fireEvent.click(screen.getByRole("button", { name: "History forward" }));
await screen.findByLabelText("Период");
await waitFor(() =>
expect(searches.at(-1)?.get("to")).toBe("2026-02-28T23:59:59.999Z"),
);
expect(searches.at(-1)?.get("from")).toBeNull();
expect(searches.at(-1)?.get("cursor")).toBeNull();
fireEvent.click(screen.getByRole("button", { name: "History back" }));
await waitFor(() =>
expect(screen.getByLabelText("Период")).toHaveTextContent("01.02.2026"),
);
expect(screen.getByRole("button", { name: "Назад" })).toBeDisabled();
expect(searches.map((query) => query.get("from"))).not.toContain(
"badT00:00:00.000Z",
);
expect(
searches.every((query) => query.get("categories") === "trade"),
).toBe(true);
},
);
it.each(["account-1", "all"])(
"allows resetting filters after a request failure for %s",
async (accountId) => {
configureTestAuth();
server.use(
http.get(
"*/api/v1/brokerage-accounts/:accountId/operations",
({ request }) =>
new URL(request.url).searchParams.has("from")
? HttpResponse.json({ code: "VALIDATION_ERROR" }, { status: 400 })
: HttpResponse.json(
resource({ items: [], hasNext: false, nextCursor: null }),
),
),
);
render(
<QueryClientProvider client={createTestQueryClient()}>
<MantineProvider env="test">
<MemoryRouter
initialEntries={[
`/accounts/${accountId}/operations?from=2026-02-01`,
]}
>
<Navigation />
<Routes>
<Route
path="/accounts/:accountId/operations"
element={<AccountOperationsPage />}
/>
</Routes>
</MemoryRouter>
</MantineProvider>
</QueryClientProvider>,
);
await screen.findByRole("button", { name: "Повторить" });
fireEvent.click(screen.getByRole("button", { name: "Сбросить" }));
await screen.findByLabelText("Период");
expect(screen.getByLabelText("URL").textContent).toBe("");
},
);
it("resets cursor pagination when filters change", async () => {
configureTestAuth();
const searches: string[] = [];
@@ -311,6 +461,53 @@ describe("AccountOperationsPage", () => {
});
});
it("keeps UTC operation boundaries in GET and source sync requests", async () => {
configureTestAuth();
const requested: Record<string, string>[] = [];
const syncRequests: unknown[] = [];
server.use(
http.get(
"*/api/v1/brokerage-accounts/:accountId/operations",
({ request }) => {
requested.push(Object.fromEntries(new URL(request.url).searchParams));
return HttpResponse.json(
resource(null, {
sync: { id: null, state: "failed", errorCode: "TBANK_UNAVAILABLE" },
}),
);
},
),
http.post("*/api/v1/tbank-sync", async ({ request }) => {
syncRequests.push(await request.json());
return HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 });
}),
);
renderPage("?from=2024-02-29&to=2024-03-01&categories=trade");
const retry = await screen.findByRole("button", { name: "Повторить" });
expect(requested).toEqual([
{
from: "2024-02-29T00:00:00.000Z",
to: "2024-03-01T23:59:59.999Z",
categories: "trade",
limit: "100",
},
]);
expect(syncRequests).toEqual([]);
fireEvent.click(retry);
await waitFor(() =>
expect(syncRequests).toEqual([
{
screen: "operations",
accountId: "account-1",
from: "2024-02-29T00:00:00.000Z",
to: "2024-03-01T23:59:59.999Z",
categories: ["trade"],
limit: 100,
},
]),
);
});
it("does not label incomplete empty operations as a confirmed empty period", async () => {
configureTestAuth();
server.use(
@@ -1,6 +1,7 @@
import { useQueryClient } from "@tanstack/react-query";
import { useEffect, useState } from "react";
import { useParams, useSearchParams } from "react-router";
import { z } from "zod";
import {
type BrokerageOperation,
brokerageOperationsQueryOptions,
@@ -14,7 +15,7 @@ import { apiErrorCode } from "@/shared/api";
type AccountOperationsPageViewModel =
| { state: "loading" }
| { state: "error"; retry: () => void }
| { state: "error"; retry: () => void; onReset: () => void }
| {
state: "loaded";
sourceError?: boolean;
@@ -47,8 +48,11 @@ const useAccountOperationsPageVm = (
const accountId = scopeId ?? routeAccountId;
const queryClient = useQueryClient();
const [searchParams, setSearchParams] = useSearchParams();
const appliedFrom = searchParams.get("from");
const appliedTo = searchParams.get("to");
const urlFrom = searchParams.get("from");
const urlTo = searchParams.get("to");
const validUrlPeriod = isValidPeriod(urlFrom, urlTo);
const appliedFrom = validUrlPeriod ? urlFrom : null;
const appliedTo = validUrlPeriod ? urlTo : null;
const appliedCategories = operationCategories.filter((category) =>
(searchParams.get("categories") ?? "").split(",").includes(category),
);
@@ -58,8 +62,8 @@ const useAccountOperationsPageVm = (
useState<OperationCategory[]>(appliedCategories);
const scopeKey = JSON.stringify([
accountId,
appliedFrom,
appliedTo,
urlFrom,
urlTo,
appliedCategories,
]);
const [pagination, setPagination] = useState<{
@@ -69,14 +73,26 @@ const useAccountOperationsPageVm = (
const cursors = pagination.scopeKey === scopeKey ? pagination.cursors : [];
const cursor = cursors.at(-1);
useEffect(() => {
setDraftFrom(searchParams.get("from"));
setDraftTo(searchParams.get("to"));
setPagination((current) =>
current.scopeKey === scopeKey ? current : { scopeKey, cursors: [] },
);
}, [scopeKey]);
useEffect(() => {
if (validUrlPeriod) return;
const normalized = new URLSearchParams(searchParams);
normalized.delete("from");
normalized.delete("to");
setSearchParams(normalized, { replace: true });
}, [validUrlPeriod, searchParams, setSearchParams]);
useEffect(() => {
setDraftFrom(appliedFrom);
setDraftTo(appliedTo);
setDraftCategories(
operationCategories.filter((category) =>
(searchParams.get("categories") ?? "").split(",").includes(category),
),
);
}, [searchParams]);
}, [appliedFrom, appliedTo, searchParams]);
const firstPageQuery = {
from: dateStart(appliedFrom),
to: dateEnd(appliedTo),
@@ -104,7 +120,15 @@ const useAccountOperationsPageVm = (
exact: true,
});
resetCursors();
} else void sync.refresh();
} else void sync.retry();
};
const onReset = () => {
setDraftFrom(null);
setDraftTo(null);
setDraftCategories([]);
setSearchParams({});
resetCursors();
void queryClient.invalidateQueries({ queryKey: resetQueryKey });
};
if (operations.isLoading) return { state: "loading" };
@@ -113,7 +137,7 @@ const useAccountOperationsPageVm = (
(!operations.data ||
apiErrorCode(operations.error) === "INVALID_OPERATIONS_CURSOR")
)
return { state: "error", retry };
return { state: "error", retry, onReset };
if (!operations.data) return { state: "loading" };
const nextCursor = operations.data.nextCursor;
@@ -139,11 +163,12 @@ const useAccountOperationsPageVm = (
items: operations.data.items,
hasNext: Boolean(operations.data.hasNext && nextCursor),
hasPrevious: Boolean(cursors.length),
canApply: isDirty,
canApply: isDirty && isValidPeriod(draftFrom, draftTo),
onDraftFromChange: setDraftFrom,
onDraftToChange: setDraftTo,
onDraftCategoriesChange: setDraftCategories,
onApply: () => {
if (!isValidPeriod(draftFrom, draftTo)) return;
setSearchParams({
...(draftFrom ? { from: draftFrom } : {}),
...(draftTo ? { to: draftTo } : {}),
@@ -153,14 +178,7 @@ const useAccountOperationsPageVm = (
});
resetCursors();
},
onReset: () => {
setDraftFrom(null);
setDraftTo(null);
setDraftCategories([]);
setSearchParams({});
resetCursors();
void queryClient.invalidateQueries({ queryKey: resetQueryKey });
},
onReset,
onNext: () => {
if (nextCursor)
setPagination({ scopeKey, cursors: [...cursors, nextCursor] });
@@ -170,6 +188,16 @@ const useAccountOperationsPageVm = (
};
};
const calendarDate = z.string().date();
function isValidPeriod(from: string | null, to: string | null) {
return (
[from, to].every(
(date) => date === null || calendarDate.safeParse(date).success,
) &&
(!from || !to || from <= to)
);
}
function dateStart(value: string | null) {
return value ? `${value}T00:00:00.000Z` : undefined;
}
@@ -1,4 +1,4 @@
import { Button } from "@mantine/core";
import { Button, Group } from "@mantine/core";
import { StateView } from "@wealthledger/ui";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
@@ -20,7 +20,14 @@ const AccountOperationsPageError: FC<AccountOperationsPageErrorProps> = (
error
title={t("ledger.errorTitle")}
text={t("ledger.errorText")}
action={<Button onClick={retry}>{t("ledger.retry")}</Button>}
action={
<Group>
<Button onClick={retry}>{t("ledger.retry")}</Button>
<Button variant="default" onClick={props.onReset}>
{t("ledger.reset")}
</Button>
</Group>
}
/>
);
};
@@ -131,7 +131,7 @@ function useAccountPageViewModel(scopeId?: string): AccountPageViewModel {
return {
state: "error" as const,
code: apiErrorCode(portfolio.error),
retry: sync.refresh,
retry: sync.retry,
};
return { state: "loading" as const };
}
@@ -140,7 +140,7 @@ function useAccountPageViewModel(scopeId?: string): AccountPageViewModel {
refreshing: portfolio.isFetching || sync.refreshing,
sourceError: sync.sourceError,
refreshError: portfolio.isError || sync.transportError || sync.sourceError,
retry: sync.refresh,
retry: sync.retry,
};
if (
portfolio.meta?.availability === "complete" &&
@@ -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 { HttpResponse, http } from "msw";
import { MemoryRouter } from "react-router";
import { describe, expect, it, vi } from "vitest";
@@ -43,6 +43,21 @@ function renderPage(queryClient: QueryClient) {
}
describe("AccountsPage", () => {
it("loads accounts after retrying an initial HTTP failure", async () => {
const modes: (string | null)[] = [];
server.use(
http.get("*/api/v1/brokerage-accounts", ({ request }) => {
modes.push(new URL(request.url).searchParams.get("sync"));
return modes.length === 1
? HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 })
: HttpResponse.json(resource([account]));
}),
);
renderPage(createTestQueryClient());
fireEvent.click(await screen.findByRole("button", { name: "Повторить" }));
expect(await screen.findByText(account.name)).toBeVisible();
expect(modes).toEqual([null, "observe"]);
});
it.each([
["loading", AccountsPageLoading, "status"],
["empty", AccountsPageEmpty, undefined],
@@ -143,8 +158,10 @@ describe("AccountsPage", () => {
expect(screen.getByText(account.name)).toBeInTheDocument();
screen.getByRole("button", { name: "Повторить" }).click();
await waitFor(() => expect(requests).toEqual([{ screen: "accounts" }]));
expect(requestCount).toBe(2);
await waitFor(() => expect(requestCount).toBe(3));
await waitFor(() => expect(warning).not.toBeInTheDocument());
expect(screen.getByText(account.name)).toBeVisible();
expect(requests).toEqual([]);
});
it("keeps a cached empty account list visible after a refresh error", async () => {
@@ -18,7 +18,7 @@ function useAccountsPageViewModel() {
return {
state: "error" as const,
code: apiErrorCode(accounts.error),
retry: sync.refresh,
retry: sync.retry,
};
}
if (!accounts.data?.length)
@@ -28,7 +28,7 @@ function useAccountsPageViewModel() {
refreshing: accounts.isFetching || sync.refreshing,
sourceError: sync.sourceError,
refreshError: accounts.isError || sync.transportError || sync.sourceError,
retry: sync.refresh,
retry: sync.retry,
};
return {
state: "loaded" as const,
@@ -37,7 +37,7 @@ function useAccountsPageViewModel() {
refreshing: accounts.isFetching || sync.refreshing,
sourceError: sync.sourceError,
refreshError: accounts.isError || sync.transportError || sync.sourceError,
retry: sync.refresh,
retry: sync.retry,
};
}
+8 -8
View File
@@ -82,7 +82,7 @@ function useBondPageVm() {
queryKey: brokerageOperationsQueryOptions("all", firstPage).queryKey,
exact: true,
});
} else void sync.refresh();
} else void sync.retry();
};
const chartData =
candles.data?.candles.map((candle) => ({
@@ -99,26 +99,26 @@ function useBondPageVm() {
? `/bonds/${encodeURIComponent(bond.data.slug)}${params.size ? `?${params.toString()}` : ""}`
: null,
bond: bond.data,
bondState: section(bond, uid ? sync.refresh : () => bond.refetch()),
bondState: section(bond, uid ? sync.retry : () => bond.refetch()),
code: apiErrorCode(bond.error),
logoSrc: instrumentLogoUrl(bond.data?.logoName),
metrics: metrics.data,
metricsState: section(metrics, sync.refresh),
metricsState: section(metrics, sync.retry),
quote: quote.data,
quoteState: section(quote, sync.refresh),
quoteState: section(quote, sync.retry),
candles: candles.data,
chartData,
priceRows: chartData.toReversed(),
candlesState: section(candles, sync.refresh),
candlesState: section(candles, sync.retry),
period,
setPeriod: (value: string) => setParam("period", value),
averageLine: null,
hasIncompleteCandle:
candles.data?.candles.some((candle) => !candle.isComplete) ?? false,
position: position.data,
positionState: section(position, sync.refresh),
positionState: section(position, sync.retry),
coupons: coupons.data,
couponsState: section(coupons, sync.refresh),
couponsState: section(coupons, sync.retry),
upcoming:
coupons.data?.coupons
.filter((event) => event.timing === "upcoming")
@@ -137,7 +137,7 @@ function useBondPageVm() {
) ?? [],
operations: operations.data,
operationsState: {
...section(operations, sync.refresh),
...section(operations, sync.retry),
retry: retryOperations,
},
eventRows:
+1 -1
View File
@@ -28,7 +28,7 @@ function useBondsPageVm() {
partial: result.meta?.availability === "partial",
code: apiErrorCode(result.error),
retry: () => {
void sync.refresh();
void sync.retry();
},
ready: debounced.length >= 2 && !waiting && Boolean(result.data),
items:
+24 -1
View File
@@ -46,6 +46,7 @@ const operation = {
};
function setup(
options: {
month?: string;
missing?: boolean;
summaryError?: boolean;
cardsError?: boolean;
@@ -101,7 +102,9 @@ function setup(
render(
<QueryClientProvider client={client}>
<MantineProvider env="test">
<MemoryRouter initialEntries={["/cards/a?month=2026-08"]}>
<MemoryRouter
initialEntries={[`/cards/a?month=${options.month ?? "2026-08"}`]}
>
<Routes>
<Route path="/cards/:cardId" element={<CardPage />} />
</Routes>
@@ -112,6 +115,26 @@ function setup(
return { requests, summaryRequests, client };
}
describe("CardPage", () => {
it.each([
["0001-01", "0001-01-31"],
["0099-12", "0099-12-31"],
["2026-09", "2026-09-30"],
["2024-02", "2024-02-29"],
])(
"sends the same legacy month %s range to history and summary",
async (month, to) => {
const { requests, summaryRequests } = setup({ month });
await screen.findByText("Перевод на «Запасная»");
await waitFor(() => expect(summaryRequests).toHaveLength(1));
for (const request of [requests[0], summaryRequests[0]]) {
const query = new URLSearchParams(request);
expect(query.get("from")).toBe(`${month}-01`);
expect(query.get("to")).toBe(to);
expect(query.has("month")).toBe(false);
}
},
);
it("shares date range with history, pages the journal and keeps current balance", async () => {
const { requests, summaryRequests } = setup();
await screen.findByRole("heading", { name: "Повседневная" });
@@ -9,6 +9,7 @@ import { createTestQueryClient } from "@/test/query";
import { resource } from "@/test/resource";
import { server } from "@/test/server";
import { StockPage } from "./StockPage";
import type { StockPageVm } from "./StockPage.vm";
const LocationProbe = () => {
const location = useLocation();
@@ -72,6 +73,7 @@ function setup(
path?: string;
slug?: string;
ambiguousTicker?: boolean;
metrics?: NonNullable<StockPageVm["company"]>["metrics"];
} = {},
) {
const requests: URL[] = [];
@@ -118,7 +120,7 @@ function setup(
: HttpResponse.json(
resource({
description: "Описание компании",
metrics: [
metrics: options.metrics ?? [
{
key: "peRatioTtm",
value: 5.6,
@@ -215,6 +217,63 @@ function setup(
};
}
describe("stock page", () => {
it("preserves unknown and unambiguous currencies with compact, ratio and percent formatting", async () => {
setup({
metrics: [
{
key: "marketCapitalization",
value: 1234567.89,
unit: "money",
currency: null,
period: null,
},
{
key: "revenueTtm",
value: 1234567.89,
unit: "money",
currency: "RUB",
period: "TTM",
},
{
key: "ebitdaTtm",
value: 1234567.89,
unit: "money",
currency: "USD",
period: "TTM",
},
{
key: "netIncomeTtm",
value: 1234567.89,
unit: "money",
currency: "CAD",
period: "TTM",
},
{
key: "peRatioTtm",
value: 5.678,
unit: "ratio",
currency: null,
period: "TTM",
},
{
key: "roe",
value: 12.3456,
unit: "percent",
currency: null,
period: null,
},
],
});
expect(await screen.findByText("1,23 млн валюта неизвестна")).toBeVisible();
for (const value of [
"1,23 млн ₽",
"1,23 млн USD",
"1,23 млн CAD",
"5,68 ×",
"12,35 %",
])
expect(screen.getByText(value)).toBeVisible();
});
it("opens a direct ticker URL and uses its resolved UID for market and personal requests", async () => {
const { requests } = setup({ path: "/stocks/SBERP", slug: "SBERP" });
await screen.findByText("Зачисление дивидендов");
@@ -290,6 +349,31 @@ describe("stock page", () => {
),
).toHaveAttribute("aria-live", "polite");
});
it("recovers an initially missing company section without reloading successful sections", async () => {
const { requests } = setup({ failCompany: true });
const retry = await screen.findByRole("button", { name: "Повторить" });
await screen.findByText("Зачисление дивидендов");
const modes: (string | null)[] = [];
server.use(
http.get("*/api/v1/stocks/:uid/company", ({ request }) => {
modes.push(new URL(request.url).searchParams.get("sync"));
return HttpResponse.json(
resource({
description: "Восстановленное описание",
metrics: [],
unavailable: [],
observedAt: quote.observedAt,
}),
);
}),
);
const previousRequests = requests.length;
fireEvent.click(retry);
expect(await screen.findByText("Восстановленное описание")).toBeVisible();
expect(screen.getByText("Зачисление дивидендов")).toBeVisible();
expect(requests).toHaveLength(previousRequests);
expect(modes).toEqual(["observe"]);
});
it("sends instrument-filtered continuation, resets it on filter changes and changes chart period", async () => {
const { requests } = setup();
await screen.findByText("Зачисление дивидендов");
@@ -190,13 +190,9 @@ const StockPageView: FC<StockPageViewProps> = (props) => {
metric.unit === "money" ? "compact" : "standard",
}).format(metric.value)}{" "}
{metric.unit === "money"
? new Intl.NumberFormat("ru-RU", {
style: "currency",
currency: metric.currency ?? "RUB",
currencyDisplay: "narrowSymbol",
})
.formatToParts(0)
.find((part) => part.type === "currency")?.value
? metric.currency === "RUB"
? "₽"
: (metric.currency ?? t("stock.currencyUnknown"))
: metric.unit === "percent"
? "%"
: "×"}
+8 -8
View File
@@ -82,7 +82,7 @@ function useStockPageVm() {
queryKey: brokerageOperationsQueryOptions("all", firstPage).queryKey,
exact: true,
});
} else void sync.refresh();
} else void sync.retry();
};
const chartData =
candles.data?.candles.map((candle) => ({
@@ -105,15 +105,15 @@ function useStockPageVm() {
? `/stocks/${encodeURIComponent(stock.data.slug)}${params.size ? `?${params.toString()}` : ""}`
: null,
stock: stock.data,
stockState: section(stock, uid ? sync.refresh : () => stock.refetch()),
stockState: section(stock, uid ? sync.retry : () => stock.refetch()),
code: apiErrorCode(stock.error),
logoSrc: instrumentLogoUrl(stock.data?.logoName),
quote: quote.data,
quoteState: section(quote, sync.refresh),
quoteState: section(quote, sync.retry),
candles: candles.data,
chartData,
priceRows: chartData.toReversed(),
candlesState: section(candles, sync.refresh),
candlesState: section(candles, sync.retry),
period,
setPeriod: (value: string) => setParam("period", value),
averageLine:
@@ -121,11 +121,11 @@ function useStockPageVm() {
hasIncompleteCandle:
candles.data?.candles.some((candle) => !candle.isComplete) ?? false,
position: position.data,
positionState: section(position, sync.refresh),
positionState: section(position, sync.retry),
company: companyData,
companyState: section(company, sync.refresh),
companyState: section(company, sync.retry),
dividends: dividends.data,
dividendsState: section(dividends, sync.refresh),
dividendsState: section(dividends, sync.retry),
upcoming:
dividends.data?.dividends
.filter((event) => event.timing === "upcoming")
@@ -144,7 +144,7 @@ function useStockPageVm() {
) ?? [],
operations: operations.data,
operationsState: {
...section(operations, sync.refresh),
...section(operations, sync.retry),
retry: retryOperations,
},
eventRows:
@@ -28,7 +28,7 @@ function useStocksPageVm() {
partial: result.meta?.availability === "partial",
code: apiErrorCode(result.error),
retry: () => {
void sync.refresh();
void sync.retry();
},
ready: debounced.length >= 2 && !waiting && Boolean(result.data),
items: