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 ( <> {location.search} ); }; 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(); }); });