import { describe, it, expect, vi, beforeEach } from 'vitest'; import nodemailer from 'nodemailer'; const mockQuery = vi.fn(); vi.mock('../db.js', () => ({ getPool: () => ({ query: mockQuery }), })); vi.mock('nodemailer', () => ({ default: { createTransport: vi.fn() }, })); import { createInvoice, generateMonthlyInvoice, getInvoiceByNumber, emailInvoice, type Invoice, } from './invoices.js'; const baseInvoice: Invoice = { id: 1, customer_id: 'cust-1', invoice_number: 'SMCP-20260501-aabb1122', amount: 25.0, currency: 'USD', status: 'draft', period_start: '2026-04-01', period_end: '2026-04-30', line_items: [{ description: 'email actions', quantity: 500, unit_price: 0.05, amount: 25.0 }], sent_at: null, paid_at: null, created_at: '2026-05-01T00:00:00Z', constructor: { name: 'RowDataPacket' } as any, }; beforeEach(() => { vi.clearAllMocks(); }); // ── generateInvoiceNumber format ───────────────────────────────────────────── describe('generateMonthlyInvoice — billing period', () => { it('queries previous month, not current', async () => { mockQuery.mockResolvedValueOnce([[]]); // usage query returns empty await generateMonthlyInvoice('cust-1'); const sql: string = mockQuery.mock.calls[0][0]; // Must reference DATE_SUB for the start of the previous month expect(sql).toContain('DATE_SUB'); // Must NOT use a plain NOW() as the lower bound (that would be current month) expect(sql).not.toMatch(/>=\s*DATE_FORMAT\(NOW\(\)/); }); it('returns null when customer has zero usage', async () => { mockQuery.mockResolvedValueOnce([[]]); // no usage rows const result = await generateMonthlyInvoice('cust-1'); expect(result).toBeNull(); }); it('creates invoice for previous month period', async () => { const now = new Date(); const expectedStart = new Date(now.getFullYear(), now.getMonth() - 1, 1) .toISOString().slice(0, 10); mockQuery .mockResolvedValueOnce([[{ platform: 'email', count: 100 }]]) // usage .mockResolvedValueOnce([[]]) // insert .mockResolvedValueOnce([[{ ...baseInvoice, period_start: expectedStart }]]); // select after insert const invoice = await generateMonthlyInvoice('cust-1'); expect(invoice?.period_start).toBe(expectedStart); }); }); // ── Invoice number format ───────────────────────────────────────────────────── describe('createInvoice — invoice number', () => { it('generates hex suffix, not decimal padded', async () => { mockQuery .mockResolvedValueOnce([[]]) // insert .mockResolvedValueOnce([[baseInvoice]]); // select await createInvoice('cust-1', 25, baseInvoice.line_items, '2026-04-01', '2026-04-30'); const insertSql: string = mockQuery.mock.calls[0][0]; const invoiceNumber: string = mockQuery.mock.calls[0][1][1]; // Should be SMCP-YYYYMMDD-<8 hex chars> expect(invoiceNumber).toMatch(/^SMCP-\d{8}-[0-9a-f]{8}$/); }); }); // ── Idempotency ─────────────────────────────────────────────────────────────── describe('createInvoice — idempotency', () => { it('returns existing invoice on uq_customer_period duplicate', async () => { const dupError = Object.assign(new Error("Duplicate entry 'cust-1-2026-04-01' for key 'invoices.uq_customer_period'"), { errno: 1062 }); mockQuery .mockRejectedValueOnce(dupError) // INSERT throws 1062 .mockResolvedValueOnce([[baseInvoice]]); // SELECT existing const invoice = await createInvoice('cust-1', 25, baseInvoice.line_items, '2026-04-01', '2026-04-30'); expect(invoice.invoice_number).toBe(baseInvoice.invoice_number); expect(invoice.customer_id).toBe('cust-1'); }); it('re-throws 1062 for other constraints (e.g. duplicate invoice_number)', async () => { const dupError = Object.assign(new Error("Duplicate entry 'SMCP-...' for key 'invoices.invoice_number'"), { errno: 1062 }); mockQuery.mockRejectedValueOnce(dupError); await expect(createInvoice('cust-1', 25, baseInvoice.line_items, '2026-04-01', '2026-04-30')) .rejects.toThrow('invoice_number'); }); }); // ── line_items JSON normalization ───────────────────────────────────────────── describe('getInvoiceByNumber — line_items normalization', () => { it('parses line_items when returned as JSON string', async () => { const rawInvoice = { ...baseInvoice, line_items: JSON.stringify(baseInvoice.line_items) as any, }; mockQuery.mockResolvedValueOnce([[rawInvoice]]); const invoice = await getInvoiceByNumber('SMCP-20260501-aabb1122'); expect(Array.isArray(invoice?.line_items)).toBe(true); expect(invoice?.line_items[0].description).toBe('email actions'); }); it('leaves line_items unchanged when already an array', async () => { mockQuery.mockResolvedValueOnce([[baseInvoice]]); const invoice = await getInvoiceByNumber('SMCP-20260501-aabb1122'); expect(Array.isArray(invoice?.line_items)).toBe(true); }); }); // ── emailInvoice ───────────────────────────────────────────────────────────── describe('emailInvoice', () => { it('sends mail with invoice_number in subject and HTML body', async () => { const sendMail = vi.fn().mockResolvedValue({ messageId: '' }); vi.mocked(nodemailer.createTransport).mockReturnValue({ sendMail } as any); await emailInvoice(baseInvoice, 'customer@example.com'); expect(sendMail).toHaveBeenCalledOnce(); const mailArgs = sendMail.mock.calls[0][0]; expect(mailArgs.to).toBe('customer@example.com'); expect(mailArgs.subject).toContain(baseInvoice.invoice_number); expect(mailArgs.html).toContain(baseInvoice.invoice_number); }); });