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:
+198
-1
@@ -2,7 +2,14 @@ import { MantineProvider } from "@mantine/core";
|
||||
import { QueryClientProvider } from "@tanstack/react-query";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { HttpResponse, http } from "msw";
|
||||
import { MemoryRouter, Route, Routes } from "react-router";
|
||||
import type { FC } from "react";
|
||||
import {
|
||||
MemoryRouter,
|
||||
Route,
|
||||
Routes,
|
||||
useLocation,
|
||||
useNavigate,
|
||||
} from "react-router";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
type BrokerageOperation,
|
||||
@@ -32,6 +39,22 @@ const operation: BrokerageOperation = {
|
||||
commission: { currency: "RUB", value: "1" },
|
||||
};
|
||||
|
||||
const Navigation: FC = () => {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
return (
|
||||
<>
|
||||
<output aria-label="URL">{location.search}</output>
|
||||
<button type="button" onClick={() => navigate(-1)}>
|
||||
History back
|
||||
</button>
|
||||
<button type="button" onClick={() => navigate(1)}>
|
||||
History forward
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
function configureTestAuth() {
|
||||
configureAuthClient({
|
||||
accessToken: () => "token",
|
||||
@@ -187,6 +210,133 @@ describe("LedgerEntryRow", () => {
|
||||
});
|
||||
|
||||
describe("AccountOperationsPage", () => {
|
||||
it.each(["account-1", "all"])(
|
||||
"normalizes invalid periods and history, preserves one-sided UTC dates and resets cursors for %s",
|
||||
async (accountId) => {
|
||||
configureTestAuth();
|
||||
const searches: URLSearchParams[] = [];
|
||||
server.use(
|
||||
http.get(
|
||||
"*/api/v1/brokerage-accounts/:accountId/operations",
|
||||
({ request }) => {
|
||||
searches.push(new URL(request.url).searchParams);
|
||||
return HttpResponse.json(
|
||||
resource({
|
||||
items: [operation],
|
||||
nextCursor: "next",
|
||||
hasNext: true,
|
||||
}),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
render(
|
||||
<QueryClientProvider client={createTestQueryClient()}>
|
||||
<MantineProvider env="test">
|
||||
<MemoryRouter
|
||||
initialIndex={0}
|
||||
initialEntries={[
|
||||
"?from=bad&to=2026-02-28&categories=trade",
|
||||
"?from=2026-02-30&categories=trade",
|
||||
"?from=2026-03-01&to=2026-02-28&categories=trade",
|
||||
"?from=2026-02-01&categories=trade",
|
||||
"?to=2026-02-28&categories=trade",
|
||||
].map((search) => `/accounts/${accountId}/operations${search}`)}
|
||||
>
|
||||
<Navigation />
|
||||
<Routes>
|
||||
<Route
|
||||
path="/accounts/:accountId/operations"
|
||||
element={<AccountOperationsPage />}
|
||||
/>
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
</MantineProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
await screen.findByLabelText("Период");
|
||||
expect(searches[0].get("from")).toBeNull();
|
||||
expect(searches[0].get("to")).toBeNull();
|
||||
for (const expected of [
|
||||
"?categories=trade",
|
||||
"?categories=trade",
|
||||
"?from=2026-02-01&categories=trade",
|
||||
]) {
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", { name: "History forward" }),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByLabelText("URL").textContent).toBe(expected),
|
||||
);
|
||||
await screen.findByLabelText("Период");
|
||||
}
|
||||
expect(searches.at(-1)?.get("from")).toBe("2026-02-01T00:00:00.000Z");
|
||||
expect(searches.at(-1)?.get("to")).toBeNull();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Далее" }));
|
||||
await waitFor(() => expect(searches.at(-1)?.get("cursor")).toBe("next"));
|
||||
await screen.findByLabelText("Период");
|
||||
fireEvent.click(screen.getByRole("button", { name: "History forward" }));
|
||||
await screen.findByLabelText("Период");
|
||||
await waitFor(() =>
|
||||
expect(searches.at(-1)?.get("to")).toBe("2026-02-28T23:59:59.999Z"),
|
||||
);
|
||||
expect(searches.at(-1)?.get("from")).toBeNull();
|
||||
expect(searches.at(-1)?.get("cursor")).toBeNull();
|
||||
fireEvent.click(screen.getByRole("button", { name: "History back" }));
|
||||
await waitFor(() =>
|
||||
expect(screen.getByLabelText("Период")).toHaveTextContent("01.02.2026"),
|
||||
);
|
||||
expect(screen.getByRole("button", { name: "Назад" })).toBeDisabled();
|
||||
expect(searches.map((query) => query.get("from"))).not.toContain(
|
||||
"badT00:00:00.000Z",
|
||||
);
|
||||
expect(
|
||||
searches.every((query) => query.get("categories") === "trade"),
|
||||
).toBe(true);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["account-1", "all"])(
|
||||
"allows resetting filters after a request failure for %s",
|
||||
async (accountId) => {
|
||||
configureTestAuth();
|
||||
server.use(
|
||||
http.get(
|
||||
"*/api/v1/brokerage-accounts/:accountId/operations",
|
||||
({ request }) =>
|
||||
new URL(request.url).searchParams.has("from")
|
||||
? HttpResponse.json({ code: "VALIDATION_ERROR" }, { status: 400 })
|
||||
: HttpResponse.json(
|
||||
resource({ items: [], hasNext: false, nextCursor: null }),
|
||||
),
|
||||
),
|
||||
);
|
||||
render(
|
||||
<QueryClientProvider client={createTestQueryClient()}>
|
||||
<MantineProvider env="test">
|
||||
<MemoryRouter
|
||||
initialEntries={[
|
||||
`/accounts/${accountId}/operations?from=2026-02-01`,
|
||||
]}
|
||||
>
|
||||
<Navigation />
|
||||
<Routes>
|
||||
<Route
|
||||
path="/accounts/:accountId/operations"
|
||||
element={<AccountOperationsPage />}
|
||||
/>
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
</MantineProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
await screen.findByRole("button", { name: "Повторить" });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Сбросить" }));
|
||||
await screen.findByLabelText("Период");
|
||||
expect(screen.getByLabelText("URL").textContent).toBe("");
|
||||
},
|
||||
);
|
||||
|
||||
it("resets cursor pagination when filters change", async () => {
|
||||
configureTestAuth();
|
||||
const searches: string[] = [];
|
||||
@@ -311,6 +461,53 @@ describe("AccountOperationsPage", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps UTC operation boundaries in GET and source sync requests", async () => {
|
||||
configureTestAuth();
|
||||
const requested: Record<string, string>[] = [];
|
||||
const syncRequests: unknown[] = [];
|
||||
server.use(
|
||||
http.get(
|
||||
"*/api/v1/brokerage-accounts/:accountId/operations",
|
||||
({ request }) => {
|
||||
requested.push(Object.fromEntries(new URL(request.url).searchParams));
|
||||
return HttpResponse.json(
|
||||
resource(null, {
|
||||
sync: { id: null, state: "failed", errorCode: "TBANK_UNAVAILABLE" },
|
||||
}),
|
||||
);
|
||||
},
|
||||
),
|
||||
http.post("*/api/v1/tbank-sync", async ({ request }) => {
|
||||
syncRequests.push(await request.json());
|
||||
return HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 });
|
||||
}),
|
||||
);
|
||||
renderPage("?from=2024-02-29&to=2024-03-01&categories=trade");
|
||||
const retry = await screen.findByRole("button", { name: "Повторить" });
|
||||
expect(requested).toEqual([
|
||||
{
|
||||
from: "2024-02-29T00:00:00.000Z",
|
||||
to: "2024-03-01T23:59:59.999Z",
|
||||
categories: "trade",
|
||||
limit: "100",
|
||||
},
|
||||
]);
|
||||
expect(syncRequests).toEqual([]);
|
||||
fireEvent.click(retry);
|
||||
await waitFor(() =>
|
||||
expect(syncRequests).toEqual([
|
||||
{
|
||||
screen: "operations",
|
||||
accountId: "account-1",
|
||||
from: "2024-02-29T00:00:00.000Z",
|
||||
to: "2024-03-01T23:59:59.999Z",
|
||||
categories: ["trade"],
|
||||
limit: 100,
|
||||
},
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not label incomplete empty operations as a confirmed empty period", async () => {
|
||||
configureTestAuth();
|
||||
server.use(
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useParams, useSearchParams } from "react-router";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
type BrokerageOperation,
|
||||
brokerageOperationsQueryOptions,
|
||||
@@ -14,7 +15,7 @@ import { apiErrorCode } from "@/shared/api";
|
||||
|
||||
type AccountOperationsPageViewModel =
|
||||
| { state: "loading" }
|
||||
| { state: "error"; retry: () => void }
|
||||
| { state: "error"; retry: () => void; onReset: () => void }
|
||||
| {
|
||||
state: "loaded";
|
||||
sourceError?: boolean;
|
||||
@@ -47,8 +48,11 @@ const useAccountOperationsPageVm = (
|
||||
const accountId = scopeId ?? routeAccountId;
|
||||
const queryClient = useQueryClient();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const appliedFrom = searchParams.get("from");
|
||||
const appliedTo = searchParams.get("to");
|
||||
const urlFrom = searchParams.get("from");
|
||||
const urlTo = searchParams.get("to");
|
||||
const validUrlPeriod = isValidPeriod(urlFrom, urlTo);
|
||||
const appliedFrom = validUrlPeriod ? urlFrom : null;
|
||||
const appliedTo = validUrlPeriod ? urlTo : null;
|
||||
const appliedCategories = operationCategories.filter((category) =>
|
||||
(searchParams.get("categories") ?? "").split(",").includes(category),
|
||||
);
|
||||
@@ -58,8 +62,8 @@ const useAccountOperationsPageVm = (
|
||||
useState<OperationCategory[]>(appliedCategories);
|
||||
const scopeKey = JSON.stringify([
|
||||
accountId,
|
||||
appliedFrom,
|
||||
appliedTo,
|
||||
urlFrom,
|
||||
urlTo,
|
||||
appliedCategories,
|
||||
]);
|
||||
const [pagination, setPagination] = useState<{
|
||||
@@ -69,14 +73,26 @@ const useAccountOperationsPageVm = (
|
||||
const cursors = pagination.scopeKey === scopeKey ? pagination.cursors : [];
|
||||
const cursor = cursors.at(-1);
|
||||
useEffect(() => {
|
||||
setDraftFrom(searchParams.get("from"));
|
||||
setDraftTo(searchParams.get("to"));
|
||||
setPagination((current) =>
|
||||
current.scopeKey === scopeKey ? current : { scopeKey, cursors: [] },
|
||||
);
|
||||
}, [scopeKey]);
|
||||
useEffect(() => {
|
||||
if (validUrlPeriod) return;
|
||||
const normalized = new URLSearchParams(searchParams);
|
||||
normalized.delete("from");
|
||||
normalized.delete("to");
|
||||
setSearchParams(normalized, { replace: true });
|
||||
}, [validUrlPeriod, searchParams, setSearchParams]);
|
||||
useEffect(() => {
|
||||
setDraftFrom(appliedFrom);
|
||||
setDraftTo(appliedTo);
|
||||
setDraftCategories(
|
||||
operationCategories.filter((category) =>
|
||||
(searchParams.get("categories") ?? "").split(",").includes(category),
|
||||
),
|
||||
);
|
||||
}, [searchParams]);
|
||||
}, [appliedFrom, appliedTo, searchParams]);
|
||||
const firstPageQuery = {
|
||||
from: dateStart(appliedFrom),
|
||||
to: dateEnd(appliedTo),
|
||||
@@ -104,7 +120,15 @@ const useAccountOperationsPageVm = (
|
||||
exact: true,
|
||||
});
|
||||
resetCursors();
|
||||
} else void sync.refresh();
|
||||
} else void sync.retry();
|
||||
};
|
||||
const onReset = () => {
|
||||
setDraftFrom(null);
|
||||
setDraftTo(null);
|
||||
setDraftCategories([]);
|
||||
setSearchParams({});
|
||||
resetCursors();
|
||||
void queryClient.invalidateQueries({ queryKey: resetQueryKey });
|
||||
};
|
||||
|
||||
if (operations.isLoading) return { state: "loading" };
|
||||
@@ -113,7 +137,7 @@ const useAccountOperationsPageVm = (
|
||||
(!operations.data ||
|
||||
apiErrorCode(operations.error) === "INVALID_OPERATIONS_CURSOR")
|
||||
)
|
||||
return { state: "error", retry };
|
||||
return { state: "error", retry, onReset };
|
||||
if (!operations.data) return { state: "loading" };
|
||||
const nextCursor = operations.data.nextCursor;
|
||||
|
||||
@@ -139,11 +163,12 @@ const useAccountOperationsPageVm = (
|
||||
items: operations.data.items,
|
||||
hasNext: Boolean(operations.data.hasNext && nextCursor),
|
||||
hasPrevious: Boolean(cursors.length),
|
||||
canApply: isDirty,
|
||||
canApply: isDirty && isValidPeriod(draftFrom, draftTo),
|
||||
onDraftFromChange: setDraftFrom,
|
||||
onDraftToChange: setDraftTo,
|
||||
onDraftCategoriesChange: setDraftCategories,
|
||||
onApply: () => {
|
||||
if (!isValidPeriod(draftFrom, draftTo)) return;
|
||||
setSearchParams({
|
||||
...(draftFrom ? { from: draftFrom } : {}),
|
||||
...(draftTo ? { to: draftTo } : {}),
|
||||
@@ -153,14 +178,7 @@ const useAccountOperationsPageVm = (
|
||||
});
|
||||
resetCursors();
|
||||
},
|
||||
onReset: () => {
|
||||
setDraftFrom(null);
|
||||
setDraftTo(null);
|
||||
setDraftCategories([]);
|
||||
setSearchParams({});
|
||||
resetCursors();
|
||||
void queryClient.invalidateQueries({ queryKey: resetQueryKey });
|
||||
},
|
||||
onReset,
|
||||
onNext: () => {
|
||||
if (nextCursor)
|
||||
setPagination({ scopeKey, cursors: [...cursors, nextCursor] });
|
||||
@@ -170,6 +188,16 @@ const useAccountOperationsPageVm = (
|
||||
};
|
||||
};
|
||||
|
||||
const calendarDate = z.string().date();
|
||||
function isValidPeriod(from: string | null, to: string | null) {
|
||||
return (
|
||||
[from, to].every(
|
||||
(date) => date === null || calendarDate.safeParse(date).success,
|
||||
) &&
|
||||
(!from || !to || from <= to)
|
||||
);
|
||||
}
|
||||
|
||||
function dateStart(value: string | null) {
|
||||
return value ? `${value}T00:00:00.000Z` : undefined;
|
||||
}
|
||||
|
||||
+9
-2
@@ -1,4 +1,4 @@
|
||||
import { Button } from "@mantine/core";
|
||||
import { Button, Group } from "@mantine/core";
|
||||
import { StateView } from "@wealthledger/ui";
|
||||
import type { FC } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -20,7 +20,14 @@ const AccountOperationsPageError: FC<AccountOperationsPageErrorProps> = (
|
||||
error
|
||||
title={t("ledger.errorTitle")}
|
||||
text={t("ledger.errorText")}
|
||||
action={<Button onClick={retry}>{t("ledger.retry")}</Button>}
|
||||
action={
|
||||
<Group>
|
||||
<Button onClick={retry}>{t("ledger.retry")}</Button>
|
||||
<Button variant="default" onClick={props.onReset}>
|
||||
{t("ledger.reset")}
|
||||
</Button>
|
||||
</Group>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user