import type { FC } from "react";
import { MantineProvider } from "@mantine/core";
import { DatesProvider } from "@mantine/dates";
import { QueryClientProvider } from "@tanstack/react-query";
import {
act,
fireEvent,
render,
renderHook,
screen,
waitFor,
within,
} from "@testing-library/react";
import "dayjs/locale/ru";
import { http, HttpResponse } from "msw";
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 { createTestQueryClient } from "@/test/query";
import { resource } from "@/test/resource";
import { server } from "@/test/server";
import { toIncomeDisplayModel } from "../../model/display";
import { AccountIncomePage } from "./AccountIncomePage";
import {
useAccountIncomePageViewModel,
} from "./AccountIncomePage.vm";
import { IncomePayments } from "./components/IncomePayments.view";
const analytics
= {
accountId: "account-1",
bucketGranularity: "day",
buckets: [
{
period: { from: "2026-08-01", to: "2026-08-01" },
totalsByCurrency: [summary("RUB", "10", "2")],
},
],
calculatedAt: "2026-08-27T00:00:00Z",
entries: [
entry(),
{
...entry(),
id: "dividend-1",
payment: money("RUB", "2"),
type: "OPERATION_TYPE_DIVIDEND",
},
],
instruments: [
{
coupon: money("RUB", "10"),
displayName: "BOND",
dividend: money("RUB", "2"),
figi: "FIGI",
instrumentType: "bond",
instrumentUid: null,
key: "income-group-opaque-v2",
receiptCount: 2,
ticker: "BOND",
total: money("RUB", "12"),
},
],
period: { from: "2026-08-01", to: "2026-08-27" },
totalsByCurrency: [summary("RUB", "10", "2")],
} satisfies IncomeAnalytics & { instruments: [unknown] };
function entry(): IncomeAnalytics["entries"][number] {
return {
accruedInterest: null,
category: "income",
commission: null,
description: "Купон",
figi: "FIGI",
id: "coupon-1",
instrumentKey: "income-group-opaque-v2",
instrumentType: "bond",
instrumentUid: null,
logoName: null,
occurredAt: "2026-08-01T12:00:00Z",
payment: money("RUB", "10"),
price: null,
quantity: null,
ticker: "BOND",
type: "OPERATION_TYPE_COUPON",
};
}
function money(currency: string, value: string) {
return { currency, value };
}
function renderPage(search = "from=2026-08-01&to=2026-08-27") {
configureAuthClient({
accessToken: () => "token",
clear: vi.fn(),
refresh: () => Promise.resolve(true),
});
const queryClient = createTestQueryClient();
const view = render(
}
path="/accounts/:accountId/income"
/>
Events}
path="/accounts/:accountId/events"
/>
,
);
return { ...view, queryClient };
}
function summary(currency: string, coupon: string, dividend: string) {
return {
coupon: money(currency, coupon),
currency,
dividend: money(currency, dividend),
total: money(currency, String(Number(coupon) + Number(dividend))),
};
}
describe("AccountIncomePage", () => {
it("keeps reads and sync disabled without an account scope", async () => {
configureAuthClient({
accessToken: () => "token",
clear: vi.fn(),
refresh: () => Promise.resolve(true),
});
const requests: string[] = [];
server.use(
http.all("*/api/v1/*", ({ request }) => {
requests.push(request.url);
return new HttpResponse(null, { status: 503 });
}),
);
const queryClient = createTestQueryClient();
const { result } = renderHook(() => useAccountIncomePageViewModel(), {
wrapper: ({ children }) => (
{children}
),
});
await new Promise(resolve => setTimeout(resolve, 0));
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[] = [];
server.use(
http.get(
"*/api/v1/brokerage-accounts/:accountId/income-analytics",
({ request }) => {
requested.push(new URL(request.url).search);
return HttpResponse.json(
resource(
null,
{
lastAttempt: {
finishedAt: "2026-08-14T00:01:00Z",
problems: [],
startedAt: "2026-08-14T00:00:00Z",
state: "failed",
},
},
),
);
},
),
);
renderPage("from=2024-02-29&to=2024-03-01");
await screen.findByText("Не удалось рассчитать пассивный доход");
expect(requested).toEqual(["?from=2024-02-29&to=2024-03-01"]);
expect(screen.queryByRole("button", { name: "Повторить" })).not.toBeInTheDocument();
});
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(), description: "Выплата A", id: "same-id" };
const second = {
...entry(),
description: "Выплата B",
id: "same-id",
payment: money("RUB", "-2"),
};
const third = {
...entry(),
description: "Выплата C",
id: "other-id",
payment: money("RUB", "1"),
};
function breakdown(entries: IncomeAnalytics["entries"], total: string) {
const display = toIncomeDisplayModel({
...analytics,
entries,
instruments: [
{
...analytics.instruments[0],
coupon: money("RUB", total),
dividend: money("RUB", "0"),
receiptCount: entries.length,
total: money("RUB", total),
},
],
});
expect(display).not.toBeNull();
return (
);
}
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("accepts an empty analytics response", async () => {
server.use(
http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () =>
HttpResponse.json(
resource({
...analytics,
buckets: [],
entries: [],
instruments: [],
totalsByCurrency: [],
}),
),
),
);
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,
entries: [
{
...analytics.entries[0],
description: "Исходная выплата",
figi: null,
instrumentKey: "server-only-key",
ticker: null,
},
{
...analytics.entries[1],
description: "Отрицательная корректировка",
instrumentKey: "server-only-key",
instrumentUid: "unrelated-uid",
payment: money("RUB", "-2"),
},
],
instruments: [
{
...analytics.instruments[0],
dividend: money("RUB", "-2"),
key: "server-only-key",
total: money("RUB", "8"),
},
],
};
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("renders realized income without comparison blocks or event requests", 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)),
),
http.get("*/api/v1/brokerage-accounts/account-1/events", eventsRequest),
);
renderPage();
expect(
await screen.findByRole("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();
expect(screen.queryByText("Предыдущий период")).not.toBeInTheDocument();
expect(screen.queryByText("Годом ранее")).not.toBeInTheDocument();
expect(screen.queryByText(/Сравнение с/)).not.toBeInTheDocument();
});
it("shows a quiet realized error", async () => {
server.use(
http.get(
"*/api/v1/brokerage-accounts/account-1/income-analytics",
() => new HttpResponse(null, { status: 503 }),
),
);
renderPage();
expect(
await screen.findByText("Не удалось рассчитать пассивный доход", undefined, {
timeout: 4000,
}),
).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Повторить" })).not.toBeInTheDocument();
});
});
const LocationProbe: FC = () => {
const location = useLocation();
const navigate = useNavigate();
return (
<>
>
);
};
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"),
},
}),
);
},
),
);
}
function selectPeriod() {
fireEvent.click(screen.getByLabelText("Период"));
fireEvent.click(screen.getByRole("button", { name: /^2 августа 2026$/i }));
fireEvent.click(screen.getByRole("button", { name: /^27 августа 2026$/i }));
}
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: "Применить" })
// eslint-disable-next-line testing-library/no-node-access -- This assertion checks DOM structure, decoration or focus behavior that semantic queries cannot establish.
.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 failure", async () => {
respondWithRequestedPeriod([]);
const { queryClient } = renderPage();
await screen.findByLabelText("Период");
selectPeriod();
const { promise: pending, resolve: release }
= Promise.withResolvers();
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(screen.getByRole("region", { name: "Выплаты" })).toBeVisible();
expect(screen.getByLabelText("Период")).toHaveTextContent(
"02.08.2026 – 27.08.2026",
);
release();
expect(screen.getByRole("region", { name: "Выплаты" })).toBeVisible();
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("Период");
const { promise: pending, resolve: release }
= Promise.withResolvers();
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],
coupon: money("USD", "10"),
dividend: money("USD", "0"),
key: "usd",
receiptCount: 1,
total: money("USD", "10"),
},
{
...analytics.instruments[0],
coupon: money("RUB", "-2"),
dividend: money("RUB", "0"),
key: "rub",
receiptCount: 1,
total: money("RUB", "-2"),
},
{
...analytics.instruments[0],
coupon: money("RUB", "-3"),
displayName: "Другой инструмент",
dividend: money("RUB", "0"),
key: "rub-negative",
receiptCount: 1,
total: money("RUB", "-3"),
},
];
const display = toIncomeDisplayModel({
...analytics,
entries: instruments.map(instrument => ({
...entry(),
instrumentKey: instrument.key,
payment: instrument.total,
})),
instruments,
totalsByCurrency: [summary("RUB", "-5", "0"), summary("USD", "10", "0")],
});
render(
,
);
expect(
screen.getByRole("heading", { name: "Выплаты" }),
).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();
});
});