chore: finish frontend ESLint migration #18

Enable every plugin recommended preset and replace Biome with ESLint formatting.

Preserve API contracts and JSX whitespace; verify source, tests, builds and tooling.
This commit is contained in:
2026-09-28 16:36:40 +03:00
committed by ksv741
parent 57630102e4
commit 72d7835227
253 changed files with 10059 additions and 8864 deletions
+71 -69
View File
@@ -1,9 +1,11 @@
import { act, renderHook, waitFor } from "@testing-library/react";
import { HttpResponse, http } from "msw";
import { http, HttpResponse } from "msw";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { apiClient, configureAuthClient } from "@/shared/api";
import { createQueryWrapper } from "@/test/query";
import { server } from "@/test/server";
import { useBankMutations } from "./mutations";
import {
type BankCard,
@@ -17,32 +19,34 @@ import {
useBankSummary,
} from "./queries";
const card: BankCard = {
id: "a",
name: "Daily",
bankName: null,
lastFour: null,
paymentSystem: null,
openingBalance: { currency: "RUB", value: "100" },
openingDate: "2026-09-01",
balance: { currency: "RUB", value: "100" },
archivedAt: null,
version: 1,
};
const card: BankCard = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{
archivedAt: null,
balance: { currency: "RUB", value: "100" },
bankName: null,
id: "a",
lastFour: null,
name: "Daily",
openingBalance: { currency: "RUB", value: "100" },
openingDate: "2026-09-01",
paymentSystem: null,
version: 1,
}
/* eslint-enable unicorn/no-null */);
const transfer: CreateBankOperation = {
amount: { currency: "RUB", value: "90071992547409.93" },
cardId: "a",
date: "2026-09-12",
id: "4e6caa65-dd52-4629-81e2-ce8a139a0612",
kind: "transfer",
cardId: "a",
targetCardId: "b",
amount: { currency: "RUB", value: "90071992547409.93" },
date: "2026-09-12",
};
function authenticate() {
configureAuthClient({
accessToken: () => "token",
refresh: async () => true,
clear: vi.fn(),
refresh: () => Promise.resolve(true),
});
}
@@ -59,30 +63,28 @@ function readHandlers(counts: Map<string, number>, categoryName = "Food") {
counts.set(key, (counts.get(key) ?? 0) + 1);
if (url.pathname.endsWith("/categories"))
return HttpResponse.json([
{ id: "food", name: categoryName, isDefault: true, version: 1 },
{ id: "food", isDefault: true, name: categoryName, version: 1 },
]);
if (url.pathname.endsWith("/summary"))
return HttpResponse.json({
month: url.searchParams.get("month"),
cardId: url.searchParams.get("cardId"),
expenses: { currency: "RUB", value: "0" },
refunds: { currency: "RUB", value: "0" },
net: { currency: "RUB", value: "0" },
categories: [],
expenses: { currency: "RUB", value: "0" },
month: url.searchParams.get("month"),
net: { currency: "RUB", value: "0" },
refunds: { currency: "RUB", value: "0" },
});
if (
url.pathname.endsWith("/operations") ||
url.pathname.endsWith("/expenses")
url.pathname.endsWith("/operations")
|| url.pathname.endsWith("/expenses")
)
return HttpResponse.json({
hasNext: false,
items: [],
page: 1,
total: 0,
hasNext: false,
});
if (url.pathname.includes("/operations/"))
return HttpResponse.json({ ...transfer, version: 1 });
return HttpResponse.json({ ...card, id: url.pathname.split("/").at(-1) });
return url.pathname.includes("/operations/") ? HttpResponse.json({ ...transfer, version: 1 }) : HttpResponse.json({ ...card, id: url.pathname.split("/").at(-1) });
}),
];
}
@@ -93,15 +95,16 @@ function useReadModels() {
const b = useBankCard("b");
const categories = useBankCategories();
const history = useBankOperations("a", {
limit: 20,
month: "2026-09",
page: 2,
limit: 20,
});
const expenses = useBankExpenses("a", { page: 1 });
const september = useBankSummary("2026-09");
const august = useBankSummary("2026-08", "b");
const original = useBankOperation("original");
return {
mutations: useBankMutations(),
reads: [
cards,
a,
@@ -113,7 +116,6 @@ function useReadModels() {
august,
original,
],
mutations: useBankMutations(),
};
}
@@ -122,7 +124,7 @@ describe("banking API", () => {
it("loads every page for all mode and fails without exposing partial results", async () => {
const pages: number[] = [];
let fail = false;
let isFail = false;
server.use(
http.get("*/api/v1/bank-cards/a/operations", ({ request }) => {
const url = new URL(request.url);
@@ -130,33 +132,33 @@ describe("banking API", () => {
pages.push(page);
expect(url.searchParams.get("limit")).toBe("100");
expect(url.searchParams.get("month")).toBe("2026-09");
if (fail && page === 2)
if (isFail && page === 2)
return HttpResponse.json({ code: "FAILED" }, { status: 500 });
return HttpResponse.json({
items: [{ ...transfer, id: String(page) }],
total: 2,
page,
hasNext: page === 1,
items: [{ ...transfer, id: String(page) }],
page,
total: 2,
});
}),
);
const { result } = renderHook(
() => useBankOperations("a", { month: "2026-09", page: 1, limit: "all" }),
() => useBankOperations("a", { limit: "all", month: "2026-09", page: 1 }),
{ wrapper: createQueryWrapper().wrapper },
);
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(pages).toEqual([1, 2]);
expect(result.current.data?.items.map((item) => item.id)).toEqual([
expect(result.current.data?.items.map(item => item.id)).toEqual([
"1",
"2",
]);
fail = true;
const failed = renderHook(
() => useBankOperations("a", { month: "2026-09", page: 1, limit: "all" }),
isFail = true;
const { result: failedResult } = renderHook(
() => useBankOperations("a", { limit: "all", month: "2026-09", page: 1 }),
{ wrapper: createQueryWrapper().wrapper },
);
await waitFor(() => expect(failed.result.current.isError).toBe(true));
expect(failed.result.current.data).toBeUndefined();
await waitFor(() => expect(failedResult.current.isError).toBe(true));
expect(failedResult.current.data).toBeUndefined();
});
it("uses session-aware banking endpoints and explicit month/page without T-Bank", async () => {
@@ -166,29 +168,29 @@ describe("banking API", () => {
wrapper: createQueryWrapper().wrapper,
});
await waitFor(() =>
expect(result.current.reads.every((read) => read.isSuccess)).toBe(true),
expect(result.current.reads.every(read => read.isSuccess)).toBe(true),
);
expect([...counts.keys()]).toContain(
expect(counts.keys()).toContain(
"/api/v1/bank-cards?includeArchived=true",
);
expect([...counts.keys()]).toContain(
"/api/v1/bank-cards/a/operations?month=2026-09&page=2&limit=20",
expect(counts.keys()).toContain(
"/api/v1/bank-cards/a/operations?limit=20&month=2026-09&page=2",
);
expect([...counts.keys()]).toContain(
"/api/v1/bank-cards/summary?month=2026-08&cardId=b",
expect(counts.keys()).toContain(
"/api/v1/bank-cards/summary?cardId=b&month=2026-08",
);
expect(counts.size).toBe(9);
});
it("replays the exact UUID and large Money string once across shared auth refresh", async () => {
let token = "expired";
const refresh = vi.fn(async () => {
const refresh = vi.fn(() => {
token = "renewed";
return true;
return Promise.resolve(true);
});
configureAuthClient({ accessToken: () => token, refresh, clear: vi.fn() });
configureAuthClient({ accessToken: () => token, clear: vi.fn(), refresh });
const bodies: unknown[] = [];
const headers: (string | null)[] = [];
const headers: (null | string)[] = [];
server.use(
http.post("*/api/v1/bank-cards/operations", async ({ request }) => {
bodies.push(await request.json());
@@ -232,10 +234,8 @@ describe("banking API", () => {
it("keeps the confirmed balance while a mutation is pending or fails, and on refetch failure", async () => {
server.use(...readHandlers(new Map()));
let finish: () => void = () => {};
const pending = new Promise<void>((resolve) => {
finish = resolve;
});
const { promise: pending, resolve: finish } = Promise.withResolvers<void>();
server.use(
http.post("*/api/v1/bank-cards/operations", async () => {
await pending;
@@ -243,7 +243,7 @@ describe("banking API", () => {
}),
);
const { result } = renderHook(
() => ({ query: useBankCard("a"), mutations: useBankMutations() }),
() => ({ mutations: useBankMutations(), query: useBankCard("a") }),
{ wrapper: createQueryWrapper().wrapper },
);
await waitFor(() => expect(result.current.query.isSuccess).toBe(true));
@@ -251,7 +251,8 @@ describe("banking API", () => {
act(() => {
saving = result.current.mutations.createOperation
.mutateAsync(transfer)
.catch(() => undefined);
// eslint-disable-next-line unicorn/prefer-await -- Attach the handler immediately and retain the pending promise for queue/race ordering.
.catch(() => {});
});
await waitFor(() =>
expect(result.current.mutations.createOperation.isPending).toBe(true),
@@ -280,17 +281,18 @@ describe("banking API", () => {
HttpResponse.json({ ...transfer, version: 1 }),
),
);
const { wrapper, queryClient } = createQueryWrapper();
const { queryClient, wrapper } = createQueryWrapper();
const brokerKey = ["get", "/api/v1/brokerage-accounts"];
queryClient.setQueryData(brokerKey, []);
const { result } = renderHook(useReadModels, { wrapper });
await waitFor(() =>
expect(result.current.reads.every((read) => read.isSuccess)).toBe(true),
expect(result.current.reads.every(read => read.isSuccess)).toBe(true),
);
await act(() =>
result.current.mutations.createOperation.mutateAsync(transfer),
);
expect([...counts.values()]).toEqual(Array(9).fill(2));
expect(counts.size).toBe(9);
for (const count of counts.values()) expect(count).toBe(2);
expect(queryClient.getQueryState(brokerKey)?.isInvalidated).toBe(false);
});
@@ -316,8 +318,8 @@ describe("banking API", () => {
await act(async () => {
await expect(
result.current.mutations.updateCard.mutateAsync({
id: "a",
body: { name: "changed", version: 1 },
id: "a",
}),
).rejects.toEqual({ code });
});
@@ -329,7 +331,7 @@ describe("banking API", () => {
let name = "Food";
server.use(
http.get("*/api/v1/bank-cards/categories", () =>
HttpResponse.json([{ id: "food", name, isDefault: true, version: 1 }]),
HttpResponse.json([{ id: "food", isDefault: true, name, version: 1 }]),
),
http.patch("*/api/v1/bank-cards/categories/food", async ({ request }) => {
const body = await request.json();
@@ -337,8 +339,8 @@ describe("banking API", () => {
name = "Groceries";
return HttpResponse.json({
id: "food",
name,
isDefault: true,
name,
version: 2,
});
}),
@@ -366,8 +368,8 @@ describe("banking API", () => {
);
await act(() =>
result.current.mutations.updateCategory.mutateAsync({
id: "food",
body: { name: "Groceries", version: 1 },
id: "food",
}),
);
await waitFor(() =>
@@ -375,8 +377,8 @@ describe("banking API", () => {
);
await act(() =>
result.current.mutations.updateOperation.mutateAsync({
id: "original",
body: { description: "corrected", version: 1 },
id: "original",
}),
);
await act(() =>
@@ -403,8 +405,8 @@ describe("banking API", () => {
}),
),
);
const { wrapper, queryClient } = createQueryWrapper();
const { result, rerender } = renderHook(() => useBankCard("a"), {
const { queryClient, wrapper } = createQueryWrapper();
const { rerender, result } = renderHook(() => useBankCard("a"), {
wrapper,
});
await waitFor(() => expect(result.current.data?.balance.value).toBe("100"));
@@ -413,7 +415,7 @@ describe("banking API", () => {
});
expect(result.current.data?.balance.value).toBe("100");
act(() => {
configureAuthClient(null);
configureAuthClient();
queryClient.clear();
});
rerender();
+29 -25
View File
@@ -1,5 +1,7 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { apiClient, apiErrorCode } from "@/shared/api";
import type {
CreateBankCard,
CreateBankOperation,
@@ -22,59 +24,61 @@ function useBankMutations() {
const queryClient = useQueryClient();
const invalidate = () =>
queryClient.invalidateQueries({
predicate: ({ queryKey: [method, path] }) =>
method === "get" &&
typeof path === "string" &&
(path === "/api/v1/bank-cards" ||
path.startsWith("/api/v1/bank-cards/")),
predicate: ({ queryKey }) => {
const [method, path] = queryKey;
return method === "get"
&& typeof path === "string"
&& (path === "/api/v1/bank-cards"
|| path.startsWith("/api/v1/bank-cards/"));
},
});
const options = {
retry: false,
onSuccess: invalidate,
onError: (error: unknown) => {
if (
[
"BANK_VERSION_CONFLICT",
"BANK_WRITE_CONFLICT",
"BANK_DEPENDENT_REFUNDS",
"BANK_INTENT_CONFLICT",
"BANK_REFUND_LIMIT",
"BANK_DEPENDENT_REFUNDS",
"BANK_VERSION_CONFLICT",
"BANK_WRITE_CONFLICT",
].includes(apiErrorCode(error) ?? "")
)
return invalidate();
},
onSuccess: invalidate,
retry: false,
} as const;
const createCard = useMutation({
const cardCreation = useMutation({
...options,
mutationFn: (body: CreateBankCard) =>
mutationData(apiClient.POST("/api/v1/bank-cards", { body })),
});
const updateCard = useMutation({
...options,
mutationFn: ({ id, body }: { id: string; body: UpdateBankCard }) =>
mutationFn: ({ body, id }: { body: UpdateBankCard; id: string }) =>
mutationData(
apiClient.PATCH("/api/v1/bank-cards/{id}", {
params: { path: { id } },
body,
params: { path: { id } },
}),
),
});
const createOperation = useMutation({
const operationCreation = useMutation({
...options,
mutationFn: (body: CreateBankOperation) =>
mutationData(apiClient.POST("/api/v1/bank-cards/operations", { body })),
});
const updateOperation = useMutation({
...options,
mutationFn: ({ id, body }: { id: string; body: UpdateBankOperation }) =>
mutationFn: ({ body, id }: { body: UpdateBankOperation; id: string }) =>
mutationData(
apiClient.PATCH("/api/v1/bank-cards/operations/{id}", {
params: { path: { id } },
body,
params: { path: { id } },
}),
),
});
const deleteOperation = useMutation({
const operationDeletion = useMutation({
...options,
mutationFn: ({ id, version }: { id: string; version: number }) =>
mutationData(
@@ -83,29 +87,29 @@ function useBankMutations() {
}),
),
});
const createCategory = useMutation({
const categoryCreation = useMutation({
...options,
mutationFn: (body: CreateExpenseCategory) =>
mutationData(apiClient.POST("/api/v1/bank-cards/categories", { body })),
});
const updateCategory = useMutation({
...options,
mutationFn: ({ id, body }: { id: string; body: UpdateExpenseCategory }) =>
mutationFn: ({ body, id }: { body: UpdateExpenseCategory; id: string }) =>
mutationData(
apiClient.PATCH("/api/v1/bank-cards/categories/{id}", {
params: { path: { id } },
body,
params: { path: { id } },
}),
),
});
return {
createCard,
createCard: cardCreation,
createCategory: categoryCreation,
createOperation: operationCreation,
deleteOperation: operationDeletion,
updateCard,
createOperation,
updateOperation,
deleteOperation,
createCategory,
updateCategory,
updateOperation,
};
}
+125 -124
View File
@@ -7,39 +7,43 @@ import {
import { moneyCents } from "../model/form-validation";
type BankCard =
paths["/api/v1/bank-cards/{id}"]["get"]["responses"][200]["content"]["application/json"];
type BankOperation =
paths["/api/v1/bank-cards/operations/{id}"]["get"]["responses"][200]["content"]["application/json"];
type BankOperations =
paths["/api/v1/bank-cards/{id}/operations"]["get"]["responses"][200]["content"]["application/json"];
type BankSummary =
paths["/api/v1/bank-cards/summary"]["get"]["responses"][200]["content"]["application/json"];
type ExpenseCategory =
paths["/api/v1/bank-cards/categories"]["get"]["responses"][200]["content"]["application/json"][number];
type CreateBankCard =
paths["/api/v1/bank-cards"]["post"]["requestBody"]["content"]["application/json"];
type UpdateBankCard =
paths["/api/v1/bank-cards/{id}"]["patch"]["requestBody"]["content"]["application/json"];
type CreateBankOperation =
paths["/api/v1/bank-cards/operations"]["post"]["requestBody"]["content"]["application/json"];
type UpdateBankOperation =
paths["/api/v1/bank-cards/operations/{id}"]["patch"]["requestBody"]["content"]["application/json"];
type CreateExpenseCategory =
paths["/api/v1/bank-cards/categories"]["post"]["requestBody"]["content"]["application/json"];
type UpdateExpenseCategory =
paths["/api/v1/bank-cards/categories/{id}"]["patch"]["requestBody"]["content"]["application/json"];
type BankCard
= paths["/api/v1/bank-cards/{id}"]["get"]["responses"][200]["content"]["application/json"];
type BankOperation
= paths["/api/v1/bank-cards/operations/{id}"]["get"]["responses"][200]["content"]["application/json"];
type BankOperations
= paths["/api/v1/bank-cards/{id}/operations"]["get"]["responses"][200]["content"]["application/json"];
type BankSummary
= paths["/api/v1/bank-cards/summary"]["get"]["responses"][200]["content"]["application/json"];
type CreateBankCard
= paths["/api/v1/bank-cards"]["post"]["requestBody"]["content"]["application/json"];
type CreateBankOperation
= paths["/api/v1/bank-cards/operations"]["post"]["requestBody"]["content"]["application/json"];
type CreateExpenseCategory
= paths["/api/v1/bank-cards/categories"]["post"]["requestBody"]["content"]["application/json"];
type ExpenseCategory
= paths["/api/v1/bank-cards/categories"]["get"]["responses"][200]["content"]["application/json"][number];
type UpdateBankCard
= paths["/api/v1/bank-cards/{id}"]["patch"]["requestBody"]["content"]["application/json"];
type UpdateBankOperation
= paths["/api/v1/bank-cards/operations/{id}"]["patch"]["requestBody"]["content"]["application/json"];
type UpdateExpenseCategory
= paths["/api/v1/bank-cards/categories/{id}"]["patch"]["requestBody"]["content"]["application/json"];
const OPERATIONS_PAGE_SIZE = 100;
function useBankCards(includeArchived = false) {
return useAuthenticatedQuery(
apiQueryClient.queryOptions("get", "/api/v1/bank-cards", {
params: {
query: { includeArchived: includeArchived ? "true" : "false" },
},
function sortBankSummary(data: BankSummary): BankSummary {
return {
...data,
categories: data.categories.toSorted((a, b) => {
const left = moneyCents(a.net.value);
const right = moneyCents(b.net.value);
if (left === null || right === null)
throw new Error("Invalid bank summary amount");
if (left > right) return -1;
return left < right ? 1 : 0;
}),
);
};
}
function useBankCard(id: string | undefined) {
@@ -51,79 +55,25 @@ function useBankCard(id: string | undefined) {
});
}
function useBankCards(isIncludeArchived = false) {
return useAuthenticatedQuery(
apiQueryClient.queryOptions("get", "/api/v1/bank-cards", {
params: {
query: { includeArchived: isIncludeArchived ? "true" : "false" },
},
}),
);
}
function useBankCategories() {
return useAuthenticatedQuery(
apiQueryClient.queryOptions("get", "/api/v1/bank-cards/categories"),
);
}
function useBankOperations(
id: string | undefined,
query: {
month?: string;
from?: string;
to?: string;
page: number;
limit?: number | "all";
},
) {
const all = query.limit === "all";
const params = {
path: { id: id ?? "" },
query: {
...query,
page: all ? 1 : query.page,
limit: query.limit === "all" ? OPERATIONS_PAGE_SIZE : query.limit,
},
};
const options = apiQueryClient.queryOptions(
"get",
"/api/v1/bank-cards/{id}/operations",
{ params },
);
// eslint-disable-next-line @tanstack/query/exhaustive-deps -- Generated options.queryKey already contains params; retain the shared cache identity.
return useAuthenticatedQuery({
queryKey: all ? ([...options.queryKey, "all"] as const) : options.queryKey,
placeholderData: (
previousData: BankOperations | undefined,
previousQuery,
) => {
const previous = previousQuery?.queryKey[2]?.params;
return !all &&
previousQuery?.queryKey.length === options.queryKey.length &&
previous?.path?.id === id &&
previous?.query?.month === query.month &&
previous?.query?.from === query.from &&
previous?.query?.to === query.to &&
previous?.query?.limit === query.limit
? previousData
: undefined;
},
queryFn: async ({ signal }) => {
const items: BankOperation[] = [];
for (let page = params.query.page; ; page++) {
const { data, error } = await apiClient.GET(
"/api/v1/bank-cards/{id}/operations",
{
params: { ...params, query: { ...params.query, page } },
signal,
},
);
// eslint-disable-next-line @typescript-eslint/only-throw-error -- Preserve generated ProblemDetails for apiErrorCode and field-error consumers.
if (error) throw error;
if (!data) throw new Error("Missing bank operations response");
if (!all) return data;
items.push(...data.items);
if (!data.hasNext) return { ...data, items, page: 1, hasNext: false };
}
},
enabled: Boolean(id && (query.month || (query.from && query.to))),
});
}
function useBankExpenses(
id: string | undefined,
query: { page: number; limit?: number },
query: { limit?: number; page: number },
) {
return useAuthenticatedQuery({
...apiQueryClient.queryOptions("get", "/api/v1/bank-cards/{id}/expenses", {
@@ -133,36 +83,6 @@ function useBankExpenses(
});
}
function sortBankSummary(data: BankSummary): BankSummary {
return {
...data,
categories: data.categories.toSorted((a, b) => {
const left = moneyCents(a.net.value);
const right = moneyCents(b.net.value);
if (left === null || right === null)
throw new Error("Invalid bank summary amount");
return left > right ? -1 : left < right ? 1 : 0;
}),
};
}
function useBankSummary(
period: string | { from: string; to: string },
cardId?: string,
) {
const query =
typeof period === "string"
? { month: period, cardId }
: { ...period, cardId };
return useAuthenticatedQuery({
...apiQueryClient.queryOptions("get", "/api/v1/bank-cards/summary", {
params: { query },
}),
select: sortBankSummary,
enabled: Boolean(period),
});
}
function useBankOperation(id: string | undefined) {
return useAuthenticatedQuery({
...apiQueryClient.queryOptions(
@@ -174,6 +94,87 @@ function useBankOperation(id: string | undefined) {
});
}
function useBankOperations(
id: string | undefined,
query: {
from?: string;
limit?: "all" | number;
month?: string;
page: number;
to?: string;
},
) {
const isAll = query.limit === "all";
const parameters = {
path: { id: id ?? "" },
query: {
...query,
limit: query.limit === "all" ? OPERATIONS_PAGE_SIZE : query.limit,
page: isAll ? 1 : query.page,
},
};
const options = apiQueryClient.queryOptions(
"get",
"/api/v1/bank-cards/{id}/operations",
{ params: parameters },
);
// eslint-disable-next-line @tanstack/query/exhaustive-deps -- Generated options.queryKey already contains params; retain the shared cache identity.
return useAuthenticatedQuery({
enabled: Boolean(id && (query.month || (query.from && query.to))),
placeholderData: (
previousData: BankOperations | undefined,
previousQuery,
) => {
const previous = previousQuery?.queryKey[2]?.params;
return !isAll
&& previousQuery?.queryKey.length === options.queryKey.length
&& previous?.path?.id === id
&& previous?.query?.month === query.month
&& previous?.query?.from === query.from
&& previous?.query?.to === query.to
&& previous?.query?.limit === query.limit
? previousData
: undefined;
},
queryFn: async ({ signal }) => {
const items: BankOperation[] = [];
for (let page = parameters.query.page; ; page++) {
const { data, error } = await apiClient.GET(
"/api/v1/bank-cards/{id}/operations",
{
params: { ...parameters, query: { ...parameters.query, page } },
signal,
},
);
// eslint-disable-next-line @typescript-eslint/only-throw-error -- Preserve generated ProblemDetails for apiErrorCode and field-error consumers.
if (error) throw error;
if (!data) throw new Error("Missing bank operations response");
if (!isAll) return data;
items.push(...data.items);
if (!data.hasNext) return { ...data, hasNext: false, items, page: 1 };
}
},
queryKey: isAll ? ([...options.queryKey, "all"] as const) : options.queryKey,
});
}
function useBankSummary(
period: string | { from: string; to: string },
cardId?: string,
) {
const query
= typeof period === "string"
? { cardId, month: period }
: { ...period, cardId };
return useAuthenticatedQuery({
...apiQueryClient.queryOptions("get", "/api/v1/bank-cards/summary", {
params: { query },
}),
enabled: Boolean(period),
select: sortBankSummary,
});
}
export {
type BankCard,
type BankOperation,
+11 -8
View File
@@ -1,6 +1,9 @@
import { expect, it } from "vitest";
import { type BankSummary, sortBankSummary } from "./queries";
const money = (value: string) => ({ currency: "RUB", value });
it("orders displayed expenses descending, preserving cents, ties and source data", () => {
const values = [
"-10",
@@ -10,23 +13,23 @@ it("orders displayed expenses descending, preserving cents, ties and source data
"-2",
"0",
];
const money = (value: string) => ({ currency: "RUB", value });
const data: BankSummary = {
month: "2026-09",
cardId: "a",
expenses: money("0"),
refunds: money("0"),
net: money("0"),
categories: values.map((value, index) => ({
categoryId: String(index),
categoryName: String(index),
expenses: money("100"),
refunds: money("0"),
net: money(value),
refunds: money("0"),
})),
expenses: money("0"),
month: "2026-09",
net: money("0"),
refunds: money("0"),
};
expect(sortBankSummary(data).categories.map((row) => row.categoryId)).toEqual(
expect(sortBankSummary(data).categories.map(row => row.categoryId)).toEqual(
["3", "1", "2", "5", "4", "0"],
);
expect(data.categories.map((row) => row.net.value)).toEqual(values);
expect(data.categories.map(row => row.net.value)).toEqual(values);
});