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:
@@ -6,6 +6,7 @@ import {
|
||||
act,
|
||||
fireEvent,
|
||||
render,
|
||||
renderHook,
|
||||
screen,
|
||||
waitFor,
|
||||
within,
|
||||
@@ -26,7 +27,10 @@ import { createQueryWrapper } from "@/test/query";
|
||||
import { server } from "@/test/server";
|
||||
import { isDate } from "../../model/period";
|
||||
import { AccountIncomePage } from "./AccountIncomePage";
|
||||
import { toIncomeDisplayModel } from "./AccountIncomePage.vm";
|
||||
import {
|
||||
toIncomeDisplayModel,
|
||||
useAccountIncomePageViewModel,
|
||||
} from "./AccountIncomePage.vm";
|
||||
import { IncomePayments } from "./components/IncomePayments.view";
|
||||
|
||||
const analytics = {
|
||||
@@ -148,6 +152,79 @@ function renderPage(search = "from=2026-08-01&to=2026-08-27") {
|
||||
}
|
||||
|
||||
describe("AccountIncomePage", () => {
|
||||
it("keeps reads and sync disabled without an account scope", async () => {
|
||||
configureAuthClient({
|
||||
accessToken: () => "token",
|
||||
refresh: async () => true,
|
||||
clear: vi.fn(),
|
||||
});
|
||||
const requests: string[] = [];
|
||||
server.use(
|
||||
http.all("*/api/v1/*", ({ request }) => {
|
||||
requests.push(request.url);
|
||||
return new HttpResponse(null, { status: 503 });
|
||||
}),
|
||||
);
|
||||
const { wrapper: QueryWrapper, queryClient } = createQueryWrapper();
|
||||
const { result } = renderHook(() => useAccountIncomePageViewModel(), {
|
||||
wrapper: ({ children }) => (
|
||||
<QueryWrapper>
|
||||
<MemoryRouter initialEntries={["?from=2024-02-29&to=2024-03-01"]}>
|
||||
{children}
|
||||
</MemoryRouter>
|
||||
</QueryWrapper>
|
||||
),
|
||||
});
|
||||
await act(async () => {});
|
||||
expect(result.current).toEqual({ state: "loading" });
|
||||
expect(queryClient.isFetching()).toBe(0);
|
||||
expect(requests).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps income calendar dates in GET and source sync requests", async () => {
|
||||
const requested: string[] = [];
|
||||
const syncRequests: unknown[] = [];
|
||||
server.use(
|
||||
http.get(
|
||||
"*/api/v1/brokerage-accounts/:accountId/income-analytics",
|
||||
({ request }) => {
|
||||
requested.push(new URL(request.url).search);
|
||||
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");
|
||||
const retry = await screen.findByRole("button", { name: "Повторить" });
|
||||
expect(requested).toEqual(["?from=2024-02-29&to=2024-03-01"]);
|
||||
expect(syncRequests).toEqual([]);
|
||||
fireEvent.click(retry);
|
||||
await waitFor(() =>
|
||||
expect(syncRequests).toEqual([
|
||||
{
|
||||
screen: "income",
|
||||
accountId: "account-1",
|
||||
from: "2024-02-29",
|
||||
to: "2024-03-01",
|
||||
},
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("requests a multi-year period from the URL without resetting it", async () => {
|
||||
const requested: string[] = [];
|
||||
server.use(
|
||||
@@ -431,20 +508,18 @@ describe("AccountIncomePage", () => {
|
||||
});
|
||||
|
||||
it("shows the unavailable year state without falling back to the previous period", async () => {
|
||||
const dependencies = [
|
||||
{ ...resource(analytics).meta, role: "selected" as const },
|
||||
{ ...resource(analytics).meta, role: "previous" as const },
|
||||
{
|
||||
...resource(null).meta,
|
||||
role: "year" as const,
|
||||
unavailableReason: "CALENDAR_OR_ACCOUNT_HISTORY",
|
||||
},
|
||||
];
|
||||
server.use(
|
||||
http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () =>
|
||||
HttpResponse.json(
|
||||
resource(analytics, {
|
||||
dependencies: [
|
||||
resource(analytics).meta,
|
||||
resource(analytics).meta,
|
||||
{
|
||||
...resource(null).meta,
|
||||
unavailableReason: "CALENDAR_OR_ACCOUNT_HISTORY",
|
||||
},
|
||||
],
|
||||
}),
|
||||
),
|
||||
HttpResponse.json(resource(analytics, { dependencies })),
|
||||
),
|
||||
);
|
||||
renderPage();
|
||||
@@ -829,11 +904,87 @@ describe("income period and snapshot boundary", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("maps comparison notices by role regardless of dependency order or resource key", () => {
|
||||
const selected = { ...resource(analytics).meta, role: "selected" as const };
|
||||
const previous = {
|
||||
...resource(null).meta,
|
||||
resourceKey: "opaque/year-looking-key",
|
||||
role: "previous" as const,
|
||||
sync: {
|
||||
id: null,
|
||||
state: "failed" as const,
|
||||
errorCode: "TBANK_UNAVAILABLE",
|
||||
},
|
||||
};
|
||||
const year = {
|
||||
...resource(null).meta,
|
||||
resourceKey: "opaque/previous-looking-key",
|
||||
role: "year" as const,
|
||||
unavailableReason: "CALENDAR_OR_ACCOUNT_HISTORY",
|
||||
};
|
||||
for (const dependencies of [
|
||||
[selected, previous, year],
|
||||
[year, selected, previous],
|
||||
]) {
|
||||
const display = toIncomeDisplayModel(analytics, {
|
||||
...resource(analytics).meta,
|
||||
dependencies,
|
||||
});
|
||||
expect(display?.previousNotice).toBe("comparisonFailed");
|
||||
expect(display?.yearNotice).toBe("yearUnavailable");
|
||||
expect(display?.previousComparison).not.toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it.each([false, true])(
|
||||
"shows unavailable comparisons for absent roles without positional fallback (legacy=%s)",
|
||||
async (legacy) => {
|
||||
server.use(
|
||||
http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () =>
|
||||
HttpResponse.json(
|
||||
resource(
|
||||
{ ...analytics, previous: null, yearComparison: null },
|
||||
{
|
||||
dependencies: legacy
|
||||
? [
|
||||
resource(null).meta,
|
||||
resource(null).meta,
|
||||
resource(null).meta,
|
||||
]
|
||||
: [],
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
renderPage();
|
||||
expect(
|
||||
await screen.findByText("Сравнение с предыдущим периодом недоступно."),
|
||||
).toBeVisible();
|
||||
expect(
|
||||
screen.getByText("Сравнение с годом ранее недоступно."),
|
||||
).toBeVisible();
|
||||
expect(
|
||||
screen.queryByText("Период сравнения ещё не загружен полностью."),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("region", { name: "Выплаты" })).toBeVisible();
|
||||
},
|
||||
);
|
||||
|
||||
it("shows selected income before comparison coverage without inventing a zero baseline", async () => {
|
||||
const dependencies = (["year", "selected", "previous"] as const).map(
|
||||
(role) => ({
|
||||
...resource(role === "selected" ? analytics : null).meta,
|
||||
role,
|
||||
}),
|
||||
);
|
||||
server.use(
|
||||
http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () =>
|
||||
HttpResponse.json(
|
||||
resource({ ...analytics, previous: null, yearComparison: null }),
|
||||
resource(
|
||||
{ ...analytics, previous: null, yearComparison: null },
|
||||
{ dependencies },
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -856,24 +1007,22 @@ it("shows selected income before comparison coverage without inventing a zero ba
|
||||
});
|
||||
|
||||
it("keeps complete previous comparison while year source failed without inventing its amounts", async () => {
|
||||
const dependencies = [
|
||||
{ ...resource(analytics).meta, role: "previous" as const },
|
||||
{
|
||||
...resource(null).meta,
|
||||
role: "year" as const,
|
||||
sync: {
|
||||
id: null,
|
||||
state: "failed" as const,
|
||||
errorCode: "TBANK_UNAVAILABLE",
|
||||
},
|
||||
},
|
||||
{ ...resource(analytics).meta, role: "selected" as const },
|
||||
];
|
||||
server.use(
|
||||
http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () =>
|
||||
HttpResponse.json(
|
||||
resource(analytics, {
|
||||
dependencies: [
|
||||
resource(analytics).meta,
|
||||
resource(analytics).meta,
|
||||
{
|
||||
...resource(null).meta,
|
||||
sync: {
|
||||
id: null,
|
||||
state: "failed",
|
||||
errorCode: "TBANK_UNAVAILABLE",
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
),
|
||||
HttpResponse.json(resource(analytics, { dependencies })),
|
||||
),
|
||||
);
|
||||
renderPage();
|
||||
|
||||
@@ -22,6 +22,7 @@ type IncomeMeta = ReturnType<typeof useIncomeAnalytics>["meta"];
|
||||
type ComparisonNotice =
|
||||
| "comparisonPending"
|
||||
| "comparisonFailed"
|
||||
| "previousUnavailable"
|
||||
| "yearUnavailable";
|
||||
|
||||
type IncomeDisplayModel = {
|
||||
@@ -84,7 +85,7 @@ const useAccountIncomePageViewModel = (
|
||||
scopeId?: string,
|
||||
): AccountIncomePageViewModel => {
|
||||
const { accountId: routeAccountId } = useParams();
|
||||
const accountId = scopeId ?? routeAccountId;
|
||||
const accountId = scopeId ?? routeAccountId ?? "";
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const requested = useMemo(
|
||||
() => ({
|
||||
@@ -130,7 +131,7 @@ const useAccountIncomePageViewModel = (
|
||||
};
|
||||
if (analytics.isLoading || !analytics.data) {
|
||||
if (analytics.isError || analytics.meta?.sync.state === "failed")
|
||||
return { state: "error", retry: sync.refresh, period };
|
||||
return { state: "error", retry: sync.retry, period };
|
||||
return { state: "loading" };
|
||||
}
|
||||
const display = toIncomeDisplayModel(analytics.data, analytics.meta);
|
||||
@@ -152,7 +153,7 @@ const useAccountIncomePageViewModel = (
|
||||
partial: analytics.meta?.availability !== "complete",
|
||||
refreshing: analytics.isFetching || sync.refreshing,
|
||||
refreshError: analytics.isError || sync.sourceError || sync.transportError,
|
||||
retry: sync.refresh,
|
||||
retry: sync.retry,
|
||||
form,
|
||||
onSubmit,
|
||||
onReset,
|
||||
@@ -236,14 +237,14 @@ function toIncomeDisplayModel(
|
||||
if (instrument.includedCount !== instrument.receiptCount) return null;
|
||||
}
|
||||
|
||||
// The income endpoint returns selected, previous and year dependencies in order.
|
||||
const [, previous, year] = meta?.dependencies ?? [];
|
||||
const previous = meta?.dependencies?.find(({ role }) => role === "previous");
|
||||
const year = meta?.dependencies?.find(({ role }) => role === "year");
|
||||
return {
|
||||
entries,
|
||||
previousComparison: comparisonDisplay(analytics.period, analytics.previous),
|
||||
comparison: comparisonDisplay(analytics.period, analytics.yearComparison),
|
||||
previousNotice: comparisonNotice(previous),
|
||||
yearNotice: comparisonNotice(year),
|
||||
previousNotice: comparisonNotice(previous, "previousUnavailable"),
|
||||
yearNotice: comparisonNotice(year, "yearUnavailable"),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -251,9 +252,10 @@ function comparisonNotice(
|
||||
dependency:
|
||||
| NonNullable<NonNullable<IncomeMeta>["dependencies"]>[number]
|
||||
| undefined,
|
||||
unavailable: "previousUnavailable" | "yearUnavailable",
|
||||
): ComparisonNotice {
|
||||
if (dependency?.unavailableReason) return "yearUnavailable";
|
||||
return dependency?.sync.state === "failed"
|
||||
if (!dependency || dependency.unavailableReason) return unavailable;
|
||||
return dependency.sync.state === "failed"
|
||||
? "comparisonFailed"
|
||||
: "comparisonPending";
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user