466 lines
16 KiB
TypeScript
466 lines
16 KiB
TypeScript
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(
|
|
<QueryClientProvider client={createTestQueryClient()}>
|
|
<MantineProvider env="test">
|
|
<OperationForm
|
|
operation={operation}
|
|
card={card}
|
|
cards={[
|
|
card,
|
|
{ ...card, id: "b", name: "Запасная", openingDate: "2026-08-01" },
|
|
{
|
|
...card,
|
|
id: "archived",
|
|
name: "Архивная",
|
|
archivedAt: "2026-01-02",
|
|
},
|
|
]}
|
|
onClose={close}
|
|
/>
|
|
</MantineProvider>
|
|
</QueryClientProvider>,
|
|
);
|
|
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<string, unknown>[] = [];
|
|
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<string, unknown>;
|
|
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"),
|
|
});
|
|
});
|
|
});
|