Files
frontend/src/pages/account/ui/AccountPage.vm.test.tsx
T

244 lines
6.3 KiB
TypeScript

import { MantineProvider } from "@mantine/core";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { renderHook, waitFor } from "@testing-library/react";
import { HttpResponse, http } from "msw";
import type { FC, PropsWithChildren } from "react";
import { MemoryRouter, Route, Routes } from "react-router";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { Portfolio } from "@/entities/portfolio";
import { configureAuthClient } from "@/shared/api";
import { server } from "@/test/server";
import { useAccountPageViewModel } from "./AccountPage.vm";
const portfolio: Portfolio = {
lastSuccessfulAt: "2026-08-13T10:00:00Z",
isStale: true,
totalsByCurrency: [{ currency: "RUB", value: "150" }],
positions: [
{
figi: "FIGI-STOCK",
displayName: "Акция",
type: "STOCK",
quantity: "2",
currentValue: { currency: "RUB", value: "100" },
averagePurchasePrice: null,
absoluteReturn: { currency: "RUB", value: "10" },
isin: null,
logoName: "akciya.png",
},
{
figi: "FIGI-BOND",
displayName: "Облигация",
type: "BOND",
quantity: "1",
currentValue: { currency: "RUB", value: "50" },
averagePurchasePrice: { currency: "RUB", value: "45" },
absoluteReturn: { currency: "RUB", value: "-5" },
isin: "RU000A0JX0J2",
logoName: null,
},
{
figi: "FIGI-FUND",
displayName: "Фонд",
type: "FUND",
quantity: "3",
currentValue: null,
averagePurchasePrice: null,
absoluteReturn: null,
isin: null,
logoName: "fond.png",
},
],
cashBalances: [{ currency: "USD", value: "1" }],
};
function configureTestAuth() {
configureAuthClient({
accessToken: () => "test-token",
refresh: async () => true,
clear: vi.fn(),
});
}
const wrapper: FC<PropsWithChildren> = (props) => {
const { children } = props;
return (
<QueryClientProvider client={new QueryClient()}>
<MantineProvider>
<MemoryRouter initialEntries={["/accounts/account-1"]}>
<Routes>
<Route path="/accounts/:accountId" element={children} />
</Routes>
</MemoryRouter>
</MantineProvider>
</QueryClientProvider>
);
};
function mockLoadedAccountPage() {
server.use(
http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () =>
HttpResponse.json(portfolio),
),
http.get("*/api/v1/brokerage-accounts/:accountId/operations", () =>
HttpResponse.json({
accountId: "account-1",
items: [
{
id: "operation-1",
occurredAt: "2026-08-13T10:00:00Z",
type: "Buy",
category: "trade",
description: "Покупка акции",
figi: "FIGI-STOCK",
instrumentUid: null,
ticker: "STK",
instrumentType: "share",
payment: { currency: "RUB", value: "-100" },
quantity: "1",
price: null,
accruedInterest: null,
commission: null,
},
],
nextCursor: null,
hasNext: false,
observedAt: "2026-08-13T10:00:00Z",
}),
),
http.get("*/api/v1/brokerage-accounts/:accountId/events", () =>
HttpResponse.json({
accountId: "account-1",
calculatedAt: "2026-08-13T10:00:00Z",
events: Array.from({ length: 6 }, (_, index) => ({
type: "coupon",
date: `2026-09-${String(index + 1).padStart(2, "0")}`,
dateKind: "event",
figi: `FIGI-EVENT-${index}`,
instrumentUid: null,
ticker: `BND${index}`,
displayName: `Купон ${index}`,
projectedAmount: { currency: "RUB", value: String(index + 1) },
})),
}),
),
);
}
describe("useAccountPageViewModel", () => {
afterEach(() => configureAuthClient(null));
it("prepares grouped position sections with derived display data", async () => {
configureTestAuth();
mockLoadedAccountPage();
const { result } = renderHook(() => useAccountPageViewModel(), { wrapper });
await waitFor(() => expect(result.current.state).toBe("loaded"));
const loaded = result.current as Extract<
typeof result.current,
{ state: "loaded" }
>;
expect(loaded.positionSections).toMatchObject([
{
kind: "stocks",
wide: true,
positions: [
{
displayName: "Акция",
shareText: "66,7 %",
returnText: "+10 ₽",
returnTone: "positive",
logoSrc: "https://invest-brands.cdn-tinkoff.ru/akciyax160.png",
},
],
},
{
kind: "bonds",
wide: true,
positions: [
{
displayName: "Облигация",
shareText: "33,3 %",
returnText: "-5 ₽",
returnTone: "negative",
logoSrc: null,
},
],
},
{
kind: "other",
wide: false,
positions: [
{
displayName: "Фонд",
shareText: null,
returnText: null,
returnTone: undefined,
logoSrc: null,
},
],
},
]);
});
it("prepares recent operations and events state for presentation views", async () => {
configureTestAuth();
mockLoadedAccountPage();
const { result } = renderHook(() => useAccountPageViewModel(), { wrapper });
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({
state: "loaded",
to: "/accounts/account-1/operations",
items: [{ id: "operation-1", description: "Покупка акции" }],
});
expect(loaded.recentEvents).toMatchObject({
state: "loaded",
to: "/accounts/account-1/events",
items: [
{ ticker: "BND0" },
{ ticker: "BND1" },
{ ticker: "BND2" },
{ ticker: "BND3" },
{ ticker: "BND4" },
],
});
});
it("keeps recent errors isolated in the ViewModel presentation state", async () => {
configureTestAuth();
server.use(
http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () =>
HttpResponse.json(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 });
await waitFor(() =>
expect(result.current).toMatchObject({
state: "loaded",
recentOperations: { state: "error" },
recentEvents: { state: "error" },
}),
);
});
});