fix(frontend): complete audit remediation

This commit is contained in:
2026-09-06 19:49:02 +03:00
parent c97f62bd98
commit 9e42148f6a
11 changed files with 508 additions and 72 deletions
@@ -1,6 +1,6 @@
import { MantineProvider } from "@mantine/core";
import { render, screen } from "@testing-library/react";
import { MemoryRouter } from "react-router";
import { MemoryRouter, Route, Routes } from "react-router";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { useBrokerageEvents } from "@/entities/brokerage-event";
import { AccountEventsPage } from "./AccountEventsPage";
@@ -35,6 +35,37 @@ describe("AccountEventsPage", () => {
} as never);
});
it("passes the selected route account to the query", () => {
render(
<MantineProvider>
<MemoryRouter initialEntries={["/accounts/selected-account/events"]}>
<Routes>
<Route
path="/accounts/:accountId/events"
element={<AccountEventsPage />}
/>
</Routes>
</MemoryRouter>
</MantineProvider>,
);
expect(query).toHaveBeenCalledWith("selected-account");
});
it("keeps the loading fallback when no result or account is available", () => {
query.mockReturnValue({
data: undefined,
isError: false,
isLoading: false,
refetch: vi.fn(),
} as never);
renderPage();
expect(query).toHaveBeenCalledWith(undefined);
expect(screen.getByRole("status", { name: "Загрузка" })).toHaveAttribute(
"aria-busy",
"true",
);
});
it("renders events and marks an offer without an amount", () => {
query.mockReturnValue({
data: {
@@ -1,20 +1,17 @@
import type { FC } from "react";
import { useParams } from "react-router";
import { useBrokerageEvents } from "@/entities/brokerage-event";
import { useAccountEventsPageViewModel } from "./AccountEventsPage.vm";
import { AccountEventsPageEmpty } from "./components/AccountEventsPageEmpty.view";
import { AccountEventsPageError } from "./components/AccountEventsPageError.view";
import { AccountEventsPageLoaded } from "./components/AccountEventsPageLoaded.view";
import { AccountEventsPageLoading } from "./components/AccountEventsPageLoading.view";
const AccountEventsPage: FC = () => {
const { accountId } = useParams();
const events = useBrokerageEvents(accountId);
if (events.isLoading) return <AccountEventsPageLoading />;
if (events.isError)
return <AccountEventsPageError retry={() => events.refetch()} />;
if (!events.data) return <AccountEventsPageLoading />;
if (!events.data.events.length) return <AccountEventsPageEmpty />;
return <AccountEventsPageLoaded events={events.data.events} />;
const viewModel = useAccountEventsPageViewModel();
if (viewModel.state === "loading") return <AccountEventsPageLoading />;
if (viewModel.state === "error")
return <AccountEventsPageError retry={viewModel.retry} />;
if (viewModel.state === "empty") return <AccountEventsPageEmpty />;
return <AccountEventsPageLoaded events={viewModel.events} />;
};
export { AccountEventsPage };
@@ -0,0 +1,23 @@
import { useParams } from "react-router";
import {
type BrokerageEvent,
useBrokerageEvents,
} from "@/entities/brokerage-event";
type AccountEventsPageViewModel =
| { state: "loading" }
| { state: "empty" }
| { state: "error"; retry: () => void }
| { state: "loaded"; events: BrokerageEvent[] };
function useAccountEventsPageViewModel(): AccountEventsPageViewModel {
const { accountId } = useParams();
const events = useBrokerageEvents(accountId);
if (events.isLoading) return { state: "loading" };
if (events.isError) return { state: "error", retry: () => events.refetch() };
if (!events.data) return { state: "loading" };
if (!events.data.events.length) return { state: "empty" };
return { state: "loaded", events: events.data.events };
}
export { useAccountEventsPageViewModel };
@@ -1,5 +1,11 @@
import { MantineProvider } from "@mantine/core";
import { render, screen, waitFor } from "@testing-library/react";
import {
fireEvent,
render,
screen,
waitFor,
within,
} from "@testing-library/react";
import { HttpResponse, http } from "msw";
import { MemoryRouter, Route, Routes } from "react-router";
import { describe, expect, it, vi } from "vitest";
@@ -9,6 +15,7 @@ import { createQueryWrapper } from "@/test/query";
import { server } from "@/test/server";
import { AccountIncomePage } from "./AccountIncomePage";
import { isDate, toIncomeDisplayModel } from "./AccountIncomePage.vm";
import { IncomeBreakdown } from "./components/IncomeBreakdown.view";
const analytics = {
accountId: "account-1",
@@ -29,7 +36,7 @@ const analytics = {
],
instruments: [
{
key: "figi:FIGI:RUB",
key: "income-group-opaque-v2",
figi: "FIGI",
instrumentUid: null,
ticker: "BOND",
@@ -38,10 +45,18 @@ const analytics = {
coupon: money("RUB", "10"),
dividend: money("RUB", "2"),
total: money("RUB", "12"),
receiptCount: 1,
receiptCount: 2,
},
],
entries: [
entry(),
{
...entry(),
id: "dividend-1",
type: "OPERATION_TYPE_DIVIDEND",
payment: money("RUB", "2"),
},
],
entries: [entry()],
} satisfies IncomeAnalytics;
function money(currency: string, value: string) {
@@ -69,6 +84,7 @@ function comparison(currency: string) {
function entry(): IncomeAnalytics["entries"][number] {
return {
id: "coupon-1",
instrumentKey: "income-group-opaque-v2",
occurredAt: "2026-08-01T12:00:00Z",
type: "OPERATION_TYPE_COUPON",
category: "income",
@@ -118,6 +134,201 @@ function renderPage() {
}
describe("AccountIncomePage", () => {
it("preserves every repeated-ID payment when an open drill-down updates", async () => {
const first = { ...entry(), id: "same-id", description: "Выплата A" };
const second = {
...entry(),
id: "same-id",
description: "Выплата B",
payment: money("RUB", "-2"),
};
const third = {
...entry(),
id: "other-id",
description: "Выплата C",
payment: money("RUB", "1"),
};
function breakdown(entries: IncomeAnalytics["entries"], total: string) {
const display = toIncomeDisplayModel({
...analytics,
instruments: [
{
...analytics.instruments[0],
coupon: money("RUB", total),
dividend: money("RUB", "0"),
total: money("RUB", total),
receiptCount: entries.length,
},
],
entries,
});
expect(display).not.toBeNull();
return (
<MantineProvider>
<IncomeBreakdown instruments={display?.instruments ?? []} />
</MantineProvider>
);
}
const { rerender } = render(breakdown([first, second, third], "9"));
fireEvent.click(screen.getByRole("button", { name: "BOND · 9 ₽ · 3" }));
const panel = await screen.findByRole("region", { name: "BOND · 9 ₽ · 3" });
const payments = () =>
within(panel)
.getAllByText(/^Выплата [ABC]$/)
.map((element) => element.textContent);
expect(payments()).toEqual(["Выплата A", "Выплата B", "Выплата C"]);
rerender(breakdown([third, first, second], "9"));
expect(payments()).toEqual(["Выплата C", "Выплата A", "Выплата B"]);
expect(within(panel).getByText("−2 ₽")).toBeInTheDocument();
rerender(breakdown([second, second, first], "6"));
expect(payments()).toEqual(["Выплата B", "Выплата B", "Выплата A"]);
expect(within(panel).getAllByText("−2 ₽")).toHaveLength(2);
rerender(breakdown([second, first], "8"));
expect(payments()).toEqual(["Выплата B", "Выплата A"]);
expect(
screen.getByRole("button", { name: "BOND · 8 ₽ · 2" }),
).toHaveAttribute("aria-expanded", "true");
});
it.each([
["missing key", undefined],
["empty key", ""],
["whitespace key", " "],
["non-string key", 12],
["unknown key", "not-in-breakdown"],
])("rejects %s and recovers through retry", async (_name, instrumentKey) => {
let response = {
...analytics,
entries: [
{ ...analytics.entries[0], instrumentKey },
analytics.entries[1],
],
};
server.use(
http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () =>
HttpResponse.json(response),
),
);
renderPage();
const retry = await screen.findByRole("button", { name: "Повторить" });
expect(screen.queryByText("BOND · 12 ₽ · 2")).not.toBeInTheDocument();
response = analytics;
fireEvent.click(retry);
expect(await screen.findByText("BOND · 12 ₽ · 2")).toBeInTheDocument();
});
it.each([
[
"duplicate instrument keys",
{
...analytics,
instruments: [...analytics.instruments, analytics.instruments[0]],
},
],
[
"currency mismatch",
{
...analytics,
entries: [
{ ...analytics.entries[0], payment: money("USD", "10") },
analytics.entries[1],
],
},
],
[
"receipt count mismatch",
{
...analytics,
instruments: [{ ...analytics.instruments[0], receiptCount: 3 }],
},
],
["missing entries", { ...analytics, entries: [] }],
[
"empty instrument key",
{ ...analytics, instruments: [{ ...analytics.instruments[0], key: "" }] },
],
])("shows a local error for %s", async (_name, response) => {
server.use(
http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () =>
HttpResponse.json(response),
),
);
renderPage();
expect(
await screen.findByRole("button", { name: "Повторить" }),
).toBeInTheDocument();
expect(screen.queryByText("BOND · 12 ₽ · 2")).not.toBeInTheDocument();
});
it("accepts an empty analytics response", async () => {
server.use(
http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () =>
HttpResponse.json({
...analytics,
totalsByCurrency: [],
buckets: [],
instruments: [],
entries: [],
}),
),
);
renderPage();
expect(
await screen.findByRole("heading", { name: "Пассивный доход" }),
).toBeInTheDocument();
expect(
screen.queryByRole("button", { name: "Повторить" }),
).not.toBeInTheDocument();
});
it("renders the signed drill-down using opaque membership rather than instrument identity", async () => {
const response = {
...analytics,
instruments: [
{
...analytics.instruments[0],
key: "server-only-key",
total: money("RUB", "8"),
dividend: money("RUB", "-2"),
},
],
entries: [
{
...analytics.entries[0],
instrumentKey: "server-only-key",
figi: null,
ticker: null,
description: "Исходная выплата",
},
{
...analytics.entries[1],
instrumentKey: "server-only-key",
instrumentUid: "unrelated-uid",
payment: money("RUB", "-2"),
description: "Отрицательная корректировка",
},
],
};
server.use(
http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () =>
HttpResponse.json(response),
),
);
renderPage();
fireEvent.click(
await screen.findByRole("button", { name: "BOND · 8 ₽ · 2" }),
);
const panel = await screen.findByRole("region", { name: "BOND · 8 ₽ · 2" });
expect(within(panel).getByText("Исходная выплата")).toBeInTheDocument();
expect(
within(panel).getByText("Отрицательная корректировка"),
).toBeInTheDocument();
expect(within(panel).getByText("−2 ₽")).toBeInTheDocument();
});
it("rejects malformed calendar dates without throwing", () => {
expect(isDate("2026-99-99")).toBe(false);
expect(isDate("2026-02-29")).toBe(false);
@@ -127,8 +338,8 @@ describe("AccountIncomePage", () => {
it("groups income entries once in the view model", () => {
const display = toIncomeDisplayModel(analytics);
expect(display.instruments[0].entries).toEqual([analytics.entries[0]]);
expect(display.trend[0].buckets[0].total).toEqual(
expect(display?.instruments[0].entries).toEqual(analytics.entries);
expect(display?.trend[0].buckets[0].total).toEqual(
analytics.buckets[0].totalsByCurrency[0],
);
});
@@ -168,7 +379,7 @@ describe("AccountIncomePage", () => {
screen.getByText("Налоговые операции не включены в этот результат."),
).toBeInTheDocument();
expect(screen.getByText("Ближайший прогноз")).toBeInTheDocument();
expect(screen.getByText("BOND · 12 ₽ · 1")).toBeInTheDocument();
expect(screen.getByText("BOND · 12 ₽ · 2")).toBeInTheDocument();
expect(
screen.getByText(
"Выбранный период: Купоны 10 ₽ · Дивиденды 2 ₽ · Итого 12 ₽",
@@ -92,10 +92,13 @@ const useAccountIncomePageViewModel = (): AccountIncomePageViewModel => {
return { state: "error", retry: () => analytics.refetch(), period };
return { state: "loading" };
}
const display = toIncomeDisplayModel(analytics.data);
if (!display)
return { state: "error", retry: () => analytics.refetch(), period };
return {
state: "loaded",
analytics: analytics.data,
display: toIncomeDisplayModel(analytics.data),
display,
forecast: toForecast(events),
form,
onSubmit,
@@ -149,14 +152,32 @@ function isDate(value: string): boolean {
);
}
function toIncomeDisplayModel(analytics: IncomeAnalytics): IncomeDisplayModel {
const entriesByInstrument = new Map<string, IncomeAnalytics["entries"]>();
function toIncomeDisplayModel(
analytics: IncomeAnalytics,
): IncomeDisplayModel | null {
const instruments = new Map<
string,
IncomeDisplayModel["instruments"][number]
>();
for (const instrument of analytics.instruments) {
if (
typeof instrument.key !== "string" ||
!instrument.key.trim() ||
instruments.has(instrument.key)
)
return null;
instruments.set(instrument.key, { ...instrument, entries: [] });
}
for (const entry of analytics.entries) {
const key = incomeEntryKey(entry);
entriesByInstrument.set(key, [
...(entriesByInstrument.get(key) ?? []),
entry,
]);
if (typeof entry.instrumentKey !== "string" || !entry.instrumentKey.trim())
return null;
const instrument = instruments.get(entry.instrumentKey);
if (!instrument || entry.payment?.currency !== instrument.total.currency)
return null;
instrument.entries.push(entry);
}
for (const instrument of instruments.values()) {
if (instrument.entries.length !== instrument.receiptCount) return null;
}
return {
@@ -169,24 +190,10 @@ function toIncomeDisplayModel(analytics: IncomeAnalytics): IncomeDisplayModel {
zeroSummary(currency),
})),
})),
instruments: analytics.instruments.map((instrument) => ({
...instrument,
entries: entriesByInstrument.get(instrument.key) ?? [],
})),
instruments: [...instruments.values()],
};
}
function incomeEntryKey(entry: IncomeAnalytics["entries"][number]): string {
const base = entry.instrumentUid
? `uid:${entry.instrumentUid}`
: entry.figi
? `figi:${entry.figi}`
: entry.ticker || entry.instrumentType
? `ticker:${entry.ticker ?? ""}:${entry.instrumentType ?? ""}`
: `operation:${entry.id}`;
return `${base}:${entry.payment?.currency ?? ""}`;
}
function zeroSummary(
currency: string,
): IncomeAnalytics["totalsByCurrency"][number] {
@@ -16,25 +16,36 @@ const IncomeBreakdown: FC<IncomeBreakdownProps> = (props) => {
<Stack>
<Title order={2}>{t("income.instruments")}</Title>
<Accordion>
{props.instruments.map((instrument) => (
<Accordion.Item key={instrument.key} value={instrument.key}>
<Accordion.Control>
{instrument.displayName} · {formatMoney(instrument.total)} ·{" "}
{instrument.receiptCount}
</Accordion.Control>
<Accordion.Panel>
<Text size="sm">
{t("income.coupon")}: {formatMoney(instrument.coupon)} ·{" "}
{t("income.dividend")}: {formatMoney(instrument.dividend)}
</Text>
<Stack mt="sm">
{instrument.entries.map((entry) => (
<LedgerEntryRow key={entry.id} operation={entry} />
))}
</Stack>
</Accordion.Panel>
</Accordion.Item>
))}
{props.instruments.map((instrument) => {
const occurrences = new Map<string, number>();
return (
<Accordion.Item key={instrument.key} value={instrument.key}>
<Accordion.Control>
{instrument.displayName} · {formatMoney(instrument.total)} ·{" "}
{instrument.receiptCount}
</Accordion.Control>
<Accordion.Panel>
<Text size="sm">
{t("income.coupon")}: {formatMoney(instrument.coupon)} ·{" "}
{t("income.dividend")}: {formatMoney(instrument.dividend)}
</Text>
<Stack mt="sm">
{instrument.entries.map((entry) => {
// Distinguish repeated IDs and reset image fallback when its source changes.
const occurrence = occurrences.get(entry.id) ?? 0;
occurrences.set(entry.id, occurrence + 1);
const key = JSON.stringify([
entry.id,
occurrence,
entry.logoName,
]);
return <LedgerEntryRow key={key} operation={entry} />;
})}
</Stack>
</Accordion.Panel>
</Accordion.Item>
);
})}
</Accordion>
</Stack>
);