Files
frontend/src/entities/bank-card/api/banking.test.tsx
T

425 lines
13 KiB
TypeScript

import { act, renderHook, waitFor } from "@testing-library/react";
import { HttpResponse, http } 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,
type CreateBankOperation,
useBankCard,
useBankCards,
useBankCategories,
useBankExpenses,
useBankOperation,
useBankOperations,
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 transfer: CreateBankOperation = {
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(),
});
}
function readHandlers(counts: Map<string, number>, categoryName = "Food") {
return [
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 }) => {
const url = new URL(request.url);
const key = url.pathname + url.search;
counts.set(key, (counts.get(key) ?? 0) + 1);
if (url.pathname.endsWith("/categories"))
return HttpResponse.json([
{ id: "food", name: categoryName, isDefault: true, 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: [],
});
if (
url.pathname.endsWith("/operations") ||
url.pathname.endsWith("/expenses")
)
return HttpResponse.json({
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) });
}),
];
}
function useReadModels() {
const cards = useBankCards(true);
const a = useBankCard("a");
const b = useBankCard("b");
const categories = useBankCategories();
const history = useBankOperations("a", {
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 {
reads: [
cards,
a,
b,
categories,
history,
expenses,
september,
august,
original,
],
mutations: useBankMutations(),
};
}
describe("banking API", () => {
beforeEach(authenticate);
it("loads every page for all mode and fails without exposing partial results", async () => {
const pages: number[] = [];
let fail = false;
server.use(
http.get("*/api/v1/bank-cards/a/operations", ({ request }) => {
const url = new URL(request.url);
const page = Number(url.searchParams.get("page"));
pages.push(page);
expect(url.searchParams.get("limit")).toBe("100");
expect(url.searchParams.get("month")).toBe("2026-09");
if (fail && page === 2)
return HttpResponse.json({ code: "FAILED" }, { status: 500 });
return HttpResponse.json({
items: [{ ...transfer, id: String(page) }],
total: 2,
page,
hasNext: page === 1,
});
}),
);
const { result } = renderHook(
() => useBankOperations("a", { month: "2026-09", page: 1, limit: "all" }),
{ 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([
"1",
"2",
]);
fail = true;
const failed = renderHook(
() => useBankOperations("a", { month: "2026-09", page: 1, limit: "all" }),
{ wrapper: createQueryWrapper().wrapper },
);
await waitFor(() => expect(failed.result.current.isError).toBe(true));
expect(failed.result.current.data).toBeUndefined();
});
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/a/operations?month=2026-09&page=2&limit=20",
);
expect([...counts.keys()]).toContain(
"/api/v1/bank-cards/summary?month=2026-08&cardId=b",
);
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 () => {
token = "renewed";
return true;
});
configureAuthClient({ accessToken: () => token, refresh, clear: vi.fn() });
const bodies: unknown[] = [];
const headers: (string | null)[] = [];
server.use(
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"]);
expect(refresh).toHaveBeenCalledTimes(1);
});
it("does not automatically retry a network failure and preserves the form-supplied intent on repeat", async () => {
const bodies: unknown[] = [];
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),
).rejects.toThrow();
});
expect(bodies).toEqual([transfer]);
await act(() => result.current.createOperation.mutateAsync(transfer));
expect(bodies).toEqual([transfer, transfer]);
});
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;
});
server.use(
http.post("*/api/v1/bank-cards/operations", async () => {
await pending;
return HttpResponse.json({ code: "INTERNAL_ERROR" }, { status: 500 });
}),
);
const { result } = renderHook(
() => ({ query: useBankCard("a"), mutations: useBankMutations() }),
{ wrapper: createQueryWrapper().wrapper },
);
await waitFor(() => expect(result.current.query.isSuccess).toBe(true));
let saving: Promise<unknown>;
act(() => {
saving = result.current.mutations.createOperation
.mutateAsync(transfer)
.catch(() => undefined);
});
await waitFor(() =>
expect(result.current.mutations.createOperation.isPending).toBe(true),
);
expect(result.current.query.data?.balance.value).toBe("100");
await act(async () => {
finish();
await saving;
});
expect(result.current.query.data?.balance.value).toBe("100");
server.use(
http.get("*/api/v1/bank-cards/a", () =>
HttpResponse.json({ code: "INTERNAL_ERROR" }, { status: 500 }),
),
);
await act(() => result.current.query.refetch());
expect(result.current.query.isError).toBe(true);
expect(result.current.query.data?.balance.value).toBe("100");
});
it("refetches both cards, every month, categories, journal and source expenses after a transfer without invalidating brokerage", async () => {
const counts = new Map<string, number>();
server.use(
...readHandlers(counts),
http.post("*/api/v1/bank-cards/operations", () =>
HttpResponse.json({ ...transfer, version: 1 }),
),
);
const { wrapper, queryClient } = 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),
);
await act(() =>
result.current.mutations.createOperation.mutateAsync(transfer),
);
expect([...counts.values()]).toEqual(Array(9).fill(2));
expect(queryClient.getQueryState(brokerKey)?.isInvalidated).toBe(false);
});
it.each([
"BANK_VERSION_CONFLICT",
"BANK_INTENT_CONFLICT",
"BANK_REFUND_LIMIT",
])("fetches current state after %s", async (code) => {
let reads = 0;
server.use(
http.get("*/api/v1/bank-cards/a", () =>
HttpResponse.json({ ...card, version: ++reads }),
),
http.patch("*/api/v1/bank-cards/a", () =>
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(
result.current.mutations.updateCard.mutateAsync({
id: "a",
body: { name: "changed", version: 1 },
}),
).rejects.toEqual({ code });
});
await waitFor(() => expect(result.current.card.data?.version).toBe(2));
});
it("sends edit/delete versions and refreshes category names without optimistic updates", async () => {
const requests: unknown[] = [];
let name = "Food";
server.use(
http.get("*/api/v1/bank-cards/categories", () =>
HttpResponse.json([{ id: "food", name, isDefault: true, version: 1 }]),
),
http.patch("*/api/v1/bank-cards/categories/food", async ({ request }) => {
const body = await request.json();
requests.push(body);
name = "Groceries";
return HttpResponse.json({
id: "food",
name,
isDefault: true,
version: 2,
});
}),
http.patch(
"*/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(),
mutations: useBankMutations(),
}),
{ wrapper: createQueryWrapper().wrapper },
);
await waitFor(() =>
expect(result.current.categories.data?.[0].name).toBe("Food"),
);
await act(() =>
result.current.mutations.updateCategory.mutateAsync({
id: "food",
body: { name: "Groceries", version: 1 },
}),
);
await waitFor(() =>
expect(result.current.categories.data?.[0].name).toBe("Groceries"),
);
await act(() =>
result.current.mutations.updateOperation.mutateAsync({
id: "original",
body: { description: "corrected", version: 1 },
}),
);
await act(() =>
result.current.mutations.deleteOperation.mutateAsync({
id: "original",
version: 2,
}),
);
expect(requests).toEqual([
{ name: "Groceries", version: 1 },
{ description: "corrected", version: 1 },
"2",
]);
});
it("disables reads after session removal and retains banking data across a T-Bank API mutation", async () => {
const counts = new Map<string, number>();
server.use(
...readHandlers(counts),
http.put("*/api/v1/tbank-connection", () =>
HttpResponse.json({
connected: true,
connectedAt: "2026-09-12T00:00:00Z",
}),
),
);
const { wrapper, queryClient } = createQueryWrapper();
const { result, rerender } = renderHook(() => useBankCard("a"), {
wrapper,
});
await waitFor(() => expect(result.current.data?.balance.value).toBe("100"));
await apiClient.PUT("/api/v1/tbank-connection", {
body: { token: "synthetic" },
});
expect(result.current.data?.balance.value).toBe("100");
act(() => {
configureAuthClient(null);
queryClient.clear();
});
rerender();
await waitFor(() => expect(result.current.fetchStatus).toBe("idle"));
expect(result.current.data).toBeUndefined();
expect(counts.get("/api/v1/bank-cards/a")).toBe(1);
});
});