refactor: enforce frontend view model boundaries
This commit is contained in:
@@ -19,8 +19,10 @@ import {
|
||||
useBankSummary,
|
||||
} from "./queries";
|
||||
|
||||
const card: BankCard = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
|
||||
{
|
||||
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
|
||||
|
||||
const card: BankCard
|
||||
= {
|
||||
archivedAt: null,
|
||||
balance: { currency: "RUB", value: "100" },
|
||||
bankName: null,
|
||||
@@ -31,8 +33,9 @@ const card: BankCard = (/* eslint-disable unicorn/no-null -- Fixture preserves t
|
||||
openingDate: "2026-09-01",
|
||||
paymentSystem: null,
|
||||
version: 1,
|
||||
}
|
||||
/* eslint-enable unicorn/no-null */);
|
||||
};
|
||||
|
||||
/* eslint-enable unicorn/no-null */
|
||||
const transfer: CreateBankOperation = {
|
||||
amount: { currency: "RUB", value: "90071992547409.93" },
|
||||
cardId: "a",
|
||||
@@ -55,6 +58,7 @@ function readHandlers(counts: Map<string, number>, categoryName = "Food") {
|
||||
http.get("*/api/v1/bank-cards", ({ request }) => {
|
||||
const key = new URL(request.url).pathname + new URL(request.url).search;
|
||||
counts.set(key, (counts.get(key) ?? 0) + 1);
|
||||
|
||||
return HttpResponse.json([card]);
|
||||
}),
|
||||
http.get("*/api/v1/bank-cards/*", ({ request }) => {
|
||||
@@ -84,7 +88,10 @@ function readHandlers(counts: Map<string, number>, categoryName = "Food") {
|
||||
page: 1,
|
||||
total: 0,
|
||||
});
|
||||
return url.pathname.includes("/operations/") ? HttpResponse.json({ ...transfer, version: 1 }) : 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) });
|
||||
}),
|
||||
];
|
||||
}
|
||||
@@ -94,15 +101,18 @@ function useReadModels() {
|
||||
const a = useBankCard("a");
|
||||
const b = useBankCard("b");
|
||||
const categories = useBankCategories();
|
||||
|
||||
const history = useBankOperations("a", {
|
||||
limit: 20,
|
||||
month: "2026-09",
|
||||
page: 2,
|
||||
});
|
||||
|
||||
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: [
|
||||
@@ -134,6 +144,7 @@ describe("banking API", () => {
|
||||
expect(url.searchParams.get("month")).toBe("2026-09");
|
||||
if (isFail && page === 2)
|
||||
return HttpResponse.json({ code: "FAILED" }, { status: 500 });
|
||||
|
||||
return HttpResponse.json({
|
||||
hasNext: page === 1,
|
||||
items: [{ ...transfer, id: String(page) }],
|
||||
@@ -142,10 +153,12 @@ describe("banking API", () => {
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
const { result } = renderHook(
|
||||
() => 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([
|
||||
@@ -153,10 +166,12 @@ describe("banking API", () => {
|
||||
"2",
|
||||
]);
|
||||
isFail = true;
|
||||
|
||||
const { result: failedResult } = renderHook(
|
||||
() => useBankOperations("a", { limit: "all", month: "2026-09", page: 1 }),
|
||||
{ wrapper: createQueryWrapper().wrapper },
|
||||
);
|
||||
|
||||
await waitFor(() => expect(failedResult.current.isError).toBe(true));
|
||||
expect(failedResult.current.data).toBeUndefined();
|
||||
});
|
||||
@@ -164,15 +179,15 @@ describe("banking API", () => {
|
||||
it("uses session-aware banking endpoints and explicit month/page without T-Bank", async () => {
|
||||
const counts = new Map<string, number>();
|
||||
server.use(...readHandlers(counts));
|
||||
|
||||
const { result } = renderHook(useReadModels, {
|
||||
wrapper: createQueryWrapper().wrapper,
|
||||
});
|
||||
|
||||
await waitFor(() =>
|
||||
expect(result.current.reads.every(read => read.isSuccess)).toBe(true),
|
||||
);
|
||||
expect(counts.keys()).toContain(
|
||||
"/api/v1/bank-cards?includeArchived=true",
|
||||
);
|
||||
expect(counts.keys()).toContain("/api/v1/bank-cards?includeArchived=true");
|
||||
expect(counts.keys()).toContain(
|
||||
"/api/v1/bank-cards/a/operations?limit=20&month=2026-09&page=2",
|
||||
);
|
||||
@@ -184,10 +199,13 @@ describe("banking API", () => {
|
||||
|
||||
it("replays the exact UUID and large Money string once across shared auth refresh", async () => {
|
||||
let token = "expired";
|
||||
|
||||
const refresh = vi.fn(() => {
|
||||
token = "renewed";
|
||||
|
||||
return Promise.resolve(true);
|
||||
});
|
||||
|
||||
configureAuthClient({ accessToken: () => token, clear: vi.fn(), refresh });
|
||||
const bodies: unknown[] = [];
|
||||
const headers: (null | string)[] = [];
|
||||
@@ -195,14 +213,17 @@ describe("banking API", () => {
|
||||
http.post("*/api/v1/bank-cards/operations", async ({ request }) => {
|
||||
bodies.push(await request.json());
|
||||
headers.push(request.headers.get("Authorization"));
|
||||
|
||||
return bodies.length === 1
|
||||
? HttpResponse.json({ code: "UNAUTHORIZED" }, { status: 401 })
|
||||
: HttpResponse.json({ ...transfer, version: 1 });
|
||||
}),
|
||||
);
|
||||
|
||||
const { result } = renderHook(useBankMutations, {
|
||||
wrapper: createQueryWrapper().wrapper,
|
||||
});
|
||||
|
||||
await act(() => result.current.createOperation.mutateAsync(transfer));
|
||||
expect(bodies).toEqual([transfer, transfer]);
|
||||
expect(headers).toEqual(["Bearer expired", "Bearer renewed"]);
|
||||
@@ -214,14 +235,17 @@ describe("banking API", () => {
|
||||
server.use(
|
||||
http.post("*/api/v1/bank-cards/operations", async ({ request }) => {
|
||||
bodies.push(await request.json());
|
||||
|
||||
return bodies.length === 1
|
||||
? HttpResponse.error()
|
||||
: HttpResponse.json({ ...transfer, version: 1 });
|
||||
}),
|
||||
);
|
||||
|
||||
const { result } = renderHook(useBankMutations, {
|
||||
wrapper: createQueryWrapper().wrapper,
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await expect(
|
||||
result.current.createOperation.mutateAsync(transfer),
|
||||
@@ -239,13 +263,16 @@ describe("banking API", () => {
|
||||
server.use(
|
||||
http.post("*/api/v1/bank-cards/operations", async () => {
|
||||
await pending;
|
||||
|
||||
return HttpResponse.json({ code: "INTERNAL_ERROR" }, { status: 500 });
|
||||
}),
|
||||
);
|
||||
|
||||
const { result } = renderHook(
|
||||
() => ({ mutations: useBankMutations(), query: useBankCard("a") }),
|
||||
{ wrapper: createQueryWrapper().wrapper },
|
||||
);
|
||||
|
||||
await waitFor(() => expect(result.current.query.isSuccess).toBe(true));
|
||||
let saving: Promise<unknown>;
|
||||
act(() => {
|
||||
@@ -310,10 +337,12 @@ describe("banking API", () => {
|
||||
HttpResponse.json({ code }, { status: 409 }),
|
||||
),
|
||||
);
|
||||
|
||||
const { result } = renderHook(
|
||||
() => ({ card: useBankCard("a"), mutations: useBankMutations() }),
|
||||
{ wrapper: createQueryWrapper().wrapper },
|
||||
);
|
||||
|
||||
await waitFor(() => expect(result.current.card.data?.version).toBe(1));
|
||||
await act(async () => {
|
||||
await expect(
|
||||
@@ -337,6 +366,7 @@ describe("banking API", () => {
|
||||
const body = await request.json();
|
||||
requests.push(body);
|
||||
name = "Groceries";
|
||||
|
||||
return HttpResponse.json({
|
||||
id: "food",
|
||||
isDefault: true,
|
||||
@@ -348,14 +378,17 @@ describe("banking API", () => {
|
||||
"*/api/v1/bank-cards/operations/original",
|
||||
async ({ request }) => {
|
||||
requests.push(await request.json());
|
||||
|
||||
return HttpResponse.json({ ...transfer, version: 2 });
|
||||
},
|
||||
),
|
||||
http.delete("*/api/v1/bank-cards/operations/original", ({ request }) => {
|
||||
requests.push(new URL(request.url).searchParams.get("version"));
|
||||
|
||||
return new HttpResponse(null, { status: 204 });
|
||||
}),
|
||||
);
|
||||
|
||||
const { result } = renderHook(
|
||||
() => ({
|
||||
categories: useBankCategories(),
|
||||
@@ -363,6 +396,7 @@ describe("banking API", () => {
|
||||
}),
|
||||
{ wrapper: createQueryWrapper().wrapper },
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(result.current.categories.data?.[0].name).toBe("Food"),
|
||||
);
|
||||
@@ -406,9 +440,11 @@ describe("banking API", () => {
|
||||
),
|
||||
);
|
||||
const { queryClient, wrapper } = createQueryWrapper();
|
||||
|
||||
const { rerender, result } = renderHook(() => useBankCard("a"), {
|
||||
wrapper,
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.data?.balance.value).toBe("100"));
|
||||
await apiClient.PUT("/api/v1/tbank-connection", {
|
||||
body: { token: "synthetic" },
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { apiClient, ApiErrorCode, apiErrorCode, isApiErrorCode } from "@/shared/api";
|
||||
import {
|
||||
apiClient,
|
||||
ApiErrorCode,
|
||||
apiErrorCode,
|
||||
isApiErrorCode,
|
||||
} from "@/shared/api";
|
||||
|
||||
import type {
|
||||
CreateBankCard,
|
||||
@@ -17,21 +22,27 @@ async function mutationData<T>(
|
||||
const { data, error } = await request;
|
||||
// eslint-disable-next-line @typescript-eslint/only-throw-error -- Preserve generated ProblemDetails for apiErrorCode and field-error consumers.
|
||||
if (error) throw error;
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
function useBankMutations() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const invalidate = () =>
|
||||
queryClient.invalidateQueries({
|
||||
predicate: ({ queryKey }) => {
|
||||
const [method, path] = queryKey;
|
||||
return method === "get"
|
||||
|
||||
return (
|
||||
method === "get"
|
||||
&& typeof path === "string"
|
||||
&& (path === "/api/v1/bank-cards"
|
||||
|| path.startsWith("/api/v1/bank-cards/"));
|
||||
|| path.startsWith("/api/v1/bank-cards/"))
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const options = {
|
||||
onError: (error: unknown) => {
|
||||
const errorCode = apiErrorCode(error);
|
||||
@@ -49,11 +60,13 @@ function useBankMutations() {
|
||||
onSuccess: invalidate,
|
||||
retry: false,
|
||||
} as const;
|
||||
|
||||
const cardCreation = useMutation({
|
||||
...options,
|
||||
mutationFn: (body: CreateBankCard) =>
|
||||
mutationData(apiClient.POST("/api/v1/bank-cards", { body })),
|
||||
});
|
||||
|
||||
const updateCard = useMutation({
|
||||
...options,
|
||||
mutationFn: ({ body, id }: { body: UpdateBankCard; id: string }) =>
|
||||
@@ -64,11 +77,13 @@ function useBankMutations() {
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
const operationCreation = useMutation({
|
||||
...options,
|
||||
mutationFn: (body: CreateBankOperation) =>
|
||||
mutationData(apiClient.POST("/api/v1/bank-cards/operations", { body })),
|
||||
});
|
||||
|
||||
const updateOperation = useMutation({
|
||||
...options,
|
||||
mutationFn: ({ body, id }: { body: UpdateBankOperation; id: string }) =>
|
||||
@@ -79,6 +94,7 @@ function useBankMutations() {
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
const operationDeletion = useMutation({
|
||||
...options,
|
||||
mutationFn: ({ id, version }: { id: string; version: number }) =>
|
||||
@@ -88,11 +104,13 @@ function useBankMutations() {
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
const categoryCreation = useMutation({
|
||||
...options,
|
||||
mutationFn: (body: CreateExpenseCategory) =>
|
||||
mutationData(apiClient.POST("/api/v1/bank-cards/categories", { body })),
|
||||
});
|
||||
|
||||
const updateCategory = useMutation({
|
||||
...options,
|
||||
mutationFn: ({ body, id }: { body: UpdateExpenseCategory; id: string }) =>
|
||||
@@ -103,6 +121,7 @@ function useBankMutations() {
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
return {
|
||||
createCard: cardCreation,
|
||||
createCategory: categoryCreation,
|
||||
|
||||
@@ -9,24 +9,34 @@ 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 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"];
|
||||
|
||||
@@ -41,12 +51,13 @@ function sortBankSummary(data: BankSummary): BankSummary {
|
||||
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) {
|
||||
function useBankCard(id?: string) {
|
||||
return useAuthenticatedQuery({
|
||||
...apiQueryClient.queryOptions("get", "/api/v1/bank-cards/{id}", {
|
||||
params: { path: { id: id ?? "" } },
|
||||
@@ -83,7 +94,7 @@ function useBankExpenses(
|
||||
});
|
||||
}
|
||||
|
||||
function useBankOperation(id: string | undefined) {
|
||||
function useBankOperation(id?: string) {
|
||||
return useAuthenticatedQuery({
|
||||
...apiQueryClient.queryOptions(
|
||||
"get",
|
||||
@@ -105,6 +116,7 @@ function useBankOperations(
|
||||
},
|
||||
) {
|
||||
const isAll = query.limit === "all";
|
||||
|
||||
const parameters = {
|
||||
path: { id: id ?? "" },
|
||||
query: {
|
||||
@@ -113,11 +125,13 @@ function useBankOperations(
|
||||
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))),
|
||||
@@ -126,6 +140,7 @@ function useBankOperations(
|
||||
previousQuery,
|
||||
) => {
|
||||
const previous = previousQuery?.queryKey[2]?.params;
|
||||
|
||||
return !isAll
|
||||
&& previousQuery?.queryKey.length === options.queryKey.length
|
||||
&& previous?.path?.id === id
|
||||
@@ -138,6 +153,7 @@ function useBankOperations(
|
||||
},
|
||||
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",
|
||||
@@ -146,6 +162,7 @@ function useBankOperations(
|
||||
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");
|
||||
@@ -154,18 +171,22 @@ function useBankOperations(
|
||||
if (!data.hasNext) return { ...data, hasNext: false, items, page: 1 };
|
||||
}
|
||||
},
|
||||
queryKey: isAll ? ([...options.queryKey, "all"] as const) : options.queryKey,
|
||||
queryKey: isAll
|
||||
? ([...options.queryKey, "all"] as const)
|
||||
: options.queryKey,
|
||||
});
|
||||
}
|
||||
|
||||
function useBankSummary(
|
||||
period: string | { from: string; to: string },
|
||||
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 },
|
||||
|
||||
@@ -28,6 +28,7 @@ it("orders displayed expenses descending, preserving cents, ties and source data
|
||||
net: money("0"),
|
||||
refunds: money("0"),
|
||||
};
|
||||
|
||||
expect(sortBankSummary(data).categories.map(row => row.categoryId)).toEqual(
|
||||
["3", "1", "2", "5", "4", "0"],
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user