feat: add stock pages and ticker navigation

Add stock search and detail pages with charts, dividends and shared event rows.

Use verified ticker links for portfolio names and logos, numeric dates and refreshed account cards.

Update the generated API contract.
This commit is contained in:
2026-09-10 21:23:41 +03:00
parent 0306526d33
commit 561222e468
56 changed files with 5051 additions and 190 deletions
+79 -2
View File
@@ -121,6 +121,83 @@ function emptyProps() {
}
describe("AccountPage states", () => {
it.each(["account-1", "all"])(
"uses verified ticker hrefs for both name and logo in %s",
async (accountId) => {
const uid = "c190ff1f-1447-4227-b543-316332699ca5";
const requests = vi.fn();
const position = {
...portfolio.positions[0],
instrumentUid: uid,
ticker: "SBERP",
displayName: "Сбербанк привилегированные",
};
server.use(
http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () =>
HttpResponse.json({
...portfolio,
positions:
accountId === "all"
? ["account-1", "account-2"].map((id) => ({
...position,
account: { id, name: id },
}))
: [position],
}),
),
http.get("*/api/v1/stocks/:uid", () => {
requests();
return HttpResponse.json({ instrumentUid: uid, slug: "SBERP" });
}),
);
configureTestAuth();
renderPage(<AccountPage accountId={accountId} />);
await waitFor(() => {
const links = screen.getAllByRole("link", {
name: position.displayName,
});
expect(links).toHaveLength(2);
for (const link of links)
expect(link).toHaveAttribute("href", "/stocks/SBERP");
});
expect(requests).toHaveBeenCalledOnce();
},
);
it.each(["unavailable", "noncanonical"])(
"keeps exact UID links and the portfolio visible when ticker is %s",
async (scenario) => {
const uid = "c190ff1f-1447-4227-b543-316332699ca5";
const requests = vi.fn();
server.use(
http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () =>
HttpResponse.json({
...portfolio,
positions: [
{
...portfolio.positions[0],
instrumentUid: uid,
ticker: "SBERP",
},
],
}),
),
http.get("*/api/v1/stocks/:uid", () => {
requests();
return scenario === "unavailable"
? HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 })
: HttpResponse.json({ instrumentUid: uid, slug: uid });
}),
);
renderAccountRoute(createTestQueryClient());
await waitFor(() => expect(requests).toHaveBeenCalledOnce());
const links = await screen.findAllByRole("link", { name: "Вторая" });
expect(links).toHaveLength(2);
for (const link of links)
expect(link).toHaveAttribute("href", `/stocks/${uid}`);
},
);
it("keeps the account and positions visible for an older HTTP response without structure", async () => {
server.use(
http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () =>
@@ -715,11 +792,11 @@ describe("AccountPage states", () => {
expect(titles.map((title) => text.indexOf(title))).toEqual(
titles.map((title) => text.indexOf(title)).sort((a, b) => a - b),
);
expect(content.getByText("10 сент. 2026 г. · RU000A0JX0J2")).toBeVisible();
expect(content.getByText("10.09.2026 · RU000A0JX0J2")).toBeVisible();
expect(content.getAllByText(/RU000A0JX0J2$/)).toHaveLength(4);
expect(content.queryByText(/Дата события/)).not.toBeInTheDocument();
expect(
content.getByText("11 сент. 2026 г. · Дата реестра · RU000A0JX0J2"),
content.getByText("11.09.2026 · Дата реестра · RU000A0JX0J2"),
).toBeVisible();
for (const amount of ["850,50 ₽", "0 USD", "1 000 ₽", "Сумма неизвестна"])
expect(content.getByText(amount)).toBeVisible();