249 lines
6.5 KiB
TypeScript
249 lines
6.5 KiB
TypeScript
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<PropsWithChildren> = props => (
|
|
<QueryWrapper>
|
|
<MantineProvider>
|
|
<MemoryRouter initialEntries={["/accounts/account-1"]}>
|
|
<Routes>
|
|
<Route element={props.children} path="/accounts/:accountId" />
|
|
</Routes>
|
|
</MemoryRouter>
|
|
</MantineProvider>
|
|
</QueryWrapper>
|
|
);
|
|
|
|
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<string, unknown>
|
|
= {
|
|
"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");
|
|
});
|