Frontend security / security (push) Successful in 10s
Frontend verify / verify (push) Successful in 4m57s
Conventional commits / commits (pull_request) Successful in 28s
Frontend security / security (pull_request) Successful in 10s
Frontend verify / verify (pull_request) Successful in 5m10s
Repair investment recovery, draft preservation, date validation, currency presentation, accessibility, and route error recovery. Correct Docker and dependency checks and consume explicit income roles.
565 lines
16 KiB
TypeScript
565 lines
16 KiB
TypeScript
import { MantineProvider } from "@mantine/core";
|
||
import { QueryClientProvider } from "@tanstack/react-query";
|
||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||
import { HttpResponse, http } from "msw";
|
||
import type { FC } from "react";
|
||
import {
|
||
MemoryRouter,
|
||
Route,
|
||
Routes,
|
||
useLocation,
|
||
useNavigate,
|
||
} from "react-router";
|
||
import { describe, expect, it, vi } from "vitest";
|
||
import {
|
||
type BrokerageOperation,
|
||
LedgerEntryRow,
|
||
} from "@/entities/broker-operation";
|
||
import { configureAuthClient } from "@/shared/api";
|
||
import { createTestQueryClient } from "@/test/query";
|
||
import { resource } from "@/test/resource";
|
||
import { server } from "@/test/server";
|
||
import { AccountOperationsPage } from "./AccountOperationsPage";
|
||
|
||
const operation: BrokerageOperation = {
|
||
id: "operation-1",
|
||
occurredAt: "2026-08-14T10:00:00Z",
|
||
type: "OPERATION_TYPE_BUY",
|
||
category: "trade",
|
||
description: "Покупка акций",
|
||
figi: null,
|
||
instrumentUid: null,
|
||
ticker: "SBER",
|
||
instrumentType: "share",
|
||
logoName: null,
|
||
payment: null,
|
||
quantity: "2",
|
||
price: { currency: "RUB", value: "100" },
|
||
accruedInterest: null,
|
||
commission: { currency: "RUB", value: "1" },
|
||
};
|
||
|
||
const Navigation: FC = () => {
|
||
const location = useLocation();
|
||
const navigate = useNavigate();
|
||
return (
|
||
<>
|
||
<output aria-label="URL">{location.search}</output>
|
||
<button type="button" onClick={() => navigate(-1)}>
|
||
History back
|
||
</button>
|
||
<button type="button" onClick={() => navigate(1)}>
|
||
History forward
|
||
</button>
|
||
</>
|
||
);
|
||
};
|
||
|
||
function configureTestAuth() {
|
||
configureAuthClient({
|
||
accessToken: () => "token",
|
||
refresh: async () => true,
|
||
clear: vi.fn(),
|
||
});
|
||
}
|
||
|
||
function renderPage(search = "") {
|
||
return render(
|
||
<QueryClientProvider client={createTestQueryClient()}>
|
||
<MantineProvider>
|
||
<MemoryRouter
|
||
initialEntries={[`/accounts/account-1/operations${search}`]}
|
||
>
|
||
<Routes>
|
||
<Route
|
||
path="/accounts/:accountId/operations"
|
||
element={<AccountOperationsPage />}
|
||
/>
|
||
</Routes>
|
||
</MemoryRouter>
|
||
</MantineProvider>
|
||
</QueryClientProvider>,
|
||
);
|
||
}
|
||
|
||
describe("LedgerEntryRow", () => {
|
||
it("marks cashless operations without rendering operation details", () => {
|
||
render(
|
||
<MantineProvider>
|
||
<MemoryRouter>
|
||
<LedgerEntryRow operation={operation} />
|
||
</MemoryRouter>
|
||
</MantineProvider>,
|
||
);
|
||
|
||
expect(screen.getByText("Без движения денег")).toBeInTheDocument();
|
||
expect(
|
||
screen.queryByRole("button", { name: "Показать детали" }),
|
||
).toBeNull();
|
||
expect(screen.queryByText("Цена: 100 ₽")).toBeNull();
|
||
expect(screen.queryByText("Количество: 2")).toBeNull();
|
||
});
|
||
|
||
it("labels a positive payment with its category type", () => {
|
||
render(
|
||
<MantineProvider>
|
||
<MemoryRouter>
|
||
<LedgerEntryRow
|
||
operation={{
|
||
...operation,
|
||
payment: { currency: "RUB", value: "15" },
|
||
}}
|
||
/>
|
||
</MemoryRouter>
|
||
</MantineProvider>,
|
||
);
|
||
|
||
expect(screen.getByText("Сделка")).toBeInTheDocument();
|
||
expect(screen.getByText("+15 ₽")).toBeInTheDocument();
|
||
});
|
||
|
||
it("labels a coupon income as Купон", () => {
|
||
render(
|
||
<MantineProvider>
|
||
<MemoryRouter>
|
||
<LedgerEntryRow
|
||
operation={{
|
||
...operation,
|
||
type: "OPERATION_TYPE_COUPON",
|
||
category: "income",
|
||
payment: { currency: "RUB", value: "8" },
|
||
}}
|
||
/>
|
||
</MemoryRouter>
|
||
</MantineProvider>,
|
||
);
|
||
|
||
expect(screen.getByText("Купон")).toBeInTheDocument();
|
||
expect(screen.getByText("+8 ₽")).toBeInTheDocument();
|
||
});
|
||
|
||
it("labels a dividend income as Дивиденд", () => {
|
||
render(
|
||
<MantineProvider>
|
||
<MemoryRouter>
|
||
<LedgerEntryRow
|
||
operation={{
|
||
...operation,
|
||
type: "OPERATION_TYPE_DIVIDEND",
|
||
category: "income",
|
||
payment: { currency: "RUB", value: "20" },
|
||
}}
|
||
/>
|
||
</MemoryRouter>
|
||
</MantineProvider>,
|
||
);
|
||
|
||
expect(screen.getByText("Дивиденд")).toBeInTheDocument();
|
||
expect(screen.getByText("+20 ₽")).toBeInTheDocument();
|
||
});
|
||
|
||
it("labels a fee as Комиссия", () => {
|
||
render(
|
||
<MantineProvider>
|
||
<MemoryRouter>
|
||
<LedgerEntryRow
|
||
operation={{
|
||
...operation,
|
||
type: "OPERATION_TYPE_SERVICE_FEE",
|
||
category: "fee",
|
||
payment: { currency: "RUB", value: "-3" },
|
||
}}
|
||
/>
|
||
</MemoryRouter>
|
||
</MantineProvider>,
|
||
);
|
||
|
||
expect(screen.getByText("Комиссия")).toBeInTheDocument();
|
||
expect(screen.getByText("−3 ₽")).toBeInTheDocument();
|
||
});
|
||
|
||
it("keeps a zero payment neutral", () => {
|
||
render(
|
||
<MantineProvider>
|
||
<MemoryRouter>
|
||
<LedgerEntryRow
|
||
operation={{
|
||
...operation,
|
||
payment: { currency: "RUB", value: "0" },
|
||
}}
|
||
/>
|
||
</MemoryRouter>
|
||
</MantineProvider>,
|
||
);
|
||
|
||
expect(screen.getByText("Без движения денег")).toBeInTheDocument();
|
||
expect(screen.queryByText("Сделка")).toBeNull();
|
||
});
|
||
|
||
it("uses an instrument initial when the ledger entry has no logo", () => {
|
||
render(
|
||
<MantineProvider>
|
||
<MemoryRouter>
|
||
<LedgerEntryRow operation={operation} />
|
||
</MemoryRouter>
|
||
</MantineProvider>,
|
||
);
|
||
|
||
expect(screen.getByText("S")).toBeInTheDocument();
|
||
});
|
||
});
|
||
|
||
describe("AccountOperationsPage", () => {
|
||
it.each(["account-1", "all"])(
|
||
"normalizes invalid periods and history, preserves one-sided UTC dates and resets cursors for %s",
|
||
async (accountId) => {
|
||
configureTestAuth();
|
||
const searches: URLSearchParams[] = [];
|
||
server.use(
|
||
http.get(
|
||
"*/api/v1/brokerage-accounts/:accountId/operations",
|
||
({ request }) => {
|
||
searches.push(new URL(request.url).searchParams);
|
||
return HttpResponse.json(
|
||
resource({
|
||
items: [operation],
|
||
nextCursor: "next",
|
||
hasNext: true,
|
||
}),
|
||
);
|
||
},
|
||
),
|
||
);
|
||
render(
|
||
<QueryClientProvider client={createTestQueryClient()}>
|
||
<MantineProvider env="test">
|
||
<MemoryRouter
|
||
initialIndex={0}
|
||
initialEntries={[
|
||
"?from=bad&to=2026-02-28&categories=trade",
|
||
"?from=2026-02-30&categories=trade",
|
||
"?from=2026-03-01&to=2026-02-28&categories=trade",
|
||
"?from=2026-02-01&categories=trade",
|
||
"?to=2026-02-28&categories=trade",
|
||
].map((search) => `/accounts/${accountId}/operations${search}`)}
|
||
>
|
||
<Navigation />
|
||
<Routes>
|
||
<Route
|
||
path="/accounts/:accountId/operations"
|
||
element={<AccountOperationsPage />}
|
||
/>
|
||
</Routes>
|
||
</MemoryRouter>
|
||
</MantineProvider>
|
||
</QueryClientProvider>,
|
||
);
|
||
await screen.findByLabelText("Период");
|
||
expect(searches[0].get("from")).toBeNull();
|
||
expect(searches[0].get("to")).toBeNull();
|
||
for (const expected of [
|
||
"?categories=trade",
|
||
"?categories=trade",
|
||
"?from=2026-02-01&categories=trade",
|
||
]) {
|
||
fireEvent.click(
|
||
screen.getByRole("button", { name: "History forward" }),
|
||
);
|
||
await waitFor(() =>
|
||
expect(screen.getByLabelText("URL").textContent).toBe(expected),
|
||
);
|
||
await screen.findByLabelText("Период");
|
||
}
|
||
expect(searches.at(-1)?.get("from")).toBe("2026-02-01T00:00:00.000Z");
|
||
expect(searches.at(-1)?.get("to")).toBeNull();
|
||
fireEvent.click(screen.getByRole("button", { name: "Далее" }));
|
||
await waitFor(() => expect(searches.at(-1)?.get("cursor")).toBe("next"));
|
||
await screen.findByLabelText("Период");
|
||
fireEvent.click(screen.getByRole("button", { name: "History forward" }));
|
||
await screen.findByLabelText("Период");
|
||
await waitFor(() =>
|
||
expect(searches.at(-1)?.get("to")).toBe("2026-02-28T23:59:59.999Z"),
|
||
);
|
||
expect(searches.at(-1)?.get("from")).toBeNull();
|
||
expect(searches.at(-1)?.get("cursor")).toBeNull();
|
||
fireEvent.click(screen.getByRole("button", { name: "History back" }));
|
||
await waitFor(() =>
|
||
expect(screen.getByLabelText("Период")).toHaveTextContent("01.02.2026"),
|
||
);
|
||
expect(screen.getByRole("button", { name: "Назад" })).toBeDisabled();
|
||
expect(searches.map((query) => query.get("from"))).not.toContain(
|
||
"badT00:00:00.000Z",
|
||
);
|
||
expect(
|
||
searches.every((query) => query.get("categories") === "trade"),
|
||
).toBe(true);
|
||
},
|
||
);
|
||
|
||
it.each(["account-1", "all"])(
|
||
"allows resetting filters after a request failure for %s",
|
||
async (accountId) => {
|
||
configureTestAuth();
|
||
server.use(
|
||
http.get(
|
||
"*/api/v1/brokerage-accounts/:accountId/operations",
|
||
({ request }) =>
|
||
new URL(request.url).searchParams.has("from")
|
||
? HttpResponse.json({ code: "VALIDATION_ERROR" }, { status: 400 })
|
||
: HttpResponse.json(
|
||
resource({ items: [], hasNext: false, nextCursor: null }),
|
||
),
|
||
),
|
||
);
|
||
render(
|
||
<QueryClientProvider client={createTestQueryClient()}>
|
||
<MantineProvider env="test">
|
||
<MemoryRouter
|
||
initialEntries={[
|
||
`/accounts/${accountId}/operations?from=2026-02-01`,
|
||
]}
|
||
>
|
||
<Navigation />
|
||
<Routes>
|
||
<Route
|
||
path="/accounts/:accountId/operations"
|
||
element={<AccountOperationsPage />}
|
||
/>
|
||
</Routes>
|
||
</MemoryRouter>
|
||
</MantineProvider>
|
||
</QueryClientProvider>,
|
||
);
|
||
await screen.findByRole("button", { name: "Повторить" });
|
||
fireEvent.click(screen.getByRole("button", { name: "Сбросить" }));
|
||
await screen.findByLabelText("Период");
|
||
expect(screen.getByLabelText("URL").textContent).toBe("");
|
||
},
|
||
);
|
||
|
||
it("resets cursor pagination when filters change", async () => {
|
||
configureTestAuth();
|
||
const searches: string[] = [];
|
||
server.use(
|
||
http.get(
|
||
"*/api/v1/brokerage-accounts/:accountId/operations",
|
||
({ request }) => {
|
||
const url = new URL(request.url);
|
||
searches.push(url.search);
|
||
return HttpResponse.json(
|
||
resource({
|
||
accountId: "account-1",
|
||
items: [operation],
|
||
nextCursor: url.searchParams.get("cursor") ? null : "next-1",
|
||
hasNext: !url.searchParams.get("cursor"),
|
||
observedAt: "2026-08-14T00:00:00Z",
|
||
}),
|
||
);
|
||
},
|
||
),
|
||
);
|
||
renderPage();
|
||
|
||
expect(
|
||
await screen.findByRole("heading", { name: "Журнал операций" }),
|
||
).toBeInTheDocument();
|
||
expect(
|
||
screen.queryByRole("link", { name: "К счету" }),
|
||
).not.toBeInTheDocument();
|
||
fireEvent.click(await screen.findByRole("button", { name: "Далее" }));
|
||
await waitFor(() => expect(searches.at(-1)).toContain("cursor=next-1"));
|
||
fireEvent.click(screen.getByRole("combobox", { name: "Категории" }));
|
||
fireEvent.click(await screen.findByText("Налог"));
|
||
fireEvent.click(screen.getByRole("button", { name: "Применить" }));
|
||
await waitFor(() => expect(searches.at(-1)).toContain("categories=tax"));
|
||
expect(searches.at(-1)).not.toContain("cursor=");
|
||
});
|
||
|
||
it("does not refetch when draft changes without apply", async () => {
|
||
configureTestAuth();
|
||
const searches: string[] = [];
|
||
server.use(
|
||
http.get(
|
||
"*/api/v1/brokerage-accounts/:accountId/operations",
|
||
({ request }) => {
|
||
const url = new URL(request.url);
|
||
searches.push(url.search);
|
||
return HttpResponse.json(
|
||
resource({
|
||
accountId: "account-1",
|
||
items: [operation],
|
||
nextCursor: null,
|
||
hasNext: false,
|
||
observedAt: "2026-08-14T00:00:00Z",
|
||
}),
|
||
);
|
||
},
|
||
),
|
||
);
|
||
renderPage();
|
||
|
||
expect(
|
||
await screen.findByRole("heading", { name: "Журнал операций" }),
|
||
).toBeInTheDocument();
|
||
await waitFor(() => expect(searches.length).toBeGreaterThan(0));
|
||
const countBefore = searches.length;
|
||
fireEvent.click(screen.getByRole("combobox", { name: "Категории" }));
|
||
fireEvent.click(await screen.findByText("Налог"));
|
||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||
expect(searches.length).toBe(countBefore);
|
||
expect(searches.some((s) => s.includes("categories=tax"))).toBe(false);
|
||
});
|
||
|
||
it("reset clears filters and refetches unfiltered", async () => {
|
||
configureTestAuth();
|
||
const searches: string[] = [];
|
||
server.use(
|
||
http.get(
|
||
"*/api/v1/brokerage-accounts/:accountId/operations",
|
||
({ request }) => {
|
||
const url = new URL(request.url);
|
||
searches.push(url.search);
|
||
return HttpResponse.json(
|
||
resource({
|
||
accountId: "account-1",
|
||
items: [operation],
|
||
nextCursor: null,
|
||
hasNext: false,
|
||
observedAt: "2026-08-14T00:00:00Z",
|
||
}),
|
||
);
|
||
},
|
||
),
|
||
);
|
||
renderPage();
|
||
|
||
expect(
|
||
await screen.findByRole("heading", { name: "Журнал операций" }),
|
||
).toBeInTheDocument();
|
||
fireEvent.click(screen.getByRole("combobox", { name: "Категории" }));
|
||
fireEvent.click(await screen.findByText("Налог"));
|
||
fireEvent.click(screen.getByRole("button", { name: "Применить" }));
|
||
await waitFor(() =>
|
||
expect(searches.some((s) => s.includes("categories=tax"))).toBe(true),
|
||
);
|
||
fireEvent.click(screen.getByRole("button", { name: "Сбросить" }));
|
||
await waitFor(() => expect(searches.at(-1)).not.toContain("categories="));
|
||
expect(screen.getByRole("button", { name: "Применить" })).toBeDisabled();
|
||
});
|
||
|
||
it("renders an error state", async () => {
|
||
configureTestAuth();
|
||
server.use(
|
||
http.get("*/api/v1/brokerage-accounts/:accountId/operations", () =>
|
||
HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 }),
|
||
),
|
||
);
|
||
renderPage();
|
||
expect(
|
||
await screen.findByText("Не удалось загрузить журнал"),
|
||
).toBeInTheDocument();
|
||
});
|
||
});
|
||
|
||
it("keeps UTC operation boundaries in GET and source sync requests", async () => {
|
||
configureTestAuth();
|
||
const requested: Record<string, string>[] = [];
|
||
const syncRequests: unknown[] = [];
|
||
server.use(
|
||
http.get(
|
||
"*/api/v1/brokerage-accounts/:accountId/operations",
|
||
({ request }) => {
|
||
requested.push(Object.fromEntries(new URL(request.url).searchParams));
|
||
return HttpResponse.json(
|
||
resource(null, {
|
||
sync: { id: null, state: "failed", errorCode: "TBANK_UNAVAILABLE" },
|
||
}),
|
||
);
|
||
},
|
||
),
|
||
http.post("*/api/v1/tbank-sync", async ({ request }) => {
|
||
syncRequests.push(await request.json());
|
||
return HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 });
|
||
}),
|
||
);
|
||
renderPage("?from=2024-02-29&to=2024-03-01&categories=trade");
|
||
const retry = await screen.findByRole("button", { name: "Повторить" });
|
||
expect(requested).toEqual([
|
||
{
|
||
from: "2024-02-29T00:00:00.000Z",
|
||
to: "2024-03-01T23:59:59.999Z",
|
||
categories: "trade",
|
||
limit: "100",
|
||
},
|
||
]);
|
||
expect(syncRequests).toEqual([]);
|
||
fireEvent.click(retry);
|
||
await waitFor(() =>
|
||
expect(syncRequests).toEqual([
|
||
{
|
||
screen: "operations",
|
||
accountId: "account-1",
|
||
from: "2024-02-29T00:00:00.000Z",
|
||
to: "2024-03-01T23:59:59.999Z",
|
||
categories: ["trade"],
|
||
limit: 100,
|
||
},
|
||
]),
|
||
);
|
||
});
|
||
|
||
it("does not label incomplete empty operations as a confirmed empty period", async () => {
|
||
configureTestAuth();
|
||
server.use(
|
||
http.get("*/api/v1/brokerage-accounts/:accountId/operations", () =>
|
||
HttpResponse.json(
|
||
resource(
|
||
{
|
||
accountId: "account-1",
|
||
items: [],
|
||
hasNext: false,
|
||
nextCursor: null,
|
||
},
|
||
{ availability: "partial" },
|
||
),
|
||
),
|
||
),
|
||
);
|
||
renderPage();
|
||
expect(await screen.findByText("Период загружен не полностью")).toBeVisible();
|
||
expect(screen.queryByText("Операций нет")).not.toBeInTheDocument();
|
||
});
|
||
|
||
it("recovers an invalid cursor at the first page without clearing applied filters", async () => {
|
||
configureTestAuth();
|
||
const searches: URLSearchParams[] = [];
|
||
server.use(
|
||
http.get(
|
||
"*/api/v1/brokerage-accounts/:accountId/operations",
|
||
({ request }) => {
|
||
const params = new URL(request.url).searchParams;
|
||
searches.push(params);
|
||
if (params.has("cursor"))
|
||
return HttpResponse.json(
|
||
{ code: "INVALID_OPERATIONS_CURSOR" },
|
||
{ status: 400 },
|
||
);
|
||
return HttpResponse.json(
|
||
resource({
|
||
items: [operation],
|
||
hasNext: true,
|
||
nextCursor: "old-cursor",
|
||
}),
|
||
);
|
||
},
|
||
),
|
||
);
|
||
renderPage("?from=2026-08-01&to=2026-08-14&categories=trade");
|
||
fireEvent.click(await screen.findByRole("button", { name: "Далее" }));
|
||
fireEvent.click(await screen.findByRole("button", { name: "Повторить" }));
|
||
await waitFor(() => expect(searches.length).toBe(3));
|
||
expect(searches[2].get("cursor")).toBeNull();
|
||
for (const key of ["from", "to", "categories"])
|
||
expect(searches[2].get(key)).toBe(searches[0].get(key));
|
||
});
|