fix: resolve frontend audit findings
Frontend security / security (push) Successful in 10s
Frontend verify / verify (push) Successful in 4m57s
Conventional commits / commits (pull_request) Successful in 28s
Frontend security / security (pull_request) Successful in 10s
Frontend verify / verify (pull_request) Successful in 5m10s
Frontend security / security (push) Successful in 10s
Frontend verify / verify (push) Successful in 4m57s
Conventional commits / commits (pull_request) Successful in 28s
Frontend security / security (pull_request) Successful in 10s
Frontend verify / verify (pull_request) Successful in 5m10s
Repair investment recovery, draft preservation, date validation, currency presentation, accessibility, and route error recovery. Correct Docker and dependency checks and consume explicit income roles.
This commit is contained in:
+119
-7
@@ -1,11 +1,18 @@
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import { type QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import {
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
within,
|
||||
} from "@testing-library/react";
|
||||
import { HttpResponse, http } 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(
|
||||
@@ -78,6 +85,74 @@ function seededClient() {
|
||||
}
|
||||
|
||||
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" };
|
||||
let finishRefetch: (() => void) | undefined;
|
||||
const response = new Promise<void>((resolve) => {
|
||||
finishRefetch = resolve;
|
||||
});
|
||||
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);
|
||||
@@ -225,7 +300,12 @@ describe("TbankConnectionSection", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves connected status and financial cache when deletion fails", async () => {
|
||||
it("keeps DELETE recovery inside the dialog and preserves cache until successful retry", async () => {
|
||||
let deletes = 0;
|
||||
let finishDelete: (() => void) | undefined;
|
||||
const response = new Promise<void>((resolve) => {
|
||||
finishDelete = resolve;
|
||||
});
|
||||
server.use(
|
||||
http.get("*/api/v1/tbank-connection", () =>
|
||||
HttpResponse.json({
|
||||
@@ -233,9 +313,13 @@ describe("TbankConnectionSection", () => {
|
||||
connectedAt: "2026-08-14T00:00:00Z",
|
||||
}),
|
||||
),
|
||||
http.delete("*/api/v1/tbank-connection", () =>
|
||||
HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 }),
|
||||
),
|
||||
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);
|
||||
@@ -243,9 +327,30 @@ describe("TbankConnectionSection", () => {
|
||||
fireEvent.click(
|
||||
await screen.findByRole("button", { name: "Удалить подключение" }),
|
||||
);
|
||||
fireEvent.click(await screen.findByRole("button", { name: /^Удалить$/ }));
|
||||
const dialog = await screen.findByRole("dialog", {
|
||||
name: "Удалить подключение?",
|
||||
});
|
||||
await waitFor(() =>
|
||||
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.activeElement).toBe(document.body);
|
||||
finishDelete?.();
|
||||
|
||||
await screen.findByText("Не удалось выполнить запрос. Попробуйте ещё раз.");
|
||||
expect(await within(dialog).findByRole("alert")).toHaveTextContent(
|
||||
"Не удалось выполнить запрос. Попробуйте ещё раз.",
|
||||
);
|
||||
expect(confirm).toHaveFocus();
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Заменить token" }),
|
||||
).toBeInTheDocument();
|
||||
@@ -253,5 +358,12 @@ describe("TbankConnectionSection", () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,7 +11,7 @@ const TbankConnectionSection: FC = () => {
|
||||
if (viewModel.state === "loading") return <TbankConnectionLoading />;
|
||||
if (viewModel.state === "error")
|
||||
return <TbankConnectionError retry={viewModel.retry} />;
|
||||
return <TbankConnectionSectionLoaded status={viewModel.status} />;
|
||||
return <TbankConnectionSectionLoaded {...viewModel} />;
|
||||
};
|
||||
|
||||
export { TbankConnectionSection };
|
||||
|
||||
+10
-3
@@ -5,10 +5,17 @@ function useTbankConnectionViewModel() {
|
||||
const status = useAuthenticatedQuery({
|
||||
...tbankConnectionQueryOptions(),
|
||||
});
|
||||
if (status.isPending) return { state: "loading" as const };
|
||||
if (status.isError)
|
||||
if (!status.data) {
|
||||
if (status.isPending) return { state: "loading" as const };
|
||||
return { state: "error" as const, retry: () => status.refetch() };
|
||||
return { state: "loaded" as const, status: status.data };
|
||||
}
|
||||
return {
|
||||
state: "loaded" as const,
|
||||
status: status.data,
|
||||
refreshing: status.isFetching,
|
||||
refreshError: status.isError,
|
||||
retry: () => status.refetch(),
|
||||
};
|
||||
}
|
||||
|
||||
export { useTbankConnectionViewModel };
|
||||
|
||||
+36
-3
@@ -8,7 +8,7 @@ import {
|
||||
} from "@mantine/core";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import type { FC } from "react";
|
||||
import { useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { apiErrorCode, apiQueryClient } from "@/shared/api";
|
||||
@@ -19,6 +19,9 @@ import {
|
||||
|
||||
type TbankConnectionSectionLoadedProps = {
|
||||
status: TbankConnectionStatus;
|
||||
refreshing: boolean;
|
||||
refreshError: boolean;
|
||||
retry: () => void;
|
||||
};
|
||||
|
||||
const TbankConnectionSectionLoaded: FC<TbankConnectionSectionLoadedProps> = (
|
||||
@@ -28,6 +31,7 @@ const TbankConnectionSectionLoaded: FC<TbankConnectionSectionLoadedProps> = (
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const [deleteOpened, setDeleteOpened] = useState(false);
|
||||
const deleteButton = useRef<HTMLButtonElement>(null);
|
||||
const [requestError, setRequestError] = useState<string | null>(null);
|
||||
const {
|
||||
register,
|
||||
@@ -53,11 +57,13 @@ const TbankConnectionSectionLoaded: FC<TbankConnectionSectionLoadedProps> = (
|
||||
});
|
||||
setDeleteOpened(false);
|
||||
},
|
||||
onError: () => setRequestError("REQUEST_ERROR"),
|
||||
},
|
||||
);
|
||||
const pending = update.isPending || remove.isPending;
|
||||
const connected = status.connected;
|
||||
useEffect(() => {
|
||||
if (deleteOpened && remove.isError) deleteButton.current?.focus();
|
||||
}, [deleteOpened, remove.isError]);
|
||||
|
||||
return (
|
||||
<Stack mt="xl">
|
||||
@@ -74,6 +80,25 @@ const TbankConnectionSectionLoaded: FC<TbankConnectionSectionLoadedProps> = (
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("tbank.hint")}
|
||||
</Text>
|
||||
{props.refreshing && (
|
||||
<Text role="status" size="sm" c="dimmed">
|
||||
{t("tbank.refreshing")}
|
||||
</Text>
|
||||
)}
|
||||
{props.refreshError && (
|
||||
<Alert color="orange" role="status" aria-live="polite">
|
||||
<Stack gap="sm">
|
||||
<Text>{t("tbank.refreshError")}</Text>
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={props.retry}
|
||||
disabled={props.refreshing || pending}
|
||||
>
|
||||
{t("tbank.retry")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Alert>
|
||||
)}
|
||||
<form
|
||||
onSubmit={handleSubmit(({ token }) => {
|
||||
setRequestError(null);
|
||||
@@ -105,7 +130,10 @@ const TbankConnectionSectionLoaded: FC<TbankConnectionSectionLoadedProps> = (
|
||||
<Button
|
||||
variant="outline"
|
||||
color="red"
|
||||
onClick={() => setDeleteOpened(true)}
|
||||
onClick={() => {
|
||||
remove.reset();
|
||||
setDeleteOpened(true);
|
||||
}}
|
||||
>
|
||||
{t("tbank.delete")}
|
||||
</Button>
|
||||
@@ -119,10 +147,15 @@ const TbankConnectionSectionLoaded: FC<TbankConnectionSectionLoadedProps> = (
|
||||
title={t("tbank.deleteTitle")}
|
||||
closeOnClickOutside={!pending}
|
||||
closeOnEscape={!pending}
|
||||
closeButtonProps={{ "aria-label": t("tbank.close"), disabled: pending }}
|
||||
>
|
||||
<Stack>
|
||||
<Text>{t("tbank.deleteText")}</Text>
|
||||
{remove.isError && (
|
||||
<Alert color="red">{t("tbank.requestError")}</Alert>
|
||||
)}
|
||||
<Button
|
||||
ref={deleteButton}
|
||||
color="red"
|
||||
loading={remove.isPending}
|
||||
disabled={pending}
|
||||
|
||||
Reference in New Issue
Block a user