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
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:
@@ -0,0 +1,330 @@
|
||||
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 { StockPage } from "./StockPage";
|
||||
|
||||
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 stock = {
|
||||
instrumentUid: uid,
|
||||
name: "Тестовая акция",
|
||||
ticker: "TEST",
|
||||
shareType: "SHARE_TYPE_COMMON",
|
||||
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 = {
|
||||
price: money("300"),
|
||||
priceAt: "2026-09-10T10:00:00Z",
|
||||
tradingStatus: "SECURITY_TRADING_STATUS_NORMAL_TRADING",
|
||||
change: money("10"),
|
||||
changePercent: "3.448276",
|
||||
previousCloseDate: "2026-09-09",
|
||||
previousClose: money("290"),
|
||||
unavailable: [],
|
||||
observedAt: "2026-09-10T10:00:00Z",
|
||||
};
|
||||
function setup(
|
||||
options: {
|
||||
noPosition?: boolean;
|
||||
failCompany?: boolean;
|
||||
failStock?: boolean;
|
||||
path?: string;
|
||||
slug?: string;
|
||||
ambiguousTicker?: boolean;
|
||||
} = {},
|
||||
) {
|
||||
const requests: URL[] = [];
|
||||
const resolved = { ...stock, slug: options.slug ?? uid };
|
||||
let failQuote = false;
|
||||
server.use(
|
||||
http.get("*/api/v1/stocks/:uid", () =>
|
||||
options.failStock
|
||||
? HttpResponse.json({ code: "NOT_FOUND" }, { status: 404 })
|
||||
: HttpResponse.json(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(resolved);
|
||||
}),
|
||||
http.get("*/api/v1/stocks/:uid/:section", ({ params, request }) => {
|
||||
requests.push(new URL(request.url));
|
||||
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: [],
|
||||
currency: "RUB",
|
||||
historyFrom: null,
|
||||
observedAt: quote.observedAt,
|
||||
});
|
||||
if (params.section === "company")
|
||||
return options.failCompany
|
||||
? HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 })
|
||||
: HttpResponse.json({
|
||||
description: "Описание компании",
|
||||
metrics: [
|
||||
{
|
||||
key: "peRatioTtm",
|
||||
value: 5.6,
|
||||
unit: "ratio",
|
||||
currency: null,
|
||||
period: "TTM",
|
||||
},
|
||||
],
|
||||
unavailable: [],
|
||||
observedAt: quote.observedAt,
|
||||
});
|
||||
return HttpResponse.json({
|
||||
dividends: [
|
||||
{
|
||||
perShare: money("32.88"),
|
||||
recordDate: "2026-10-13",
|
||||
lastBuyDate: "2026-10-12",
|
||||
paymentDate: "2026-10-27",
|
||||
timing: "upcoming",
|
||||
estimatedAmount: options.noPosition ? null : money("328.8"),
|
||||
},
|
||||
{
|
||||
perShare: money("20"),
|
||||
recordDate: "2025-10-13",
|
||||
paymentDate: null,
|
||||
lastBuyDate: 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_DIVIDEND",
|
||||
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 ?? `/stocks/${uid}`]}>
|
||||
<LocationProbe />
|
||||
<Routes>
|
||||
<Route path="/stocks/:stockId" element={<StockPage />} />
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
</MantineProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
return {
|
||||
requests,
|
||||
failQuote: () => {
|
||||
failQuote = true;
|
||||
},
|
||||
};
|
||||
}
|
||||
describe("stock page", () => {
|
||||
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({ path: "/stocks/SAME", ambiguousTicker: true });
|
||||
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();
|
||||
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.getByText("Событие компании")).toBeVisible();
|
||||
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());
|
||||
});
|
||||
it("keeps sold-position history and market content when company data fails", async () => {
|
||||
setup({ noPosition: true, failCompany: true });
|
||||
expect(await screen.findByText("Зачисление дивидендов")).toBeVisible();
|
||||
expect(
|
||||
screen.queryByRole("heading", { name: "Моя позиция" }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("heading", { name: "График цены" })).toBeVisible();
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent(
|
||||
"Не удалось загрузить этот раздел",
|
||||
);
|
||||
});
|
||||
it("sends instrument-filtered continuation, resets it on filter changes 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),
|
||||
);
|
||||
fireEvent.click(screen.getByRole("radio", { name: "Год" }));
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
requests.some((url) => url.searchParams.get("period") === "year"),
|
||||
).toBe(true),
|
||||
);
|
||||
});
|
||||
it("keeps the last quote on a refresh failure", async () => {
|
||||
const controls = setup();
|
||||
await screen.findByText("300 ₽");
|
||||
controls.failQuote();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Обновить цену" }));
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent(
|
||||
"последние полученные",
|
||||
);
|
||||
expect(screen.getByText("300 ₽")).toBeVisible();
|
||||
});
|
||||
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",
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user