feat(reports-p2): CRUD layer for report_runs + report_schedules

Builds the API + service layer the P1 schema migration 0084 set up:

- src/lib/validators/reports.ts: new schemas for list/create on runs +
  full CRUD on schedules. Locked enums for kind / output / cadence /
  status so the route layer can reject invalid combinations early.
- src/lib/services/report-runs.service.ts: list with kind/status/template
  filters, create with cross-port template guard + config.kind
  discriminator check, updateReportRunStatus for the future P3 worker to
  flip status through pending/rendering/complete/failed.
- src/lib/services/report-schedules.service.ts: full CRUD plus
  nextRunFor() deterministic cadence math. nextRunAt is recomputed on
  cadence change or on re-enable (off->on) but left untouched on no-op
  edits so a mid-cycle recipient swap doesn't slip the fire-time.
- /api/v1/reports/runs (GET + POST) + /api/v1/reports/runs/[id] (GET)
- /api/v1/reports/schedules (GET + POST) +
  /api/v1/reports/schedules/[id] (GET + PATCH + DELETE)
- tests/integration/report-runs-schedules.test.ts: 9 cases covering the
  cross-port FK guard, the config.kind cross-check, listing filters,
  cadence math for all three v1 cadences, the no-op-doesn't-slip rule,
  and the ON DELETE SET NULL contract on schedule deletion.

Permission gating: list/get on reports.view_dashboard (read), all mutations
on reports.export (write). Matches the existing /reports/templates routes.

P3 (the BullMQ render+email queue) is the next slice; it'll consume the
pending rows produced here.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-25 14:26:18 +02:00
parent 7476eabec6
commit 1e31ed66f1
8 changed files with 990 additions and 0 deletions

View File

@@ -0,0 +1,258 @@
/**
* 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;
});