import "dayjs/locale/ru"; import { MantineProvider } from "@mantine/core"; import { DatesProvider } from "@mantine/dates"; import { act, fireEvent, render, 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 { AccountIncomePage } from "./AccountIncomePage"; import { isDate, toIncomeDisplayModel } 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( } /> Events} /> , ); return { ...result, queryClient }; } describe("AccountIncomePage", () => { 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(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 ( ); } 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(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(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({ ...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(); 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({ ...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 selected = screen.getByRole("region", { name: "01.08.2026 – 27.08.2026", }); expect( screen.getByRole("region", { name: "01.08.2025 – 27.08.2025" }), ).toBeInTheDocument(); expect(screen.queryByText(/Предыдущий период/)).not.toBeInTheDocument(); 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 = screen.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 () => { server.use( http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () => HttpResponse.json(analytics), ), ); renderPage(); expect( await screen.findByText("Сравнение с годом ранее недоступно."), ).toBeInTheDocument(); expect( screen.queryByRole("region", { name: "Разница" }), ).not.toBeInTheDocument(); expect(screen.queryByText(/Предыдущий период/)).not.toBeInTheDocument(); }); 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 ( <> {location.search} ); }; function inputDate(label: string, value: string) { const field = screen.getByLabelText(label); fireEvent.focus(field); fireEvent.input(field, { target: { value } }); fireEvent.blur(field); return field; } 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({ ...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("С")).toHaveValue("01.08.2026"); expect(screen.getByLabelText("По")).toHaveValue("27.08.2026"); inputDate("С", "02.08.2026"); 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("С")).toHaveValue("02.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("С")).toHaveValue("01.08.2026"), ); }); it.each([ ["С", "", "Введите существующую дату"], ["С", "31.02.2026", "Введите существующую дату"], ["По", "29.02.2026", "Введите существующую дату"], ["По", "2026-08-02", "Введите существующую дату"], ["С", "01.01.2099", "Дата не может быть позже"], ["По", "01.01.2099", "Дата не может быть позже"], ["По", "31.07.2026", "Укажите корректный период"], ])( "rejects visible %s=%s without replacing data or URL", async (label, value, error) => { const requests: string[] = []; respondWithRequestedPeriod(requests); renderPage(); await screen.findByLabelText("С"); const field = inputDate(label, value); fireEvent.click(screen.getByRole("button", { name: "Применить" })); expect(await screen.findByText(new RegExp(error))).toBeInTheDocument(); expect(field).toHaveValue(value); expect(field).toHaveAttribute("aria-invalid", "true"); await waitFor(() => expect(field).toHaveFocus()); expect(requests).toHaveLength(1); expect(screen.getByTestId("location")).toHaveTextContent( "from=2026-08-01&to=2026-08-27", ); expect( screen.queryByText("2026-08-01 — 2026-08-27"), ).not.toBeInTheDocument(); expect(screen.getByRole("region", { name: "Выплаты" })).toBeVisible(); }, ); it("replaces invalid raw text with a calendar choice and closes the calendar with Escape", async () => { respondWithRequestedPeriod([]); renderPage(); const from = await screen.findByLabelText("С"); act(() => from.focus()); fireEvent.input(from, { target: { value: "invalid" } }); // The selected month stays available even when the visible draft is invalid. const day = await screen.findByRole("button", { name: /^2 августа 2026$/i, }); fireEvent.click(day); expect(from).toHaveValue("02.08.2026"); act(() => from.focus()); fireEvent.keyDown(from, { key: "Escape" }); await waitFor(() => expect( screen.queryByRole("button", { name: /^2 августа 2026$/i }), ).not.toBeInTheDocument(), ); }); 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("С")).toHaveValue("01.09.2026"); expect(screen.getByLabelText("По")).toHaveValue("01.09.2026"); inputDate("С", "wrong"); fireEvent.click(screen.getByRole("button", { name: "Применить" })); await screen.findByText(/Введите существующую дату/); fireEvent.click(screen.getByRole("button", { name: "Сбросить" })); await waitFor(() => expect(screen.getByLabelText("С")).toHaveValue("01.09.2026"), ); expect( screen.queryByText(/Введите существующую дату/), ).not.toBeInTheDocument(); expect(screen.getByLabelText("По")).toHaveValue("01.09.2026"); 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("С"); inputDate("С", "02.08.2026"); let release!: () => void; const pending = new Promise((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("С")).toHaveValue("02.08.2026"); release(); expect( await screen.findByText(/Показан последний загруженный результат/), ).toBeInTheDocument(); expect(screen.getByRole("region", { name: "Выплаты" })).toBeVisible(); respondWithRequestedPeriod([]); fireEvent.click(screen.getByRole("button", { name: "Повторить" })); await waitFor(() => expect( screen.queryByText(/Показан последний загруженный результат/), ).not.toBeInTheDocument(), ); expect(screen.getByLabelText("С")).toHaveValue("02.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((resolve) => { release = resolve; }); server.use( http.get( "*/api/v1/brokerage-accounts/account-1/income-analytics", async () => { await pending; return HttpResponse.json({ ...analytics, period: { from: "2026-08-02", to: "2026-08-27" }, }); }, ), ); inputDate("С", "02.08.2026"); 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("С")).toHaveValue("02.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( , ); 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); }); });