Files
frontend/src/pages/stock/ui/StockPage/StockPage.test.tsx
T
codex-bot 8ce1e8e444
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 9s
Frontend verify / verify (push) Failing after 7m5s
feat(investments): warn and offer retry after exhausted read retries (WealthLedger/frontend#55) (#136)
2026-11-03 09:49:08 +03:00

517 lines
16 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 { MantineProvider } from "@mantine/core";
import { QueryClientProvider } from "@tanstack/react-query";
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { http, HttpResponse } from "msw";
import { MemoryRouter, Route, Routes, useLocation } from "react-router";
import { describe, expect, it, vi } from "vitest";
import { configureAuthClient } from "@/shared/api";
import { createTestQueryClient } from "@/test/query";
import { resource } from "@/test/resource";
import { server } from "@/test/server";
import type { StockPageVm } from "./StockPage.vm";
import { StockPage } from "./StockPage";
const LocationProbe = () => {
const location = useLocation();
return (
<output data-testid="location">
{location.pathname}
{location.search}
</output>
);
};
const uid = "e6123145-9665-43e0-8413-cd61b8aa9b13";
const money = (value: string) => ({ currency: "RUB", value });
const stock
= {
currency: "RUB",
historyFrom: null,
instrumentUid: uid,
logoName: null,
lot: 10,
name: "Тестовая акция",
sector: "financial",
shareType: "SHARE_TYPE_COMMON",
ticker: "TEST",
};
const position = {
absoluteReturn: money("1000"),
accounts: [
{
absoluteReturn: money("1000"),
account: { id: "main", name: "Основной" },
averagePurchasePrice: money("200"),
currentValue: money("3000"),
instrumentUid: uid,
quantity: "10",
returnPercent: "50",
},
],
averagePurchasePrice: money("200"),
currentValue: money("3000"),
isStale: false,
observedAt: "2026-09-10T10:00:00Z",
quantity: "10",
returnPercent: "50",
};
const quote = {
change: money("10"),
changePercent: "3.448276",
observedAt: "2026-09-10T10:00:00Z",
previousClose: money("290"),
previousCloseDate: "2026-09-09",
price: money("300"),
priceAt: "2026-09-10T10:00:00Z",
tradingStatus: "SECURITY_TRADING_STATUS_NORMAL_TRADING",
unavailable: [],
};
function setup(
options: {
ambiguousTicker?: boolean;
candles?: NonNullable<StockPageVm["candles"]>["candles"];
cursorError?: string;
failCompany?: boolean;
failCompanyAfter?: { value: boolean };
failStock?: boolean;
metrics?: NonNullable<StockPageVm["company"]>["metrics"];
noPosition?: boolean;
path?: string;
slug?: string;
} = {},
) {
const requests: URL[] = [];
const resolved = { ...stock, slug: options.slug ?? uid };
let isFailQuote = false;
let cursorError = options.cursorError;
let operationPayment = "200";
server.use(
http.get("*/api/v1/stocks/:uid", () =>
options.failStock
? HttpResponse.json({ code: "NOT_FOUND" }, { status: 404 })
: HttpResponse.json(resource(resolved)),
),
http.get("*/api/v1/stocks/by-ticker/:ticker", ({ request }) => {
requests.push(new URL(request.url));
return options.ambiguousTicker
? HttpResponse.json({ code: "STOCK_TICKER_AMBIGUOUS" }, { status: 409 })
: HttpResponse.json(resource(resolved));
}),
http.get("*/api/v1/stocks/:uid/:section", ({ params, request }) => {
requests.push(new URL(request.url));
if (params.section === "quote")
return isFailQuote
? HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 })
: HttpResponse.json(resource(quote));
if (params.section === "position")
return HttpResponse.json(
resource(
options.noPosition
? { ...position, accounts: [], quantity: "0" }
: position,
),
);
if (params.section === "candles")
return HttpResponse.json(
resource(
{
candles: options.candles ?? [],
currency: "RUB",
historyFrom: null,
observedAt: quote.observedAt,
},
),
);
if (params.section === "company")
return (options.failCompanyAfter?.value ?? options.failCompany)
? HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 })
: HttpResponse.json(
resource(
{
description: "Описание компании",
metrics: options.metrics ?? [
{
currency: null,
key: "peRatioTtm",
period: "TTM",
unit: "ratio",
value: 5.6,
},
],
observedAt: quote.observedAt,
unavailable: [],
},
),
);
return HttpResponse.json(
resource(
{
dividends: [
{
estimatedAmount: options.noPosition ? null : money("328.8"),
lastBuyDate: "2026-10-12",
paymentDate: "2026-10-27",
perShare: money("32.88"),
recordDate: "2026-10-13",
timing: "upcoming",
},
{
estimatedAmount: null,
lastBuyDate: null,
paymentDate: null,
perShare: money("20"),
recordDate: "2025-10-13",
timing: "past",
},
],
estimateUnavailable: false,
from: "2021-09-10",
observedAt: quote.observedAt,
positionAt: null,
positionIsStale: false,
to: "2027-09-10",
},
),
);
}),
http.get("*/api/v1/brokerage-accounts/all/operations", ({ request }) => {
const url = new URL(request.url);
requests.push(url);
const isNext = url.searchParams.has("cursor");
if (isNext && cursorError)
return HttpResponse.json({ code: cursorError }, { status: 400 });
return HttpResponse.json(
resource(
{
accountId: "all",
hasNext: !isNext,
items: [
{
account: { id: "main", name: "Основной" },
category: isNext ? "trade" : "income",
description: "",
id: isNext ? "sold" : "dividend",
occurredAt: isNext
? "2022-01-02T10:00:00Z"
: "2025-01-02T10:00:00Z",
payment: money(operationPayment),
price: isNext ? money("100") : null,
quantity: isNext ? "2" : null,
type: isNext
? "OPERATION_TYPE_SELL"
: "OPERATION_TYPE_DIVIDEND",
},
],
nextCursor: isNext ? null : "next",
observedAt: quote.observedAt,
},
),
);
}),
);
configureAuthClient({
accessToken: () => "synthetic",
clear: vi.fn(),
refresh: () => Promise.resolve(false),
});
const client = createTestQueryClient();
render(
<QueryClientProvider client={client}>
<MantineProvider>
<MemoryRouter initialEntries={[options.path ?? `/stocks/${uid}`]}>
<LocationProbe />
<Routes>
<Route element={<StockPage />} path="/stocks/:stockId" />
</Routes>
</MemoryRouter>
</MantineProvider>
</QueryClientProvider>,
);
return {
failQuote: () => {
isFailQuote = true;
},
recoverOperations: () => {
cursorError = undefined;
operationPayment = "201";
},
requests,
};
}
describe("stock page", () => {
it("preserves unknown and unambiguous currencies with compact, ratio and percent formatting", async () => {
setup(
{
metrics: [
{
currency: null,
key: "marketCapitalization",
period: null,
unit: "money",
value: 1_234_567.89,
},
{
currency: "RUB",
key: "revenueTtm",
period: "TTM",
unit: "money",
value: 1_234_567.89,
},
{
currency: "USD",
key: "ebitdaTtm",
period: "TTM",
unit: "money",
value: 1_234_567.89,
},
{
currency: "CAD",
key: "netIncomeTtm",
period: "TTM",
unit: "money",
value: 1_234_567.89,
},
{
currency: null,
key: "peRatioTtm",
period: "TTM",
unit: "ratio",
value: 5.678,
},
{
currency: null,
key: "roe",
period: null,
unit: "percent",
value: 12.3456,
},
],
},
);
expect(await screen.findByText("1,23 млн валюта неизвестна")).toBeVisible();
for (const value of [
"1,23 млн ₽",
"1,23 млн USD",
"1,23 млн CAD",
"5,68 ×",
"12,35 %",
])
expect(screen.getByText(value)).toBeVisible();
});
it("opens a direct ticker URL and uses its resolved UID for market and personal requests", async () => {
const { requests } = setup({ path: "/stocks/SBERP", slug: "SBERP" });
await screen.findByText("Зачисление дивидендов");
expect(screen.getByTestId("location")).toHaveTextContent("/stocks/SBERP");
expect(
requests.some(url => url.pathname === `/api/v1/stocks/${uid}/quote`),
).toBe(true);
expect(
requests.some(url => url.searchParams.get("instrumentUid") === uid),
).toBe(true);
expect(requests.some(url => url.pathname.includes("/SBERP/quote"))).toBe(
false,
);
});
it("replaces a legacy UID URL with the verified ticker while preserving filters", async () => {
setup({ path: `/stocks/${uid}?period=year&events=trades`, slug: "SBERP" });
await waitFor(() =>
expect(screen.getByTestId("location")).toHaveTextContent(
"/stocks/SBERP?period=year&events=trades",
),
);
expect(await screen.findByText("Дивиденд")).toBeVisible();
});
it("offers search for ambiguous tickers without requesting personal data", async () => {
const { requests } = setup({ ambiguousTicker: true, path: "/stocks/SAME" });
expect(
await screen.findByText(/Этот тикер относится к нескольким выпускам/),
).toBeVisible();
expect(
requests.some(
url =>
url.pathname.endsWith("/operations")
|| url.pathname.endsWith("/position"),
),
).toBe(false);
});
it("opens by UID, displays server calculations and distinguishes company and personal dividends", async () => {
setup({ candles: [
{ close: "300.125", isComplete: true, time: "2026-09-09T00:00:00Z" },
{ close: "301.123456789", isComplete: false, time: "2026-09-10T00:00:00Z" },
] });
expect(
await screen.findByRole("heading", { name: "Тестовая акция" }),
).toBeVisible();
expect(
await screen.findByRole("heading", { name: "Моя позиция" }),
).toBeVisible();
expect(screen.getAllByText("3 000 ₽")[0]).toBeVisible();
expect(
screen.getByText("К закрытию предыдущего торгового дня, 09.09.2026"),
).toBeVisible();
expect(await screen.findByText("Зачисление дивидендов")).toBeVisible();
expect(screen.getAllByText("на акцию").length).toBeGreaterThan(0);
expect(screen.getByText("328,8 ₽")).toBeVisible();
expect(screen.getByText("P/E · TTM")).toBeVisible();
expect(screen.getByText("Описание компании")).toBeVisible();
expect(
screen.queryByRole("button", { name: "О компании" }),
).not.toBeInTheDocument();
expect(screen.getByText(/13\.10\.2026/)).toBeVisible();
fireEvent.click(
screen.getByRole("button", { name: "История дивидендов компании" }),
);
await waitFor(() => expect(screen.getByText(/13\.10\.2025/)).toBeVisible());
expect(screen.getByText("Текущая средняя покупка: 200 ₽")).toBeVisible();
fireEvent.click(screen.getByRole("button", { name: /Цены в таблице/ }));
const rows = within(await screen.findByRole("table")).getAllByRole("row");
expect(rows[1]).toHaveTextContent("10.09.2026");
expect(rows[1]).toHaveTextContent("301,123456789 ₽");
expect(rows[1]).toHaveTextContent("Формируется");
expect(rows[2]).toHaveTextContent("09.09.2026");
await waitFor(() => expect(screen.getByText("300,125 ₽")).toBeVisible());
});
it("keeps sold-position history and market content when company data fails", async () => {
setup({ failCompany: true, noPosition: true });
expect(await screen.findByText("Зачисление дивидендов")).toBeVisible();
expect(
screen.queryByRole("heading", { name: "Моя позиция" }),
).not.toBeInTheDocument();
expect(screen.getByRole("heading", { name: "График цены" })).toBeVisible();
});
it("keeps successful sections visible when company data is initially missing", async () => {
const { requests } = setup({ failCompany: true });
await screen.findByText("Зачисление дивидендов");
const previousRequests = requests.length;
expect(screen.getByText("Зачисление дивидендов")).toBeVisible();
expect(requests).toHaveLength(previousRequests);
});
it(
"retries only the failed company section and recovers it in place",
{ timeout: 10_000 },
async () => {
const failing = { value: true };
const { requests } = setup({ failCompanyAfter: failing });
await screen.findByText("Зачисление дивидендов");
// Automatic read retries wait 1s + 2s before the failure is final.
const retry = await screen.findByRole(
"button",
{ name: "Повторить" },
{ timeout: 6000 },
);
expect(screen.getByRole("heading", { name: "График цены" })).toBeVisible();
expect(screen.getAllByRole("button", { name: "Повторить" })).toHaveLength(1);
const before = requests.length;
const companyBefore = requests.filter(url => url.pathname.endsWith("/company")).length;
failing.value = false;
fireEvent.click(retry);
await waitFor(() =>
expect(screen.queryByRole("button", { name: "Повторить" }))
.not.toBeInTheDocument());
expect(requests.filter(url => url.pathname.endsWith("/company")))
.toHaveLength(companyBefore + 1);
expect(requests.length - before).toBe(1);
expect(screen.getByText("Описание компании")).toBeVisible();
},
);
it("sends instrument-filtered continuation, restores it on returning to the filter and changes chart period", async () => {
const { requests } = setup();
await screen.findByText("Зачисление дивидендов");
fireEvent.click(screen.getByRole("button", { name: "Далее" }));
expect(await screen.findByText("Продажа")).toBeVisible();
expect(screen.getByText("2 шт.")).toBeVisible();
expect(screen.getByText("Цена: 100 ₽")).toBeVisible();
const snapshotTime = new Intl.DateTimeFormat("ru-RU", { dateStyle: "short", timeStyle: "short" });
const operationTime = snapshotTime.format(new Date("2022-01-02T10:00:00Z"));
expect(screen.getByText(operationTime, { exact: false })).toBeVisible();
expect(
requests.some(
url =>
url.searchParams.get("cursor") === "next"
&& url.searchParams.get("instrumentUid") === uid,
),
).toBe(true);
fireEvent.click(screen.getByRole("radio", { name: "Сделки" }));
await waitFor(() =>
expect(
requests.some(
url =>
url.searchParams.get("categories") === "trade"
&& !url.searchParams.has("cursor"),
),
).toBe(true),
);
expect(await screen.findByText("Зачисление дивидендов")).toBeVisible();
expect(screen.getByRole("button", { name: "Назад" })).toBeDisabled();
fireEvent.click(screen.getByRole("radio", { name: "Все" }));
expect(await screen.findByText("Продажа")).toBeVisible();
expect(screen.getByRole("button", { name: "Назад" })).toBeEnabled();
fireEvent.click(screen.getByRole("button", { name: "Назад" }));
expect(await screen.findByText("Зачисление дивидендов")).toBeVisible();
expect(screen.getByRole("button", { name: "Назад" })).toBeDisabled();
fireEvent.click(screen.getByRole("radio", { name: "Год" }));
await waitFor(() =>
expect(
requests.some(url => url.searchParams.get("period") === "year"),
).toBe(true),
);
});
it.each([
"INVALID_OPERATIONS_CURSOR",
"TBANK_UNAVAILABLE",
])(
"keeps history quiet after %s without technical retry",
async (code) => {
const controls = setup({ cursorError: code });
await screen.findByText("Зачисление дивидендов");
fireEvent.click(screen.getByRole("button", { name: "Далее" }));
controls.recoverOperations();
expect(screen.queryByRole("button", { name: "Повторить" })).not.toBeInTheDocument();
expect(screen.getByRole("heading", { name: "Моя история событий" })).toBeVisible();
},
);
it("keeps the last quote without manual refresh", async () => {
const controls = setup({
path: `/stocks/${uid}?period=year&events=trades`,
});
await screen.findByText("300 ₽");
controls.failQuote();
expect(screen.getByText("300 ₽")).toBeVisible();
expect(screen.queryByRole("button", { name: "Обновить" })).not.toBeInTheDocument();
});
it("offers recovery for an unavailable direct link", async () => {
setup({ failStock: true });
expect(
await screen.findByRole("heading", { name: "Акция недоступна" }),
).toBeVisible();
expect(screen.getByRole("link", { name: "Все акции" })).toHaveAttribute(
"href",
"/stocks",
);
});
});