import type { FC, PropsWithChildren } from "react"; import { MantineProvider } from "@mantine/core"; import { act, 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 type { Portfolio } from "@/entities/portfolio"; import { configureAuthClient } from "@/shared/api"; import { createQueryWrapper } from "@/test/query"; import { resource } from "@/test/resource"; import { server } from "@/test/server"; import { toAccountPagePositionSections, useAccountPageViewModel, } from "./AccountPage.vm"; const portfolio: Portfolio = { cashBalances: [{ currency: "USD", value: "1" }], isStale: true, lastSuccessfulAt: "2026-08-13T10:00:00Z", positions: [ { absoluteReturn: { currency: "RUB", value: "10" }, averagePurchasePrice: null, currentValue: { currency: "RUB", value: "100" }, displayName: "Акция", figi: "FIGI-STOCK", isin: null, logoName: "akciya.png", quantity: "2", ticker: null, type: "STOCK", }, { absoluteReturn: { currency: "RUB", value: "-5" }, averagePurchasePrice: { currency: "RUB", value: "45" }, currentValue: { currency: "RUB", value: "50" }, displayName: "Облигация", figi: "FIGI-BOND", isin: "RU000A0JX0J2", logoName: null, quantity: "1", ticker: null, type: "BOND", }, { absoluteReturn: null, averagePurchasePrice: null, currentValue: null, displayName: "Фонд", figi: "FIGI-FUND", isin: null, logoName: "fond.png", quantity: "3", ticker: null, type: "FUND", }, ], structure: { currencies: [], unvaluedCategories: [] }, totalsByCurrency: [{ currency: "RUB", value: "150" }], }; 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("excludes currency instruments only from display, preserving order and share denominators", () => { const otherTypes = [ "OPTION", "OTHER", "FUTURE", "STRUCTURED_NOTE", "DIGITAL_FINANCIAL_ASSET", "FUND", ] as const; const snapshot: Portfolio = { ...portfolio, cashBalances: [{ currency: "RUB", value: "1000" }], positions: [ ...portfolio.positions, { ...portfolio.positions[0], currentValue: { currency: "RUB", value: "50" }, figi: "FIGI-CURRENCY", type: "CURRENCY", }, ...otherTypes.map( type => ({ ...portfolio.positions[2], currentValue: type === "OPTION" ? { currency: "RUB", value: "50" } : null, figi: `FIGI-${type}-NEXT`, type, }), ), ], structure: { currencies: [], unvaluedCategories: [] }, totalsByCurrency: [{ currency: "RUB", value: "1250" }], }; const original = structuredClone(snapshot); const sections = toAccountPagePositionSections(snapshot); expect(sections.map(section => section.kind)).toEqual([ "stocks", "bonds", "other", ]); expect(sections[2].groups.map(group => group.positions[0].figi)).toEqual([ "FIGI-FUND", ...otherTypes.map(type => `FIGI-${type}-NEXT`), ]); expect(sections[0].groups[0].positions[0].shareText).toBe("40 %"); expect(sections[1].groups[0].positions[0].shareText).toBe("20 %"); expect(sections[2].groups[1].positions[0].shareText).toBe("20 %"); expect(snapshot).toEqual(original); }); it("prepares grouped position sections with derived display data", () => { expect( toAccountPagePositionSections(portfolio).map(({ groups, kind }) => ({ kind, positions: groups.flatMap(group => group.positions), })), ).toMatchObject([ { kind: "stocks", positions: [ { displayName: "Акция", logoSrc: "https://invest-brands.cdn-tinkoff.ru/akciyax160.png", returnText: "+10 ₽", returnTone: "positive", shareText: "66,7 %", }, ], }, { kind: "bonds", positions: [ { displayName: "Облигация", logoSrc: null, returnText: "-5 ₽", returnTone: "negative", shareText: "33,3 %", }, ], }, { kind: "other", positions: [ { displayName: "Фонд", logoSrc: null, returnText: null, returnTone: undefined, shareText: null, }, ], }, ]); }); 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: "TBANK_UNAVAILABLE" }, { status: 503 }), ), http.get("*/api/v1/brokerage-accounts/:accountId/events", () => HttpResponse.json({ code: "MOEX_UNAVAILABLE" }, { status: 503 }), ), ); 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, { availability: "partial" })), ), http.get("*/api/v1/brokerage-accounts/:accountId/operations", () => HttpResponse.json( resource( { hasNext: false, items: [], nextCursor: null }, { availability: "partial" }, ), ), ), http.get("*/api/v1/brokerage-accounts/:accountId/events", () => HttpResponse.json( resource( { events: [], incomeTotalsByCurrency: [] }, { availability: "partial" }, ), ), ), ); 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("refreshes every overview section through one account descriptor and observational reads", async () => { configureTestAuth(); const sections: Record = { "events": { events: [], incomeTotalsByCurrency: [] }, "income-summary": { totalsByCurrency: [] }, "operations": { hasNext: false, items: [], nextCursor: null }, portfolio, }; const observed: string[] = []; const scopes: unknown[] = []; let version = "1"; server.use( ...Object.entries(sections).map(([path, data]) => http.get( `*/api/v1/brokerage-accounts/:accountId/${path}`, ({ request }) => { if (new URL(request.url).searchParams.get("sync") === "observe") observed.push(path); return HttpResponse.json( resource(data, { resourceKey: path, stateVersion: version }), ); }, ), ), http.post("*/api/v1/tbank-sync", async ({ request }) => { scopes.push(await request.json()); version = "2"; return HttpResponse.json( { id: "overview", now: "2026-09-25T00:00:00Z", scope: { accountId: "account-1", screen: "account" }, }, { status: 202 }, ); }), http.get("*/api/v1/tbank-sync/:id", () => HttpResponse.json( { errorCode: null, id: "overview", now: "2026-09-25T00:00:00Z", progress: { completed: 4, discovering: false, total: 4 }, resources: Object.keys(sections).map( resourceKey => resource(null, { resourceKey, stateVersion: version }).meta, ), scope: null, state: "succeeded", }, ), ), ); const { result } = renderHook(() => useAccountPageViewModel(), { wrapper: createWrapper(), }); await waitFor(() => expect(result.current).toMatchObject({ incomeSummary: { state: "empty" }, recentEvents: { state: "empty" }, recentOperations: { state: "empty" }, state: "loaded", }), ); await act(async () => { if (result.current.state === "loaded") await result.current.retry(); }); await waitFor(() => expect(new Set(observed)).toEqual(new Set(Object.keys(sections))), ); expect(scopes).toEqual([{ accountId: "account-1", screen: "account" }]); });