472 lines
14 KiB
TypeScript
472 lines
14 KiB
TypeScript
import { act, renderHook, waitFor } from "@testing-library/react";
|
|
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 type { BankCard, CreateBankOperation } from "./types";
|
|
|
|
import {
|
|
useCreateBankOperation,
|
|
useDeleteBankOperation,
|
|
useUpdateBankCard,
|
|
useUpdateBankOperation,
|
|
useUpdateExpenseCategory,
|
|
} from "./mutations";
|
|
import {
|
|
useBankCard,
|
|
useBankCards,
|
|
useBankCategories,
|
|
useBankExpenses,
|
|
useBankOperation,
|
|
useBankOperations,
|
|
useBankSummary,
|
|
} from "./queries";
|
|
|
|
const card: BankCard
|
|
= {
|
|
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,
|
|
};
|
|
|
|
const transfer: CreateBankOperation = {
|
|
amount: { currency: "RUB", value: "90071992547409.93" },
|
|
cardId: "a",
|
|
date: "2026-09-12",
|
|
id: "4e6caa65-dd52-4629-81e2-ce8a139a0612",
|
|
kind: "transfer",
|
|
targetCardId: "b",
|
|
};
|
|
|
|
function authenticate() {
|
|
configureAuthClient({
|
|
accessToken: () => "token",
|
|
clear: vi.fn(),
|
|
refresh: () => Promise.resolve(true),
|
|
});
|
|
}
|
|
|
|
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", isDefault: true, name: categoryName, version: 1 },
|
|
]);
|
|
if (url.pathname.endsWith("/summary"))
|
|
return HttpResponse.json({
|
|
cardId: url.searchParams.get("cardId"),
|
|
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")
|
|
)
|
|
return HttpResponse.json({
|
|
hasNext: false,
|
|
items: [],
|
|
page: 1,
|
|
total: 0,
|
|
});
|
|
|
|
return url.pathname.includes("/operations/")
|
|
? HttpResponse.json({ ...transfer, version: 1 })
|
|
: 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", {
|
|
from: "2026-09-01",
|
|
limit: 20, page: 2,
|
|
to: "2026-09-30",
|
|
});
|
|
|
|
const expenses = useBankExpenses("a", { page: 1 });
|
|
const september = useBankSummary({ from: "2026-09-01", to: "2026-09-30" });
|
|
const august = useBankSummary({ from: "2026-08-01", to: "2026-08-31" }, "b");
|
|
const original = useBankOperation("original");
|
|
|
|
return {
|
|
createOperation: useCreateBankOperation(),
|
|
reads: [
|
|
cards,
|
|
a,
|
|
b,
|
|
categories,
|
|
history,
|
|
expenses,
|
|
september,
|
|
august,
|
|
original,
|
|
],
|
|
};
|
|
}
|
|
|
|
describe("banking API", () => {
|
|
beforeEach(authenticate);
|
|
|
|
it("loads every page for all mode and fails without exposing partial results", async () => {
|
|
const pages: number[] = [];
|
|
let isFail = 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("from")).toBe("2026-09-01");
|
|
expect(url.searchParams.get("to")).toBe("2026-09-30");
|
|
if (isFail && page === 2)
|
|
return HttpResponse.json({ code: "FAILED" }, { status: 500 });
|
|
|
|
return HttpResponse.json({
|
|
hasNext: page === 1,
|
|
items: [{ ...transfer, id: String(page) }],
|
|
page,
|
|
total: 2,
|
|
});
|
|
}),
|
|
);
|
|
|
|
const { result } = renderHook(
|
|
() => useBankOperations("a", { from: "2026-09-01", limit: "all", page: 1, to: "2026-09-30" }),
|
|
{ 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",
|
|
]);
|
|
isFail = true;
|
|
|
|
const { result: failedResult } = renderHook(
|
|
() => useBankOperations("a", { from: "2026-09-01", limit: "all", page: 1, to: "2026-09-30" }),
|
|
{ wrapper: createQueryWrapper().wrapper },
|
|
);
|
|
|
|
await waitFor(() => expect(failedResult.current.isError).toBe(true));
|
|
expect(failedResult.current.data).toBeUndefined();
|
|
});
|
|
|
|
it("uses session-aware banking endpoints and explicit period/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");
|
|
const urls = Array.from(counts.keys(), path => new URL(path, "http://localhost"));
|
|
const history = urls.find(url => url.pathname === "/api/v1/bank-cards/a/operations");
|
|
const summary = urls.find(url => url.pathname === "/api/v1/bank-cards/summary" && url.searchParams.get("cardId") === "b");
|
|
expect(Object.fromEntries(history?.searchParams ?? [])).toEqual({
|
|
from: "2026-09-01", limit: "20", page: "2", to: "2026-09-30",
|
|
});
|
|
expect(Object.fromEntries(summary?.searchParams ?? [])).toEqual({
|
|
cardId: "b", from: "2026-08-01", to: "2026-08-31",
|
|
});
|
|
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(() => {
|
|
token = "renewed";
|
|
|
|
return Promise.resolve(true);
|
|
});
|
|
|
|
configureAuthClient({ accessToken: () => token, clear: vi.fn(), refresh });
|
|
const bodies: unknown[] = [];
|
|
const headers: (null | string)[] = [];
|
|
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(useCreateBankOperation, {
|
|
wrapper: createQueryWrapper().wrapper,
|
|
});
|
|
|
|
await act(() => result.current.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(useCreateBankOperation, {
|
|
wrapper: createQueryWrapper().wrapper,
|
|
});
|
|
|
|
await act(async () => {
|
|
await expect(
|
|
result.current.mutateAsync(transfer),
|
|
).rejects.toThrow();
|
|
});
|
|
expect(bodies).toEqual([transfer]);
|
|
await act(() => result.current.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()));
|
|
|
|
const { promise: pending, resolve: finish } = Promise.withResolvers<void>();
|
|
server.use(
|
|
http.post("*/api/v1/bank-cards/operations", async () => {
|
|
await pending;
|
|
|
|
return HttpResponse.json({ code: "INTERNAL_ERROR" }, { status: 500 });
|
|
}),
|
|
);
|
|
|
|
const { result } = renderHook(
|
|
() => ({ createOperation: useCreateBankOperation(), query: useBankCard("a") }),
|
|
{ wrapper: createQueryWrapper().wrapper },
|
|
);
|
|
|
|
await waitFor(() => expect(result.current.query.isSuccess).toBe(true));
|
|
let saving: Promise<unknown>;
|
|
act(() => {
|
|
saving = result.current.createOperation
|
|
.mutateAsync(transfer)
|
|
// 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.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 period, 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 { 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),
|
|
);
|
|
await act(() =>
|
|
result.current.createOperation.mutateAsync(transfer),
|
|
);
|
|
expect(counts.size).toBe(9);
|
|
for (const count of counts.values()) expect(count).toBe(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"), updateCard: useUpdateBankCard() }),
|
|
{ wrapper: createQueryWrapper().wrapper },
|
|
);
|
|
|
|
await waitFor(() => expect(result.current.card.data?.version).toBe(1));
|
|
await act(async () => {
|
|
await expect(
|
|
result.current.updateCard.mutateAsync({
|
|
body: { name: "changed", version: 1 },
|
|
id: "a",
|
|
}),
|
|
).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", isDefault: true, name, 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",
|
|
isDefault: true,
|
|
name,
|
|
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(),
|
|
deleteOperation: useDeleteBankOperation(),
|
|
updateCategory: useUpdateExpenseCategory(),
|
|
updateOperation: useUpdateBankOperation(),
|
|
}),
|
|
{ wrapper: createQueryWrapper().wrapper },
|
|
);
|
|
|
|
await waitFor(() =>
|
|
expect(result.current.categories.data?.[0]?.name).toBe("Food"),
|
|
);
|
|
await act(() =>
|
|
result.current.updateCategory.mutateAsync({
|
|
body: { name: "Groceries", version: 1 },
|
|
id: "food",
|
|
}),
|
|
);
|
|
await waitFor(() =>
|
|
expect(result.current.categories.data?.[0]?.name).toBe("Groceries"),
|
|
);
|
|
await act(() =>
|
|
result.current.updateOperation.mutateAsync({
|
|
body: { description: "corrected", version: 1 },
|
|
id: "original",
|
|
}),
|
|
);
|
|
await act(() =>
|
|
result.current.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 { 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" },
|
|
});
|
|
expect(result.current.data?.balance.value).toBe("100");
|
|
act(() => {
|
|
configureAuthClient();
|
|
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);
|
|
});
|
|
});
|