600 lines
17 KiB
TypeScript
600 lines
17 KiB
TypeScript
import { beforeEach, describe, expect, mock, test } from "bun:test";
|
|
import { PaymentStatus } from "../src/generated/prisma/client";
|
|
|
|
const mockPrisma = {
|
|
periodicExpense: {
|
|
findMany: mock(),
|
|
findUniqueOrThrow: mock(),
|
|
create: mock(),
|
|
update: mock(),
|
|
},
|
|
expense: {
|
|
findMany: mock(),
|
|
findFirst: mock(),
|
|
findUniqueOrThrow: mock(),
|
|
create: mock(),
|
|
update: mock(),
|
|
count: mock(),
|
|
},
|
|
};
|
|
|
|
mock.module("../src/lib/prisma", () => ({
|
|
prisma: mockPrisma,
|
|
}));
|
|
|
|
beforeEach(() => {
|
|
for (const model of Object.values(mockPrisma)) {
|
|
for (const fn of Object.values(
|
|
model as Record<string, ReturnType<typeof mock>>,
|
|
)) {
|
|
fn.mockClear();
|
|
}
|
|
}
|
|
mockPrisma.expense.count.mockResolvedValue(0);
|
|
});
|
|
|
|
const {
|
|
createPeriodicExpenseSchema,
|
|
createNonPeriodicExpenseSchema,
|
|
payExpenseSchema,
|
|
updateExpenseSchema,
|
|
buildDueDate,
|
|
getCurrentYearMonthUTC,
|
|
listPeriodicExpenses,
|
|
createPeriodicExpense,
|
|
updatePeriodicExpense,
|
|
softDeletePeriodicExpense,
|
|
listExpenses,
|
|
createNonPeriodicExpense,
|
|
payExpense,
|
|
updateExpense,
|
|
generateExpensesForCurrentMonth,
|
|
generateMonthlyExpense,
|
|
} = await import("../src/modules/expenses/expenses.service");
|
|
|
|
describe("schemas", () => {
|
|
describe("createPeriodicExpenseSchema", () => {
|
|
test("accepts valid input", () => {
|
|
const result = createPeriodicExpenseSchema.parse({
|
|
description: "Gimnasio",
|
|
defaultDueDay: 10,
|
|
defaultAmount: 1500.5,
|
|
periods: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12],
|
|
});
|
|
expect(result.description).toBe("Gimnasio");
|
|
});
|
|
|
|
test("rejects empty description", () => {
|
|
expect(() =>
|
|
createPeriodicExpenseSchema.parse({
|
|
description: "",
|
|
defaultDueDay: 10,
|
|
defaultAmount: 100,
|
|
periods: [1],
|
|
}),
|
|
).toThrow();
|
|
});
|
|
|
|
test("rejects description over 50 chars", () => {
|
|
expect(() =>
|
|
createPeriodicExpenseSchema.parse({
|
|
description: "a".repeat(51),
|
|
defaultDueDay: 10,
|
|
defaultAmount: 100,
|
|
periods: [1],
|
|
}),
|
|
).toThrow();
|
|
});
|
|
|
|
test("rejects due day < 1", () => {
|
|
expect(() =>
|
|
createPeriodicExpenseSchema.parse({
|
|
description: "Test",
|
|
defaultDueDay: 0,
|
|
defaultAmount: 100,
|
|
periods: [1],
|
|
}),
|
|
).toThrow();
|
|
});
|
|
|
|
test("rejects due day > 31", () => {
|
|
expect(() =>
|
|
createPeriodicExpenseSchema.parse({
|
|
description: "Test",
|
|
defaultDueDay: 32,
|
|
defaultAmount: 100,
|
|
periods: [1],
|
|
}),
|
|
).toThrow();
|
|
});
|
|
|
|
test("rejects non-positive amount", () => {
|
|
expect(() =>
|
|
createPeriodicExpenseSchema.parse({
|
|
description: "Test",
|
|
defaultDueDay: 15,
|
|
defaultAmount: 0,
|
|
periods: [1],
|
|
}),
|
|
).toThrow();
|
|
});
|
|
|
|
test("rejects empty periods", () => {
|
|
expect(() =>
|
|
createPeriodicExpenseSchema.parse({
|
|
description: "Test",
|
|
defaultDueDay: 15,
|
|
defaultAmount: 100,
|
|
periods: [],
|
|
}),
|
|
).toThrow();
|
|
});
|
|
|
|
test("rejects invalid period month", () => {
|
|
expect(() =>
|
|
createPeriodicExpenseSchema.parse({
|
|
description: "Test",
|
|
defaultDueDay: 15,
|
|
defaultAmount: 100,
|
|
periods: [0, 13],
|
|
}),
|
|
).toThrow();
|
|
});
|
|
});
|
|
|
|
describe("createNonPeriodicExpenseSchema", () => {
|
|
test("accepts valid input", () => {
|
|
const result = createNonPeriodicExpenseSchema.parse({
|
|
description: "Ropa",
|
|
amount: 2500,
|
|
dueDate: "2026-06-15T00:00:00.000Z",
|
|
});
|
|
expect(result.description).toBe("Ropa");
|
|
});
|
|
|
|
test("rejects negative amount", () => {
|
|
expect(() =>
|
|
createNonPeriodicExpenseSchema.parse({
|
|
description: "Ropa",
|
|
amount: -100,
|
|
dueDate: "2026-06-15T00:00:00.000Z",
|
|
}),
|
|
).toThrow();
|
|
});
|
|
});
|
|
|
|
describe("payExpenseSchema", () => {
|
|
test("accepts valid payment", () => {
|
|
const result = payExpenseSchema.parse({
|
|
amountPayed: 1400,
|
|
paymentDate: "2026-06-10T00:00:00.000Z",
|
|
});
|
|
expect(result.amountPayed).toBe(1400);
|
|
});
|
|
|
|
test("allows missing paymentDate", () => {
|
|
const result = payExpenseSchema.parse({ amountPayed: 1400 });
|
|
expect(result.amountPayed).toBe(1400);
|
|
expect(result.paymentDate).toBeUndefined();
|
|
});
|
|
|
|
test("rejects zero amountPayed", () => {
|
|
expect(() => payExpenseSchema.parse({ amountPayed: 0 })).toThrow();
|
|
});
|
|
});
|
|
|
|
describe("updateExpenseSchema", () => {
|
|
test("accepts valid input", () => {
|
|
const result = updateExpenseSchema.parse({
|
|
amount: 2500,
|
|
dueDate: "2026-07-15T00:00:00.000Z",
|
|
});
|
|
expect(result.amount).toBe(2500);
|
|
expect(result.dueDate).toBe("2026-07-15T00:00:00.000Z");
|
|
});
|
|
|
|
test("rejects non-positive amount", () => {
|
|
expect(() =>
|
|
updateExpenseSchema.parse({
|
|
amount: 0,
|
|
dueDate: "2026-07-15T00:00:00.000Z",
|
|
}),
|
|
).toThrow();
|
|
});
|
|
|
|
test("rejects invalid dueDate", () => {
|
|
expect(() =>
|
|
updateExpenseSchema.parse({
|
|
amount: 100,
|
|
dueDate: "not-a-date",
|
|
}),
|
|
).toThrow();
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("buildDueDate", () => {
|
|
test("creates valid date", () => {
|
|
const date = buildDueDate(2026, 6, 15);
|
|
expect(date.getUTCFullYear()).toBe(2026);
|
|
expect(date.getUTCMonth()).toBe(5); // 0-indexed
|
|
expect(date.getUTCDate()).toBe(15);
|
|
});
|
|
|
|
test("clamps day to last day of month when dueDay exceeds month length", () => {
|
|
const date = buildDueDate(2026, 2, 31);
|
|
expect(date.getUTCMonth()).toBe(1); // February (0-indexed)
|
|
expect(date.getUTCDate()).toBe(28);
|
|
});
|
|
|
|
test("handles december correctly", () => {
|
|
const date = buildDueDate(2026, 12, 31);
|
|
expect(date.getUTCFullYear()).toBe(2026);
|
|
expect(date.getUTCMonth()).toBe(11);
|
|
expect(date.getUTCDate()).toBe(31);
|
|
});
|
|
|
|
test("handles leap year february", () => {
|
|
const date = buildDueDate(2024, 2, 29);
|
|
expect(date.getUTCFullYear()).toBe(2024);
|
|
expect(date.getUTCMonth()).toBe(1);
|
|
expect(date.getUTCDate()).toBe(29);
|
|
});
|
|
});
|
|
|
|
describe("getCurrentYearMonthUTC", () => {
|
|
test("returns valid year and month", () => {
|
|
const { year, month } = getCurrentYearMonthUTC();
|
|
expect(year).toBeGreaterThan(2020);
|
|
expect(month).toBeGreaterThanOrEqual(1);
|
|
expect(month).toBeLessThanOrEqual(12);
|
|
});
|
|
});
|
|
|
|
describe("listPeriodicExpenses", () => {
|
|
test("returns only non-deleted expenses ordered by createdAt desc", async () => {
|
|
const mockItems = [
|
|
{ id: 1, description: "Gas", isDeleted: false, createdAt: new Date() },
|
|
];
|
|
mockPrisma.periodicExpense.findMany.mockResolvedValueOnce(mockItems);
|
|
|
|
const result = await listPeriodicExpenses();
|
|
expect(result).toEqual(mockItems);
|
|
expect(mockPrisma.periodicExpense.findMany).toHaveBeenCalledWith({
|
|
where: { isDeleted: false },
|
|
orderBy: { description: "asc" },
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("createPeriodicExpense", () => {
|
|
test("creates and detects current month applicability", async () => {
|
|
const input = {
|
|
description: "Gimnasio",
|
|
defaultDueDay: 10,
|
|
defaultAmount: 1500,
|
|
periods: [6],
|
|
};
|
|
const created = {
|
|
id: 1,
|
|
...input,
|
|
isDeleted: false,
|
|
createdAt: new Date(),
|
|
};
|
|
mockPrisma.periodicExpense.create.mockResolvedValueOnce(created);
|
|
|
|
const result = await createPeriodicExpense(input);
|
|
expect(result.id).toBe(1);
|
|
// currentMonthApplicable depends on actual month; just verify truthy/falsy
|
|
expect("currentMonthApplicable" in result).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("updatePeriodicExpense", () => {
|
|
test("updates and returns the record", async () => {
|
|
const input = {
|
|
description: "Gas actualizado",
|
|
defaultDueDay: 5,
|
|
defaultAmount: 2000,
|
|
periods: [1, 7],
|
|
};
|
|
const updated = { id: 1, ...input, isDeleted: false };
|
|
mockPrisma.periodicExpense.update.mockResolvedValueOnce(updated);
|
|
|
|
const result = await updatePeriodicExpense(1, input);
|
|
expect(result.description).toBe("Gas actualizado");
|
|
expect(mockPrisma.periodicExpense.update).toHaveBeenCalledWith({
|
|
where: { id: 1 },
|
|
data: input,
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("softDeletePeriodicExpense", () => {
|
|
test("sets isDeleted to true", async () => {
|
|
const deleted = { id: 1, isDeleted: true };
|
|
mockPrisma.periodicExpense.update.mockResolvedValueOnce(deleted);
|
|
|
|
const result = await softDeletePeriodicExpense(1);
|
|
expect(result.isDeleted).toBe(true);
|
|
expect(mockPrisma.periodicExpense.update).toHaveBeenCalledWith({
|
|
where: { id: 1 },
|
|
data: { isDeleted: true },
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("listExpenses", () => {
|
|
test("returns all expenses when no status filter", async () => {
|
|
mockPrisma.expense.findMany.mockResolvedValueOnce([]);
|
|
await listExpenses({});
|
|
expect(mockPrisma.expense.findMany).toHaveBeenCalledWith({
|
|
where: {},
|
|
include: { periodicExpense: true },
|
|
orderBy: { dueDate: "desc" },
|
|
skip: 0,
|
|
take: 10,
|
|
});
|
|
});
|
|
|
|
test("filters by status when provided", async () => {
|
|
mockPrisma.expense.findMany.mockResolvedValueOnce([]);
|
|
await listExpenses({ status: "PENDING" });
|
|
expect(mockPrisma.expense.findMany).toHaveBeenCalledWith({
|
|
where: { status: "PENDING" },
|
|
include: { periodicExpense: true },
|
|
orderBy: { dueDate: "desc" },
|
|
skip: 0,
|
|
take: 10,
|
|
});
|
|
});
|
|
|
|
test("filters by periodicExpenseId when provided", async () => {
|
|
mockPrisma.expense.findMany.mockResolvedValueOnce([]);
|
|
await listExpenses({ periodicExpenseId: 3 });
|
|
expect(mockPrisma.expense.findMany).toHaveBeenCalledWith({
|
|
where: { periodicExpenseId: 3 },
|
|
include: { periodicExpense: true },
|
|
orderBy: { dueDate: "desc" },
|
|
skip: 0,
|
|
take: 10,
|
|
});
|
|
});
|
|
|
|
test("filters by search query with case-insensitive contains", async () => {
|
|
mockPrisma.expense.findMany.mockResolvedValueOnce([]);
|
|
await listExpenses({ search: "Gim" });
|
|
expect(mockPrisma.expense.findMany).toHaveBeenCalledWith({
|
|
where: { description: { contains: "Gim", mode: "insensitive" } },
|
|
include: { periodicExpense: true },
|
|
orderBy: { dueDate: "desc" },
|
|
skip: 0,
|
|
take: 10,
|
|
});
|
|
});
|
|
|
|
test("combines status, periodicExpenseId, and search filters", async () => {
|
|
mockPrisma.expense.findMany.mockResolvedValueOnce([]);
|
|
await listExpenses({
|
|
status: "PENDING",
|
|
periodicExpenseId: 1,
|
|
search: "Agua",
|
|
});
|
|
expect(mockPrisma.expense.findMany).toHaveBeenCalledWith({
|
|
where: {
|
|
status: "PENDING",
|
|
periodicExpenseId: 1,
|
|
description: { contains: "Agua", mode: "insensitive" },
|
|
},
|
|
include: { periodicExpense: true },
|
|
orderBy: { dueDate: "desc" },
|
|
skip: 0,
|
|
take: 10,
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("createNonPeriodicExpense", () => {
|
|
test("creates expense with PAYED status and sets amountPayed", async () => {
|
|
const input = {
|
|
description: "Ropa",
|
|
amount: 2500,
|
|
dueDate: "2026-06-15T00:00:00.000Z",
|
|
};
|
|
const created = {
|
|
id: 1,
|
|
description: "Ropa",
|
|
amount: 2500,
|
|
amountPayed: 2500,
|
|
status: PaymentStatus.PAYED,
|
|
year: 2026,
|
|
month: 6,
|
|
};
|
|
mockPrisma.expense.create.mockResolvedValueOnce(created);
|
|
|
|
const result = await createNonPeriodicExpense(input);
|
|
expect(result.status).toBe(PaymentStatus.PAYED);
|
|
expect(result.amountPayed).toBe(2500);
|
|
expect(mockPrisma.expense.create).toHaveBeenCalledWith({
|
|
data: expect.objectContaining({
|
|
description: "Ropa",
|
|
amount: 2500,
|
|
amountPayed: 2500,
|
|
status: PaymentStatus.PAYED,
|
|
paymentDate: expect.any(Date),
|
|
}),
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("payExpense", () => {
|
|
test("updates status to PAYED with amountPayed and paymentDate", async () => {
|
|
const paidExpense = {
|
|
id: 1,
|
|
status: PaymentStatus.PAYED,
|
|
amountPayed: 1400,
|
|
paymentDate: new Date("2026-06-10T00:00:00.000Z"),
|
|
};
|
|
mockPrisma.expense.update.mockResolvedValueOnce(paidExpense);
|
|
|
|
const result = await payExpense(1, {
|
|
amountPayed: 1400,
|
|
paymentDate: "2026-06-10T00:00:00.000Z",
|
|
});
|
|
expect(result.status).toBe(PaymentStatus.PAYED);
|
|
expect(result.amountPayed).toBe(1400);
|
|
});
|
|
|
|
test("defaults paymentDate to now when not provided", async () => {
|
|
mockPrisma.expense.update.mockImplementationOnce(
|
|
async ({ where, data }) => ({
|
|
id: where.id,
|
|
...data,
|
|
status: PaymentStatus.PAYED,
|
|
amountPayed: data.amountPayed,
|
|
paymentDate: data.paymentDate,
|
|
}),
|
|
);
|
|
|
|
const result = await payExpense(1, { amountPayed: 1500 });
|
|
expect(result.status).toBe(PaymentStatus.PAYED);
|
|
expect(result.paymentDate).toBeInstanceOf(Date);
|
|
});
|
|
});
|
|
|
|
describe("updateExpense", () => {
|
|
test("updates amount and dueDate for a PENDING expense", async () => {
|
|
const existing = {
|
|
id: 1,
|
|
status: PaymentStatus.PENDING,
|
|
amount: 1500,
|
|
dueDate: new Date("2026-06-15T00:00:00.000Z"),
|
|
year: 2026,
|
|
month: 6,
|
|
};
|
|
mockPrisma.expense.findUniqueOrThrow.mockResolvedValueOnce(existing);
|
|
mockPrisma.expense.update.mockImplementationOnce(
|
|
async ({ where, data }) => ({ id: where.id, ...existing, ...data }),
|
|
);
|
|
|
|
const result = await updateExpense(1, {
|
|
amount: 2000,
|
|
dueDate: "2026-07-20T00:00:00.000Z",
|
|
});
|
|
expect(result.amount).toBe(2000);
|
|
expect(result.year).toBe(2026);
|
|
expect(result.month).toBe(7);
|
|
});
|
|
|
|
test("rejects update for PAYED expense", async () => {
|
|
mockPrisma.expense.findUniqueOrThrow.mockResolvedValueOnce({
|
|
id: 1,
|
|
status: PaymentStatus.PAYED,
|
|
});
|
|
|
|
expect(
|
|
updateExpense(1, {
|
|
amount: 100,
|
|
dueDate: "2026-07-15T00:00:00.000Z",
|
|
}),
|
|
).rejects.toThrow("Solo se pueden editar gastos pendientes");
|
|
});
|
|
});
|
|
|
|
describe("generateExpensesForCurrentMonth", () => {
|
|
test("skips existing expenses to avoid duplicates", async () => {
|
|
const { month } = getCurrentYearMonthUTC();
|
|
mockPrisma.periodicExpense.findMany.mockResolvedValueOnce([
|
|
{
|
|
id: 1,
|
|
description: "Gas",
|
|
defaultDueDay: 15,
|
|
defaultAmount: 2000,
|
|
periods: [month],
|
|
},
|
|
]);
|
|
mockPrisma.expense.findFirst.mockResolvedValueOnce({ id: 99 });
|
|
|
|
const results = await generateExpensesForCurrentMonth();
|
|
expect(results).toHaveLength(1);
|
|
expect(results[0].status).toBe("already_exists");
|
|
expect(mockPrisma.expense.create).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test("creates expenses for applicable periodic expenses", async () => {
|
|
const { month } = getCurrentYearMonthUTC();
|
|
const periodic = {
|
|
id: 1,
|
|
description: "Gas",
|
|
defaultDueDay: 15,
|
|
defaultAmount: 2000,
|
|
periods: [month],
|
|
};
|
|
mockPrisma.periodicExpense.findMany.mockResolvedValueOnce([periodic]);
|
|
mockPrisma.expense.findFirst.mockResolvedValueOnce(null);
|
|
mockPrisma.expense.create.mockResolvedValueOnce({
|
|
id: 1,
|
|
description: "Gas",
|
|
periodicExpenseId: 1,
|
|
amount: 2000,
|
|
status: "PENDING",
|
|
});
|
|
|
|
const results = await generateExpensesForCurrentMonth();
|
|
expect(results).toHaveLength(1);
|
|
expect(results[0].status).toBe("created");
|
|
});
|
|
|
|
test("ignores deleted periodic expenses", async () => {
|
|
mockPrisma.periodicExpense.findMany.mockResolvedValueOnce([]);
|
|
|
|
const results = await generateExpensesForCurrentMonth();
|
|
expect(results).toHaveLength(0);
|
|
expect(mockPrisma.periodicExpense.findMany).toHaveBeenCalledWith({
|
|
where: { isDeleted: false, periods: { has: expect.any(Number) } },
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("generateMonthlyExpense", () => {
|
|
test("creates expense for given periodic expense", async () => {
|
|
const periodic = {
|
|
id: 1,
|
|
description: "Gas",
|
|
defaultDueDay: 15,
|
|
defaultAmount: 2000,
|
|
periods: [6],
|
|
};
|
|
mockPrisma.periodicExpense.findUniqueOrThrow.mockResolvedValueOnce(
|
|
periodic,
|
|
);
|
|
mockPrisma.expense.findFirst.mockResolvedValueOnce(null);
|
|
mockPrisma.expense.create.mockResolvedValueOnce({
|
|
id: 1,
|
|
description: "Gas",
|
|
periodicExpenseId: 1,
|
|
amount: 2000,
|
|
status: "PENDING",
|
|
});
|
|
|
|
const result = await generateMonthlyExpense(1);
|
|
expect(result.id).toBe(1);
|
|
});
|
|
|
|
test("returns existing expense instead of duplicating", async () => {
|
|
const existing = { id: 99, description: "Gas", periodicExpenseId: 1 };
|
|
mockPrisma.periodicExpense.findUniqueOrThrow.mockResolvedValueOnce({
|
|
id: 1,
|
|
description: "Gas",
|
|
defaultDueDay: 15,
|
|
defaultAmount: 2000,
|
|
periods: [6],
|
|
});
|
|
mockPrisma.expense.findFirst.mockResolvedValueOnce(existing);
|
|
|
|
const result = await generateMonthlyExpense(1);
|
|
expect(result.id).toBe(99);
|
|
expect(result).toEqual(existing);
|
|
});
|
|
});
|