259 lines
8.3 KiB
TypeScript
259 lines
8.3 KiB
TypeScript
|
|
/**
|
||
|
|
* 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;
|
||
|
|
});
|