import { MantineProvider } from "@mantine/core"; import { QueryClientProvider } from "@tanstack/react-query"; import { fireEvent, render, screen, waitFor, within, } from "@testing-library/react"; import { HttpResponse, http } from "msw"; import { describe, expect, it, vi } from "vitest"; import type { BankCard, BankOperation } from "@/entities/bank-card"; import { configureAuthClient } from "@/shared/api"; import { createTestQueryClient } from "@/test/query"; import { server } from "@/test/server"; import { OperationForm } from "./OperationForm"; const money = (value: string) => ({ currency: "RUB", value }); const card: BankCard = { id: "a", name: "Повседневная", bankName: null, lastFour: null, paymentSystem: null, balance: money("0"), openingBalance: money("0"), openingDate: "2026-01-01", archivedAt: null, version: 1, }; function setup(operation?: BankOperation) { configureAuthClient({ accessToken: () => "test", refresh: async () => false, clear: () => {}, }); server.use( http.get("*/api/v1/bank-cards/categories", () => HttpResponse.json([ { id: "default", name: "Остальное", isDefault: true, version: 2 }, ]), ), ); const close = vi.fn(); render( , ); return close; } async function select(label: string, value: string | RegExp) { fireEvent.click(screen.getByRole("combobox", { name: new RegExp(label) })); fireEvent.click(await screen.findByRole("option", { name: value })); } describe("OperationForm", () => { it("uses renamed default category and exact maximum positive money despite zero balance", async () => { const close = setup(); await waitFor(() => expect(screen.getByRole("combobox", { name: /Категория/ })).toHaveValue( "Остальное", ), ); fireEvent.change(screen.getByLabelText(/Дата операции/), { target: { value: "07.08.2026" }, }); fireEvent.blur(screen.getByLabelText(/Дата операции/)); expect(screen.getByLabelText(/Дата операции/)).toHaveValue("07.08.2026"); let body: unknown; server.use( http.post("*/api/v1/bank-cards/operations", async ({ request }) => { body = await request.json(); return HttpResponse.json({}, { status: 201 }); }), ); fireEvent.change(screen.getByLabelText(/Сумма/), { target: { value: "92233720368547758,07" }, }); fireEvent.click(screen.getByRole("button", { name: "Сохранить" })); await waitFor(() => expect(close).toHaveBeenCalled()); expect(body).toMatchObject({ categoryId: "default", date: "2026-08-07", amount: money("92233720368547758.07"), }); }); it("checks both transfer opening dates, excludes archive and reuses intent after a lost response", async () => { setup(); await select("Вид операции", "Перевод"); await select("Карта-получатель", "Запасная"); fireEvent.change(screen.getByLabelText(/Сумма/), { target: { value: "0" }, }); fireEvent.change(screen.getByLabelText(/Дата операции/), { target: { value: "31.07.2026" }, }); 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({}, { status: 201 }); }), ); fireEvent.click(screen.getByRole("button", { name: "Сохранить" })); expect( await screen.findByText("Сумма операции должна быть больше нуля."), ).toBeInTheDocument(); expect(screen.getByText(/Дата не может быть раньше/)).toBeInTheDocument(); expect(bodies).toHaveLength(0); fireEvent.change(screen.getByLabelText(/Сумма/), { target: { value: "150,01" }, }); fireEvent.change(screen.getByLabelText(/Дата операции/), { target: { value: "02.08.2026" }, }); fireEvent.click(screen.getByRole("button", { name: "Сохранить" })); await screen.findByText(/Не удалось подтвердить сохранение/); expect(screen.getByLabelText(/Сумма/)).toHaveValue("150,01"); fireEvent.click(screen.getByRole("button", { name: "Сохранить" })); await waitFor(() => expect(bodies).toHaveLength(2)); expect(bodies[0]).toEqual(bodies[1]); expect(bodies[0]).toMatchObject({ kind: "transfer", cardId: "a", targetCardId: "b", amount: money("150.01"), }); }); it("topup omits categories and preserves entered description", async () => { setup(); await select("Вид операции", "Пополнение"); expect( screen.queryByRole("combobox", { name: /Категория/ }), ).not.toBeInTheDocument(); let body: unknown; server.use( http.post("*/api/v1/bank-cards/operations", async ({ request }) => { body = await request.json(); return HttpResponse.json({}, { status: 201 }); }), ); fireEvent.change(screen.getByLabelText(/Сумма/), { target: { value: "0.10" }, }); fireEvent.change(screen.getByLabelText(/Описание/), { target: { value: "Внесение" }, }); fireEvent.click(screen.getByRole("button", { name: "Сохранить" })); await waitFor(() => expect(body).toBeDefined()); expect(body).toMatchObject({ kind: "topup", amount: money("0.10"), description: "Внесение", }); expect(body).not.toHaveProperty("categoryId"); }); it("finds a previous-month purchase across pages, checks remaining refund and retains the complete-refund intent", async () => { const purchase: BankOperation = { id: "purchase", kind: "expense", cardId: "a", cardName: card.name, targetCardId: null, targetCardName: null, categoryId: "default", categoryName: "Остальное", originalExpenseId: null, amount: money("500"), availableRefund: money("500"), date: "2026-07-01", description: "Прошлая покупка", version: 1, createdAt: "2026-07-01T00:00:00Z", }; server.use( http.get("*/api/v1/bank-cards/a/expenses", ({ request }) => { return HttpResponse.json({ items: new URL(request.url).searchParams.get("page") === "2" ? [purchase] : [], page: 1, total: 1, hasNext: new URL(request.url).searchParams.get("page") !== "2", }); }), http.get("*/api/v1/bank-cards/operations/purchase", () => HttpResponse.json(purchase), ), ); const close = setup(); await select("Вид операции", "Возврат"); await waitFor(() => expect(screen.getByRole("button", { name: "Далее" })).toBeEnabled(), ); fireEvent.click(screen.getByRole("button", { name: "Далее" })); await waitFor(() => expect(screen.getByRole("button", { name: "Назад" })).toBeEnabled(), ); await select("Исходная покупка", /Прошлая покупка/); await screen.findByText("Доступно к возврату: 500 ₽"); fireEvent.change(screen.getByLabelText(/Сумма/), { target: { value: "500.01" }, }); fireEvent.click(screen.getByRole("button", { name: "Сохранить" })); await screen.findByText("Сумма превышает доступный возврат."); const bodies: unknown[] = []; server.use( http.post("*/api/v1/bank-cards/operations", async ({ request }) => { bodies.push(await request.json()); purchase.availableRefund = money("0"); return bodies.length === 1 ? HttpResponse.error() : HttpResponse.json({}, { status: 201 }); }), ); fireEvent.change(screen.getByLabelText(/Сумма/), { target: { value: "500" }, }); fireEvent.change(screen.getByLabelText(/Дата операции/), { target: { value: "01.08.2026" }, }); fireEvent.click(screen.getByRole("button", { name: "Сохранить" })); await screen.findByText(/Не удалось подтвердить сохранение/); fireEvent.click(screen.getByRole("button", { name: "Перечитать запись" })); await screen.findByText("Доступно к возврату: 0 ₽"); fireEvent.click(screen.getByRole("button", { name: "Сохранить" })); await waitFor(() => expect(close).toHaveBeenCalled()); expect(bodies[0]).toEqual(bodies[1]); expect(bodies[0]).toMatchObject({ kind: "refund", originalExpenseId: "purchase", date: "2026-08-01", }); }); it("edits a full refund with its own amount excluded from the limit and immutable purchase", async () => { const refund: BankOperation = { id: "refund", kind: "refund", cardId: "a", cardName: card.name, targetCardId: null, targetCardName: null, categoryId: "default", categoryName: "Остальное", originalExpenseId: "purchase", amount: money("500"), availableRefund: money("500"), date: "2026-08-01", description: "", version: 2, createdAt: "2026-08-01T00:00:00Z", }; const purchase = { ...refund, id: "purchase", kind: "expense", date: "2026-07-01", availableRefund: money("0"), }; server.use( http.get("*/api/v1/bank-cards/a/expenses", () => HttpResponse.json({ items: [purchase], page: 1, total: 1, hasNext: false, }), ), http.get("*/api/v1/bank-cards/operations/purchase", () => HttpResponse.json(purchase), ), http.get("*/api/v1/bank-cards/operations/refund", () => HttpResponse.json(refund), ), ); const close = setup(refund); await screen.findByText("Доступно к возврату: 500 ₽"); expect( screen.getByRole("combobox", { name: /Исходная покупка/ }), ).toBeDisabled(); expect( screen.getByRole("combobox", { name: /Вид операции/ }), ).toBeDisabled(); fireEvent.change(screen.getByLabelText(/Описание/), { target: { value: "Полностью возвращено" }, }); let body: unknown; server.use( http.patch( "*/api/v1/bank-cards/operations/refund", async ({ request }) => { body = await request.json(); return HttpResponse.json(refund); }, ), ); await waitFor(() => expect(screen.getByRole("button", { name: "Сохранить" })).toBeEnabled(), ); fireEvent.click(screen.getByRole("button", { name: "Сохранить" })); await waitFor(() => expect(close).toHaveBeenCalled()); expect(body).toMatchObject({ version: 2, amount: money("500"), originalExpenseId: "purchase", description: "Полностью возвращено", }); }); it("keeps edit draft and stale version until explicit adoption after conflict", async () => { const operation: BankOperation = { id: "expense", kind: "expense", cardId: "a", cardName: card.name, targetCardId: null, targetCardName: null, categoryId: "default", categoryName: "Остальное", originalExpenseId: null, amount: money("100"), availableRefund: money("100"), date: "2026-08-01", description: "Первое", version: 1, createdAt: "2026-08-01T00:00:00Z", }; let readVersion = 1; const bodies: Record[] = []; server.use( http.get("*/api/v1/bank-cards/operations/expense", () => HttpResponse.json({ ...operation, version: readVersion, description: readVersion === 1 ? "Первое" : "Другое исправление", }), ), http.patch( "*/api/v1/bank-cards/operations/expense", async ({ request }) => { const body = (await request.json()) as Record; bodies.push(body); readVersion = 2; return body.version === 1 ? HttpResponse.json( { code: "BANK_VERSION_CONFLICT" }, { status: 409 }, ) : HttpResponse.json({ ...operation, ...body }); }, ), ); const close = setup(operation); fireEvent.change(screen.getByLabelText(/Описание/), { target: { value: "Мой черновик" }, }); fireEvent.change(screen.getByLabelText(/Сумма/), { target: { value: "150" }, }); await waitFor(() => expect(screen.getByRole("button", { name: "Сохранить" })).toBeEnabled(), ); fireEvent.click(screen.getByRole("button", { name: "Сохранить" })); await screen.findByText(/Запись уже изменена/); expect(screen.getByLabelText(/Описание/)).toHaveValue("Мой черновик"); fireEvent.click(screen.getByRole("button", { name: "Перечитать запись" })); await screen.findByRole("button", { name: "Использовать актуальную версию 2", }); expect(screen.getByLabelText(/Сумма/)).toHaveValue("150"); fireEvent.click(screen.getByRole("button", { name: "Сохранить" })); await waitFor(() => expect(bodies).toHaveLength(2)); expect(bodies[1].version).toBe(1); fireEvent.click( screen.getByRole("button", { name: "Использовать актуальную версию 2" }), ); fireEvent.click(screen.getByRole("button", { name: "Сохранить" })); await waitFor(() => expect(close).toHaveBeenCalled()); expect(bodies[2]).toMatchObject({ version: 2, description: "Мой черновик", amount: money("150"), }); }); it("creates a category inside expense without losing operation fields", async () => { setup(); fireEvent.change(screen.getByLabelText(/Сумма/), { target: { value: "001.20" }, }); fireEvent.change(screen.getByLabelText(/Описание/), { target: { value: "Черновик расхода" }, }); fireEvent.click(screen.getByRole("button", { name: "Добавить категорию" })); const dialog = screen.getByRole("dialog", { name: "Категории" }); fireEvent.change(within(dialog).getByLabelText(/Название категории/), { target: { value: " кафе " }, }); let created = false; server.use( http.post("*/api/v1/bank-cards/categories", () => { created = true; return HttpResponse.json( { id: "new", name: "кафе", version: 1, isDefault: false }, { status: 201 }, ); }), http.get("*/api/v1/bank-cards/categories", () => HttpResponse.json([ { id: "default", name: "Остальное", isDefault: true, version: 2 }, ...(created ? [{ id: "new", name: "кафе", isDefault: false, version: 1 }] : []), ]), ), ); fireEvent.click( within(dialog).getByRole("button", { name: "Добавить категорию" }), ); await waitFor(() => expect( screen.queryByRole("dialog", { name: "Категории" }), ).not.toBeInTheDocument(), ); expect(screen.getByLabelText(/Сумма/)).toHaveValue("001.20"); expect(screen.getByLabelText(/Описание/)).toHaveValue("Черновик расхода"); await waitFor(() => expect(screen.getByRole("combobox", { name: /Категория/ })).toHaveValue( "кафе", ), ); let body: unknown; server.use( http.post("*/api/v1/bank-cards/operations", async ({ request }) => { body = await request.json(); return HttpResponse.json({}, { status: 201 }); }), ); fireEvent.click(screen.getByRole("button", { name: "Сохранить" })); await waitFor(() => expect(body).toBeDefined()); expect(body).toMatchObject({ categoryId: "new", description: "Черновик расхода", amount: money("1.20"), }); }); });