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, 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: "NOT_FOUND" }, { status: 404 }); 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(); 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(); 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; 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: "NOT_FOUND" }, { status: 404 }), ), ); 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(); 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(); 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); }); });