Files
frontend/src/features/tbank-connection/ui/TbankConnectionSection/TbankConnectionSection.test.tsx
T
ksv741 7e976c330c
Frontend verify / commits (pull_request) Successful in 30s
Frontend verify / security (pull_request) Successful in 11s
Frontend verify / verify (pull_request) Successful in 8m25s
refactor: simplify errors and focus tests
2026-09-28 21:25:28 +03:00

409 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { MantineProvider } from "@mantine/core";
import { type QueryClient, QueryClientProvider } from "@tanstack/react-query";
import {
fireEvent,
render,
screen,
waitFor,
within,
} from "@testing-library/react";
import { http, HttpResponse } from "msw";
import { describe, expect, it, vi } from "vitest";
import { apiQueryClient, configureAuthClient } from "@/shared/api";
import { createTestQueryClient } from "@/test/query";
import { server } from "@/test/server";
import { tbankConnectionQueryOptions } from "../../model/connection-boundary";
import { TbankConnectionSection } from "./TbankConnectionSection";
const brokerageAccountsQueryKey = apiQueryClient.queryOptions(
"get",
"/api/v1/brokerage-accounts",
).queryKey;
const portfolioQueryKey = apiQueryClient.queryOptions(
"get",
"/api/v1/brokerage-accounts/{accountId}/portfolio",
{ params: { path: { accountId: "account-1" } } },
).queryKey;
const operationsQueryKey = apiQueryClient.queryOptions(
"get",
"/api/v1/brokerage-accounts/{accountId}/operations",
{ params: { path: { accountId: "account-1" } } },
).queryKey;
const stockQueryKey = apiQueryClient.queryOptions(
"get",
"/api/v1/stocks/{uid}/position",
{ params: { path: { uid: "e6123145-9665-43e0-8413-cd61b8aa9b13" } } },
).queryKey;
const bondQueryKey = apiQueryClient.queryOptions(
"get",
"/api/v1/bonds/{uid}/position",
{ params: { path: { uid: "e6123145-9665-43e0-8413-cd61b8aa9b13" } } },
).queryKey;
const bankCardsQueryKey = apiQueryClient.queryOptions(
"get",
"/api/v1/bank-cards",
{
params: { query: { includeArchived: "false" } },
},
).queryKey;
const unrelatedQueryKey = apiQueryClient.queryOptions(
"get",
"/api/v1/auth/me",
).queryKey;
function renderSection(client: QueryClient) {
configureAuthClient({
accessToken: () => "test-token",
clear: vi.fn(),
refresh: () => Promise.resolve(true),
});
return render(
<MantineProvider>
<QueryClientProvider client={client}>
<TbankConnectionSection />
</QueryClientProvider>
</MantineProvider>,
);
}
function seededClient() {
const client = createTestQueryClient();
client.setQueryData(brokerageAccountsQueryKey, [
{
id: "account-1",
name: "Основной счет",
openedOn: null,
type: "BROKERAGE",
},
]);
client.setQueryData(portfolioQueryKey, { source: "previous-connection" });
client.setQueryData(stockQueryKey, { source: "previous-connection" });
client.setQueryData(bondQueryKey, { source: "previous-connection" });
client.setQueryData(unrelatedQueryKey, { id: "user-1" });
client.setQueryData(bankCardsQueryKey, [{ id: "bank-card-1" }]);
return client;
}
describe("TbankConnectionSection", () => {
it("preserves cached status and a draft entered during a delayed failed refetch", async () => {
const status = { connected: true, connectedAt: "2026-08-14T00:00:00Z" };
const { promise: response, resolve: finishRefetch }
= Promise.withResolvers<void>();
let reads = 0;
server.use(
http.get("*/api/v1/tbank-connection", async () => {
reads += 1;
if (reads === 1) {
await response;
return HttpResponse.json(
{ code: "TBANK_UNAVAILABLE" },
{ status: 503 },
);
}
return HttpResponse.json(status);
}),
);
const client = seededClient();
client.setQueryData(tbankConnectionQueryOptions().queryKey, status);
renderSection(client);
await waitFor(() => expect(reads).toBe(1));
const token = screen.getByLabelText("Token T-Bank");
fireEvent.change(token, { target: { value: "replacement-draft" } });
finishRefetch?.();
expect(
await screen.findByText(/Не удалось обновить статус подключения/),
).toBeVisible();
expect(screen.getByLabelText("Token T-Bank")).toBe(token);
expect(token).toHaveValue("replacement-draft");
expect(screen.getByText("Подключено с 14.08.2026")).toBeVisible();
expect(client.getQueryData(tbankConnectionQueryOptions().queryKey)).toEqual(
status,
);
expect(client.getQueryData(portfolioQueryKey)).toEqual({
source: "previous-connection",
});
fireEvent.click(screen.getByRole("button", { name: "Повторить" }));
await waitFor(() => expect(reads).toBe(2));
await waitFor(() =>
expect(
screen.queryByText(/Не удалось обновить статус подключения/),
).not.toBeInTheDocument(),
);
expect(token).toHaveValue("replacement-draft");
});
it("shows a cold-start error without a form and recovers by retrying status", async () => {
let reads = 0;
server.use(
http.get("*/api/v1/tbank-connection", () => {
reads += 1;
return reads === 1
? HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 })
: HttpResponse.json(
{ connected: false, connectedAt: null },
);
}),
);
renderSection(createTestQueryClient());
expect(
await screen.findByText(/Не удалось загрузить статус подключения/),
).toBeVisible();
expect(screen.queryByLabelText("Token T-Bank")).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Повторить" }));
expect(await screen.findByLabelText("Token T-Bank")).toBeVisible();
expect(reads).toBe(2);
});
it("commits connection status and resets the complete brokerage query subtree after connecting", async () => {
const client = seededClient();
renderSection(client);
await screen.findByText("Не подключено");
fireEvent.change(screen.getByLabelText("Token T-Bank"), {
target: { value: "replacement-token" },
});
fireEvent.click(screen.getByRole("button", { name: "Подключить" }));
expect(
await screen.findByRole("button", { name: "Заменить token" }),
).toBeInTheDocument();
expect(client.getQueryData(brokerageAccountsQueryKey)).toBeUndefined();
expect(client.getQueryData(bankCardsQueryKey)).toEqual([
{ id: "bank-card-1" },
]);
expect(client.getQueryState(bankCardsQueryKey)?.isInvalidated).toBe(false);
expect(client.getQueryData(portfolioQueryKey)).toBeUndefined();
expect(client.getQueryData(stockQueryKey)).toBeUndefined();
expect(client.getQueryData(bondQueryKey)).toBeUndefined();
expect(client.getQueryData(unrelatedQueryKey)).toEqual({ id: "user-1" });
});
it("creates the same financial boundary when an existing connection is replaced", async () => {
server.use(
http.get("*/api/v1/tbank-connection", () =>
HttpResponse.json({
connected: true,
connectedAt: "2026-08-13T00:00:00Z",
}),
),
);
const client = seededClient();
renderSection(client);
await screen.findByRole("button", { name: "Заменить token" });
fireEvent.change(screen.getByLabelText("Token T-Bank"), {
target: { value: "replacement-token" },
});
fireEvent.click(screen.getByRole("button", { name: "Заменить token" }));
await waitFor(() =>
expect(client.getQueryData(portfolioQueryKey)).toBeUndefined(),
);
expect(client.getQueryData(brokerageAccountsQueryKey)).toBeUndefined();
expect(client.getQueryData(bankCardsQueryKey)).toEqual([
{ id: "bank-card-1" },
]);
expect(client.getQueryState(bankCardsQueryKey)?.isInvalidated).toBe(false);
});
it("commits disconnected status and resets financial queries after deleting", async () => {
server.use(
http.get("*/api/v1/tbank-connection", () =>
HttpResponse.json({
connected: true,
connectedAt: "2026-08-14T00:00:00Z",
}),
),
);
const client = seededClient();
renderSection(client);
fireEvent.click(
await screen.findByRole("button", { name: "Удалить подключение" }),
);
fireEvent.click(await screen.findByRole("button", { name: /^Удалить$/ }));
expect(
await screen.findByRole("button", { name: "Подключить" }),
).toBeInTheDocument();
expect(client.getQueryData(brokerageAccountsQueryKey)).toBeUndefined();
expect(client.getQueryData(bankCardsQueryKey)).toEqual([
{ id: "bank-card-1" },
]);
expect(client.getQueryState(bankCardsQueryKey)?.isInvalidated).toBe(false);
expect(client.getQueryData(portfolioQueryKey)).toBeUndefined();
expect(client.getQueryData(stockQueryKey)).toBeUndefined();
expect(client.getQueryData(bondQueryKey)).toBeUndefined();
expect(client.getQueryData(unrelatedQueryKey)).toEqual({ id: "user-1" });
});
it("cancels an in-flight previous-boundary query and ignores its late response", async () => {
const client = seededClient();
let isAborted = false;
const { promise: previousResponse, resolve: finishPrevious }
= Promise.withResolvers<{ source: string }>();
const previousRequest = client
.query({
queryFn: ({ signal }) => {
signal.addEventListener("abort", () => {
isAborted = true;
});
return previousResponse;
},
queryKey: operationsQueryKey,
})
// eslint-disable-next-line unicorn/prefer-await -- Attach the handler immediately and retain the pending promise for queue/race ordering.
.catch(() => {});
renderSection(client);
await screen.findByText("Не подключено");
fireEvent.change(screen.getByLabelText("Token T-Bank"), {
target: { value: "replacement-token" },
});
fireEvent.click(screen.getByRole("button", { name: "Подключить" }));
await waitFor(() => expect(isAborted).toBe(true));
await previousRequest;
finishPrevious?.({ source: "previous-connection" });
await Promise.resolve();
expect(client.getQueryData(operationsQueryKey)).toBeUndefined();
});
it.each([
["INVALID_TBANK_TOKEN", "Проверьте token и его права."],
["TBANK_UNAVAILABLE", "T-Bank временно недоступен. Повторите попытку позже."],
[undefined, "Не удалось выполнить запрос. Попробуйте ещё раз."],
])("preserves the draft and cache after %s, then retries successfully", async (code, message) => {
const { promise: response, resolve: finishUpdate }
= Promise.withResolvers<Response>();
const update = vi.fn()
.mockImplementationOnce(() =>
HttpResponse.json({ code }, { status: 503 }),
)
.mockImplementationOnce(() => response);
server.use(http.put("*/api/v1/tbank-connection", update));
const client = seededClient();
renderSection(client);
await screen.findByText("Не подключено");
const token = screen.getByLabelText("Token T-Bank");
fireEvent.change(token, { target: { value: "replacement-token" } });
fireEvent.click(screen.getByRole("button", { name: "Подключить" }));
await screen.findByText(message);
expect(token).toHaveValue("replacement-token");
expect(screen.getByText("Не подключено")).toBeVisible();
expect(client.getQueryData(brokerageAccountsQueryKey)).toBeDefined();
expect(client.getQueryData(portfolioQueryKey)).toEqual({
source: "previous-connection",
});
fireEvent.click(screen.getByRole("button", { name: "Подключить" }));
await waitFor(() => expect(update).toHaveBeenCalledTimes(2));
expect(screen.queryByText(message)).not.toBeInTheDocument();
expect(token).toBeDisabled();
expect(token).toHaveValue("replacement-token");
finishUpdate(HttpResponse.json({
connected: true, connectedAt: "2026-08-14T00:00:00Z",
}));
expect(await screen.findByText("Подключено с 14.08.2026")).toBeVisible();
expect(token).toHaveValue("");
expect(screen.queryByText(message)).not.toBeInTheDocument();
});
it("keeps DELETE recovery inside the dialog and preserves cache until successful retry", async () => {
let deletes = 0;
const { promise: response, resolve: finishDelete }
= Promise.withResolvers<void>();
server.use(
http.get("*/api/v1/tbank-connection", () =>
HttpResponse.json({
connected: true,
connectedAt: "2026-08-14T00:00:00Z",
}),
),
http.delete("*/api/v1/tbank-connection", async () => {
deletes += 1;
if (deletes === 1) await response;
return deletes === 1
? HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 })
: new HttpResponse(null, { status: 204 });
}),
);
const client = seededClient();
renderSection(client);
fireEvent.click(
await screen.findByRole("button", { name: "Удалить подключение" }),
);
const dialog = await screen.findByRole("dialog", {
name: "Удалить подключение?",
});
await waitFor(() =>
// eslint-disable-next-line testing-library/no-node-access -- This assertion checks DOM structure, decoration or focus behavior that semantic queries cannot establish.
expect(dialog.contains(document.activeElement)).toBe(true),
);
const confirm = within(dialog).getByRole("button", { name: /^Удалить$/ });
confirm.focus();
fireEvent.click(confirm);
await waitFor(() => expect(confirm).toBeDisabled());
expect(
within(dialog).getByRole("button", { name: "Закрыть" }),
).toBeDisabled();
// JSDOM does not move focus when a focused button becomes disabled.
document.body.tabIndex = -1;
document.body.focus();
document.body.removeAttribute("tabindex");
expect(document.body).toHaveFocus();
finishDelete?.();
expect(await within(dialog).findByRole("alert")).toHaveTextContent(
"Не удалось выполнить запрос. Попробуйте ещё раз.",
);
expect(confirm).toHaveFocus();
expect(
screen.getByRole("button", { name: "Заменить token" }),
).toBeInTheDocument();
expect(client.getQueryData(brokerageAccountsQueryKey)).toBeDefined();
expect(client.getQueryData(portfolioQueryKey)).toEqual({
source: "previous-connection",
});
fireEvent.click(within(dialog).getByRole("button", { name: /^Удалить$/ }));
await waitFor(() =>
expect(screen.queryByRole("dialog")).not.toBeInTheDocument(),
);
expect(screen.getByText("Не подключено")).toBeVisible();
expect(client.getQueryData(portfolioQueryKey)).toBeUndefined();
expect(deletes).toBe(2);
});
});