refactor: enforce account page view model boundary
This commit is contained in:
@@ -0,0 +1,232 @@
|
||||
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 { ReactNode } from "react";
|
||||
import { MemoryRouter, Route, Routes } from "react-router";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { Portfolio } from "@/entities/portfolio";
|
||||
import { server } from "@/test/server";
|
||||
import { useAccountPageViewModel } from "./AccountPage.vm";
|
||||
|
||||
vi.mock("@/features/auth", () => ({
|
||||
useAuth: () => ({ accessToken: "test-token" }),
|
||||
}));
|
||||
|
||||
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 wrapper({ children }: { children: ReactNode }) {
|
||||
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}`,
|
||||
currency: "RUB",
|
||||
amount: String(index + 1),
|
||||
})),
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
describe("useAccountPageViewModel", () => {
|
||||
it("prepares grouped position sections with derived display data", async () => {
|
||||
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 () => {
|
||||
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 () => {
|
||||
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" },
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user