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:
258
tests/integration/report-runs-schedules.test.ts
Normal file
258
tests/integration/report-runs-schedules.test.ts
Normal 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;
|
||||
});
|
||||
Reference in New Issue
Block a user