refactor: enforce frontend view model boundaries
Frontend verify / commits (pull_request) Successful in 28s
Frontend verify / security (pull_request) Successful in 10s
Frontend verify / verify (pull_request) Successful in 8m19s

This commit is contained in:
2026-09-28 18:48:23 +03:00
parent 5b69003516
commit ca0ad4f824
287 changed files with 8558 additions and 4538 deletions
+44 -8
View File
@@ -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" },