import { MantineProvider } from "@mantine/core"; import { QueryClientProvider } from "@tanstack/react-query"; import { fireEvent, render, screen, waitFor } 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 { BondPage } from "./BondPage"; 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 bond = { aci: money("12"), amortization: true, callDate: null, couponQuantityPerYear: 4, currency: "RUB", floatingCoupon: true, historyFrom: null, initialNominal: money("1000"), instrumentUid: uid, isin: "TESTISIN", logoName: null, lot: 10, maturityDate: "2030-05-15", name: "Тестовая облигация", nominal: money("500"), perpetual: false, sector: "financial", subordinated: false, 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 = { aci: money("12"), changePercentagePoints: "-0.875", cleanPrice: money("490.625"), dirtyPrice: money("502.625"), observedAt: "2026-09-10T10:00:00Z", previousCloseDate: "2026-09-09", priceAt: "2026-09-10T10:00:00Z", pricePercent: "98.125", tradingStatus: null, unavailable: [], }; function setup( options: { ambiguousTicker?: boolean; failCoupons?: boolean; noPosition?: boolean; path?: string; ratingState?: "active" | "missing" | "unavailable" | "withdrawn"; slug?: string; } = {}, ) { const requests: URL[] = []; const resolved = { ...bond, slug: options.slug ?? uid }; let isFailQuote = false; server.use( http.get("*/api/v1/bonds/:uid", () => HttpResponse.json(resource(resolved))), http.get("*/api/v1/bonds/by-ticker/:ticker", ({ request }) => { requests.push(new URL(request.url)); return options.ambiguousTicker ? HttpResponse.json({ code: "BOND_TICKER_AMBIGUOUS" }, { status: 409 }) : HttpResponse.json(resource(resolved)); }), http.get("*/api/v1/bonds/:uid/metrics", ({ request }) => { requests.push(new URL(request.url)); return HttpResponse.json( resource( { coupon: money("32.88"), couponDate: "2026-10-27", couponPeriodDays: 90, couponType: "COUPON_TYPE_FLOATING", couponYieldPercent: "27.18", couponYieldPriceAt: quote.priceAt, creditRating: options.ratingState === "missing" || options.ratingState === "unavailable" ? null : { agency: "NCR", date: "2026-04-17", observedAt: quote.observedAt, sourceUrl: "https://ratings.ru/ratings/issues/123/", status: options.ratingState === "withdrawn" ? "withdrawn" : "active", value: options.ratingState === "withdrawn" ? null : "AAA.ru", }, effectiveYield: { date: "2030-05-15", dateType: "MATDATE", pricePercent: "53.384", tradeAt: quote.priceAt, value: "15.6262", }, observedAt: quote.observedAt, unavailable: options.ratingState === "unavailable" ? ["rating"] : [], }, ), ); }), http.get("*/api/v1/bonds/: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: [ { close: "99.25", isComplete: true, time: "2026-09-09T00:00:00Z", }, ], currency: null, historyFrom: null, observedAt: quote.observedAt, }, ), ); if (options.failCoupons) return HttpResponse.json( { code: "TBANK_UNAVAILABLE" }, { status: 503 }, ); return HttpResponse.json( resource( { coupons: [ { couponType: "COUPON_TYPE_FLOATING", estimatedAmount: options.noPosition ? null : money("328.8"), number: "1", paymentDate: "2026-10-27", perBond: money("32.88"), periodDays: 90, recordDate: "2026-10-13", timing: "upcoming", }, { couponType: null, estimatedAmount: null, number: "2", paymentDate: null, perBond: money("20"), periodDays: null, 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"); 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("200"), price: isNext ? money("100") : null, quantity: isNext ? "2" : null, type: isNext ? "OPERATION_TYPE_SELL" : "OPERATION_TYPE_COUPON", }, ], nextCursor: isNext ? null : "next", observedAt: quote.observedAt, }, ), ); }), ); configureAuthClient({ accessToken: () => "synthetic", clear: vi.fn(), refresh: () => Promise.resolve(false), }); const client = createTestQueryClient(); render( } path="/bonds/:bondId" /> , ); return { failQuote: () => { isFailQuote = true; }, requests, }; } describe("bond page", () => { it("opens a direct ticker, keeps percentages distinct from money, and filters personal history by UID", async () => { const { requests } = setup({ path: "/bonds/TEST", slug: "TEST" }); expect( await screen.findByRole("heading", { name: "Тестовая облигация" }), ).toBeVisible(); expect(await screen.findByText("98,125 %")).toBeVisible(); expect(screen.getByText("490,625 ₽")).toBeVisible(); expect(screen.getByText("502,625 ₽")).toBeVisible(); expect(screen.getByText("Плавающий купон")).toBeVisible(); expect(screen.getByText("15.05.2030")).toBeVisible(); expect(await screen.findByText("15,6262 %")).toBeVisible(); expect(screen.getByText("AAA.ru")).toBeVisible(); expect( screen.getByRole("link", { name: /НКР · рейтинг выпуска/ }), ).toHaveAttribute("href", "https://ratings.ru/ratings/issues/123/"); expect(screen.getByText("27,18 %")).toBeVisible(); expect(screen.getByText(/MOEX · к погашению/)).toBeVisible(); expect(await screen.findByText("328,8 ₽")).toBeVisible(); await waitFor(() => expect( requests.some(url => url.searchParams.get("instrumentUid") === uid), ).toBe(true), ); expect( requests.some(url => url.pathname === `/api/v1/bonds/${uid}/quote`), ).toBe(true); fireEvent.click(screen.getByRole("button", { name: /Цены в таблице/ })); await waitFor(() => expect(screen.getByText("99,25 %")).toBeVisible()); expect(screen.queryByText("99,25 ₽")).not.toBeInTheDocument(); expect(screen.queryByText(/Текущая средняя покупка/)).not.toBeInTheDocument(); fireEvent.click( screen.getByRole("button", { name: "Прошедшие и недатированные купоны" }), ); await waitFor(() => expect(screen.getByText("20 ₽")).toBeVisible()); }); it.each([ ["withdrawn", "Отозван"], ["missing", "В реестре НКР не найден"], ["unavailable", "Источник недоступен"], ] as const)( "distinguishes %s credit rating while keeping yield visible", async (ratingState, expected) => { setup({ ratingState }); expect(await screen.findByText(expected)).toBeVisible(); expect(screen.queryByText("AAA.ru")).not.toBeInTheDocument(); expect(screen.getByText("15,6262 %")).toBeVisible(); }, ); it("canonicalizes a UID link while preserving period and event filters", async () => { setup({ path: `/bonds/${uid}?period=year&events=trades`, slug: "TEST" }); await waitFor(() => expect(screen.getByTestId("location")).toHaveTextContent( "/bonds/TEST?period=year&events=trades", ), ); }); it("keeps market content and sold-position history when coupons fail", async () => { setup({ failCoupons: true, noPosition: true }); await screen.findByText("98,125 %"); expect( screen.queryByRole("heading", { name: "Моя позиция" }), ).not.toBeInTheDocument(); expect( screen.getByRole("heading", { name: "История операций" }), ).toBeVisible(); }); it( "offers a local retry for the failed coupons section only", { timeout: 10_000 }, async () => { setup({ failCoupons: true }); await screen.findByText("98,125 %"); // Automatic read retries wait 1s + 2s before the failure is final. await screen.findByRole( "button", { name: "Повторить" }, { timeout: 6000 }, ); expect(screen.getAllByRole("button", { name: "Повторить" })).toHaveLength(1); expect(screen.getByText("98,125 %")).toBeVisible(); }, ); it("preserves cached quote without manual refresh", async () => { const controls = setup({ path: `/bonds/${uid}?period=year&events=trades` }); await screen.findByText("98,125 %"); controls.failQuote(); expect(screen.getByText("98,125 %")).toBeVisible(); expect(screen.queryByRole("button", { name: "Обновить" })).not.toBeInTheDocument(); }); it("offers search for an ambiguous or missing instrument without loading personal data", async () => { const { requests } = setup({ ambiguousTicker: true, path: "/bonds/SAME" }); expect( await screen.findByText(/Тикер соответствует нескольким выпускам/), ).toBeVisible(); expect( screen.getByRole("link", { name: "К поиску облигаций" }), ).toHaveAttribute("href", "/bonds"); expect(requests.some(url => url.pathname.endsWith("/position"))).toBe( false, ); }); it("resets continuation when selecting coupons and supports changing the chart period", async () => { const { requests } = setup(); await screen.findByText("98,125 %"); fireEvent.click(await screen.findByRole("button", { name: "Далее" })); expect(await screen.findByText("Продажа")).toBeVisible(); fireEvent.click(screen.getByRole("radio", { name: "Купоны" })); await waitFor(() => expect( requests.some( url => url.searchParams.get("categories") === "income" && !url.searchParams.has("cursor"), ), ).toBe(true), ); fireEvent.click(screen.getByRole("radio", { name: "Год" })); await waitFor(() => expect( requests.some(url => url.searchParams.get("period") === "year"), ).toBe(true), ); }); });