refactor: simplify errors and focus tests
Frontend verify / commits (pull_request) Successful in 30s
Frontend verify / security (pull_request) Successful in 11s
Frontend verify / verify (pull_request) Successful in 8m25s

This commit is contained in:
2026-09-28 21:25:28 +03:00
parent 320ea5922a
commit 7e976c330c
10 changed files with 70 additions and 232 deletions
@@ -1,85 +0,0 @@
import { renderHook, waitFor } from "@testing-library/react";
import { http, HttpResponse } from "msw";
import { describe, expect, it, vi } from "vitest";
import { configureAuthClient } from "@/shared/api";
import { createQueryWrapper } from "@/test/query";
import { resource } from "@/test/resource";
import { server } from "@/test/server";
import { useBrokerageAccounts } from "./useBrokerageAccounts";
describe("useBrokerageAccounts", () => {
it("loads accounts through the authenticated query seam", async () => {
configureAuthClient({
accessToken: () => "token",
clear: vi.fn(),
refresh: () => Promise.resolve(true),
});
server.use(
http.get("*/api/v1/brokerage-accounts", () =>
HttpResponse.json(
resource([
{
id: "account-1",
name: "Основной",
openedOn: "2024-08-13",
type: "BROKERAGE",
},
]),
),
),
);
const { result } = renderHook(() => useBrokerageAccounts(), {
wrapper: createQueryWrapper().wrapper,
});
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data).toEqual([
{
id: "account-1",
name: "Основной",
openedOn: "2024-08-13",
type: "BROKERAGE",
},
]);
});
it("keeps the current connection snapshot after a background refetch error", async () => {
configureAuthClient({
accessToken: () => "token",
clear: vi.fn(),
refresh: () => Promise.resolve(true),
});
let calls = 0;
server.use(
http.get("*/api/v1/brokerage-accounts", () => {
calls += 1;
return calls === 1
? HttpResponse.json(
resource([
{
id: "account-1",
name: "Основной",
openedOn: "2024-08-13",
type: "BROKERAGE",
},
]),
)
: HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 });
}),
);
const { result } = renderHook(() => useBrokerageAccounts(), {
wrapper: createQueryWrapper().wrapper,
});
await waitFor(() => expect(result.current.isSuccess).toBe(true));
void result.current.refetch();
await waitFor(() => expect(result.current.isError).toBe(true));
expect(result.current.data?.[0].id).toBe("account-1");
});
});
@@ -1,66 +0,0 @@
import { renderHook, waitFor } from "@testing-library/react";
import { http, HttpResponse } from "msw";
import { describe, expect, it, vi } from "vitest";
import { configureAuthClient } from "@/shared/api";
import { createQueryWrapper } from "@/test/query";
import { resource } from "@/test/resource";
import { server } from "@/test/server";
import { usePortfolio } from "./usePortfolio";
describe("usePortfolio", () => {
it("loads the account portfolio", async () => {
configureAuthClient({
accessToken: () => "token",
clear: vi.fn(),
refresh: () => Promise.resolve(true),
});
const { result } = renderHook(() => usePortfolio("account-1"), {
wrapper: createQueryWrapper().wrapper,
});
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data).toMatchObject({ positions: [] });
});
it("keeps the current connection snapshot after a background refetch error", async () => {
configureAuthClient({
accessToken: () => "token",
clear: vi.fn(),
refresh: () => Promise.resolve(true),
});
let calls = 0;
server.use(
http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () => {
calls += 1;
return calls === 1
? HttpResponse.json(
resource({
cashBalances: [],
isStale: false,
lastSuccessfulAt: "2026-08-14T00:00:00Z",
positions: [],
structure: { currencies: [], unvaluedCategories: [] },
totalsByCurrency: [],
}),
)
: HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 });
}),
);
const { result } = renderHook(() => usePortfolio("account-1"), {
wrapper: createQueryWrapper().wrapper,
});
await waitFor(() => expect(result.current.isSuccess).toBe(true));
void result.current.refetch();
await waitFor(() => expect(result.current.isError).toBe(true));
expect(result.current.data).toMatchObject({
lastSuccessfulAt: "2026-08-14T00:00:00Z",
});
});
});
@@ -292,31 +292,47 @@ describe("TbankConnectionSection", () => {
expect(client.getQueryData(operationsQueryKey)).toBeUndefined();
});
it("preserves status and financial cache when a connection update fails", async () => {
server.use(
http.put("*/api/v1/tbank-connection", () =>
HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 }),
),
);
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("Не подключено");
fireEvent.change(screen.getByLabelText("Token T-Bank"), {
target: { value: "replacement-token" },
});
const token = screen.getByLabelText("Token T-Bank");
fireEvent.change(token, { target: { value: "replacement-token" } });
fireEvent.click(screen.getByRole("button", { name: "Подключить" }));
await screen.findByText(
"T-Bank временно недоступен. Повторите попытку позже.",
);
expect(
screen.getByRole("button", { name: "Подключить" }),
).toBeInTheDocument();
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 () => {
@@ -41,7 +41,7 @@ const TbankConnectionSectionLoaded: FC<TbankConnectionSectionLoadedProps> = (
t,
unavailableMessage,
update,
} = useTbankConnectionSectionLoadedVm(props);
} = useTbankConnectionSectionLoadedVm(props.status);
return (
<Stack mt="xl">
@@ -92,7 +92,7 @@ const TbankConnectionSectionLoaded: FC<TbankConnectionSectionLoadedProps> = (
error={errors.token?.message}
/>
{requestError && (
{update.isError && (
<Alert color="red">
{t(
isApiErrorCode(requestError, ApiErrorCode.InvalidTbankToken)
@@ -14,26 +14,12 @@ import {
commitTbankConnectionBoundary,
type TbankConnectionStatus,
} from "../../model/connection-boundary";
enum ConnectionErrorCode {
RequestFailed = "REQUEST_ERROR",
}
type TbankConnectionSectionLoadedProps = {
refreshError: boolean;
refreshing: boolean;
retry: () => void;
status: TbankConnectionStatus;
};
function useTbankConnectionSectionLoadedVm(
props: TbankConnectionSectionLoadedProps,
) {
const { status } = props;
function useTbankConnectionSectionLoadedVm(status: TbankConnectionStatus) {
const { t } = useTranslation();
const queryClient = useQueryClient();
const [isDeleteOpened, setIsDeleteOpened] = useState(false);
const removalButtonRef = useRef<HTMLButtonElement>(null);
const [requestError, setRequestError] = useState<null | string>(null);
const {
formState: { errors },
@@ -43,8 +29,6 @@ function useTbankConnectionSectionLoadedVm(
} = useForm<{ token: string }>();
const update = apiQueryClient.useMutation("put", "/api/v1/tbank-connection", {
onError: error =>
setRequestError(apiErrorCode(error) ?? ConnectionErrorCode.RequestFailed),
onSuccess: (data) => {
commitTbankConnectionBoundary(queryClient, data);
reset();
@@ -66,6 +50,8 @@ function useTbankConnectionSectionLoadedVm(
},
);
const requestError = apiErrorCode(update.error);
const unavailableMessage = isApiErrorCode(
requestError,
ApiErrorCode.TbankUnavailable,
@@ -80,7 +66,6 @@ function useTbankConnectionSectionLoadedVm(
}, [isDeleteOpened, remove.isError]);
const onSubmit = handleSubmit(({ token }) => {
setRequestError(null);
update.mutate({ body: { token } });
});
@@ -210,23 +210,9 @@ describe("useAccountPageViewModel", () => {
expect(snapshot).toEqual(original);
});
it("prepares grouped position sections with derived display data", async () => {
configureTestAuth();
mockLoadedAccountPage();
const { result } = renderHook(() => useAccountPageViewModel(), {
wrapper: createWrapper(),
});
await waitFor(() => expect(result.current.state).toBe("loaded"));
const loaded = result.current as Extract<
typeof result.current,
{ state: "loaded" }
>;
it("prepares grouped position sections with derived display data", () => {
expect(
loaded.positionSections.map(({ groups, kind }) => ({
toAccountPagePositionSections(portfolio).map(({ groups, kind }) => ({
kind,
positions: groups.flatMap(group => group.positions),
})),
@@ -5,7 +5,7 @@ import { describe, expect, it } from "vitest";
import { CurrencyCards } from "./CurrencyCards.view";
describe("CurrencyCards currency presentation", () => {
it.each([0, 1, 3, 5])(
it.each([0, 5])(
"renders only the %i supplied totals in upstream order",
(count) => {
const totals = [
@@ -74,20 +74,4 @@ describe("CurrencyCards currency presentation", () => {
);
},
);
it("keeps USD and CAD as distinct supplied values", () => {
render(
<MantineProvider>
<CurrencyCards
amounts={[
{ currency: "USD", value: "1.00" },
{ currency: "CAD", value: "2.00" },
]}
/>
</MantineProvider>,
);
expect(
screen.getAllByRole("definition").map(node => node.textContent),
).toEqual(["1,00 $", "2,00 CAD"]);
});
});
@@ -1,9 +1,9 @@
import { MantineProvider } from "@mantine/core";
import { QueryClientProvider } from "@tanstack/react-query";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { act, 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 { afterEach, describe, expect, it, vi } from "vitest";
import { configureAuthClient } from "@/shared/api";
import { createTestQueryClient } from "@/test/query";
@@ -12,6 +12,8 @@ import { server } from "@/test/server";
import { BondsPage } from "./BondsPage";
afterEach(() => vi.useRealTimers());
describe("bond search", () => {
it("waits for a meaningful query and uses the server-verified ticker link", async () => {
const query = vi.fn();
@@ -46,6 +48,7 @@ describe("bond search", () => {
clear: vi.fn(),
refresh: () => Promise.resolve(false),
});
vi.useFakeTimers();
render(
<QueryClientProvider client={createTestQueryClient()}>
<MantineProvider>
@@ -55,10 +58,16 @@ describe("bond search", () => {
</MantineProvider>
</QueryClientProvider>,
);
const input = screen.getByLabelText("Поиск облигаций");
fireEvent.change(input, { target: { value: "S" } });
await act(() => vi.advanceTimersByTimeAsync(300));
expect(query).not.toHaveBeenCalled();
fireEvent.change(screen.getByLabelText("Поиск облигаций"), {
target: { value: "SU26238RMFS4" },
});
fireEvent.change(input, { target: { value: "SB" } });
await act(() => vi.advanceTimersByTimeAsync(299));
expect(query).not.toHaveBeenCalled();
// The final input uses real timers for network and query notifications.
vi.useRealTimers();
fireEvent.change(input, { target: { value: "SU26238RMFS4" } });
expect(await screen.findByRole("link", { name: /ОФЗ/ })).toHaveAttribute(
"href",
"/bonds/SU26238RMFS4",
@@ -67,6 +76,6 @@ describe("bond search", () => {
expect(screen.getByText("Режим: TQOB")).toBeVisible();
expect(screen.getByText("ISIN: RU0009029540")).toBeVisible();
expect(screen.getByText("Мосбиржа")).toBeVisible();
await waitFor(() => expect(query).toHaveBeenCalledWith("SU26238RMFS4"));
await waitFor(() => expect(query).toHaveBeenCalledExactlyOnceWith("SU26238RMFS4"));
});
});
@@ -1,9 +1,9 @@
import { MantineProvider } from "@mantine/core";
import { QueryClientProvider } from "@tanstack/react-query";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { act, 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 { afterEach, describe, expect, it, vi } from "vitest";
import { configureAuthClient } from "@/shared/api";
import { createTestQueryClient } from "@/test/query";
@@ -12,6 +12,8 @@ import { server } from "@/test/server";
import { StocksPage } from "./StocksPage";
afterEach(() => vi.useRealTimers());
describe("stock search", () => {
it("waits for a meaningful query and uses the server-verified ticker link", async () => {
const query = vi.fn();
@@ -44,6 +46,7 @@ describe("stock search", () => {
clear: vi.fn(),
refresh: () => Promise.resolve(false),
});
vi.useFakeTimers();
render(
<QueryClientProvider client={createTestQueryClient()}>
<MantineProvider>
@@ -53,10 +56,16 @@ describe("stock search", () => {
</MantineProvider>
</QueryClientProvider>,
);
const input = screen.getByLabelText("Поиск акций");
fireEvent.change(input, { target: { value: "S" } });
await act(() => vi.advanceTimersByTimeAsync(300));
expect(query).not.toHaveBeenCalled();
fireEvent.change(screen.getByLabelText("Поиск акций"), {
target: { value: "SBER" },
});
fireEvent.change(input, { target: { value: "SB" } });
await act(() => vi.advanceTimersByTimeAsync(299));
expect(query).not.toHaveBeenCalled();
// The final input uses real timers for network and query notifications.
vi.useRealTimers();
fireEvent.change(input, { target: { value: "SBER" } });
expect(
await screen.findByRole("link", { name: /Сбербанк/ }),
).toHaveAttribute("href", "/stocks/SBER");
@@ -64,6 +73,6 @@ describe("stock search", () => {
expect(screen.getByText("Режим: TQBR")).toBeVisible();
expect(screen.getByText("ISIN: RU0009029540")).toBeVisible();
expect(screen.getByText("Мосбиржа")).toBeVisible();
await waitFor(() => expect(query).toHaveBeenCalledWith("SBER"));
await waitFor(() => expect(query).toHaveBeenCalledExactlyOnceWith("SBER"));
});
});