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>, )) { 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, amountPayed: null, paymentDate: null, }; 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("allows updating a PAYED expense", async () => { const existing = { id: 1, status: PaymentStatus.PAYED, amount: 1500, amountPayed: 1500, dueDate: new Date("2026-06-15T00:00:00.000Z"), paymentDate: new Date("2026-06-10T00: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", amountPayed: 1800, }); expect(result.amount).toBe(2000); expect(result.year).toBe(2026); expect(result.month).toBe(7); expect(result.amountPayed).toBe(1800); }); test("reverting a PAYED expense to PENDING clears payment info", async () => { const existing = { id: 1, status: PaymentStatus.PAYED, amount: 1500, amountPayed: 1500, dueDate: new Date("2026-06-15T00:00:00.000Z"), paymentDate: new Date("2026-06-10T00: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: 1500, dueDate: "2026-06-15T00:00:00.000Z", status: "PENDING", }); expect(result.status).toBe(PaymentStatus.PENDING); }); test("sets amountPayed and paymentDate to null when reverting to PENDING", async () => { const existing = { id: 1, status: PaymentStatus.PAYED, amount: 1500, amountPayed: 1500, dueDate: new Date("2026-06-15T00:00:00.000Z"), paymentDate: new Date("2026-06-10T00: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: 1500, dueDate: "2026-06-15T00:00:00.000Z", status: "PENDING", }); expect(result.amountPayed).toBeNull(); expect(result.paymentDate).toBeNull(); }); }); 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); }); });