301 lines
8.2 KiB
TypeScript
301 lines
8.2 KiB
TypeScript
import { MantineProvider } from "@mantine/core";
|
|
import { QueryClientProvider } from "@tanstack/react-query";
|
|
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
|
import { http, HttpResponse } from "msw";
|
|
import { MemoryRouter } from "react-router";
|
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
|
|
import { configureAuthClient } from "@/shared/api";
|
|
import { createTestQueryClient } from "@/test/query";
|
|
import { server } from "@/test/server";
|
|
|
|
import { StocksPage } from "./StocksPage";
|
|
import { useStocksPageVm } from "./StocksPage.vm";
|
|
|
|
const pageOperations: Promise<void>[] = [];
|
|
|
|
const StocksPageVmProbe = () => {
|
|
const vm = useStocksPageVm();
|
|
|
|
return (
|
|
<>
|
|
<button
|
|
onClick={() => {
|
|
pageOperations.push(vm.search());
|
|
}}
|
|
>
|
|
search
|
|
</button>
|
|
<button
|
|
onClick={() => {
|
|
pageOperations.push(vm.changePage(2));
|
|
}}
|
|
>
|
|
page
|
|
</button>
|
|
<output>{vm.items.map(item => item.name).join(", ")}</output>
|
|
</>
|
|
);
|
|
};
|
|
|
|
afterEach(() => {
|
|
pageOperations.length = 0;
|
|
|
|
return vi.useRealTimers();
|
|
});
|
|
|
|
describe("stock search", () => {
|
|
it("waits for a meaningful query and uses the server-verified ticker link", async () => {
|
|
const query = vi.fn();
|
|
server.use(
|
|
http.post("*/api/v1/stocks/screener/search", async ({ request }) => {
|
|
query(await request.json());
|
|
|
|
return HttpResponse.json({
|
|
asOf: "2026-10-10T10:00:00Z",
|
|
hasNext: false,
|
|
items: [{
|
|
countryOfRisk: "RU",
|
|
currency: "RUB",
|
|
instrumentUid: "e6123145-9665-43e0-8413-cd61b8aa9b13",
|
|
isin: "RU0009029540",
|
|
logoName: "sber.png",
|
|
lot: 10,
|
|
name: "Сбербанк",
|
|
realExchange: "REAL_EXCHANGE_MOEX",
|
|
sector: "financial",
|
|
shareType: "SHARE_TYPE_COMMON",
|
|
ticker: "SBER",
|
|
}],
|
|
page: 1,
|
|
searchId: "search-id",
|
|
});
|
|
}),
|
|
);
|
|
configureAuthClient({
|
|
accessToken: () => "synthetic",
|
|
clear: vi.fn(),
|
|
refresh: () => Promise.resolve(false),
|
|
});
|
|
render(
|
|
<QueryClientProvider client={createTestQueryClient()}>
|
|
<MantineProvider>
|
|
<MemoryRouter>
|
|
<StocksPage />
|
|
</MemoryRouter>
|
|
</MantineProvider>
|
|
</QueryClientProvider>,
|
|
);
|
|
const input = screen.getByLabelText("Название или тикер");
|
|
fireEvent.change(input, { target: { value: "SBER" } });
|
|
expect(query).not.toHaveBeenCalled();
|
|
fireEvent.click(screen.getByRole("button", { name: "Найти" }));
|
|
expect(
|
|
await screen.findByRole("link", { name: /Сбербанк/ }),
|
|
).toHaveAttribute("href", "/stocks/e6123145-9665-43e0-8413-cd61b8aa9b13");
|
|
expect(screen.getByText("Список получен 10.10.2026, 10:00")).toBeVisible();
|
|
expect(screen.getByText("Лот: 10 шт.")).toBeVisible();
|
|
expect(screen.getByText("ISIN: RU0009029540")).toBeVisible();
|
|
expect(screen.getAllByText("Мосбиржа")[0]).toBeVisible();
|
|
expect(screen.getByAltText("")).toHaveAttribute(
|
|
"src",
|
|
"https://invest-brands.cdn-tinkoff.ru/sberx160.png",
|
|
);
|
|
await waitFor(() => expect(query).toHaveBeenCalledExactlyOnceWith({ query: "SBER" }));
|
|
});
|
|
|
|
it("searches by the selected sector without a text query", async () => {
|
|
const query = vi.fn();
|
|
server.use(
|
|
http.post("*/api/v1/stocks/screener/search", async ({ request }) => {
|
|
query(await request.json());
|
|
|
|
return HttpResponse.json({
|
|
asOf: "2026-10-10T10:00:00Z",
|
|
hasNext: false,
|
|
items: [],
|
|
page: 1,
|
|
searchId: "sector-search",
|
|
});
|
|
}),
|
|
);
|
|
configureAuthClient({
|
|
accessToken: () => "synthetic",
|
|
clear: vi.fn(),
|
|
refresh: () => Promise.resolve(false),
|
|
});
|
|
render(
|
|
<QueryClientProvider client={createTestQueryClient()}>
|
|
<MantineProvider>
|
|
<MemoryRouter>
|
|
<StocksPage />
|
|
</MemoryRouter>
|
|
</MantineProvider>
|
|
</QueryClientProvider>,
|
|
);
|
|
fireEvent.click(screen.getByRole("combobox", { name: "Сектор" }));
|
|
fireEvent.click(await screen.findByRole("option", { name: "Энергетика" }));
|
|
fireEvent.click(screen.getByRole("button", { name: "Найти" }));
|
|
|
|
await waitFor(() => expect(query).toHaveBeenCalledExactlyOnceWith({
|
|
sector: "energy",
|
|
}));
|
|
});
|
|
|
|
it("shows known result pages and reads the selected page", async () => {
|
|
const requestedPages = vi.fn();
|
|
server.use(
|
|
http.post("*/api/v1/stocks/screener/search", () => HttpResponse.json({
|
|
asOf: "2026-10-10T10:00:00Z",
|
|
hasNext: true,
|
|
items: [],
|
|
page: 1,
|
|
searchId: "paged-search",
|
|
})),
|
|
http.get("*/api/v1/stocks/screener/search/paged-search", ({ request }) => {
|
|
const page = Number(new URL(request.url).searchParams.get("page"));
|
|
requestedPages(page);
|
|
|
|
return HttpResponse.json({
|
|
asOf: "2026-10-10T10:00:00Z",
|
|
hasNext: false,
|
|
items: [],
|
|
page,
|
|
searchId: "paged-search",
|
|
});
|
|
}),
|
|
);
|
|
configureAuthClient({
|
|
accessToken: () => "synthetic",
|
|
clear: vi.fn(),
|
|
refresh: () => Promise.resolve(false),
|
|
});
|
|
render(
|
|
<QueryClientProvider client={createTestQueryClient()}>
|
|
<MantineProvider>
|
|
<MemoryRouter>
|
|
<StocksPage />
|
|
</MemoryRouter>
|
|
</MantineProvider>
|
|
</QueryClientProvider>,
|
|
);
|
|
fireEvent.click(screen.getByRole("button", { name: "Найти" }));
|
|
fireEvent.click(await screen.findByRole(
|
|
"button",
|
|
{ name: "Страница 2" },
|
|
));
|
|
|
|
await waitFor(() =>
|
|
expect(requestedPages).toHaveBeenCalledExactlyOnceWith(2),
|
|
);
|
|
});
|
|
|
|
it("keeps a newer search when an earlier page request finishes late", async () => {
|
|
const oldPage = Promise.withResolvers<void>();
|
|
let searches = 0;
|
|
|
|
server.use(
|
|
http.post("*/api/v1/stocks/screener/search", () => {
|
|
searches += 1;
|
|
|
|
return HttpResponse.json({
|
|
asOf: "2026-10-10T10:00:00Z",
|
|
hasNext: searches === 1,
|
|
items: [{
|
|
countryOfRisk: "RU",
|
|
currency: "RUB",
|
|
instrumentUid: searches === 1 ? "old" : "new",
|
|
isin: null,
|
|
logoName: null,
|
|
lot: 1,
|
|
name: searches === 1 ? "Старый" : "Новый",
|
|
realExchange: "REAL_EXCHANGE_MOEX",
|
|
sector: "financial",
|
|
shareType: "SHARE_TYPE_COMMON",
|
|
ticker: searches === 1 ? "OLD" : "NEW",
|
|
}],
|
|
page: 1,
|
|
searchId: searches === 1 ? "old-search" : "new-search",
|
|
});
|
|
}),
|
|
http.get("*/api/v1/stocks/screener/search/old-search", async () => {
|
|
await oldPage.promise;
|
|
|
|
return HttpResponse.json({
|
|
asOf: "2026-10-10T10:00:00Z",
|
|
hasNext: false,
|
|
items: [{
|
|
countryOfRisk: "RU",
|
|
currency: "RUB",
|
|
instrumentUid: "old-page",
|
|
isin: null,
|
|
logoName: null,
|
|
lot: 1,
|
|
name: "Старый результат",
|
|
realExchange: "REAL_EXCHANGE_MOEX",
|
|
sector: "financial",
|
|
shareType: "SHARE_TYPE_COMMON",
|
|
ticker: "OLD",
|
|
}],
|
|
page: 2,
|
|
searchId: "old-search",
|
|
});
|
|
}),
|
|
);
|
|
render(<StocksPageVmProbe />);
|
|
fireEvent.click(screen.getByRole("button", { name: "search" }));
|
|
await screen.findByText("Старый");
|
|
fireEvent.click(screen.getByRole("button", { name: "page" }));
|
|
fireEvent.click(screen.getByRole("button", { name: "search" }));
|
|
await screen.findByText("Новый");
|
|
await act(async () => {
|
|
oldPage.resolve();
|
|
await pageOperations[0];
|
|
});
|
|
|
|
expect(screen.getByText("Новый")).toBeVisible();
|
|
expect(screen.queryByText("Старый результат")).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("disables filters and shows loading while searching", async () => {
|
|
const { promise, resolve } = Promise.withResolvers<void>();
|
|
server.use(
|
|
http.post("*/api/v1/stocks/screener/search", async () => {
|
|
await promise;
|
|
|
|
return HttpResponse.json({
|
|
asOf: "2026-10-10T10:00:00Z",
|
|
hasNext: false,
|
|
items: [],
|
|
page: 1,
|
|
searchId: "pending-search",
|
|
});
|
|
}),
|
|
);
|
|
configureAuthClient({
|
|
accessToken: () => "synthetic",
|
|
clear: vi.fn(),
|
|
refresh: () => Promise.resolve(false),
|
|
});
|
|
render(
|
|
<QueryClientProvider client={createTestQueryClient()}>
|
|
<MantineProvider>
|
|
<MemoryRouter>
|
|
<StocksPage />
|
|
</MemoryRouter>
|
|
</MantineProvider>
|
|
</QueryClientProvider>,
|
|
);
|
|
fireEvent.click(screen.getByRole("button", { name: "Найти" }));
|
|
|
|
await waitFor(() => expect(
|
|
screen.getByRole("combobox", { name: "Сектор" }),
|
|
).toBeDisabled());
|
|
expect(screen.getByRole("button", { name: "Найти" })).toHaveAttribute(
|
|
"data-loading",
|
|
"true",
|
|
);
|
|
resolve();
|
|
});
|
|
});
|