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