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(
,
);
}
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();
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();
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();
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);
});
});