refactor: colocate frontend component files
Frontend security / security (push) Successful in 9s
Frontend verify / verify (push) Successful in 5m10s
Conventional commits / commits (pull_request) Successful in 25s
Frontend security / security (pull_request) Successful in 9s
Frontend verify / verify (pull_request) Successful in 5m10s

This commit is contained in:
2026-09-23 08:34:58 +03:00
parent 85fcfe3d07
commit b987fd8e78
190 changed files with 131 additions and 108 deletions
@@ -0,0 +1,357 @@
import { MantineProvider } from "@mantine/core";
import { QueryClientProvider } from "@tanstack/react-query";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { HttpResponse, http } 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 { 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) => ({ value, currency: "RUB" });
const bond = {
instrumentUid: uid,
name: "Тестовая облигация",
ticker: "TEST",
nominal: money("500"),
initialNominal: money("1000"),
aci: money("12"),
maturityDate: "2030-05-15",
callDate: null,
couponQuantityPerYear: 4,
floatingCoupon: true,
amortization: true,
perpetual: false,
subordinated: false,
isin: "TESTISIN",
currency: "RUB",
sector: "financial",
lot: 10,
logoName: null,
historyFrom: null,
};
const position = {
quantity: "10",
currentValue: money("3000"),
averagePurchasePrice: money("200"),
absoluteReturn: money("1000"),
returnPercent: "50",
accounts: [
{
instrumentUid: uid,
quantity: "10",
currentValue: money("3000"),
averagePurchasePrice: money("200"),
absoluteReturn: money("1000"),
returnPercent: "50",
account: { id: "main", name: "Основной" },
},
],
isStale: false,
observedAt: "2026-09-10T10:00:00Z",
};
const quote = {
pricePercent: "98.125",
cleanPrice: money("490.625"),
aci: money("12"),
dirtyPrice: money("502.625"),
priceAt: "2026-09-10T10:00:00Z",
tradingStatus: null,
changePercentagePoints: "-0.875",
previousCloseDate: "2026-09-09",
unavailable: [],
observedAt: "2026-09-10T10:00:00Z",
};
function setup(
options: {
noPosition?: boolean;
failCoupons?: boolean;
ratingState?: "active" | "withdrawn" | "missing" | "unavailable";
failBond?: boolean;
path?: string;
slug?: string;
ambiguousTicker?: boolean;
} = {},
) {
const requests: URL[] = [];
const resolved = { ...bond, slug: options.slug ?? uid };
let failQuote = false;
server.use(
http.get("*/api/v1/bonds/:uid", () =>
options.failBond
? HttpResponse.json({ code: "NOT_FOUND" }, { status: 404 })
: HttpResponse.json(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(resolved);
}),
http.get("*/api/v1/bonds/:uid/:section", ({ params, request }) => {
requests.push(new URL(request.url));
if (params.section === "metrics")
return HttpResponse.json({
creditRating:
options.ratingState === "missing" ||
options.ratingState === "unavailable"
? null
: {
agency: "NCR",
value: options.ratingState === "withdrawn" ? null : "AAA.ru",
status:
options.ratingState === "withdrawn"
? "withdrawn"
: "active",
date: "2026-04-17",
sourceUrl: "https://ratings.ru/ratings/issues/123/",
observedAt: quote.observedAt,
},
coupon: money("32.88"),
couponDate: "2026-10-27",
couponType: "COUPON_TYPE_FLOATING",
couponPeriodDays: 90,
couponYieldPercent: "27.18",
couponYieldPriceAt: quote.priceAt,
effectiveYield: {
value: "15.6262",
pricePercent: "53.384",
date: "2030-05-15",
dateType: "MATDATE",
tradeAt: quote.priceAt,
},
unavailable: options.ratingState === "unavailable" ? ["rating"] : [],
observedAt: quote.observedAt,
});
if (params.section === "quote")
return failQuote
? HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 })
: HttpResponse.json(quote);
if (params.section === "position")
return HttpResponse.json(
options.noPosition
? { ...position, quantity: "0", accounts: [] }
: position,
);
if (params.section === "candles")
return HttpResponse.json({
candles: [
{ time: "2026-09-09T00:00:00Z", close: "99.25", isComplete: true },
],
currency: null,
historyFrom: null,
observedAt: quote.observedAt,
});
if (options.failCoupons)
return HttpResponse.json(
{ code: "TBANK_UNAVAILABLE" },
{ status: 503 },
);
return HttpResponse.json({
coupons: [
{
perBond: money("32.88"),
recordDate: "2026-10-13",
number: "1",
couponType: "COUPON_TYPE_FLOATING",
periodDays: 90,
paymentDate: "2026-10-27",
timing: "upcoming",
estimatedAmount: options.noPosition ? null : money("328.8"),
},
{
perBond: money("20"),
recordDate: "2025-10-13",
paymentDate: null,
number: "2",
couponType: null,
periodDays: null,
timing: "past",
estimatedAmount: null,
},
],
from: "2021-09-10",
to: "2027-09-10",
observedAt: quote.observedAt,
positionAt: null,
estimateUnavailable: false,
positionIsStale: false,
});
}),
http.get("*/api/v1/brokerage-accounts/all/operations", ({ request }) => {
const url = new URL(request.url);
requests.push(url);
const next = url.searchParams.has("cursor");
return HttpResponse.json({
accountId: "all",
observedAt: quote.observedAt,
hasNext: !next,
nextCursor: next ? null : "next",
items: [
{
id: next ? "sold" : "dividend",
occurredAt: next ? "2022-01-02T10:00:00Z" : "2025-01-02T10:00:00Z",
type: next ? "OPERATION_TYPE_SELL" : "OPERATION_TYPE_COUPON",
category: next ? "trade" : "income",
description: "",
account: { id: "main", name: "Основной" },
payment: money("200"),
quantity: next ? "2" : null,
price: next ? money("100") : null,
},
],
});
}),
);
configureAuthClient({
accessToken: () => "synthetic",
refresh: async () => false,
clear: vi.fn(),
});
const client = createTestQueryClient();
render(
<QueryClientProvider client={client}>
<MantineProvider>
<MemoryRouter initialEntries={[options.path ?? `/bonds/${uid}`]}>
<LocationProbe />
<Routes>
<Route path="/bonds/:bondId" element={<BondPage />} />
</Routes>
</MemoryRouter>
</MantineProvider>
</QueryClientProvider>,
);
return {
requests,
failQuote: () => {
failQuote = true;
},
};
}
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();
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({ noPosition: true, failCoupons: true });
await screen.findByText("98,125 %");
expect(await screen.findByRole("alert")).toHaveTextContent(
"Не удалось загрузить этот раздел",
);
expect(
screen.queryByRole("heading", { name: "Моя позиция" }),
).not.toBeInTheDocument();
expect(
screen.getByRole("heading", { name: "Моя история событий" }),
).toBeVisible();
});
it("preserves cached quote on refresh failure", async () => {
const controls = setup();
await screen.findByText("98,125 %");
controls.failQuote();
fireEvent.click(screen.getByRole("button", { name: "Обновить цену" }));
expect(await screen.findByRole("alert")).toHaveTextContent(
"последние полученные",
);
expect(screen.getByText("98,125 %")).toBeVisible();
});
it("offers search for an ambiguous or missing instrument without loading personal data", async () => {
const { requests } = setup({ path: "/bonds/SAME", ambiguousTicker: true });
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),
);
});
});