Files
frontend/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.test.tsx
T
ksv741 f6db28defa
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
fix: resolve frontend audit findings
Repair investment recovery, draft preservation, date validation,
currency presentation, accessibility, and route error recovery.
Correct Docker and dependency checks and consume explicit income roles.
2026-09-27 11:38:20 +03:00

1041 lines
31 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { resource } from "@/test/resource";
import "dayjs/locale/ru";
import { MantineProvider } from "@mantine/core";
import { DatesProvider } from "@mantine/dates";
import {
act,
fireEvent,
render,
renderHook,
screen,
waitFor,
within,
} from "@testing-library/react";
import { HttpResponse, http } from "msw";
import type { FC } from "react";
import {
MemoryRouter,
Route,
Routes,
useLocation,
useNavigate,
} from "react-router";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { IncomeAnalytics } from "@/entities/income";
import { configureAuthClient } from "@/shared/api";
import { createQueryWrapper } from "@/test/query";
import { server } from "@/test/server";
import { isDate } from "../../model/period";
import { AccountIncomePage } from "./AccountIncomePage";
import {
toIncomeDisplayModel,
useAccountIncomePageViewModel,
} from "./AccountIncomePage.vm";
import { IncomePayments } from "./components/IncomePayments.view";
const analytics = {
accountId: "account-1",
period: { from: "2026-08-01", to: "2026-08-27" },
calculatedAt: "2026-08-27T00:00:00Z",
totalsByCurrency: [summary("RUB", "10", "2")],
previous: {
period: { from: "2026-07-01", to: "2026-07-31" },
totalsByCurrency: [comparison("RUB")],
},
yearComparison: null,
bucketGranularity: "day",
buckets: [
{
period: { from: "2026-08-01", to: "2026-08-01" },
totalsByCurrency: [summary("RUB", "10", "2")],
},
],
instruments: [
{
key: "income-group-opaque-v2",
figi: "FIGI",
instrumentUid: null,
ticker: "BOND",
instrumentType: "bond",
displayName: "BOND",
coupon: money("RUB", "10"),
dividend: money("RUB", "2"),
total: money("RUB", "12"),
receiptCount: 2,
},
],
entries: [
entry(),
{
...entry(),
id: "dividend-1",
type: "OPERATION_TYPE_DIVIDEND",
payment: money("RUB", "2"),
},
],
} satisfies IncomeAnalytics;
function money(currency: string, value: string) {
return { currency, value };
}
function summary(currency: string, coupon: string, dividend: string) {
return {
currency,
coupon: money(currency, coupon),
dividend: money(currency, dividend),
total: money(currency, String(Number(coupon) + Number(dividend))),
};
}
function comparison(currency: string) {
return {
currency,
selected: summary(currency, "10", "2"),
compared: summary(currency, "1", "0"),
difference: summary(currency, "9", "2"),
};
}
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",
description: "Купон",
figi: "FIGI",
instrumentUid: null,
ticker: "BOND",
instrumentType: "bond",
logoName: null,
payment: money("RUB", "10"),
quantity: null,
price: null,
accruedInterest: null,
commission: null,
};
}
function renderPage(search = "from=2026-08-01&to=2026-08-27") {
configureAuthClient({
accessToken: () => "token",
refresh: async () => true,
clear: vi.fn(),
});
const { wrapper: QueryWrapper, queryClient } = createQueryWrapper();
const result = render(
<MantineProvider env="test">
<DatesProvider settings={{ locale: "ru" }}>
<QueryWrapper>
<MemoryRouter
initialEntries={[`/accounts/account-1/income?${search}`]}
>
<LocationProbe />
<Routes>
<Route
path="/accounts/:accountId/income"
element={<AccountIncomePage />}
/>
<Route
path="/accounts/:accountId/events"
element={<h1>Events</h1>}
/>
</Routes>
</MemoryRouter>
</QueryWrapper>
</DatesProvider>
</MantineProvider>,
);
return { ...result, queryClient };
}
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(
http.get(
"*/api/v1/brokerage-accounts/:accountId/income-analytics",
({ request }) => {
requested.push(new URL(request.url).search);
return HttpResponse.json(resource(analytics));
},
),
);
renderPage("from=2020-01-01&to=2025-12-31");
await waitFor(() =>
expect(requested).toContain("?from=2020-01-01&to=2025-12-31"),
);
});
it("preserves every repeated-ID payment when an open card 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 env="test">
<IncomePayments entries={display?.entries ?? []} />
</MantineProvider>
);
}
const { rerender } = render(breakdown([first, second, third], "9"));
const panel = await screen.findByRole("region", { name: "Выплаты" });
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(within(panel).getAllByRole("listitem")).toHaveLength(2);
});
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(resource(response)),
),
);
renderPage();
const retry = await screen.findByRole("button", { name: "Повторить" });
expect(
screen.queryByRole("region", { name: "Выплаты" }),
).not.toBeInTheDocument();
response = analytics;
fireEvent.click(retry);
expect(
await screen.findByRole("region", { name: "Выплаты" }),
).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(resource(response)),
),
);
renderPage();
expect(
await screen.findByRole("button", { name: "Повторить" }),
).toBeInTheDocument();
expect(
screen.queryByRole("region", { name: "Выплаты" }),
).not.toBeInTheDocument();
});
it("accepts an empty analytics response", async () => {
server.use(
http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () =>
HttpResponse.json(
resource({
...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(resource(response)),
),
);
renderPage();
const panel = await screen.findByRole("region", { name: "Выплаты" });
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);
expect(isDate("2024-02-29")).toBe(true);
});
it("renders realized text and comparisons without subscribing to events", async () => {
const eventsRequest = vi.fn(() => new HttpResponse(null, { status: 503 }));
server.use(
http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () =>
HttpResponse.json(
resource({
...analytics,
yearComparison: {
period: { from: "2025-08-01", to: "2025-08-27" },
totalsByCurrency: [comparison("RUB")],
},
}),
),
),
http.get("*/api/v1/brokerage-accounts/account-1/events", eventsRequest),
);
renderPage();
await waitFor(() =>
expect(
screen.getByRole("heading", { name: "Пассивный доход" }),
).toBeInTheDocument(),
);
expect(
screen.getByText("Налоговые операции не включены в этот результат."),
).toBeInTheDocument();
expect(screen.queryByText("Ближайший прогноз")).not.toBeInTheDocument();
expect(screen.queryByText("Динамика")).not.toBeInTheDocument();
expect(eventsRequest).not.toHaveBeenCalled();
expect(screen.getByRole("region", { name: "Выплаты" })).toBeInTheDocument();
const year = screen.getByRole("region", { name: "Годом ранее" });
const selected = within(year).getByRole("region", {
name: "01.08.2026 – 27.08.2026",
});
expect(
screen.getByRole("region", { name: "01.08.2025 – 27.08.2025" }),
).toBeInTheDocument();
expect(
screen.getByRole("heading", { name: "Предыдущий период" }),
).toBeVisible();
expect(screen.queryByText("Выбранный период")).not.toBeInTheDocument();
expect(screen.queryByText("Сравниваемый период")).not.toBeInTheDocument();
expect(
screen.getByRole("heading", { name: "Годом ранее", level: 2 }),
).toBeInTheDocument();
expect(screen.queryByText("Сравнения")).not.toBeInTheDocument();
expect(screen.queryByText("RUB", { exact: true })).not.toBeInTheDocument();
for (const amount of ["10 ₽", "2 ₽", "12 ₽"]) {
expect(within(selected).getByText(amount)).toBeInTheDocument();
}
const difference = within(year).getByRole("region", { name: "Разница" });
for (const amount of ["+9 ₽", "+2 ₽", "+11 ₽"]) {
expect(within(difference).getByText(amount)).toBeInTheDocument();
}
expect(within(difference).getByText("+900 %")).toBeInTheDocument();
expect(within(difference).getByText("+1 100 %")).toBeInTheDocument();
expect(within(difference).queryByText("нет базы")).not.toBeInTheDocument();
expect(within(difference).getAllByText(/%$/)).toHaveLength(2);
expect(
within(difference).getByText("+2 ₽").closest("dd"),
).toHaveTextContent(/^\+2 ₽$/);
});
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 })),
),
);
renderPage();
expect(
await screen.findByText("Сравнение с годом ранее недоступно."),
).toBeInTheDocument();
expect(
screen.queryByRole("region", { name: "Годом ранее" }),
).not.toBeInTheDocument();
expect(
screen.getByRole("region", { name: "Предыдущий период" }),
).toBeVisible();
});
it("shows a recoverable realized error", async () => {
server.use(
http.get(
"*/api/v1/brokerage-accounts/account-1/income-analytics",
() => new HttpResponse(null, { status: 503 }),
),
);
renderPage();
await waitFor(() =>
expect(
screen.getByRole("button", { name: "Повторить" }),
).toBeInTheDocument(),
);
});
});
describe("income comparison display", () => {
it.each([
["25", "100", "+25 %"],
["-25", "100", "-25 %"],
["0", "100", "0 %"],
["-0.000", "100", "0 %"],
["0.0001", "100", "0 %"],
["1", "3", "+33,3 %"],
["0.01", "0.04", "+25 %"],
["123456789012345678.123456789", "123456789012345678.123456789", "+100 %"],
["10", "0", null],
["0", "0", null],
["10", "-0.00", null],
["10", "-100", null],
["9".repeat(400), "1", null],
["1", "9".repeat(400), null],
])("formats change %s against base %s", (difference, base, expected) => {
const value = comparison("RUB");
value.difference.coupon = money("RUB", difference);
value.compared.coupon = money("RUB", base);
const display = toIncomeDisplayModel({
...analytics,
yearComparison: {
period: { from: "2025-08-01", to: "2025-08-27" },
totalsByCurrency: [value],
},
});
const result = display?.comparison?.values[0];
expect(result?.percentages.coupon?.replaceAll("\u00a0", " ") ?? null).toBe(
expected,
);
expect(result?.difference.coupon.value).toBe(difference);
expect(result?.compared.coupon.value).toBe(base);
});
it("formats calendar ranges across years and preserves separate currencies", () => {
const display = toIncomeDisplayModel({
...analytics,
period: { from: "2025-12-01", to: "2026-01-09" },
yearComparison: {
period: { from: "2024-12-01", to: "2025-01-09" },
totalsByCurrency: [comparison("RUB"), comparison("USD")],
},
});
expect(display?.comparison?.selectedPeriod.replaceAll(/\s/g, " ")).toBe(
"01.12.2025 – 09.01.2026",
);
expect(display?.comparison?.comparedPeriod.replaceAll(/\s/g, " ")).toBe(
"01.12.2024 – 09.01.2025",
);
expect(display?.comparison?.values.map((value) => value.currency)).toEqual([
"RUB",
"USD",
]);
});
});
const LocationProbe: FC = () => {
const location = useLocation();
const navigate = useNavigate();
return (
<>
<output data-testid="location">{location.search}</output>
<button type="button" onClick={() => navigate(-1)}>
Back
</button>
</>
);
};
function selectPeriod() {
fireEvent.click(screen.getByLabelText("Период"));
fireEvent.click(screen.getByRole("button", { name: /^2 августа 2026$/i }));
fireEvent.click(screen.getByRole("button", { name: /^27 августа 2026$/i }));
}
function respondWithRequestedPeriod(requests: string[]) {
server.use(
http.get(
"*/api/v1/brokerage-accounts/account-1/income-analytics",
({ request }) => {
const url = new URL(request.url);
requests.push(url.search);
return HttpResponse.json(
resource({
...analytics,
period: {
from: url.searchParams.get("from"),
to: url.searchParams.get("to"),
},
}),
);
},
),
);
}
describe("income period and snapshot boundary", () => {
afterEach(() => vi.useRealTimers());
it("restores formatted URL dates, keeps draft unapplied, submits ISO and follows browser history", async () => {
const requests: string[] = [];
respondWithRequestedPeriod(requests);
renderPage();
expect(await screen.findByLabelText("Период")).toHaveTextContent(
"01.08.2026 – 27.08.2026",
);
selectPeriod();
expect(requests).toHaveLength(1);
expect(
screen.queryByText("2026-08-01 — 2026-08-27"),
).not.toBeInTheDocument();
expect(screen.getByTestId("location")).toHaveTextContent(
"from=2026-08-01&to=2026-08-27",
);
fireEvent.submit(
screen
.getByRole("button", { name: "Применить" })
.closest("form") as HTMLFormElement,
);
await waitFor(() => expect(requests).toHaveLength(2));
expect(await screen.findByLabelText("Период")).toHaveTextContent(
"02.08.2026 – 27.08.2026",
);
expect(screen.getByTestId("location")).toHaveTextContent(
"from=2026-08-02&to=2026-08-27",
);
fireEvent.click(screen.getByRole("button", { name: "Back" }));
await waitFor(() =>
expect(screen.getByLabelText("Период")).toHaveTextContent(
"01.08.2026 – 27.08.2026",
),
);
});
it("rejects an incomplete range without replacing the URL and allows a single day", async () => {
const requests: string[] = [];
respondWithRequestedPeriod(requests);
renderPage();
const period = await screen.findByLabelText("Период");
fireEvent.click(period);
fireEvent.click(screen.getByRole("button", { name: /^2 августа 2026$/i }));
fireEvent.keyDown(period, { key: "Escape" });
fireEvent.click(screen.getByRole("button", { name: "Применить" }));
expect(await screen.findByText(/Введите существующую дату/)).toBeVisible();
expect(requests).toHaveLength(1);
if (period.getAttribute("aria-expanded") !== "true")
fireEvent.click(period);
fireEvent.click(screen.getByRole("button", { name: /^2 августа 2026$/i }));
fireEvent.click(screen.getByRole("button", { name: "Применить" }));
await waitFor(() =>
expect(requests.at(-1)).toBe("?from=2026-08-02&to=2026-08-02"),
);
});
it("resets a dirty default period and errors using Moscow's day across a UTC month boundary", async () => {
vi.useFakeTimers({ toFake: ["Date"] });
vi.setSystemTime(new Date("2026-08-31T21:30:00Z"));
const requests: string[] = [];
respondWithRequestedPeriod(requests);
renderPage("");
expect(await screen.findByLabelText("Период")).toHaveTextContent(
"01.09.2026 – 01.09.2026",
);
fireEvent.click(screen.getByLabelText("Период"));
fireEvent.click(screen.getByRole("button", { name: /^1 сентября 2026$/i }));
fireEvent.keyDown(screen.getByLabelText("Период"), { key: "Escape" });
fireEvent.click(screen.getByRole("button", { name: "Применить" }));
await screen.findByText(/Введите существующую дату/);
fireEvent.click(screen.getByRole("button", { name: "Сбросить" }));
await waitFor(() =>
expect(screen.getByLabelText("Период")).toHaveTextContent(
"01.09.2026 – 01.09.2026",
),
);
expect(
screen.queryByText(/Введите существующую дату/),
).not.toBeInTheDocument();
expect(screen.getByTestId("location")).toHaveTextContent(
"from=2026-09-01&to=2026-09-01",
);
expect(requests).toEqual(["?from=2026-09-01&to=2026-09-01"]);
});
it("keeps valid data and draft during refetch, warns on failure and retries", async () => {
respondWithRequestedPeriod([]);
const { queryClient } = renderPage();
await screen.findByLabelText("Период");
selectPeriod();
let release!: () => void;
const pending = new Promise<void>((resolve) => {
release = resolve;
});
server.use(
http.get(
"*/api/v1/brokerage-accounts/account-1/income-analytics",
async () => {
await pending;
return new HttpResponse(null, { status: 503 });
},
),
);
act(() => {
void queryClient.invalidateQueries();
});
expect(
await screen.findByRole("status", { name: "Обновляем пассивный доход" }),
).toBeInTheDocument();
expect(screen.getByRole("region", { name: "Выплаты" })).toBeVisible();
expect(screen.getByLabelText("Период")).toHaveTextContent(
"02.08.2026 – 27.08.2026",
);
release();
expect(
await screen.findByText(/Показан последний загруженный результат/),
).toBeInTheDocument();
expect(screen.getByRole("region", { name: "Выплаты" })).toBeVisible();
server.use(
http.post("*/api/v1/tbank-sync", () =>
HttpResponse.json(
{
id: "income",
scope: { screen: "income", accountId: "account-1" },
now: "2026-09-25T00:00:00Z",
},
{ status: 202 },
),
),
http.get("*/api/v1/tbank-sync/:id", () =>
HttpResponse.json({
id: "income",
scope: null,
now: "2026-09-25T00:00:00Z",
state: "succeeded",
errorCode: null,
progress: { total: 1, completed: 1, discovering: false },
resources: [resource(null, { stateVersion: "2" }).meta],
}),
),
http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () =>
HttpResponse.json(resource(analytics, { stateVersion: "2" })),
),
);
fireEvent.click(screen.getByRole("button", { name: "Повторить" }));
await waitFor(() =>
expect(
screen.queryByText(/Показан последний загруженный результат/),
).not.toBeInTheDocument(),
);
expect(screen.getByLabelText("Период")).toHaveTextContent(
"02.08.2026 – 27.08.2026",
);
});
it("shows busy skeleton for a new uncached period without relabeling the previous result", async () => {
respondWithRequestedPeriod([]);
renderPage();
await screen.findByLabelText("Период");
let release!: () => void;
const pending = new Promise<void>((resolve) => {
release = resolve;
});
server.use(
http.get(
"*/api/v1/brokerage-accounts/account-1/income-analytics",
async () => {
await pending;
return HttpResponse.json(
resource({
...analytics,
period: { from: "2026-08-02", to: "2026-08-27" },
}),
);
},
),
);
selectPeriod();
fireEvent.click(screen.getByRole("button", { name: "Применить" }));
expect(
await screen.findByRole("status", { name: "Загрузка" }),
).toHaveAttribute("aria-busy", "true");
expect(
screen.queryByRole("region", { name: "Выплаты" }),
).not.toBeInTheDocument();
expect(
screen.queryByText("2026-08-02 — 2026-08-27"),
).not.toBeInTheDocument();
release();
expect(await screen.findByLabelText("Период")).toHaveTextContent(
"02.08.2026 – 27.08.2026",
);
});
it("shows one flat payment card per entry in server order without currency or instrument groups", () => {
const instruments = [
{
...analytics.instruments[0],
key: "usd",
total: money("USD", "10"),
coupon: money("USD", "10"),
dividend: money("USD", "0"),
receiptCount: 1,
},
{
...analytics.instruments[0],
key: "rub",
total: money("RUB", "-2"),
coupon: money("RUB", "-2"),
dividend: money("RUB", "0"),
receiptCount: 1,
},
{
...analytics.instruments[0],
key: "rub-negative",
displayName: "Другой инструмент",
total: money("RUB", "-3"),
coupon: money("RUB", "-3"),
dividend: money("RUB", "0"),
receiptCount: 1,
},
];
const display = toIncomeDisplayModel({
...analytics,
totalsByCurrency: [summary("RUB", "-5", "0"), summary("USD", "10", "0")],
instruments,
entries: instruments.map((instrument) => ({
...entry(),
instrumentKey: instrument.key,
payment: instrument.total,
})),
});
render(
<MantineProvider env="test">
<IncomePayments entries={display?.entries ?? []} />
</MantineProvider>,
);
expect(
screen.getByRole("heading", { name: "Выплаты" }),
).toBeInTheDocument();
expect(screen.queryByRole("heading", { level: 3 })).not.toBeInTheDocument();
expect(screen.queryByRole("heading", { level: 4 })).not.toBeInTheDocument();
expect(screen.queryByText("RUB")).not.toBeInTheDocument();
expect(screen.queryByText("USD")).not.toBeInTheDocument();
const cards = screen.getAllByRole("listitem");
expect(cards).toHaveLength(3);
expect(cards[0]).toHaveTextContent("+10 USD");
expect(cards[1]).toHaveTextContent("−2 ₽");
expect(cards[2]).toHaveTextContent("−3 ₽");
expect(screen.queryByText(/Итого:|Выплат:/)).not.toBeInTheDocument();
expect(screen.queryByRole("button")).not.toBeInTheDocument();
expect(screen.getAllByText("Купон")).toHaveLength(6);
});
});
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 },
{ dependencies },
),
),
),
);
renderPage();
expect(await screen.findByRole("region", { name: "Выплаты" })).toBeVisible();
expect(
screen.queryByRole("region", { name: "Год назад" }),
).not.toBeInTheDocument();
expect(screen.getAllByText("12 ₽").length).toBeGreaterThan(0);
expect(
screen.getAllByText("Период сравнения ещё не загружен полностью."),
).toHaveLength(2);
expect(
screen.queryByText("Сравнение с годом ранее недоступно."),
).not.toBeInTheDocument();
expect(
screen.queryByRole("region", { name: "Разница" }),
).not.toBeInTheDocument();
expect(screen.getByLabelText("Период")).toBeVisible();
});
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 })),
),
);
renderPage();
expect(
await screen.findByRole("region", { name: "Предыдущий период" }),
).toBeVisible();
expect(
screen.getByText(
"Не удалось загрузить период сравнения. Повторите обновление.",
),
).toBeVisible();
expect(
screen.queryByRole("region", { name: "Годом ранее" }),
).not.toBeInTheDocument();
});