/** * Reports P2 — integration tests for report_runs + report_schedules CRUD. * * Covers: * - createReportRun rejects template id from another port * - createReportRun rejects when config.kind ≠ outer kind * - listReportRuns filters by kind / status / templateId * - createReportSchedule computes nextRunAt deterministically per cadence * - updateReportSchedule recomputes nextRunAt on cadence change but NOT * on a no-op edit * - deleteReportSchedule leaves linked report_runs with a NULL schedule_id * (ON DELETE SET NULL contract) */ import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { and, eq } from 'drizzle-orm'; import { db } from '@/lib/db'; import { reportRuns, reportSchedules, reportTemplates } from '@/lib/db/schema/reports'; import { user } from '@/lib/db/schema/users'; let makePort: typeof import('../helpers/factories').makePort; let makeAuditMeta: typeof import('../helpers/factories').makeAuditMeta; let runsSvc: typeof import('@/lib/services/report-runs.service'); let schedulesSvc: typeof import('@/lib/services/report-schedules.service'); let TEST_USER_ID = ''; beforeAll(async () => { const factories = await import('../helpers/factories'); makePort = factories.makePort; makeAuditMeta = factories.makeAuditMeta; runsSvc = await import('@/lib/services/report-runs.service'); schedulesSvc = await import('@/lib/services/report-schedules.service'); // Schedules + run-triggered-by-user FK against the real user table; pull // the first seeded row so test inserts don't trip 23503. const [u] = await db.select({ id: user.id }).from(user).limit(1); if (!u) throw new Error('No user available; run pnpm db:seed first'); TEST_USER_ID = u.id; }); function testMeta(portId: string) { return makeAuditMeta({ portId, userId: TEST_USER_ID }); } async function makeTemplate(portId: string, kind = 'dashboard') { const [row] = await db .insert(reportTemplates) .values({ portId, kind, name: `T-${crypto.randomUUID().slice(0, 8)}`, config: { kind }, createdBy: TEST_USER_ID, }) .returning(); return row!; } describe('report-runs.service', () => { it('rejects a templateId from a different port', async () => { const portA = await makePort(); const portB = await makePort(); const tmplB = await makeTemplate(portB.id); await expect( runsSvc.createReportRun( { kind: 'dashboard', templateId: tmplB.id, config: { kind: 'dashboard' }, outputFormat: 'pdf', }, { portId: portA.id, triggeredBy: 'user', triggeredByUserId: TEST_USER_ID, meta: testMeta(portA.id), }, ), ).rejects.toThrow(/report template/i); }); it('rejects when config.kind does not match outer kind', async () => { const port = await makePort(); await expect( runsSvc.createReportRun( { kind: 'clients', config: { kind: 'dashboard' }, outputFormat: 'pdf' }, { portId: port.id, triggeredBy: 'user', triggeredByUserId: TEST_USER_ID, meta: testMeta(port.id), }, ), ).rejects.toThrow(/config\.kind must equal/); }); it('filters listReportRuns by kind + status', async () => { const port = await makePort(); await runsSvc.createReportRun( { kind: 'dashboard', config: { kind: 'dashboard' }, outputFormat: 'pdf' }, { portId: port.id, triggeredBy: 'user', triggeredByUserId: TEST_USER_ID, meta: testMeta(port.id), }, ); await runsSvc.createReportRun( { kind: 'clients', config: { kind: 'clients' }, outputFormat: 'csv' }, { portId: port.id, triggeredBy: 'user', triggeredByUserId: TEST_USER_ID, meta: testMeta(port.id), }, ); const dashboardOnly = await runsSvc.listReportRuns(port.id, { kind: 'dashboard', page: 1, pageSize: 20, }); expect(dashboardOnly.data.every((r) => r.kind === 'dashboard')).toBe(true); expect(dashboardOnly.total).toBe(1); const allPending = await runsSvc.listReportRuns(port.id, { status: 'pending', page: 1, pageSize: 20, }); expect(allPending.total).toBe(2); }); }); describe('report-schedules.service', () => { it('nextRunFor weekly_monday_9 always lands on a Monday strictly after now', () => { const cases = [ new Date('2026-05-25T10:00:00Z'), // Monday after target hour new Date('2026-05-27T08:00:00Z'), // Wednesday before new Date('2026-05-31T23:59:00Z'), // Sunday late ]; for (const now of cases) { const out = schedulesSvc.nextRunFor('weekly_monday_9', now); expect(out.getUTCDay()).toBe(1); expect(out.getUTCHours()).toBe(9); expect(out.getTime()).toBeGreaterThan(now.getTime()); } }); it('nextRunFor monthly_first_9 lands on the 1st of the next month after target hour', () => { const now = new Date('2026-05-25T10:00:00Z'); const out = schedulesSvc.nextRunFor('monthly_first_9', now); expect(out.getUTCDate()).toBe(1); expect(out.getUTCMonth()).toBe(5); // June expect(out.getUTCHours()).toBe(9); }); it('nextRunFor quarterly_first_9 picks the next quarter start', () => { const now = new Date('2026-05-25T10:00:00Z'); const out = schedulesSvc.nextRunFor('quarterly_first_9', now); expect(out.getUTCDate()).toBe(1); expect(out.getUTCMonth()).toBe(6); // July expect(out.getUTCHours()).toBe(9); }); it('createReportSchedule computes nextRunAt + persists', async () => { const port = await makePort(); const tmpl = await makeTemplate(port.id); const row = await schedulesSvc.createReportSchedule( { templateId: tmpl.id, cadence: 'weekly_monday_9', recipients: [{ email: 'ops@example.com' }], outputFormat: 'pdf', enabled: true, }, { portId: port.id, meta: testMeta(port.id) }, ); expect(row.nextRunAt.getUTCDay()).toBe(1); expect(row.recipients).toHaveLength(1); expect(row.enabled).toBe(true); }); it('updateReportSchedule recomputes nextRunAt only on cadence change OR re-enable', async () => { const port = await makePort(); const tmpl = await makeTemplate(port.id); const row = await schedulesSvc.createReportSchedule( { templateId: tmpl.id, cadence: 'weekly_monday_9', recipients: [{ email: 'ops@example.com' }], outputFormat: 'pdf', enabled: true, }, { portId: port.id, meta: testMeta(port.id) }, ); const originalNext = row.nextRunAt.getTime(); // No-op edit (recipient change) — nextRunAt stays. const after = await schedulesSvc.updateReportSchedule( row.id, { recipients: [{ email: 'sales@example.com' }] }, { portId: port.id, meta: testMeta(port.id) }, ); expect(after.nextRunAt.getTime()).toBe(originalNext); // Cadence change — nextRunAt advances to next monthly first. const afterCadence = await schedulesSvc.updateReportSchedule( row.id, { cadence: 'monthly_first_9' }, { portId: port.id, meta: testMeta(port.id) }, ); expect(afterCadence.nextRunAt.getUTCDate()).toBe(1); }); it('deleteReportSchedule sets schedule_id NULL on linked runs (no orphans)', async () => { const port = await makePort(); const tmpl = await makeTemplate(port.id); const sched = await schedulesSvc.createReportSchedule( { templateId: tmpl.id, cadence: 'weekly_monday_9', recipients: [{ email: 'ops@example.com' }], outputFormat: 'pdf', enabled: true, }, { portId: port.id, meta: testMeta(port.id) }, ); const run = await runsSvc.createReportRun( { kind: 'dashboard', templateId: tmpl.id, config: { kind: 'dashboard' }, outputFormat: 'pdf', }, { portId: port.id, triggeredBy: 'schedule', scheduleId: sched.id, meta: testMeta(port.id), }, ); await schedulesSvc.deleteReportSchedule(sched.id, port.id, testMeta(port.id)); const after = await db.query.reportRuns.findFirst({ where: and(eq(reportRuns.id, run.id), eq(reportRuns.portId, port.id)), }); expect(after?.scheduleId).toBeNull(); }); }); afterAll(async () => { // No global cleanup; per-test ports keep rows isolated. void reportRuns; void reportSchedules; });