Files
frontend/src/app/routes/AppRoutes/AllAccounts.test.tsx
T
ksv741 d61cdd2e8f
Frontend verify / commits (pull_request) Successful in 41s
Frontend verify / security (pull_request) Successful in 12s
Frontend verify / verify (pull_request) Successful in 6m41s
feat: unify income payment cards and drop period comparisons (WealthLedger/main#33)
- income page renders only selected-period totals and payments; the
  comparison blocks, notices and display-only percentage math removed
- new shared PayoutCard presents executed payouts, forecasts and
  per-security schedules with explicit money semantics
- ledger rows, upcoming events, stock dividends and bond coupons use
  the same card; period filter lost its duplicated paper wrapper
2026-10-13 17:45:58 +03:00

399 lines
11 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 { beforeEach, describe, expect, it } 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 { createTestQueryClient } from "@/test/query";
import { resource } from "@/test/resource";
import { server } from "@/test/server";
import { AppRoutes } from "./AppRoutes";
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,
logoName: null,
quantity: "2",
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;
}
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: [],
}),
),
),
);
});
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", "events", "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("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 of ["operations", "income", "events"])
expect(
screen
.getAllByRole("link")
.find(
link => link.getAttribute("href") === `/accounts/all/${suffix}`,
),
).toBeInTheDocument();
});
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();
});
});
});