Files
frontend/src/pages/accounts/ui/AccountsPage/AccountsPage.test.tsx
T
ksv741 b40066838c
Frontend verify / commits (pull_request) Successful in 35s
Frontend verify / security (pull_request) Successful in 10s
Frontend verify / verify (pull_request) Successful in 7m20s
refactor!: support RUB only (WealthLedger/main#55)
Adopt RUB-only API contract; remove currency cards, selectors,
screener currency filter and multi-currency branches.

BREAKING CHANGE: requires the RUB-only backend contract.
2026-11-11 08:22:44 +03:00

422 lines
13 KiB
TypeScript

import { MantineProvider } from "@mantine/core";
import { type QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { http, HttpResponse } from "msw";
import { MemoryRouter } from "react-router";
import { describe, expect, it, vi } from "vitest";
import { brokerageAccountsQueryKey } from "@/entities/brokerage-account";
import { configureAuthClient } from "@/shared/api";
import { createTestQueryClient } from "@/test/query";
import { resource } from "@/test/resource";
import { server } from "@/test/server";
import { AccountsPage } from "./AccountsPage";
import { AccountsPageEmpty } from "./components/AccountsPageEmpty.view";
import { AccountsPageError } from "./components/AccountsPageError.view";
import { AccountsPageLoading } from "./components/AccountsPageLoading.view";
const account = {
id: "account-1",
name: "Основной",
openedOn: "2026-01-01",
type: "BROKERAGE",
} as const;
function configureTestAuth() {
configureAuthClient({
accessToken: () => "test-token",
clear: vi.fn(),
refresh: () => Promise.resolve(true),
});
}
function portfolioSummary(totals: { currency: string; value: string }) {
return {
bondPositionCount: 1,
cash: { currency: "RUB", value: "0" },
fundPositionCount: 0,
stockPositionCount: 0,
structure: {
categories: [],
hasNegativeValues: false,
totalValue: "0",
unvaluedCategories: [],
},
totalPositionCount: 1,
totals,
unrealizedReturn: {
absolute: "0",
costBasis: "0",
partial: false,
percent: null,
},
};
}
function renderPage(queryClient: QueryClient) {
configureTestAuth();
return render(
<QueryClientProvider client={queryClient}>
<MantineProvider>
<MemoryRouter>
<AccountsPage />
</MemoryRouter>
</MantineProvider>
</QueryClientProvider>,
);
}
describe("AccountsPage", () => {
it("summarizes account totals and reports excluded accounts", async () => {
server.use(http.get("*/api/v1/brokerage-accounts", () =>
HttpResponse.json(resource([
{
...account,
portfolioSummary: portfolioSummary({ currency: "RUB", value: "100.10" }),
},
{
...account,
id: "account-2",
name: "Второй",
portfolioSummary: portfolioSummary({ currency: "RUB", value: "50.20" }),
},
{
...account,
id: "account-3",
name: "Без сводки",
portfolioSummary: null,
},
{
...account,
id: "account-4",
name: "Только деньги",
portfolioSummary: {
...portfolioSummary({ currency: "RUB", value: "25" }),
bondPositionCount: 0,
cash: { currency: "RUB", value: "25" },
totalPositionCount: 0,
},
},
{
...account,
id: "account-5",
name: "Без сумм",
portfolioSummary: {
...portfolioSummary({ currency: "RUB", value: "0" }),
totalPositionCount: 0,
},
},
])),
));
const client = createTestQueryClient();
renderPage(client);
expect(await screen.findByText("175,30 ₽")).toBeInTheDocument();
expect(screen.queryByText("Ваши инвестиции по счетам"))
.not.toBeInTheDocument();
expect(screen.getByText("5 счетов")).toBeInTheDocument();
expect(screen.getByText("Нет текущих сумм")).toBeInTheDocument();
expect(screen.getByText(
"Ещё 1 счёт не включён: нет актуального портфеля",
)).toBeInTheDocument();
});
it("loads accounts after retrying an initial HTTP failure", async () => {
const modes: (null | string)[] = [];
server.use(
http.get("*/api/v1/brokerage-accounts", ({ request }) => {
modes.push(new URL(request.url).searchParams.get("sync"));
return modes.length === 1
? HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 })
: HttpResponse.json(resource([account]));
}),
);
renderPage(createTestQueryClient());
expect(await screen.findByText(account.name, undefined, {
timeout: 4000,
})).toBeVisible();
expect(modes).toEqual([null, null]);
});
it.each([
["loading", AccountsPageLoading, "status"],
["empty", AccountsPageEmpty, undefined],
] as const)("renders only the %s state", (_name, State, role) => {
render(
<MantineProvider>
<State />
</MantineProvider>,
);
// eslint-disable-next-line vitest/no-conditional-expect -- The fixed table case selects the expected outcome; every case asserts its own branch.
if (role) expect(screen.getByRole(role)).toBeInTheDocument();
else
// eslint-disable-next-line vitest/no-conditional-expect -- The fixed table case selects the expected outcome; every case asserts its own branch.
expect(
screen.getByRole("heading", { name: "Нет инвестиционных счетов" }),
).toBeInTheDocument();
});
it("routes connection errors to settings without technical retry", () => {
render(
<MantineProvider>
<MemoryRouter>
<AccountsPageError code="TBANK_NOT_CONNECTED" />
</MemoryRouter>
</MantineProvider>,
);
expect(screen.queryByRole("button", { name: "Повторить" }))
.not.toBeInTheDocument();
expect(
screen.getByRole("link", { name: "Открыть настройки T-Bank" }),
).toHaveAttribute("href", "/settings");
});
it("keeps the cached account list visible during a background refresh", async () => {
let requestCount = 0;
let resolveRefresh: ((response: Response) => void) | undefined;
server.use(
http.get("*/api/v1/brokerage-accounts", () => {
requestCount += 1;
if (requestCount === 1) return HttpResponse.json(resource([account]));
{
const deferred = Promise.withResolvers<Response>();
resolveRefresh = deferred.resolve;
return deferred.promise;
}
}),
);
const queryClient = createTestQueryClient();
renderPage(queryClient);
await screen.findByText(account.name);
void queryClient.invalidateQueries({
queryKey: brokerageAccountsQueryKey,
});
await waitFor(() => expect(requestCount).toBe(2));
expect(screen.getByText(account.name)).toBeInTheDocument();
expect(screen.queryByRole("status", { name: "Обновляем данные" }))
.not.toBeInTheDocument();
resolveRefresh?.(HttpResponse.json(resource([account])));
});
it("keeps the cached account list quietly after a refresh error", async () => {
let requestCount = 0;
server.use(
http.get("*/api/v1/brokerage-accounts", () => {
requestCount += 1;
if (requestCount === 1) return HttpResponse.json(resource([account]));
return requestCount === 2
? HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 })
: HttpResponse.json(resource([account]));
}),
);
const queryClient = createTestQueryClient();
renderPage(queryClient);
await screen.findByText(account.name);
void queryClient.invalidateQueries({
queryKey: brokerageAccountsQueryKey,
});
await waitFor(() => expect(requestCount).toBe(2));
expect(
screen.queryByText(
"Не удалось обновить данные. Показаны последние полученные.",
),
).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Повторить" }))
.not.toBeInTheDocument();
expect(screen.getByText(account.name)).toBeInTheDocument();
});
it("keeps a cached empty account list visible after a refresh error", async () => {
let requestCount = 0;
server.use(
http.get("*/api/v1/brokerage-accounts", () => {
requestCount += 1;
return requestCount === 1
? HttpResponse.json(resource([]))
: HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 });
}),
);
const queryClient = createTestQueryClient();
renderPage(queryClient);
await screen.findByText("Нет инвестиционных счетов");
void queryClient.invalidateQueries({
queryKey: brokerageAccountsQueryKey,
});
await waitFor(() => expect(requestCount).toBe(2));
expect(
screen.queryByText(
"Не удалось обновить данные. Показаны последние полученные.",
),
).not.toBeInTheDocument();
expect(screen.getByText("Нет инвестиционных счетов")).toBeInTheDocument();
});
});
// Automatic read retries wait 1s + 2s before the failure is final.
const RETRIES_EXHAUSTED_MS = 6000;
const TEST_MS = 10_000;
describe("exhausted read retries", () => {
it(
"keeps the cached list, warns and retries the same accounts GET",
{ timeout: TEST_MS },
async () => {
let isFailing = false;
let requests = 0;
server.use(
http.get("*/api/v1/brokerage-accounts", () => {
requests += 1;
if (isFailing)
return HttpResponse.json(
{ code: "TBANK_UNAVAILABLE" },
{ status: 503 },
);
return HttpResponse.json(resource([account]));
}),
);
const queryClient = createTestQueryClient();
renderPage(queryClient);
await screen.findByText("Основной");
expect(screen.queryByRole("button", { name: "Повторить" }))
.not.toBeInTheDocument();
isFailing = true;
void queryClient.invalidateQueries({
queryKey: brokerageAccountsQueryKey,
});
const retry = await screen.findByRole(
"button",
{ name: "Повторить" },
{ timeout: RETRIES_EXHAUSTED_MS },
);
expect(screen.getByText("Не удалось обновить данные")).toBeInTheDocument();
expect(screen.getByText("Основной")).toBeInTheDocument();
expect(requests).toBe(4);
isFailing = false;
fireEvent.click(retry);
await waitFor(() =>
expect(screen.queryByText("Не удалось обновить данные"))
.not.toBeInTheDocument());
expect(requests).toBe(5);
expect(screen.getByText("Основной")).toBeInTheDocument();
},
);
it(
"offers retry on the unavailable state after an initial failure",
{ timeout: TEST_MS },
async () => {
let isFailing = true;
server.use(
http.get("*/api/v1/brokerage-accounts", () =>
isFailing
? HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 })
: HttpResponse.json(resource([account]))),
);
renderPage(createTestQueryClient());
const retry = await screen.findByRole(
"button",
{ name: "Повторить" },
{ timeout: RETRIES_EXHAUSTED_MS },
);
isFailing = false;
fireEvent.click(retry);
expect(await screen.findByText("Основной")).toBeInTheDocument();
},
);
it("does not offer a plain retry when the connection is lost", async () => {
server.use(
http.get("*/api/v1/brokerage-accounts", () =>
HttpResponse.json({ code: "TBANK_NOT_CONNECTED" }, { status: 409 })),
);
renderPage(createTestQueryClient());
expect(await screen.findByRole("link", { name: "Открыть настройки T-Bank" }))
.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Повторить" }))
.not.toBeInTheDocument();
});
});
it.each(["none", "failed"] as const)(
"keeps incomplete empty membership distinct from no accounts (attempt=%s)",
async (state) => {
server.use(
http.get("*/api/v1/brokerage-accounts", () =>
HttpResponse.json(resource([], {
completeness: "incomplete",
lastAttempt: {
finishedAt: state === "failed" ? "2026-08-14T00:01:00Z" : null,
problems: [],
startedAt: state === "none" ? null : "2026-08-14T00:00:00Z",
state,
},
})),
),
);
const client = createTestQueryClient();
renderPage(client);
await waitFor(() =>
expect(client.getQueryData(brokerageAccountsQueryKey)).toMatchObject({
data: [],
meta: { completeness: "incomplete" },
}),
);
expect(screen.queryByText("Нет инвестиционных счетов"))
.not.toBeInTheDocument();
if (state === "failed")
// eslint-disable-next-line vitest/no-conditional-expect -- Each fixed state has a different observable recovery contract.
expect(await screen.findByRole("heading", { name: "Пока недоступно" }))
.toBeVisible();
else
// eslint-disable-next-line vitest/no-conditional-expect -- Each fixed state has a different observable recovery contract.
expect(screen.getByRole("status")).toBeVisible();
},
);
it("keeps unknown membership loading instead of declaring no accounts", async () => {
server.use(
http.get("*/api/v1/brokerage-accounts", () =>
HttpResponse.json(resource(null)),
),
);
const client = createTestQueryClient();
renderPage(client);
await waitFor(() =>
expect(client.getQueryData(brokerageAccountsQueryKey)).toMatchObject(
{
data: null,
},
),
);
expect(
screen.queryByText("Нет инвестиционных счетов"),
).not.toBeInTheDocument();
expect(screen.getByRole("status")).toBeVisible();
});