409 lines
14 KiB
TypeScript
409 lines
14 KiB
TypeScript
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);
|
||
});
|
||
});
|