Files
frontend/src/app/routes/AppRoutes/AllAccounts.test.tsx
T

441 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { FC } from "react";
import { MantineProvider } from "@mantine/core";
import { QueryClientProvider } from "@tanstack/react-query";
import {
act,
fireEvent,
render,
screen,
waitFor,
} from "@testing-library/react";
import { http, HttpResponse } from "msw";
import { MemoryRouter, useLocation } from "react-router";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { BrokerageOperation } from "@/entities/broker-operation";
import type { IncomeAnalytics } from "@/entities/income";
import type { Portfolio } from "@/entities/portfolio";
import { AuthProvider } from "@/features/auth";
import { defaultPeriod, formatDate } from "@/shared/lib";
import { createTestQueryClient } from "@/test/query";
import { resource } from "@/test/resource";
import { server } from "@/test/server";
import { AppRoutes } from "./AppRoutes";
// Mantine calendar labels follow dayjs default (English) locale.
function dayLabel(date: string) {
const monthName = new Intl.DateTimeFormat("en", { month: "long" })
.format(new Date(`${date}T12:00:00Z`));
return `${Number(date.slice(8))} ${monthName} ${date.slice(0, 4)}`;
}
const accounts = [
{ id: "a", name: "Основной" },
{ id: "b", name: "ИИС" },
];
const position: Portfolio["positions"][number]
= {
absoluteReturn: null,
averagePurchasePrice: null,
currentValue: { currency: "RUB", value: "600" },
displayName: "Сбербанк",
figi: "SBER",
isin: null,
issuer: null,
logoName: null,
quantity: "2",
sector: null,
ticker: "SBER",
type: "STOCK",
};
const portfolio: Portfolio = {
cashBalances: [],
isStale: false,
lastSuccessfulAt: "2026-08-15T12:00:00Z",
positions: accounts.map(account => ({ ...position, account })),
structure: { currencies: [], unvaluedCategories: [] },
totalsByCurrency: [{ currency: "RUB", value: "1200" }],
};
const receipt: BrokerageOperation
= {
accruedInterest: null,
category: "income",
commission: null,
description: "Выплата купона",
figi: null,
id: "same",
instrumentType: "bond",
instrumentUid: null,
logoName: null,
occurredAt: "2026-08-15T12:00:00Z",
payment: { currency: "RUB", value: "1" },
price: null,
quantity: null,
ticker: "BOND",
type: "OPERATION_TYPE_COUPON",
};
const items = accounts.map(account => ({ ...receipt, account }));
const analytics: IncomeAnalytics
= {
accountId: "all",
bucketGranularity: "day",
buckets: [],
calculatedAt: "2026-09-01T12:00:00Z",
entries: items.map(entry => ({ ...entry, instrumentKey: "bond:RUB" })),
instruments: [
{
coupon: { currency: "RUB", value: "2" },
displayName: "Облигация",
dividend: { currency: "RUB", value: "0" },
figi: null,
instrumentType: "bond",
instrumentUid: null,
key: "bond:RUB",
receiptCount: 2,
ticker: "BOND",
total: { currency: "RUB", value: "2" },
},
],
period: { from: "2026-08-01", to: "2026-08-31" },
totalsByCurrency: [
{
coupon: { currency: "RUB", value: "2" },
currency: "RUB",
dividend: { currency: "RUB", value: "0" },
total: { currency: "RUB", value: "2" },
},
],
};
const CurrentLocation: FC = () => {
const location = useLocation();
return (
<output aria-label="Текущий путь">
{location.pathname}
{location.search}
</output>
);
};
function renderPage(path: string) {
const client = createTestQueryClient();
render(
<QueryClientProvider client={client}>
<MantineProvider env="test">
<MemoryRouter initialEntries={[path]}>
<AuthProvider>
<AppRoutes />
<CurrentLocation />
</AuthProvider>
</MemoryRouter>
</MantineProvider>
</QueryClientProvider>,
);
return client;
}
afterEach(() => vi.useRealTimers());
beforeEach(() => {
server.use(
http.post("*/api/v1/auth/refresh", () =>
HttpResponse.json({
accessToken: "test",
user: { email: "user@example.com", id: "user" },
}),
),
http.get("*/api/v1/brokerage-accounts", () =>
HttpResponse.json(
resource(
accounts.map(
account =>
({
...account,
openedOn: null,
portfolioSummary: null,
type: "BROKERAGE",
}),
),
),
),
),
http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () =>
HttpResponse.json(resource(portfolio)),
),
http.get(
"*/api/v1/brokerage-accounts/:accountId/operations",
({ params }) =>
HttpResponse.json(
resource(
{
accountId: params.accountId,
hasNext: false,
items,
nextCursor: null,
observedAt: receipt.occurredAt,
},
),
),
),
http.get("*/api/v1/brokerage-accounts/:accountId/income-analytics", () =>
HttpResponse.json(resource(analytics)),
),
http.get("*/api/v1/brokerage-accounts/:accountId/events", () =>
HttpResponse.json(
resource({
accountId: "all",
calculatedAt: receipt.occurredAt,
events: accounts.map(
account =>
({
account,
date: "2026-09-15",
dateKind: "event",
displayName: "Облигация",
figi: null,
instrumentUid: null,
isin: null,
logoName: null,
projectedAmount: { currency: "RUB", value: "2" },
ticker: "BOND",
type: "coupon",
}),
),
incomeTotalsByCurrency: [],
window: { from: "2026-09-01", to: "2026-10-01" },
}),
),
),
);
});
describe("All accounts routes", () => {
it.each([
["/accounts/all", "Все счета"],
["/accounts/all/operations", "Журнал операций"],
["/accounts/all/income?from=2026-08-01&to=2026-08-31", "Пассивный доход"],
["/accounts/all/events", "Будущие события"],
])("opens %s directly", async (path, title) => {
renderPage(path);
expect(
// The lazy route also imports charts; parallel coverage instrumentation
// can exceed RTL's one-second default before the route is mounted.
await screen.findByRole(
"heading",
{ level: 1, name: title },
{ timeout: 5000 },
),
).toBeInTheDocument();
expect(screen.getByRole("combobox", { name: "Счёт" })).toHaveValue(
"Все счета",
);
});
it.each(["operations", "income"])(
"restores applied dates when switching accounts in %s",
async (section) => {
const search = "?from=2026-08-01&to=2026-08-31";
renderPage(`/accounts/all/${section}${search}`);
fireEvent.click(await screen.findByLabelText("Период"));
fireEvent.click(
await screen.findByRole("button", { name: /10 August 2026/i }),
);
fireEvent.click(
await screen.findByRole("button", { name: /31 August 2026/i }),
);
expect(screen.getByLabelText("Период")).toHaveTextContent(
"10.08.2026 – 31.08.2026",
);
fireEvent.click(screen.getByRole("combobox", { name: "Счёт" }));
fireEvent.click(await screen.findByRole("option", { name: "ИИС" }));
await waitFor(() =>
expect(screen.getByLabelText("Период")).toHaveTextContent(
"01.08.2026 – 31.08.2026",
),
);
expect(screen.getByLabelText("Текущий путь")).toHaveTextContent(
`/accounts/b/${section}${search}`,
);
},
);
it("restores applied dates when switching accounts in events", async () => {
vi.useFakeTimers({ toFake: ["Date"] });
vi.setSystemTime(new Date("2026-10-15T12:00:00Z"));
// The events calendar blocks past dates, so pick within selectable dates.
const search = `?from=${defaultPeriod()[0]}&to=${defaultPeriod()[1]}`;
renderPage(`/accounts/all/events${search}`);
const picker = await screen.findByLabelText("Период");
fireEvent.click(picker);
const [fromDate, toDate] = defaultPeriod();
fireEvent.click(
await screen.findByRole("button", { name: dayLabel(fromDate) }),
);
fireEvent.click(
await screen.findByRole("button", { name: dayLabel(toDate) }),
);
expect(screen.getByLabelText("Период")).toHaveTextContent(
`${formatDate(fromDate)} – ${formatDate(toDate)}`,
);
fireEvent.click(screen.getByRole("combobox", { name: "Счёт" }));
fireEvent.click(await screen.findByRole("option", { name: "ИИС" }));
await waitFor(() =>
expect(screen.getByLabelText("Текущий путь")).toHaveTextContent(
`/accounts/b/events${search}`,
),
);
});
it("groups the same security and links its accounts and overview sections", async () => {
renderPage("/accounts/all");
expect(await screen.findAllByText("Сбербанк")).toHaveLength(1);
for (const account of accounts)
expect(screen.getByRole("link", { name: account.name })).toHaveAttribute(
"href",
`/accounts/${account.id}`,
);
for (const [suffix, tab] of [
["operations", "Операции"],
["income", "Доход"],
["events", "События"],
]) {
fireEvent.click(screen.getByRole("tab", { name: tab }));
expect(screen.getByRole("link", { name: "Подробнее" }))
.toHaveAttribute("href", `/accounts/all/${suffix}`);
}
});
it("switches accounts within operations with applied filters and a fresh cursor", async () => {
const requests: URL[] = [];
server.use(
http.get(
"*/api/v1/brokerage-accounts/:accountId/operations",
({ params, request }) => {
const url = new URL(request.url);
requests.push(url);
return HttpResponse.json(
resource(
{
accountId: params.accountId,
hasNext: !url.searchParams.has("cursor"),
items: [
{
...receipt,
description: url.searchParams.has("cursor")
? "Вторая страница"
: "Первая страница",
},
],
nextCursor: url.searchParams.has("cursor") ? null : "next",
observedAt: receipt.occurredAt,
},
),
);
},
),
);
renderPage(
"/accounts/all/operations?from=2026-08-01&to=2026-08-31&categories=income",
);
await screen.findByText("Первая страница");
fireEvent.click(screen.getByRole("button", { name: "Далее" }));
await screen.findByText("Вторая страница");
fireEvent.click(screen.getByRole("combobox", { name: "Счёт" }));
fireEvent.click(await screen.findByRole("option", { name: "ИИС" }));
await waitFor(() =>
expect(
requests.some(url => url.pathname.includes("/b/operations")),
).toBe(true),
);
const selected = requests.find(url =>
url.pathname.includes("/b/operations"),
);
expect(selected?.searchParams.get("from")).toBe("2026-08-01T00:00:00.000Z");
expect(selected?.searchParams.get("to")).toBe("2026-08-31T23:59:59.999Z");
expect(selected?.searchParams.get("categories")).toBe("income");
expect(selected?.searchParams.has("cursor")).toBe(false);
expect(screen.getByLabelText("Текущий путь")).toHaveTextContent(
"/accounts/b/operations?from=2026-08-01&to=2026-08-31&categories=income",
);
await screen.findByText("Первая страница");
fireEvent.click(screen.getByRole("combobox", { name: "Счёт" }));
fireEvent.click(await screen.findByRole("option", { name: "Все счета" }));
await waitFor(() => expect(screen.getByText("Первая страница")).toBeVisible());
expect(screen.getByRole("button", { name: "Назад" })).toBeDisabled();
});
it("restarts an expired aggregate cursor without losing filters", async () => {
server.use(
http.get("*/api/v1/brokerage-accounts/all/operations", ({ request }) =>
new URL(request.url).searchParams.has("cursor")
? HttpResponse.json(
{ code: "INVALID_OPERATIONS_CURSOR" },
{ status: 400 },
)
: HttpResponse.json(
resource({
accountId: "all",
hasNext: true,
items,
nextCursor: "expired",
observedAt: receipt.occurredAt,
}),
),
),
);
renderPage("/accounts/all/operations?from=2026-08-01&to=2026-08-31");
await screen.findAllByText("Выплата купона");
fireEvent.click(screen.getByRole("button", { name: "Далее" }));
await waitFor(() => {
expect(screen.queryByRole("button", { name: "Повторить" })).not.toBeInTheDocument();
});
expect(await screen.findAllByText("Выплата купона")).toHaveLength(2);
expect(screen.getByLabelText("Текущий путь")).toHaveTextContent(
"from=2026-08-01&to=2026-08-31",
);
});
it("retains the complete portfolio during a failed refresh", async () => {
const view = renderPage("/accounts/all");
await screen.findAllByText("Сбербанк");
server.use(
http.get("*/api/v1/brokerage-accounts/all/portfolio", () =>
HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 }),
),
);
await act(() =>
view.invalidateQueries({
predicate: ({ queryKey }) => String(queryKey[1]).endsWith("/portfolio"),
}),
);
expect(screen.getAllByText("Сбербанк")).toHaveLength(1);
await waitFor(() => {
expect(
screen.queryByRole("button", {
name: "Повторить",
}),
).not.toBeInTheDocument();
});
});
});