Adopt RUB-only API contract; remove currency cards, selectors, screener currency filter and multi-currency branches. BREAKING CHANGE: requires the RUB-only backend contract.
422 lines
13 KiB
TypeScript
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();
|
|
});
|