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, 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(); 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((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; 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(); 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(); 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); }); });