484 lines
15 KiB
TypeScript
484 lines
15 KiB
TypeScript
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;
|
||
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.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();
|
||
expect(screen.queryByRole("button", { name: "Повторить" })).not.toBeInTheDocument();
|
||
});
|
||
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);
|
||
expect(screen.queryByRole("button", { name: "Повторить" })).not.toBeInTheDocument();
|
||
});
|
||
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();
|
||
expect(screen.getByText(/02.01.2022, 10:00 UTC/)).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",
|
||
);
|
||
});
|
||
});
|