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 ( {location.pathname} {location.search} ); }; 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(); 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", ); }); });