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 (
);
};
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["candles"];
cursorError?: string;
failCompany?: boolean;
failStock?: boolean;
metrics?: NonNullable["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(
} path="/stocks/:stockId" />
,
);
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();
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",
);
});
});