import { describe, it, expect, vi, beforeEach } from 'vitest'; const { mockRedisSet, mockRedisEval } = vi.hoisted(() => ({ mockRedisSet: vi.fn(), mockRedisEval: vi.fn(), })); const { mockQuery } = vi.hoisted(() => ({ mockQuery: vi.fn() })); const { mockGenerateMonthlyInvoice } = vi.hoisted(() => ({ mockGenerateMonthlyInvoice: vi.fn(), })); vi.mock('../redis.js', () => ({ default: { set: mockRedisSet, eval: mockRedisEval }, })); vi.mock('../db.js', () => ({ getPool: () => ({ query: mockQuery }), })); vi.mock('./invoices.js', () => ({ generateMonthlyInvoice: (...args: any[]) => mockGenerateMonthlyInvoice(...args), })); import { runInvoiceCron } from './cron.js'; const TWO_CUSTOMERS = [[{ id: 'cust-a' }, { id: 'cust-b' }]]; beforeEach(() => { vi.clearAllMocks(); mockRedisEval.mockResolvedValue(1); mockGenerateMonthlyInvoice.mockResolvedValue(null); }); describe('runInvoiceCron — Redis lock', () => { it('runs invoice loop when lock is acquired', async () => { mockRedisSet.mockResolvedValue('OK'); mockQuery.mockResolvedValue(TWO_CUSTOMERS); await runInvoiceCron(); expect(mockRedisSet).toHaveBeenCalledWith( 'invoice:cron:lock', expect.any(String), { NX: true, EX: 3600 } ); expect(mockQuery).toHaveBeenCalled(); }); it('skips invoice loop when lock is already held', async () => { mockRedisSet.mockResolvedValue(null); await runInvoiceCron(); expect(mockQuery).not.toHaveBeenCalled(); expect(mockGenerateMonthlyInvoice).not.toHaveBeenCalled(); }); it('releases lock via compare-delete Lua script after success', async () => { mockRedisSet.mockResolvedValue('OK'); mockQuery.mockResolvedValue([[{ id: 'cust-1' }]]); await runInvoiceCron(); expect(mockRedisEval).toHaveBeenCalledWith( expect.stringContaining('redis.call("get"'), expect.objectContaining({ keys: ['invoice:cron:lock'], arguments: [expect.any(String)] }) ); }); it('does NOT release lock when the loop throws', async () => { mockRedisSet.mockResolvedValue('OK'); mockQuery.mockRejectedValue(new Error('DB down')); await expect(runInvoiceCron()).rejects.toThrow('DB down'); expect(mockRedisEval).not.toHaveBeenCalled(); }); }); describe('runInvoiceCron — per-customer error isolation', () => { it('continues processing remaining customers after one fails', async () => { mockRedisSet.mockResolvedValue('OK'); mockQuery.mockResolvedValue([[{ id: 'cust-a' }, { id: 'cust-b' }, { id: 'cust-c' }]]); mockGenerateMonthlyInvoice .mockResolvedValueOnce({ invoice_number: 'SMCP-1', customer_id: 'cust-a' }) .mockRejectedValueOnce(new Error('Stripe error')) .mockResolvedValueOnce({ invoice_number: 'SMCP-3', customer_id: 'cust-c' }); await runInvoiceCron(); expect(mockGenerateMonthlyInvoice).toHaveBeenCalledTimes(3); expect(mockRedisEval).toHaveBeenCalled(); }); });