import type { FC, PropsWithChildren } from "react"; import { MantineProvider } from "@mantine/core"; import { renderHook, waitFor } from "@testing-library/react"; import { http, HttpResponse } from "msw"; import { MemoryRouter, Route, Routes } from "react-router"; import { describe, expect, it, vi } from "vitest"; import { configureAuthClient } from "@/shared/api"; import { createQueryWrapper } from "@/test/query"; import { resource } from "@/test/resource"; import { server } from "@/test/server"; import { portfolio } from "../../model/positions.fixtures"; import { useAccountPageViewModel } from "./AccountPage.vm"; function configureTestAuth() { configureAuthClient({ accessToken: () => "test-token", clear: vi.fn(), refresh: () => Promise.resolve(true), }); } function createWrapper() { const { wrapper: QueryWrapper } = createQueryWrapper(); const Wrapper: FC = props => ( ); return Wrapper; } function mockLoadedAccountPage() { server.use( http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () => HttpResponse.json(resource(portfolio)), ), http.get("*/api/v1/brokerage-accounts/:accountId/operations", () => HttpResponse.json( resource( { accountId: "account-1", hasNext: false, items: [ { accruedInterest: null, category: "trade", commission: null, description: "Покупка акции", figi: "FIGI-STOCK", id: "operation-1", instrumentType: "share", instrumentUid: null, occurredAt: "2026-08-13T10:00:00Z", payment: { currency: "RUB", value: "-100" }, price: null, quantity: "1", ticker: "STK", type: "Buy", }, ], nextCursor: null, observedAt: "2026-08-13T10:00:00Z", }, ), ), ), http.get("*/api/v1/brokerage-accounts/:accountId/events", () => HttpResponse.json( resource({ accountId: "account-1", calculatedAt: "2026-08-13T10:00:00Z", events: Array.from( { length: 6 }, (_, index) => ({ date: `2026-09-${String(index + 1).padStart(2, "0")}`, dateKind: "event", displayName: `Купон ${index}`, figi: `FIGI-EVENT-${index}`, instrumentUid: null, projectedAmount: { currency: "RUB", value: String(index + 1) }, ticker: `BND${index}`, type: "coupon", }), ), }), ), ), ); } describe("useAccountPageViewModel", () => { it("prepares recent operations and events state for presentation views", async () => { configureTestAuth(); mockLoadedAccountPage(); const { result } = renderHook(() => useAccountPageViewModel(), { wrapper: createWrapper(), }); await waitFor(() => expect( (result.current as { recentEvents?: { state: string } }).recentEvents, )?.toMatchObject({ state: "loaded" }), ); const loaded = result.current as Extract< typeof result.current, { state: "loaded" } >; expect(loaded.recentOperations).toMatchObject({ items: [{ description: "Покупка акции", id: "operation-1" }], state: "loaded", to: "/accounts/account-1/operations", }); expect(loaded.recentEvents).toMatchObject({ items: [ { ticker: "BND0" }, { ticker: "BND1" }, { ticker: "BND2" }, { ticker: "BND3" }, { ticker: "BND4" }, ], state: "loaded", to: "/accounts/account-1/events", }); }); it("keeps recent errors isolated in the ViewModel presentation state", async () => { configureTestAuth(); server.use( http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () => HttpResponse.json(resource(portfolio)), ), http.get("*/api/v1/brokerage-accounts/:accountId/operations", () => HttpResponse.json({ code: "DEMO_FAILURE" }, { status: 400 }), ), http.get("*/api/v1/brokerage-accounts/:accountId/events", () => HttpResponse.json({ code: "DEMO_FAILURE" }, { status: 400 }), ), ); const { result } = renderHook(() => useAccountPageViewModel(), { wrapper: createWrapper(), }); await waitFor(() => expect(result.current).toMatchObject({ recentEvents: { state: "error" }, recentOperations: { state: "error" }, state: "loaded", }), ); }); }); it("keeps overview sections independent when their source data is incomplete", async () => { configureTestAuth(); mockLoadedAccountPage(); server.use( http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () => HttpResponse.json(resource(portfolio, { completeness: "incomplete" })), ), http.get("*/api/v1/brokerage-accounts/:accountId/operations", () => HttpResponse.json( resource( { hasNext: false, items: [], nextCursor: null }, { completeness: "incomplete" }, ), ), ), http.get("*/api/v1/brokerage-accounts/:accountId/events", () => HttpResponse.json( resource( { events: [], incomeTotalsByCurrency: [] }, { completeness: "incomplete" }, ), ), ), ); const { result } = renderHook(() => useAccountPageViewModel(), { wrapper: createWrapper(), }); await waitFor(() => expect(result.current.state).toBe("loaded")); await waitFor(() => expect(result.current).toMatchObject({ partial: true, recentEvents: { items: [], state: "partial" }, recentOperations: { items: [], state: "partial" }, }), ); }); it("keeps overview sections loaded without exposing technical retry", async () => { configureTestAuth(); const sections: Record = { "events": { events: [], incomeTotalsByCurrency: [] }, "income-summary": { totalsByCurrency: [] }, "operations": { hasNext: false, items: [], nextCursor: null }, portfolio, }; server.use( ...Object.entries(sections).map(([path, data]) => http.get( `*/api/v1/brokerage-accounts/:accountId/${path}`, () => { return HttpResponse.json( resource(data, { dataRevision: "1", resourceKey: path }), ); }, ), ), ); const { result } = renderHook(() => useAccountPageViewModel(), { wrapper: createWrapper(), }); await waitFor(() => expect(result.current).toMatchObject({ incomeSummary: { state: "empty" }, recentEvents: { state: "empty" }, recentOperations: { state: "empty" }, state: "loaded", }), ); expect(result.current).not.toHaveProperty("retry"); });