415 lines
12 KiB
TypeScript
415 lines
12 KiB
TypeScript
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 (
|
|
<output data-testid="location">
|
|
{location.pathname}
|
|
|
|
{location.search}
|
|
</output>
|
|
);
|
|
};
|
|
|
|
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(
|
|
<QueryClientProvider client={client}>
|
|
<MantineProvider>
|
|
<MemoryRouter initialEntries={[options.path ?? `/bonds/${uid}`]}>
|
|
<LocationProbe />
|
|
|
|
<Routes>
|
|
<Route element={<BondPage />} path="/bonds/:bondId" />
|
|
</Routes>
|
|
</MemoryRouter>
|
|
</MantineProvider>
|
|
</QueryClientProvider>,
|
|
);
|
|
|
|
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),
|
|
);
|
|
});
|
|
});
|