feat: platform backbone and field capture complete (checkpoint)

Platform backbone (plan 2026-10-13-plattform-rueckgrat), all tasks reviewed:
- webhooks with delivery table, retries and stable delivery id
- calendar handlers; a finished task removes its Outlook event
- tasks, automations, inspections, varroa, treatments, feedings,
  harvests, colonies, wintering, stock and bottling record domain events
  in the same transaction; legacy post-commit calls removed
- row locks through one helper (lockRowForUpdate, lockRowsForShare,
  lockRowsForUpdate)
- job framework with JobRun, cli exit codes, cleanup, task templates,
  daily evaluation, hourly app-backup and the rekey job
- system API and page "Verwaltung > System" with retry
- @nestjs/schedule removed, guard tests against in-process schedulers

Field capture T3 (plan 2026-10-13-t3-erfassung-am-stand), all tasks reviewed:
- inspection photo offline without metadata
- bulk capture for treatments, feedings and varroa (backend + frontend)
- offline stand mode with service worker, offline pages and packs
- offline session, cold start and logout on shared devices

Docs: ARCHITECTURE.md assumptions and sections updated, TASKS.md.
Gates: backend lint, typecheck, 1770 unit, 420 e2e, build, no schema
drift; frontend lint, typecheck, 913 tests, build. Not rolled out.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-14 11:36:38 +02:00
1 parent c01a38835a
commit cdd30cf3ee
288 files changed
+39165 -2853

No files matched your search

@@ -0,0 +1,137 @@
import { INestApplicationContext } from '@nestjs/common';
import * as fs from 'node:fs/promises';
import * as os from 'node:os';
import * as path from 'node:path';
import { PermissionCatalogSyncService } from '../src/auth/permissions/permission-catalog-sync.service';
import { BackupSchedulerService } from '../src/backup/backup-scheduler.service';
import { createJobsContext, runCli } from '../src/jobs/run-cli';
import { PrismaService } from '../src/prisma/prisma.service';
// Whole-database exports of the shared test database - well beyond the 5 s
// default when other specs run alongside.
const EXPORT_TIMEOUT_MS = 120_000;
// Once a year (1 January, 03:00 Berlin): a second run right after the first
// is never due, whatever hour the test runs in.
const YEARLY = '0 3 1 1 *';
const backupFiles = async (directory: string): Promise<string[]> =>
(await fs.readdir(directory)).filter(
(name) => name.startsWith('apicore-backup-') && name.endsWith('.json'),
);
/**
* `node dist/cli.js app-backup` against the test database: runCli with the
* real JobsModule context, backups into a temporary directory.
*/
describe('app-backup job (e2e)', () => {
const testStart = new Date(Date.now() - 1000);
const originalEnv = { ...process.env };
let prisma: PrismaService;
let root: string;
beforeAll(async () => {
prisma = new PrismaService();
await prisma.$connect();
root = await fs.mkdtemp(path.join(os.tmpdir(), 'apicore-app-backup-'));
process.env.BACKUP_CRON_SCHEDULE = YEARLY;
process.env.BACKUP_RETENTION_COUNT = '5';
});
afterAll(async () => {
const runs = await prisma.jobRun.findMany({
where: { job: 'app-backup', startedAt: { gte: testStart } },
select: { id: true },
});
const runIds = runs.map((run) => run.id);
await prisma.notification.deleteMany({
where: { relatedType: 'JobRun', relatedId: { in: runIds } },
});
await prisma.jobRun.deleteMany({ where: { id: { in: runIds } } });
await prisma.$disconnect();
await fs.rm(root, { recursive: true, force: true });
process.env = { ...originalEnv };
});
it('runs in the job context without the in-process cron and without the catalog sync', async () => {
const app = await createJobsContext();
try {
expect(app.get(BackupSchedulerService)).toBeInstanceOf(
BackupSchedulerService,
);
expect(() =>
app.get(PermissionCatalogSyncService, { strict: false }),
).toThrow();
} finally {
await app.close();
}
});
it(
'writes one backup for two runs in the same hour',
async () => {
const directory = path.join(root, 'twice');
process.env.BACKUP_STORAGE_PATH = directory;
await expect(runCli(['app-backup'])).resolves.toBe(0);
await expect(runCli(['app-backup'])).resolves.toBe(0);
const files = await backupFiles(directory);
expect(files).toHaveLength(1);
const runs = await prisma.jobRun.findMany({
where: { job: 'app-backup', startedAt: { gte: testStart } },
orderBy: [{ startedAt: 'asc' }, { id: 'asc' }],
});
expect(runs.map((run) => run.status)).toEqual(['SUCCEEDED', 'SUCCEEDED']);
const { size } = await fs.stat(path.join(directory, files[0]));
expect(runs[0].result).toEqual({
written: true,
fileName: files[0],
sizeBytes: size,
pruned: 0,
});
expect(runs[1].result).toEqual({ written: false, reason: 'not-due' });
},
EXPORT_TIMEOUT_MS,
);
it(
'writes one backup when two job pods check at the same time',
async () => {
const directory = path.join(root, 'concurrent');
process.env.BACKUP_STORAGE_PATH = directory;
// Two contexts, two connection pools: like two pods.
const pods: INestApplicationContext[] = await Promise.all([
createJobsContext(),
createJobsContext(),
]);
try {
const results = await Promise.all(
pods.map((pod) => pod.get(BackupSchedulerService).runIfDue()),
);
expect(results.map((result) => result.written).sort()).toEqual([
false,
true,
]);
expect(results).toContainEqual({ written: false, reason: 'not-due' });
expect(await backupFiles(directory)).toHaveLength(1);
} finally {
await Promise.all(pods.map((pod) => pod.close()));
}
},
EXPORT_TIMEOUT_MS,
);
it('succeeds without writing anything while no path is configured', async () => {
delete process.env.BACKUP_STORAGE_PATH;
const app = await createJobsContext();
try {
await expect(app.get(BackupSchedulerService).runIfDue()).resolves.toEqual(
{ written: false, reason: 'no-path' },
);
} finally {
await app.close();
}
});
});
+177 -77
View File
@@ -6,6 +6,7 @@ import { PrismaService } from '../src/prisma/prisma.service';
import { AutomationTriggerType, Prisma } from '../generated/prisma';
import { createTestApp } from './utils/create-test-app';
import { login } from './utils/api';
import { drainDomainEvents } from './utils/domain-events';
interface AutomationRuleBody {
id: string;
@@ -28,6 +29,25 @@ describe('Automation rules (e2e)', () => {
let apiaryId: string;
let colonyId: string;
let varroaRuleId: string;
let swarmRuleId: string;
/**
* Records an inspection or varroa measurement and runs the handlers of
* its event - the rules evaluate there, not in the request.
*/
async function record(path: string, body: object): Promise<string> {
const response = await request(app.getHttpServer())
.post(`/api/v1/${path}`)
.set(
'Authorization',
`Bearer ${path === 'inspections' ? staffToken : adminToken}`,
)
.send({ colonyId, date: new Date().toISOString(), ...body })
.expect(201);
const id = (response.body as { id: string }).id;
await drainDomainEvents(app, [id]);
return id;
}
beforeAll(async () => {
app = await createTestApp();
@@ -70,13 +90,41 @@ describe('Automation rules (e2e)', () => {
});
afterAll(async () => {
await prisma.automationRule.updateMany({
where: { triggerType: 'VARROA_THRESHOLD' },
data: { active: true },
// Rule tasks and the rules' own entries are written without a user -
// they are not covered by the userId cleanup below. Deliveries go with
// their event (ON DELETE CASCADE).
const ruleTaskIds = (
await prisma.task.findMany({
where: { relatedId: colonyId },
select: { id: true },
})
).map((task) => task.id);
const byColony = { where: { colonyId }, select: { id: true } };
const recordIds = [
...(await prisma.inspection.findMany(byColony)),
...(await prisma.varroaMeasurement.findMany(byColony)),
].map((record) => record.id);
await prisma.auditLog.deleteMany({
where: {
OR: [
{ entityType: 'Task', entityId: { in: ruleTaskIds } },
{
entityType: 'AutomationRule',
afterData: { path: ['relatedId'], equals: colonyId },
},
],
},
});
await prisma.domainEvent.deleteMany({
where: { aggregateId: { in: [...ruleTaskIds, ...recordIds] } },
});
await prisma.calendarSyncOutboxEntry.deleteMany({
where: { sourceId: { in: [...ruleTaskIds, ...recordIds] } },
});
await prisma.task.deleteMany({ where: { relatedId: colonyId } });
await prisma.notification.deleteMany({ where: { relatedId: colonyId } });
await prisma.varroaMeasurement.deleteMany({ where: { colonyId } });
await prisma.swarmControlCheck.deleteMany({ where: { colonyId } });
await prisma.inspection.deleteMany({ where: { colonyId } });
await prisma.colony.deleteMany({ where: { id: colonyId } });
await prisma.apiary.deleteMany({ where: { id: apiaryId } });
@@ -112,19 +160,13 @@ describe('Automation rules (e2e)', () => {
);
expect(varroaRule).toBeDefined();
varroaRuleId = varroaRule!.id;
swarmRuleId = rules.find(
(rule) => rule.triggerType === 'SWARM_MOOD_HIGH',
)!.id;
});
it('creates a task and notification when queen not seen and no eggs present', async () => {
await request(app.getHttpServer())
.post('/api/v1/inspections')
.set('Authorization', `Bearer ${staffToken}`)
.send({
colonyId,
date: new Date().toISOString(),
queenSeen: false,
eggsPresent: false,
})
.expect(201);
await record('inspections', { queenSeen: false, eggsPresent: false });
const tasks = await prisma.task.findMany({
where: { relatedId: colonyId },
@@ -139,16 +181,7 @@ describe('Automation rules (e2e)', () => {
});
it('does not trigger when the queen was seen', async () => {
await request(app.getHttpServer())
.post('/api/v1/inspections')
.set('Authorization', `Bearer ${staffToken}`)
.send({
colonyId,
date: new Date().toISOString(),
queenSeen: true,
eggsPresent: false,
})
.expect(201);
await record('inspections', { queenSeen: true, eggsPresent: false });
const tasks = await prisma.task.findMany({
where: { relatedId: colonyId },
@@ -157,15 +190,7 @@ describe('Automation rules (e2e)', () => {
});
it('triggers a feeding task when feed status is kritisch', async () => {
await request(app.getHttpServer())
.post('/api/v1/inspections')
.set('Authorization', `Bearer ${staffToken}`)
.send({
colonyId,
date: new Date().toISOString(),
feedStatus: 'kritisch',
})
.expect(201);
await record('inspections', { feedStatus: 'kritisch' });
const tasks = await prisma.task.findMany({
where: { relatedId: colonyId },
@@ -174,44 +199,95 @@ describe('Automation rules (e2e)', () => {
expect(tasks.some((task) => task.title.includes('Fütterung'))).toBe(true);
});
it('triggers a treatment check when the varroa threshold is exceeded', async () => {
it('evaluates no rule on a corrected inspection', async () => {
const before = await prisma.task.count({ where: { relatedId: colonyId } });
const id = await record('inspections', { queenSeen: true });
// Close the open tasks so the one-open-task guard cannot be what keeps
// a new task from appearing - only the missing evaluation may.
await prisma.task.updateMany({
where: { relatedId: colonyId },
data: { status: 'DONE' },
});
await request(app.getHttpServer())
.post('/api/v1/varroa-measurements')
.set('Authorization', `Bearer ${adminToken}`)
.send({
colonyId,
date: new Date().toISOString(),
method: 'NATURAL_MITE_FALL',
miteCount: 20,
// One day on the board: 20 mites per day, above the default 10.
durationMinutes: 24 * 60,
})
.expect(201);
.patch(`/api/v1/inspections/${id}`)
.set('Authorization', `Bearer ${staffToken}`)
.send({ queenSeen: false, eggsPresent: false, feedStatus: 'kritisch' })
.expect(200);
await drainDomainEvents(app, [id]);
expect(await prisma.task.count({ where: { relatedId: colonyId } })).toBe(
before,
);
});
it('triggers a treatment check when the varroa threshold is exceeded', async () => {
const before = await prisma.task.count({ where: { relatedId: colonyId } });
await record('varroa-measurements', {
method: 'NATURAL_MITE_FALL',
miteCount: 20,
// One day on the board: 20 mites per day, above the default 10.
durationMinutes: 24 * 60,
});
const tasks = await prisma.task.findMany({
where: { relatedId: colonyId },
});
expect(tasks).toHaveLength(3);
expect(tasks).toHaveLength(before + 1);
expect(tasks.some((task) => task.title.includes('Behandlung'))).toBe(true);
});
it('does not compare a sample-based count against the per-day threshold', async () => {
await prisma.task.updateMany({
where: { relatedId: colonyId },
data: { status: 'DONE' },
});
const before = await prisma.task.count({ where: { relatedId: colonyId } });
await request(app.getHttpServer())
.post('/api/v1/varroa-measurements')
.set('Authorization', `Bearer ${adminToken}`)
.send({
colonyId,
date: new Date().toISOString(),
method: 'POWDERED_SUGAR',
miteCount: 50,
})
.expect(201);
await record('varroa-measurements', {
method: 'POWDERED_SUGAR',
miteCount: 50,
});
expect(await prisma.task.count({ where: { relatedId: colonyId } })).toBe(
before,
);
});
it('re-evaluates a corrected varroa measurement against the threshold', async () => {
await prisma.task.updateMany({
where: { relatedId: colonyId },
data: { status: 'DONE' },
});
const before = await prisma.task.count({ where: { relatedId: colonyId } });
// Five mites per day: below the threshold.
const id = await record('varroa-measurements', {
method: 'NATURAL_MITE_FALL',
miteCount: 5,
durationMinutes: 24 * 60,
});
expect(await prisma.task.count({ where: { relatedId: colonyId } })).toBe(
before,
);
// The count was miscounted: 25 mites per day.
await request(app.getHttpServer())
.patch(`/api/v1/varroa-measurements/${id}`)
.set('Authorization', `Bearer ${adminToken}`)
.send({ miteCount: 25 })
.expect(200);
await drainDomainEvents(app, [id]);
const tasks = await prisma.task.findMany({
where: {
relatedId: colonyId,
sourceAutomationTrigger: 'VARROA_THRESHOLD',
},
});
expect(tasks.filter((task) => task.status === 'OPEN')).toHaveLength(1);
expect(await prisma.task.count({ where: { relatedId: colonyId } })).toBe(
before + 1,
);
});
it('lets an admin clear a rule config, which falls back to its defaults', async () => {
const before = await prisma.automationRule.findUniqueOrThrow({
where: { id: varroaRuleId },
@@ -243,34 +319,58 @@ describe('Automation rules (e2e)', () => {
}
});
/**
* Deactivates SWARM_MOOD_HIGH, not a rule other e2e files rely on: the
* rule rows are shared, and switching off VARROA_THRESHOLD here would make
* a parallel file's varroa test see no task. Only swarm-control.e2e
* evaluates this rule, with checks that never trigger it (LOW or none).
*/
it('lets an admin deactivate a rule, preventing further triggers', async () => {
await request(app.getHttpServer())
.patch(`/api/v1/automation-rules/${varroaRuleId}`)
.set('Authorization', `Bearer ${adminToken}`)
.send({ active: false })
.expect(200);
// Close the earlier tasks so the one-open-task-per-rule guard cannot be
// what keeps a new task from appearing - only the deactivation may.
const highSwarmMood = () =>
request(app.getHttpServer())
.post('/api/v1/swarm-control-checks')
.set('Authorization', `Bearer ${adminToken}`)
.send({
colonyId,
date: new Date().toISOString(),
swarmMood: 'HIGH',
})
.expect(201);
const swarmTasks = () =>
prisma.task.count({
where: {
relatedId: colonyId,
sourceAutomationTrigger: 'SWARM_MOOD_HIGH',
},
});
// While active, high swarm mood leaves a task - the rule really fires.
// (Swarm checks stay on the post-commit path: evaluated before the
// response, no event to drain.)
await highSwarmMood();
expect(await swarmTasks()).toBe(1);
// Close it so the one-open-task guard cannot be what keeps a new task
// from appearing - only the deactivation may.
await prisma.task.updateMany({
where: { relatedId: colonyId },
data: { status: 'DONE' },
});
await request(app.getHttpServer())
.post('/api/v1/varroa-measurements')
.set('Authorization', `Bearer ${adminToken}`)
.send({
colonyId,
date: new Date().toISOString(),
method: 'NATURAL_MITE_FALL',
miteCount: 30,
durationMinutes: 24 * 60,
})
.expect(201);
try {
await request(app.getHttpServer())
.patch(`/api/v1/automation-rules/${swarmRuleId}`)
.set('Authorization', `Bearer ${adminToken}`)
.send({ active: false })
.expect(200);
const tasks = await prisma.task.findMany({
where: { relatedId: colonyId },
});
expect(tasks).toHaveLength(3);
await highSwarmMood();
expect(await swarmTasks()).toBe(1);
} finally {
await prisma.automationRule.update({
where: { id: swarmRuleId },
data: { active: true },
});
}
});
});
+136 -3
View File
@@ -17,6 +17,12 @@ interface BottlingBatchBody {
lotNumber: string;
}
interface RecordedEvent {
type: string;
payload: unknown;
actorUserId: string | null;
}
describe('Bottling (e2e)', () => {
let app: INestApplication<App>;
let prisma: PrismaService;
@@ -24,12 +30,49 @@ describe('Bottling (e2e)', () => {
const password = 'SuperSecret123!';
let beekeeperId: string;
let beekeeperToken: string;
// Removing a bottling needs bottling-batch:delete, which BEEKEEPER lacks.
const adminEmail = `bottling-e2e-admin-${Date.now()}@example.com`;
let adminId: string;
let adminToken: string;
let apiaryId: string;
let colonyId: string;
let harvestId: string;
let extractionId: string;
let honeyBatchId: string;
let bottlingBatchId: string;
const seenEventIds = new Set<string>();
/**
* The events recorded on the bottling since the last call, by type - one
* request's events share a correlation id.
*/
async function newEventsOf(id: string): Promise<RecordedEvent[]> {
const events = (
await prisma.domainEvent.findMany({
where: { aggregateType: 'BottlingBatch', aggregateId: id },
select: {
id: true,
type: true,
payload: true,
actorUserId: true,
correlationId: true,
},
})
).filter((event) => !seenEventIds.has(event.id));
events.forEach((event) => seenEventIds.add(event.id));
expect(new Set(events.map((event) => event.correlationId)).size).toBe(1);
return events
.map(({ type, payload, actorUserId }) => ({ type, payload, actorUserId }))
.sort((a, b) => a.type.localeCompare(b.type));
}
async function honeyBatchAmount(): Promise<number | null> {
const response = await request(app.getHttpServer())
.get(`/api/v1/honey-batches/${honeyBatchId}`)
.set('Authorization', `Bearer ${beekeeperToken}`)
.expect(200);
return (response.body as HoneyBatchBody).amount;
}
beforeAll(async () => {
app = await createTestApp();
@@ -46,6 +89,16 @@ describe('Bottling (e2e)', () => {
});
beekeeperId = beekeeper.id;
beekeeperToken = (await login(app, beekeeperEmail, password)).accessToken;
const admin = await prisma.user.create({
data: {
email: adminEmail,
passwordHash,
name: 'Admin',
role: { connect: { key: 'ADMIN' } },
},
});
adminId = admin.id;
adminToken = (await login(app, adminEmail, password)).accessToken;
const apiary = await prisma.apiary.create({
data: { name: `Stand ${Date.now()}` },
@@ -79,6 +132,12 @@ describe('Bottling (e2e)', () => {
afterAll(async () => {
if (bottlingBatchId) {
await prisma.domainEvent.deleteMany({
where: {
aggregateType: 'BottlingBatch',
aggregateId: bottlingBatchId,
},
});
await prisma.bottlingBatch.deleteMany({ where: { id: bottlingBatchId } });
}
await prisma.honeyBatch.deleteMany({ where: { id: honeyBatchId } });
@@ -88,9 +147,12 @@ describe('Bottling (e2e)', () => {
await prisma.harvest.deleteMany({ where: { id: harvestId } });
await prisma.colony.deleteMany({ where: { id: colonyId } });
await prisma.apiary.deleteMany({ where: { id: apiaryId } });
await prisma.refreshToken.deleteMany({ where: { userId: beekeeperId } });
await prisma.auditLog.deleteMany({ where: { userId: beekeeperId } });
await prisma.user.deleteMany({ where: { id: beekeeperId } });
const userIds = [beekeeperId, adminId].filter(Boolean);
await prisma.refreshToken.deleteMany({
where: { userId: { in: userIds } },
});
await prisma.auditLog.deleteMany({ where: { userId: { in: userIds } } });
await prisma.user.deleteMany({ where: { id: { in: userIds } } });
await app.close();
});
@@ -134,4 +196,75 @@ describe('Bottling (e2e)', () => {
expect(honeyBatch.amount).toBe(0);
expect(honeyBatch.status).toBe('BOTTLED');
});
it('bottling create records BatchBottled and StockChanged together', async () => {
expect(await newEventsOf(bottlingBatchId)).toEqual([
{
type: 'BatchBottled',
payload: { honeyBatchId },
actorUserId: beekeeperId,
},
{
type: 'StockChanged',
payload: {
bottlingBatchIds: [bottlingBatchId],
productIds: [],
rawMaterialKinds: [],
},
actorUserId: beekeeperId,
},
]);
});
it('a correction records BottlingCorrected and StockChanged together', async () => {
await request(app.getHttpServer())
.patch(`/api/v1/bottling-batches/${bottlingBatchId}`)
.set('Authorization', `Bearer ${beekeeperToken}`)
.send({ jarCount: 40 })
.expect(200);
// 10 jars of 500 g fewer: 5 kg go back to the honey batch.
expect(await honeyBatchAmount()).toBe(5);
expect(await newEventsOf(bottlingBatchId)).toEqual([
{
type: 'BottlingCorrected',
payload: { honeyBatchId, removed: false },
actorUserId: beekeeperId,
},
{
type: 'StockChanged',
payload: {
bottlingBatchIds: [bottlingBatchId],
productIds: [],
rawMaterialKinds: [],
},
actorUserId: beekeeperId,
},
]);
});
it('a removal records BottlingCorrected { removed: true } and StockChanged together', async () => {
await request(app.getHttpServer())
.delete(`/api/v1/bottling-batches/${bottlingBatchId}`)
.set('Authorization', `Bearer ${adminToken}`)
.expect(200);
expect(await honeyBatchAmount()).toBe(25);
expect(await newEventsOf(bottlingBatchId)).toEqual([
{
type: 'BottlingCorrected',
payload: { honeyBatchId, removed: true },
actorUserId: adminId,
},
{
type: 'StockChanged',
payload: {
bottlingBatchIds: [bottlingBatchId],
productIds: [],
rawMaterialKinds: [],
},
actorUserId: adminId,
},
]);
});
});
@@ -11,6 +11,7 @@ import { WAKE_CHANNELS } from '../src/common/worker-wakeup';
import { PgListener } from '../src/worker/pg-listener';
import { createTestApp } from './utils/create-test-app';
import { login } from './utils/api';
import { drainDomainEvents } from './utils/domain-events';
/**
* The M365 outbox against the real database, with Microsoft Graph replaced
@@ -119,6 +120,9 @@ describe('Calendar sync outbox (e2e)', () => {
await prisma.calendarSyncOutboxEntry.deleteMany({
where: { sourceType: 'TASK', sourceId: { in: taskIds } },
});
await prisma.domainEvent.deleteMany({
where: { aggregateType: 'Task', aggregateId: { in: taskIds } },
});
await prisma.task.deleteMany({ where: { id: { in: taskIds } } });
const admin = await prisma.user.findUnique({
where: { email: adminEmail },
@@ -237,6 +241,35 @@ describe('Calendar sync outbox (e2e)', () => {
expect(entry.lastError).toBeNull();
});
it('removes the Graph event of a task that was completed, and settles the entry SKIPPED', async () => {
const task = await queueTask('Completed with event');
await calendarSync.processOutbox();
const synced = await entryFor(task.id);
expect(synced.status).toBe('SYNCED');
const graphEventId = synced.graphEventId;
expect(graphEventId).toMatch(/^event-/);
const deleteEvent = jest.spyOn(graph, 'deleteEvent');
// The real path: the request records TaskCompleted, the dispatcher runs
// the calendar-sync.task handler, which queues the entry again.
await request(app.getHttpServer())
.post(`/api/v1/tasks/${task.id}/complete`)
.set('Authorization', `Bearer ${token}`)
.expect(201);
expect((await entryFor(task.id)).status).toBe('SYNCED');
await drainDomainEvents(app, [task.id]);
expect((await entryFor(task.id)).status).toBe('PENDING');
await calendarSync.processOutbox();
expect(deleteEvent).toHaveBeenCalledWith('group-e2e', graphEventId);
expect(await entryFor(task.id)).toMatchObject({
status: 'SKIPPED',
graphEventId: null,
claimedAt: null,
});
});
describe('API', () => {
const auth = () => ({ Authorization: `Bearer ${token}` });
+128 -1
View File
@@ -5,6 +5,7 @@ import { App } from 'supertest/types';
import { PrismaService } from '../src/prisma/prisma.service';
import { createTestApp } from './utils/create-test-app';
import { login } from './utils/api';
import { drainDomainEvents } from './utils/domain-events';
interface ColonyBody {
id: string;
@@ -35,6 +36,8 @@ describe('Colonies (e2e)', () => {
let colonyId: string;
let childColonyId: string;
let colonyToMergeId: string;
let healthColonyId: string;
let raceColonyId: string;
beforeAll(async () => {
app = await createTestApp();
@@ -74,13 +77,43 @@ describe('Colonies (e2e)', () => {
});
afterAll(async () => {
const colonyIds = [
colonyId,
childColonyId,
colonyToMergeId,
healthColonyId,
raceColonyId,
].filter(Boolean);
// Rule tasks are written without a user - their audit entry and
// TaskCreated are not covered by the userId cleanup below.
const ruleTaskIds = (
await prisma.task.findMany({
where: { relatedId: { in: colonyIds } },
select: { id: true },
})
).map((task) => task.id);
await prisma.auditLog.deleteMany({
where: { entityType: 'Task', entityId: { in: ruleTaskIds } },
});
await prisma.domainEvent.deleteMany({
where: {
OR: [
{ aggregateType: 'Colony', aggregateId: { in: colonyIds } },
{ aggregateType: 'Task', aggregateId: { in: ruleTaskIds } },
],
},
});
await prisma.task.deleteMany({ where: { id: { in: ruleTaskIds } } });
await prisma.notification.deleteMany({
where: { relatedId: { in: colonyIds } },
});
await prisma.colonyLineage.deleteMany({
where: { OR: [{ fromColonyId: colonyId }, { toColonyId: colonyId }] },
});
await prisma.colonyApiaryHistory.deleteMany({ where: { colonyId } });
await prisma.colony.deleteMany({
where: {
id: { in: [colonyId, childColonyId, colonyToMergeId].filter(Boolean) },
id: { in: colonyIds },
},
});
await prisma.apiary.deleteMany({
@@ -180,6 +213,11 @@ describe('Colonies (e2e)', () => {
.set('Authorization', `Bearer ${beekeeperToken}`)
.expect(200);
expect((merged.body as ColonyBody).status).toBe('MERGED');
const ended = await prisma.domainEvent.findMany({
where: { type: 'ColonyEnded', aggregateId: colonyToMergeId },
});
expect(ended.map((event) => event.payload)).toEqual([{ status: 'MERGED' }]);
});
it('dissolves the colony without deleting it', async () => {
@@ -189,6 +227,16 @@ describe('Colonies (e2e)', () => {
.expect(201);
expect((response.body as ColonyBody).status).toBe('DISSOLVED');
const events = await prisma.domainEvent.findMany({
where: { aggregateId: colonyId },
orderBy: { occurredAt: 'asc' },
});
expect(events.map((event) => event.type)).toEqual([
'ColonyStatusChanged',
'ColonyEnded',
]);
expect(events[1].payload).toEqual({ status: 'DISSOLVED' });
await request(app.getHttpServer())
.get(`/api/v1/colonies/${colonyId}`)
.set('Authorization', `Bearer ${staffToken}`)
@@ -230,4 +278,83 @@ describe('Colonies (e2e)', () => {
});
expect(colony.status).toBe('WEAK');
});
it('creates the COLONY_HEALTH_CRITICAL task only after the event is processed, and only on the transition', async () => {
const created = await request(app.getHttpServer())
.post('/api/v1/colonies')
.set('Authorization', `Bearer ${beekeeperToken}`)
.send({
code: `V-${Date.now()}-H`,
apiaryId: apiaryAId,
colonyType: 'PRODUCTION_COLONY',
})
.expect(201);
healthColonyId = (created.body as ColonyBody).id;
await request(app.getHttpServer())
.patch(`/api/v1/colonies/${healthColonyId}`)
.set('Authorization', `Bearer ${beekeeperToken}`)
.send({ healthStatus: 'CRITICAL' })
.expect(200);
const event = await prisma.domainEvent.findFirstOrThrow({
where: { type: 'ColonyStatusChanged', aggregateId: healthColonyId },
});
expect(event.payload).toEqual({
from: { status: 'ACTIVE', healthStatus: null },
to: { status: 'ACTIVE', healthStatus: 'CRITICAL' },
});
expect(
await prisma.task.count({ where: { relatedId: healthColonyId } }),
).toBe(0);
await drainDomainEvents(app, [healthColonyId]);
expect(
await prisma.task.count({ where: { relatedId: healthColonyId } }),
).toBe(1);
// Already critical: a further edit is no transition and opens no second task.
await request(app.getHttpServer())
.patch(`/api/v1/colonies/${healthColonyId}`)
.set('Authorization', `Bearer ${beekeeperToken}`)
.send({ notes: 'weiter kritisch' })
.expect(200);
await drainDomainEvents(app, [healthColonyId]);
expect(
await prisma.task.count({ where: { relatedId: healthColonyId } }),
).toBe(1);
});
it('two concurrent PATCHes into CRITICAL produce one transition event and one task', async () => {
const created = await request(app.getHttpServer())
.post('/api/v1/colonies')
.set('Authorization', `Bearer ${beekeeperToken}`)
.send({
code: `V-${Date.now()}-R`,
apiaryId: apiaryAId,
colonyType: 'PRODUCTION_COLONY',
})
.expect(201);
raceColonyId = (created.body as ColonyBody).id;
await Promise.all(
[1, 2].map(() =>
request(app.getHttpServer())
.patch(`/api/v1/colonies/${raceColonyId}`)
.set('Authorization', `Bearer ${beekeeperToken}`)
.send({ healthStatus: 'CRITICAL' })
.expect(200),
),
);
expect(
await prisma.domainEvent.count({
where: { type: 'ColonyStatusChanged', aggregateId: raceColonyId },
}),
).toBe(1);
await drainDomainEvents(app, [raceColonyId]);
expect(
await prisma.task.count({ where: { relatedId: raceColonyId } }),
).toBe(1);
});
});
@@ -0,0 +1,252 @@
import { INestApplicationContext } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import { Prisma } from '../generated/prisma';
import { DailyEvaluationJob } from '../src/jobs/daily-evaluation.job';
import { JobsModule } from '../src/jobs/jobs.module';
import { PrismaService } from '../src/prisma/prisma.service';
const d = (day: string): Date => new Date(`${day}T00:00:00.000Z`);
/** When the CronJob runs on `day` (04:30 Europe/Berlin). */
const at = (day: string): Date => new Date(`${day}T02:30:00.000Z`);
const NOTHING_NEW = {
WithdrawalEnded: 0,
TaskDue: 0,
QueenAged: 0,
VarroaMeasurementDue: 0,
InspectionOverdue: 0,
};
/**
* The job evaluates the whole database. The e2e files share it and run in
* parallel, so the evaluation runs on days in 2001: no other file has
* records there, and the events of this file stay its own. Called through
* evaluate(day) instead of runCli, which would also write JobRun rows that
* test/jobs.e2e-spec.ts counts.
*/
describe('daily-evaluation (e2e)', () => {
const suffix = `${Date.now()}-${Math.round(Math.random() * 1e6)}`;
let app: INestApplicationContext;
let prisma: PrismaService;
let job: DailyEvaluationJob;
const ids: Record<string, string> = {};
const eventsOf = (aggregateId: string) =>
prisma.domainEvent.findMany({
where: { aggregateId },
select: {
type: true,
aggregateType: true,
payload: true,
dedupeKey: true,
actorUserId: true,
},
orderBy: { type: 'asc' },
});
beforeAll(async () => {
const moduleRef = await Test.createTestingModule({
imports: [JobsModule],
}).compile();
app = await moduleRef.init();
prisma = app.get(PrismaService);
job = app.get(DailyEvaluationJob);
const user = await prisma.user.create({
data: {
email: `daily-evaluation-e2e-${suffix}@example.com`,
passwordHash: 'not-used',
name: 'Daily Evaluation E2E',
role: { connect: { key: 'STAFF' } },
},
});
ids.user = user.id;
const apiary = await prisma.apiary.create({
data: { name: `Daily Evaluation ${suffix}` },
});
ids.apiary = apiary.id;
const queen = async (
name: string,
data: Pick<Prisma.QueenUncheckedCreateInput, 'hatchYear' | 'status'>,
) => {
const created = await prisma.queen.create({
data: { code: `DE-E2E-${suffix}-${name}`, ...data },
});
ids[`queen:${name}`] = created.id;
};
await queen('aged', { hatchYear: 1999, status: 'ACTIVE' });
await queen('young', { hatchYear: 2000, status: 'ACTIVE' });
await queen('reserve', { hatchYear: 1999, status: 'RESERVE' });
// Living, created 2001-04-20 and never measured: varroa due 2001-05-18.
const living = await prisma.colony.create({
data: {
code: `DE-E2E-${suffix}-living`,
apiaryId: apiary.id,
colonyType: 'PRODUCTION_COLONY',
currentQueenId: ids['queen:aged'],
createdAt: d('2001-04-20'),
},
});
ids.living = living.id;
const dead = await prisma.colony.create({
data: {
code: `DE-E2E-${suffix}-dead`,
apiaryId: apiary.id,
colonyType: 'PRODUCTION_COLONY',
status: 'DECEASED',
createdAt: d('2001-04-20'),
},
});
ids.dead = dead.id;
// Inspected 2001-04-28: the next inspection is overdue on 2001-05-19.
for (const colonyId of [living.id, dead.id]) {
await prisma.inspection.create({
data: { colonyId, userId: user.id, date: d('2001-04-28') },
});
}
const treatment = async (
name: string,
data: Pick<
Prisma.TreatmentUncheckedCreateInput,
'date' | 'endDate' | 'withdrawalPeriodDays'
>,
) => {
const created = await prisma.treatment.create({
data: {
colonyId: living.id,
userId: user.id,
product: 'Oxalsäure',
...data,
},
});
ids[`treatment:${name}`] = created.id;
};
// 2001-05-10 + 7 = 2001-05-17, in the window of 2001-05-20.
await treatment('ending', {
date: d('2001-05-08'),
endDate: d('2001-05-10'),
withdrawalPeriodDays: 7,
});
// 2001-05-01 + 7 = 2001-05-08, before the window.
await treatment('ended-earlier', {
date: d('2001-05-01'),
withdrawalPeriodDays: 7,
});
await treatment('no-withdrawal', { date: d('2001-05-15') });
const task = async (
name: string,
data: Pick<Prisma.TaskUncheckedCreateInput, 'dueDate' | 'status'>,
) => {
const created = await prisma.task.create({
data: { title: `Daily evaluation ${suffix} ${name}`, ...data },
});
ids[`task:${name}`] = created.id;
};
await task('open', { dueDate: d('2001-05-18'), status: 'OPEN' });
await task('done', { dueDate: d('2001-05-18'), status: 'DONE' });
});
afterAll(async () => {
const aggregateIds = Object.entries(ids)
.filter(([key]) => !['user', 'apiary'].includes(key))
.map(([, id]) => id);
await prisma.domainEvent.deleteMany({
where: { aggregateId: { in: aggregateIds } },
});
const colonyIds = [ids.living, ids.dead].filter(Boolean);
await prisma.treatment.deleteMany({
where: { colonyId: { in: colonyIds } },
});
await prisma.inspection.deleteMany({
where: { colonyId: { in: colonyIds } },
});
await prisma.task.deleteMany({
where: { title: { startsWith: `Daily evaluation ${suffix}` } },
});
await prisma.colony.deleteMany({ where: { id: { in: colonyIds } } });
await prisma.queen.deleteMany({
where: { code: { startsWith: `DE-E2E-${suffix}` } },
});
if (ids.apiary) {
await prisma.apiary.deleteMany({ where: { id: ids.apiary } });
}
if (ids.user) {
await prisma.user.deleteMany({ where: { id: ids.user } });
}
await app.close();
});
it('running the job twice yields exactly one WithdrawalEnded per treatment and day', async () => {
const first = await job.evaluate(at('2001-05-20'));
const second = await job.evaluate(at('2001-05-20'));
// The next day the occurrence is still in the window - and still known.
const nextDay = await job.evaluate(at('2001-05-21'));
expect(first.WithdrawalEnded).toBeGreaterThanOrEqual(1);
expect(second).toEqual(NOTHING_NEW);
expect(nextDay).toEqual(NOTHING_NEW);
expect(await eventsOf(ids['treatment:ending'])).toEqual([
{
type: 'WithdrawalEnded',
aggregateType: 'Treatment',
payload: { colonyId: ids.living, endedOn: '2001-05-17' },
dedupeKey: `WithdrawalEnded:${ids['treatment:ending']}:2001-05-17`,
actorUserId: null,
},
]);
expect(await eventsOf(ids['treatment:ended-earlier'])).toEqual([]);
expect(await eventsOf(ids['treatment:no-withdrawal'])).toEqual([]);
});
it('records due tasks and overdue checks only for open tasks and living colonies', async () => {
await job.evaluate(at('2001-05-20'));
expect(await eventsOf(ids['task:open'])).toEqual([
{
type: 'TaskDue',
aggregateType: 'Task',
payload: { dueOn: '2001-05-18' },
dedupeKey: `TaskDue:${ids['task:open']}:2001-05-18`,
actorUserId: null,
},
]);
expect(await eventsOf(ids['task:done'])).toEqual([]);
expect(await eventsOf(ids.living)).toEqual([
{
type: 'InspectionOverdue',
aggregateType: 'Colony',
payload: { dueOn: '2001-05-19', lastInspectedOn: '2001-04-28' },
dedupeKey: `InspectionOverdue:${ids.living}:2001-05-19`,
actorUserId: null,
},
{
type: 'VarroaMeasurementDue',
aggregateType: 'Colony',
payload: { dueOn: '2001-05-18', lastMeasuredOn: null },
dedupeKey: `VarroaMeasurementDue:${ids.living}:2001-05-18`,
actorUserId: null,
},
]);
expect(await eventsOf(ids.dead)).toEqual([]);
});
it('reports active queens from their second year in the first days of January', async () => {
await job.evaluate(at('2001-01-05'));
expect(await job.evaluate(at('2001-01-08'))).toEqual(NOTHING_NEW);
expect(await eventsOf(ids['queen:aged'])).toEqual([
{
type: 'QueenAged',
aggregateType: 'Queen',
payload: { hatchYear: 1999, colonyId: ids.living },
dedupeKey: `QueenAged:${ids['queen:aged']}:2001-01-01`,
actorUserId: null,
},
]);
expect(await eventsOf(ids['queen:young'])).toEqual([]);
expect(await eventsOf(ids['queen:reserve'])).toEqual([]);
});
});
@@ -5,6 +5,7 @@ import { App } from 'supertest/types';
import { PrismaService } from '../src/prisma/prisma.service';
import { createTestApp } from './utils/create-test-app';
import { login } from './utils/api';
import { drainDomainEvents } from './utils/domain-events';
/**
* Regression tests for data-integrity rules that cut across modules:
@@ -84,6 +85,21 @@ describe('Data integrity (e2e)', () => {
});
afterAll(async () => {
// The events this file's requests recorded (all by its own user) and
// the follow-up events their handlers recorded under the same
// correlation id - other e2e files share the database.
if (userId) {
const correlationIds = (
await prisma.domainEvent.findMany({
where: { actorUserId: userId },
select: { correlationId: true },
distinct: ['correlationId'],
})
).map((event) => event.correlationId);
await prisma.domainEvent.deleteMany({
where: { correlationId: { in: correlationIds } },
});
}
await app.close();
});
@@ -117,6 +133,7 @@ describe('Data integrity (e2e)', () => {
expect(
await prisma.inspection.count({ where: { colonyId: colony.id } }),
).toBe(1);
await drainDomainEvents(app, [(first.body as { id: string }).id]);
// The automation fired once, not once per delivery attempt.
expect(
await prisma.task.count({
@@ -397,6 +414,76 @@ describe('Data integrity (e2e)', () => {
expect((second.body as { completedAt: string }).completedAt).toBe(
(first.body as { completedAt: string }).completedAt,
);
// Announced once: a second TaskCompleted would send the webhook twice.
await expect(
prisma.domainEvent.count({
where: { aggregateId: id, type: 'TaskCompleted' },
}),
).resolves.toBe(1);
});
it('announces a completion once when two requests complete a task at the same time', async () => {
const created = await request(server())
.post('/api/v1/tasks')
.set(auth())
.send({ title: `Gleichzeitig erledigt ${suffix}` })
.expect(201);
const id = (created.body as { id: string }).id;
const [first, second] = await Promise.all([
request(server())
.post(`/api/v1/tasks/${id}/complete`)
.set(auth())
.expect(201),
request(server())
.post(`/api/v1/tasks/${id}/complete`)
.set(auth())
.expect(201),
]);
expect((second.body as { completedAt: string }).completedAt).toBe(
(first.body as { completedAt: string }).completedAt,
);
await expect(
prisma.domainEvent.count({
where: { aggregateId: id, type: 'TaskCompleted' },
}),
).resolves.toBe(1);
await expect(
prisma.auditLog.count({
where: { entityType: 'Task', entityId: id, action: 'UPDATE' },
}),
).resolves.toBe(1);
});
it('announces a completion once when an edit into DONE and "Erledigt" arrive at the same time', async () => {
const created = await request(server())
.post('/api/v1/tasks')
.set(auth())
.send({ title: `Bearbeitet und erledigt ${suffix}` })
.expect(201);
const id = (created.body as { id: string }).id;
const [edited, completed] = await Promise.all([
request(server())
.patch(`/api/v1/tasks/${id}`)
.set(auth())
.send({ status: 'DONE' })
.expect(200),
request(server())
.post(`/api/v1/tasks/${id}/complete`)
.set(auth())
.expect(201),
]);
expect((edited.body as { completedAt: string }).completedAt).toBe(
(completed.body as { completedAt: string }).completedAt,
);
await expect(
prisma.domainEvent.count({
where: { aggregateId: id, type: 'TaskCompleted' },
}),
).resolves.toBe(1);
});
it('clears completedAt when a done task is reopened', async () => {
@@ -442,6 +529,20 @@ describe('Data integrity (e2e)', () => {
where: { id: template.id },
});
expect(refreshed.nextDueDate.getTime()).toBeGreaterThan(Date.now());
// One TaskCreated carrying that due date, so the calendar entry and
// the webhook follow the generated task like any other.
const events = await prisma.domainEvent.findMany({
where: { aggregateType: 'Task', aggregateId: generated[0].id },
});
expect(events).toEqual([
expect.objectContaining({
type: 'TaskCreated',
actorUserId: null,
payload: expect.objectContaining({
dueDate: generated[0].dueDate?.toISOString().slice(0, 10),
}) as unknown,
}),
]);
});
});
+186
View File
@@ -16,6 +16,17 @@ interface FeedingSummaryEntry {
totalAmount: number;
}
interface BulkFeedingBody {
bulkId: string;
records: (FeedingBody & {
feedType: string;
amount: number;
unit: string;
batchNumber: string | null;
userId: string;
})[];
}
describe('Feedings (e2e)', () => {
let app: INestApplication<App>;
let prisma: PrismaService;
@@ -71,6 +82,15 @@ describe('Feedings (e2e)', () => {
});
afterAll(async () => {
const feedingIds = (
await prisma.feeding.findMany({
where: { colonyId },
select: { id: true },
})
).map((feeding) => feeding.id);
await prisma.domainEvent.deleteMany({
where: { aggregateType: 'Feeding', aggregateId: { in: feedingIds } },
});
await prisma.feeding.deleteMany({ where: { colonyId } });
await prisma.colony.deleteMany({ where: { id: colonyId } });
await prisma.apiary.deleteMany({ where: { id: apiaryId } });
@@ -116,6 +136,48 @@ describe('Feedings (e2e)', () => {
expect(feedingId).toEqual(expect.any(String));
});
it('wrote the audit entry and FeedingRecorded together with the feeding', async () => {
expect(
await prisma.auditLog.count({
where: {
entityType: 'Feeding',
entityId: feedingId,
action: 'CREATE',
userId: staffId,
},
}),
).toBe(1);
const events = await prisma.domainEvent.findMany({
where: { aggregateType: 'Feeding', aggregateId: feedingId },
});
expect(events).toHaveLength(1);
expect(events[0]).toEqual(
expect.objectContaining({
type: 'FeedingRecorded',
actorUserId: staffId,
payload: { colonyId },
}),
);
});
it('records FeedingCorrected on an edit', async () => {
await request(app.getHttpServer())
.patch(`/api/v1/feedings/${feedingId}`)
.set('Authorization', `Bearer ${staffToken}`)
.send({ notes: 'korrigiert' })
.expect(200);
const corrections = await prisma.domainEvent.findMany({
where: {
aggregateType: 'Feeding',
aggregateId: feedingId,
type: 'FeedingCorrected',
},
});
expect(corrections).toHaveLength(1);
expect(corrections[0].payload).toEqual({ colonyId });
});
it('lists feedings for the colony', async () => {
const response = await request(app.getHttpServer())
.get(`/api/v1/colonies/${colonyId}/feedings`)
@@ -134,4 +196,128 @@ describe('Feedings (e2e)', () => {
const litersEntry = summary.find((entry) => entry.unit === 'l');
expect(litersEntry?.totalAmount).toBe(2.5);
});
describe('POST /feedings/bulk', () => {
const suffix = Date.now();
const shared = {
date: '2026-09-01',
feedType: 'FEED_SYRUP',
product: 'Apiinvert',
amount: 10,
unit: 'kg',
batchNumber: 'L-2026-09',
};
let bulkColonyIds: string[];
let endedColonyId: string;
const postBulk = (token: string, body: object) =>
request(app.getHttpServer())
.post('/api/v1/feedings/bulk')
.set('Authorization', `Bearer ${token}`)
.send(body);
const storedFeedings = () =>
prisma.feeding.count({
where: { colonyId: { in: [...bulkColonyIds, endedColonyId] } },
});
beforeAll(async () => {
const colonyAt = async (
code: string,
status: 'ACTIVE' | 'SOLD' = 'ACTIVE',
) =>
(
await prisma.colony.create({
data: {
code: `${code}-${suffix}`,
apiaryId,
colonyType: 'PRODUCTION_COLONY',
status,
},
})
).id;
bulkColonyIds = [await colonyAt('FB-1'), await colonyAt('FB-2')];
endedColonyId = await colonyAt('FB-X', 'SOLD');
});
afterAll(async () => {
const colonyIds = [...bulkColonyIds, endedColonyId];
const feedingIds = (
await prisma.feeding.findMany({
where: { colonyId: { in: colonyIds } },
select: { id: true },
})
).map((feeding) => feeding.id);
await prisma.domainEvent.deleteMany({
where: { aggregateType: 'Feeding', aggregateId: { in: feedingIds } },
});
await prisma.feeding.deleteMany({
where: { colonyId: { in: colonyIds } },
});
await prisma.colony.deleteMany({ where: { id: { in: colonyIds } } });
});
it('bulk creates one feeding per colony with the shared fields', async () => {
const response = await postBulk(staffToken, {
...shared,
colonyIds: bulkColonyIds,
}).expect(201);
const { bulkId, records } = response.body as BulkFeedingBody;
expect(records.map((record) => record.colonyId)).toEqual(bulkColonyIds);
for (const record of records) {
expect(record).toEqual(
expect.objectContaining({
feedType: 'FEED_SYRUP',
amount: 10,
unit: 'kg',
batchNumber: 'L-2026-09',
userId: staffId,
}),
);
}
expect(
await prisma.domainEvent.count({
where: {
type: 'FeedingRecorded',
payload: { path: ['bulkId'], equals: bulkId },
},
}),
).toBe(2);
expect(
await prisma.auditLog.count({
where: {
entityType: 'Feeding',
action: 'CREATE',
entityId: { in: records.map((record) => record.id) },
},
}),
).toBe(2);
});
it('bulk rejects the whole batch when one colony has ended and names it', async () => {
const before = await storedFeedings();
const response = await postBulk(staffToken, {
...shared,
colonyIds: [...bulkColonyIds, endedColonyId],
}).expect(400);
expect(
(response.body as { invalidColonyIds: string[] }).invalidColonyIds,
).toEqual([endedColonyId]);
expect(await storedFeedings()).toBe(before);
});
it('bulk rejects a duplicate colony', () =>
postBulk(staffToken, {
...shared,
colonyIds: [bulkColonyIds[0], bulkColonyIds[0]],
}).expect(400));
it('bulk rejects a role without feedings:create', () =>
postBulk(readonlyToken, { ...shared, colonyIds: bulkColonyIds }).expect(
403,
));
});
});
@@ -5,6 +5,7 @@ import { App } from 'supertest/types';
import { PrismaService } from '../src/prisma/prisma.service';
import { createTestApp } from './utils/create-test-app';
import { login } from './utils/api';
import { drainDomainEvents } from './utils/domain-events';
interface RuleBody {
id: string;
@@ -22,6 +23,7 @@ describe('Follow-up controls (e2e)', () => {
const suffix = `${Date.now()}-${Math.round(Math.random() * 1e6)}`;
let token: string;
let apiaryId: string;
const colonyIds: string[] = [];
const server = () => app.getHttpServer();
const auth = () => ({ Authorization: `Bearer ${token}` });
@@ -39,17 +41,21 @@ describe('Follow-up controls (e2e)', () => {
}
async function newColony(code: string) {
return prisma.colony.create({
const colony = await prisma.colony.create({
data: {
code: `${code}-${suffix}`,
apiaryId,
colonyType: 'PRODUCTION_COLONY',
},
});
colonyIds.push(colony.id);
return colony;
}
function treat(colonyId: string, body: Record<string, unknown> = {}) {
return request(server())
// The follow-up check comes from the TreatmentRecorded handler, not from
// the request: the helper drains the events of this treatment.
async function treat(colonyId: string, body: Record<string, unknown> = {}) {
const response = await request(server())
.post('/api/v1/treatments')
.set(auth())
.send({
@@ -59,6 +65,7 @@ describe('Follow-up controls (e2e)', () => {
...body,
})
.expect(201);
await drainDomainEvents(app, [(response.body as { id: string }).id]);
}
beforeAll(async () => {
@@ -88,6 +95,36 @@ describe('Follow-up controls (e2e)', () => {
},
data: { active: false, config: {} },
});
// Follow-up tasks are written without a user: remove their audit entry
// and TaskCreated (deliveries go with the event, ON DELETE CASCADE).
const followUpTaskIds = (
await prisma.task.findMany({
where: { relatedId: { in: colonyIds } },
select: { id: true },
})
).map((task) => task.id);
await prisma.auditLog.deleteMany({
where: { entityType: 'Task', entityId: { in: followUpTaskIds } },
});
const measureIds = [
...(await prisma.treatment.findMany({
where: { colonyId: { in: colonyIds } },
select: { id: true },
})),
...(await prisma.feeding.findMany({
where: { colonyId: { in: colonyIds } },
select: { id: true },
})),
].map((measure) => measure.id);
await prisma.domainEvent.deleteMany({
where: { aggregateId: { in: measureIds } },
});
await prisma.auditLog.deleteMany({
where: { entityId: { in: measureIds } },
});
await prisma.domainEvent.deleteMany({
where: { aggregateType: 'Task', aggregateId: { in: followUpTaskIds } },
});
await app.close();
});
@@ -150,7 +187,7 @@ describe('Follow-up controls (e2e)', () => {
.expect(200);
const colony = await newColony('FEED');
await request(server())
const response = await request(server())
.post('/api/v1/feedings')
.set(auth())
.send({
@@ -161,6 +198,11 @@ describe('Follow-up controls (e2e)', () => {
unit: 'l',
})
.expect(201);
// Not before the FeedingRecorded handler ran.
expect(await prisma.task.count({ where: { relatedId: colony.id } })).toBe(
0,
);
await drainDomainEvents(app, [(response.body as { id: string }).id]);
const task = await prisma.task.findFirstOrThrow({
where: {
+86
View File
@@ -5,6 +5,7 @@ import { App } from 'supertest/types';
import { PrismaService } from '../src/prisma/prisma.service';
import { createTestApp } from './utils/create-test-app';
import { login } from './utils/api';
import { drainDomainEvents } from './utils/domain-events';
interface HarvestBody {
id: string;
@@ -26,6 +27,7 @@ describe('Harvests (e2e)', () => {
let colonyOneId: string;
let colonyTwoId: string;
let harvestId: string;
let subscriptionId: string;
beforeAll(async () => {
app = await createTestApp();
@@ -76,7 +78,16 @@ describe('Harvests (e2e)', () => {
});
afterAll(async () => {
if (subscriptionId) {
// Its deliveries go with it, ON DELETE CASCADE.
await prisma.webhookSubscription.deleteMany({
where: { id: subscriptionId },
});
}
if (harvestId) {
await prisma.domainEvent.deleteMany({
where: { aggregateType: 'Harvest', aggregateId: harvestId },
});
await prisma.harvestColony.deleteMany({ where: { harvestId } });
await prisma.harvest.deleteMany({ where: { id: harvestId } });
}
@@ -124,6 +135,60 @@ describe('Harvests (e2e)', () => {
expect(harvest.colonies).toHaveLength(2);
});
it('wrote the audit entry and HarvestRecorded with the colony ids together with the harvest', async () => {
expect(
await prisma.auditLog.count({
where: {
entityType: 'Harvest',
entityId: harvestId,
action: 'CREATE',
userId: staffId,
},
}),
).toBe(1);
const events = await prisma.domainEvent.findMany({
where: { aggregateType: 'Harvest', aggregateId: harvestId },
});
expect(events).toHaveLength(1);
expect(events[0].type).toBe('HarvestRecorded');
expect(events[0].actorUserId).toBe(staffId);
const payload = events[0].payload as {
apiaryId: string;
colonyIds: string[];
};
expect(payload.apiaryId).toBe(apiaryId);
expect([...payload.colonyIds].sort()).toEqual(
[colonyOneId, colonyTwoId].sort(),
);
});
it('queues harvest.created from the event handler, once', async () => {
subscriptionId = (
await prisma.webhookSubscription.create({
data: {
url: 'https://hooks.example.com/harvests-e2e',
events: ['harvest.created'],
secret: `harvests-e2e-${Date.now()}`,
createdByUserId: staffId,
},
})
).id;
// The request itself queued nothing.
expect(
await prisma.webhookDelivery.count({ where: { subscriptionId } }),
).toBe(0);
await drainDomainEvents(app, [harvestId]);
await drainDomainEvents(app, [harvestId]);
const deliveries = await prisma.webhookDelivery.findMany({
where: { subscriptionId },
});
expect(deliveries).toHaveLength(1);
expect(deliveries[0].event).toBe('harvest.created');
expect(deliveries[0].body).toContain(harvestId);
});
it('returns the harvest with its colonies on lookup', async () => {
const response = await request(app.getHttpServer())
.get(`/api/v1/harvests/${harvestId}`)
@@ -135,4 +200,25 @@ describe('Harvests (e2e)', () => {
expect.arrayContaining([colonyOneId, colonyTwoId]),
);
});
it('records HarvestCorrected with the new colony set on an edit', async () => {
await request(app.getHttpServer())
.patch(`/api/v1/harvests/${harvestId}`)
.set('Authorization', `Bearer ${staffToken}`)
.send({ colonyIds: [colonyOneId] })
.expect(200);
const corrections = await prisma.domainEvent.findMany({
where: {
aggregateType: 'Harvest',
aggregateId: harvestId,
type: 'HarvestCorrected',
},
});
expect(corrections).toHaveLength(1);
expect(corrections[0].payload).toEqual({
apiaryId,
colonyIds: [colonyOneId],
});
});
});
@@ -5,6 +5,7 @@ import { App } from 'supertest/types';
import { PrismaService } from '../src/prisma/prisma.service';
import { createTestApp } from './utils/create-test-app';
import { login } from './utils/api';
import { drainDomainEvents } from './utils/domain-events';
interface InspectionBody {
id: string;
@@ -74,8 +75,27 @@ describe('Inspections (e2e)', () => {
afterAll(async () => {
if (taskId) {
await prisma.domainEvent.deleteMany({
where: { aggregateType: 'Task', aggregateId: taskId },
});
await prisma.task.delete({ where: { id: taskId } });
}
const inspectionIds = (
await prisma.inspection.findMany({
where: { colonyId },
select: { id: true },
})
).map((inspection) => inspection.id);
// Deliveries go with their event (ON DELETE CASCADE).
await prisma.domainEvent.deleteMany({
where: {
aggregateType: 'Inspection',
aggregateId: { in: inspectionIds },
},
});
await prisma.calendarSyncOutboxEntry.deleteMany({
where: { sourceType: 'INSPECTION', sourceId: { in: inspectionIds } },
});
await prisma.inspection.deleteMany({ where: { colonyId } });
await prisma.colony.deleteMany({ where: { id: colonyId } });
await prisma.apiary.deleteMany({ where: { id: apiaryId } });
@@ -122,6 +142,61 @@ describe('Inspections (e2e)', () => {
},
});
expect(auditEntry).not.toBeNull();
// Committed with the inspection; rules, calendar and webhook follow
// from it once the handlers run - nothing of that in the request.
const events = await prisma.domainEvent.findMany({
where: { aggregateType: 'Inspection', aggregateId: inspectionId },
});
expect(events).toEqual([
expect.objectContaining({
type: 'InspectionRecorded',
actorUserId: staffId,
payload: { colonyId },
status: 'PENDING',
}),
]);
});
it('records InspectionCorrected for an edit, whose only follow-up is the calendar', async () => {
await drainDomainEvents(app, [inspectionId]);
await request(app.getHttpServer())
.patch(`/api/v1/inspections/${inspectionId}`)
.set('Authorization', `Bearer ${staffToken}`)
.send({ notes: 'Nachtrag: Weiselzelle gesehen' })
.expect(200);
await expect(
prisma.auditLog.count({
where: {
entityType: 'Inspection',
entityId: inspectionId,
action: 'UPDATE',
userId: staffId,
},
}),
).resolves.toBe(1);
const correction = await prisma.domainEvent.findFirstOrThrow({
where: { aggregateId: inspectionId, type: 'InspectionCorrected' },
});
expect(correction).toMatchObject({
aggregateType: 'Inspection',
actorUserId: staffId,
payload: { colonyId },
});
await drainDomainEvents(app, [inspectionId]);
// A correction evaluates no automation rule and sends no webhook.
const deliveries = await prisma.domainEventDelivery.findMany({
where: { eventId: correction.id },
});
expect(deliveries).toEqual([
expect.objectContaining({
handler: 'calendar-sync.inspection',
status: 'DONE',
}),
]);
});
it('lists inspections for the colony', async () => {
@@ -142,6 +217,28 @@ describe('Inspections (e2e)', () => {
const task = response.body as TaskBody;
taskId = task.id;
expect(task.sourceInspectionId).toBe(inspectionId);
// The same trail as a task from the task page.
await expect(
prisma.auditLog.count({
where: {
entityType: 'Task',
entityId: taskId,
action: 'CREATE',
userId: staffId,
},
}),
).resolves.toBe(1);
const events = await prisma.domainEvent.findMany({
where: { aggregateType: 'Task', aggregateId: taskId },
});
expect(events).toEqual([
expect.objectContaining({
type: 'TaskCreated',
actorUserId: staffId,
payload: { relatedType: 'COLONY', relatedId: colonyId, dueDate: null },
}),
]);
});
it('stores, clears and validates broodFrames', async () => {
+1 -1
View File
@@ -9,6 +9,6 @@
"^.+\\.(t|j)s$": "ts-jest"
},
"transformIgnorePatterns": [
"node_modules/(?!(otplib|@otplib|@scure|@noble|openid-client|oauth4webapi|jose|@nestjs/schedule|cron|croner|uuid)/)"
"node_modules/(?!(otplib|@otplib|@scure|@noble|openid-client|oauth4webapi|jose|cron|croner|uuid)/)"
]
}
+415
View File
@@ -0,0 +1,415 @@
import { INestApplicationContext } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import { DailyEvaluationJob } from '../src/jobs/daily-evaluation.job';
import { JOB_ABORTED_ERROR } from '../src/system/job-run.service';
import { JobsModule } from '../src/jobs/jobs.module';
import { runCli } from '../src/jobs/run-cli';
import { PrismaService } from '../src/prisma/prisma.service';
const HOUR_MS = 60 * 60 * 1000;
const DAY_MS = 24 * HOUR_MS;
const ago = (ms: number): Date => new Date(Date.now() - ms);
/**
* The jobs run like `node dist/cli.js <job>` does: runCli with the real
* JobsModule context (or, for the failure case, the same module with one
* job replaced). Rows are tagged with this file's suffix and removed in
* afterAll - the test database is shared by parallel e2e files.
*/
describe('Jobs (e2e)', () => {
const suffix = `${Date.now()}-${Math.round(Math.random() * 1e6)}`;
const testStart = new Date(Date.now() - 1000);
let prisma: PrismaService;
const jobRunIds: string[] = [];
const taskIds: string[] = [];
let templateId: string | undefined;
let subscriptionId: string | undefined;
let adminId: string | undefined;
const runsSinceStart = (job: string) =>
prisma.jobRun.findMany({
where: { job, startedAt: { gte: testStart } },
orderBy: { startedAt: 'asc' },
});
beforeAll(async () => {
prisma = new PrismaService();
await prisma.$connect();
});
afterAll(async () => {
const runs = await prisma.jobRun.findMany({
where: { startedAt: { gte: testStart } },
select: { id: true },
});
const runIds = [...jobRunIds, ...runs.map((run) => run.id)];
await prisma.notification.deleteMany({
where: { relatedType: 'JobRun', relatedId: { in: runIds } },
});
await prisma.jobRun.deleteMany({ where: { id: { in: runIds } } });
if (templateId) {
const generated = await prisma.task.findMany({
where: { sourceTemplateId: templateId },
select: { id: true },
});
taskIds.push(...generated.map((task) => task.id));
await prisma.auditLog.deleteMany({
where: { entityType: 'Task', entityId: { in: taskIds } },
});
await prisma.domainEvent.deleteMany({
where: { aggregateType: 'Task', aggregateId: { in: taskIds } },
});
await prisma.task.deleteMany({ where: { id: { in: taskIds } } });
await prisma.taskTemplate.deleteMany({ where: { id: templateId } });
}
await prisma.domainEvent.deleteMany({
where: { aggregateId: `jobs-e2e-${suffix}` },
});
if (subscriptionId) {
await prisma.webhookSubscription.deleteMany({
where: { id: subscriptionId },
});
}
await prisma.throttlerHit.deleteMany({
where: { key: { startsWith: `jobs-e2e-${suffix}` } },
});
await prisma.idempotencyRecord.deleteMany({
where: { key: { startsWith: `jobs-e2e-${suffix}` } },
});
await prisma.consumedToken.deleteMany({
where: { jti: { startsWith: `jobs-e2e-${suffix}` } },
});
if (adminId) {
await prisma.notification.deleteMany({ where: { userId: adminId } });
await prisma.user.deleteMany({ where: { id: adminId } });
}
await prisma.$disconnect();
});
describe('cleanup', () => {
const tag = (name: string): string => `jobs-e2e-${suffix}-${name}`;
const ids: Record<string, string> = {};
beforeAll(async () => {
const event = async (
name: string,
status: 'DONE' | 'FAILED' | 'PENDING',
age: number,
) => {
const created = await prisma.domainEvent.create({
data: {
type: 'TaskDue',
aggregateType: 'Task',
aggregateId: `jobs-e2e-${suffix}`,
payload: {},
correlationId: tag(name),
status,
occurredAt: ago(age),
},
});
ids[`event:${name}`] = created.id;
};
await event('done-old', 'DONE', 31 * DAY_MS);
await event('done-recent', 'DONE', 29 * DAY_MS);
await event('failed-old', 'FAILED', 91 * DAY_MS);
await event('failed-recent', 'FAILED', 89 * DAY_MS);
await event('pending-old', 'PENDING', 91 * DAY_MS);
const subscription = await prisma.webhookSubscription.create({
data: {
url: 'https://receiver.example.com/hook',
events: ['task.created'],
secret: 'not-used',
active: false,
},
});
subscriptionId = subscription.id;
const delivery = async (
name: string,
status: 'DELIVERED' | 'FAILED' | 'PENDING',
age: number,
) => {
const created = await prisma.webhookDelivery.create({
data: {
subscriptionId: subscription.id,
event: 'task.created',
body: '{}',
status,
createdAt: ago(age),
},
});
ids[`delivery:${name}`] = created.id;
};
await delivery('delivered-old', 'DELIVERED', 31 * DAY_MS);
await delivery('delivered-recent', 'DELIVERED', 29 * DAY_MS);
await delivery('failed-old', 'FAILED', 91 * DAY_MS);
await delivery('failed-recent', 'FAILED', 89 * DAY_MS);
await delivery('pending-old', 'PENDING', 91 * DAY_MS);
// "rekey" notifies holders of system:manage; cleanup sends that for
// the stale run, the afterAll removes it by relatedId.
const run = async (
name: string,
status: 'RUNNING' | 'SUCCEEDED' | 'FAILED',
age: number,
) => {
const created = await prisma.jobRun.create({
data: { job: 'rekey', status, startedAt: ago(age) },
});
ids[`run:${name}`] = created.id;
jobRunIds.push(created.id);
};
await run('succeeded-old', 'SUCCEEDED', 91 * DAY_MS);
await run('failed-recent', 'FAILED', 89 * DAY_MS);
await run('running-stale', 'RUNNING', 7 * HOUR_MS);
await run('running-fresh', 'RUNNING', 5 * HOUR_MS);
await prisma.throttlerHit.createMany({
data: [
{
key: tag('expired'),
throttlerName: 'default',
hits: 3,
expiresAt: ago(60_000),
},
{
key: tag('blocked'),
throttlerName: 'default',
hits: 11,
expiresAt: ago(60_000),
blockedUntil: ago(-HOUR_MS),
},
{
key: tag('block-ended'),
throttlerName: 'default',
hits: 11,
expiresAt: ago(2 * 60_000),
blockedUntil: ago(60_000),
},
{
key: tag('open-window'),
throttlerName: 'default',
hits: 1,
expiresAt: ago(-60_000),
},
],
});
await prisma.idempotencyRecord.createMany({
data: [
{
userId: tag('user'),
key: tag('old'),
scope: 'POST /api/v1/tasks',
createdAt: ago(91 * DAY_MS),
},
{
userId: tag('user'),
key: tag('recent'),
scope: 'POST /api/v1/tasks',
createdAt: ago(89 * DAY_MS),
},
],
});
await prisma.consumedToken.createMany({
data: [
{ jti: tag('expired'), expiresAt: ago(2 * HOUR_MS) },
// Expired, but within the 1 h margin for api pods' clocks.
{ jti: tag('in-grace'), expiresAt: ago(30 * 60_000) },
{ jti: tag('valid'), expiresAt: ago(-HOUR_MS) },
],
});
});
it('node dist/cli.js cleanup equivalent: runCli(["cleanup"]) writes a SUCCEEDED JobRun with counts', async () => {
expect(await runCli(['cleanup'])).toBe(0);
const [run, ...others] = await runsSinceStart('cleanup');
expect(others).toHaveLength(0);
expect(run.status).toBe('SUCCEEDED');
expect(run.finishedAt).toEqual(expect.any(Date));
expect(run.error).toBeNull();
const result = run.result as Record<string, number>;
// Other e2e files share the database: at least this file's rows.
expect(result.idempotencyRecords).toBeGreaterThanOrEqual(1);
expect(result.consumedTokens).toBeGreaterThanOrEqual(1);
expect(result.throttlerHits).toBeGreaterThanOrEqual(2);
expect(result['domainEvents.done']).toBeGreaterThanOrEqual(1);
expect(result['domainEvents.failed']).toBeGreaterThanOrEqual(1);
expect(result['webhookDeliveries.delivered']).toBeGreaterThanOrEqual(1);
expect(result['webhookDeliveries.failed']).toBeGreaterThanOrEqual(1);
expect(result['jobRuns.aborted']).toBeGreaterThanOrEqual(1);
expect(result['jobRuns.deleted']).toBeGreaterThanOrEqual(1);
});
it('removed exactly the expired rows', async () => {
const remainingEvents = await prisma.domainEvent.findMany({
where: { aggregateId: `jobs-e2e-${suffix}` },
select: { correlationId: true },
});
expect(remainingEvents.map((e) => e.correlationId).sort()).toEqual(
[tag('done-recent'), tag('failed-recent'), tag('pending-old')].sort(),
);
const remainingDeliveries = await prisma.webhookDelivery.findMany({
where: { subscriptionId },
select: { id: true },
});
expect(remainingDeliveries.map((d) => d.id).sort()).toEqual(
[
ids['delivery:delivered-recent'],
ids['delivery:failed-recent'],
ids['delivery:pending-old'],
].sort(),
);
const remainingHits = await prisma.throttlerHit.findMany({
where: { key: { startsWith: `jobs-e2e-${suffix}` } },
select: { key: true },
});
expect(remainingHits.map((h) => h.key).sort()).toEqual(
[tag('blocked'), tag('open-window')].sort(),
);
const remainingRecords = await prisma.idempotencyRecord.findMany({
where: { userId: tag('user') },
select: { key: true },
});
expect(remainingRecords.map((r) => r.key)).toEqual([tag('recent')]);
const remainingTokens = await prisma.consumedToken.findMany({
where: { jti: { startsWith: `jobs-e2e-${suffix}` } },
select: { jti: true },
});
expect(remainingTokens.map((t) => t.jti).sort()).toEqual(
[tag('in-grace'), tag('valid')].sort(),
);
});
it('marks runs older than 6 h that never finished as aborted and deletes runs older than 90 days', async () => {
const runs = await prisma.jobRun.findMany({
where: {
id: {
in: [
ids['run:succeeded-old'],
ids['run:failed-recent'],
ids['run:running-stale'],
ids['run:running-fresh'],
],
},
},
});
const byId = new Map(runs.map((run) => [run.id, run]));
expect(byId.has(ids['run:succeeded-old'])).toBe(false);
expect(byId.get(ids['run:failed-recent'])?.status).toBe('FAILED');
expect(byId.get(ids['run:running-stale'])).toMatchObject({
status: 'FAILED',
error: JOB_ABORTED_ERROR,
finishedAt: expect.any(Date) as Date,
});
expect(byId.get(ids['run:running-fresh'])).toMatchObject({
status: 'RUNNING',
finishedAt: null,
});
});
});
describe('task-templates', () => {
it('task-templates creates the due task once even when run twice', async () => {
const template = await prisma.taskTemplate.create({
data: {
title: `Jobs e2e ${suffix}`,
recurrenceType: 'INTERVAL_DAYS',
intervalDays: 7,
nextDueDate: ago(DAY_MS),
},
});
templateId = template.id;
expect(await runCli(['task-templates'])).toBe(0);
expect(await runCli(['task-templates'])).toBe(0);
const generated = await prisma.task.findMany({
where: { sourceTemplateId: template.id },
});
expect(generated).toHaveLength(1);
expect(generated[0].createdByUserId).toBeNull();
const advanced = await prisma.taskTemplate.findUniqueOrThrow({
where: { id: template.id },
});
expect(advanced.nextDueDate.getTime()).toBeGreaterThan(Date.now());
const runs = await runsSinceStart('task-templates');
expect(runs.map((run) => run.status)).toEqual(['SUCCEEDED', 'SUCCEEDED']);
for (const run of runs) {
expect(run.result).toEqual({ created: expect.any(Number) as number });
}
});
});
describe('a failing job', () => {
// The same module as dist/cli.js, with the daily evaluation replaced by
// a job that always fails.
const failingContext = async (): Promise<INestApplicationContext> => {
const moduleRef = await Test.createTestingModule({
imports: [JobsModule],
})
.overrideProvider(DailyEvaluationJob)
.useValue({
name: 'daily-evaluation',
run: () => Promise.reject(new Error('weather service unreachable')),
})
.compile();
return moduleRef.init();
};
beforeAll(async () => {
const admin = await prisma.user.create({
data: {
email: `jobs-e2e-admin-${suffix}@example.com`,
passwordHash: 'not-used',
name: 'Jobs Admin',
role: { connect: { key: 'ADMIN' } },
},
});
adminId = admin.id;
// The dedupe is per job and UTC day across the whole database: an
// earlier failure notification of today (another run of this file,
// a crashed run) would suppress the one this test expects.
await prisma.$executeRaw`
DELETE FROM "Notification" n
USING "JobRun" r
WHERE r."id" = n."relatedId"
AND n."relatedType" = 'JobRun'
AND r."job" = 'daily-evaluation'
AND n."createdAt" >= date_trunc('day', now())`;
});
it('exits 1, stores the error and notifies permission holders once per day', async () => {
expect(await runCli(['daily-evaluation'], failingContext)).toBe(1);
expect(await runCli(['daily-evaluation'], failingContext)).toBe(1);
const runs = await runsSinceStart('daily-evaluation');
expect(runs).toHaveLength(2);
for (const run of runs) {
expect(run).toMatchObject({
status: 'FAILED',
error: 'weather service unreachable',
result: null,
});
}
const notifications = await prisma.notification.findMany({
where: { userId: adminId, relatedType: 'JobRun' },
});
expect(notifications).toEqual([
expect.objectContaining({
priority: 'WARNUNG',
message: 'Job „Tägliche Auswertung“ fehlgeschlagen',
relatedId: runs[0].id,
read: false,
}),
]);
});
});
});
@@ -1,4 +1,4 @@
import { INestApplication } from '@nestjs/common';
import { INestApplication, Logger } from '@nestjs/common';
import * as bcrypt from 'bcryptjs';
import request from 'supertest';
import { App } from 'supertest/types';
@@ -6,14 +6,18 @@ import { PrismaService } from '../src/prisma/prisma.service';
import { NotificationsService } from '../src/notifications/notifications.service';
import { CalendarSyncSettingsService } from '../src/calendar-sync/calendar-sync-settings.service';
import { AutomationService } from '../src/automation/automation.service';
import { AutomationTriggerType, Prisma } from '../generated/prisma';
import { createTestApp } from './utils/create-test-app';
import { login } from './utils/api';
import { drainDomainEvents } from './utils/domain-events';
/**
* Side effects that follow a committed write (automation rules, calendar
* sync, webhooks) must never turn that write into an error response: the
* offline queue would send the request again and store the record twice -
* for treatments that is a duplicate entry in the Bestandsbuch.
* Side effects of a committed write (automation rules, calendar sync,
* webhooks) must never turn that write into an error response: the offline
* queue would send the request again and store the record twice - for
* treatments that is a duplicate entry in the Bestandsbuch. Producers that
* record a domain event run none of them in the request; the handlers do,
* and a failed handler is retried without repeating what already worked.
*/
describe('Post-commit side effects (e2e)', () => {
let app: INestApplication<App>;
@@ -22,32 +26,121 @@ describe('Post-commit side effects (e2e)', () => {
const email = `post-commit-e2e-${suffix}@example.com`;
const password = 'SuperSecret123!';
let token: string;
let userId: string;
let apiaryId: string;
const colonyIds: string[] = [];
const stockItemIds: string[] = [];
const server = () => app.getHttpServer();
const auth = () => ({ Authorization: `Bearer ${token}` });
function post(path: string, body: object, idempotencyKey?: string) {
const call = request(server()).post(path).set(auth());
return (
idempotencyKey === undefined
? call
: call.set('Idempotency-Key', idempotencyKey)
).send(body);
}
async function createColony(code: string) {
return prisma.colony.create({
const colony = await prisma.colony.create({
data: {
code: `${code}-${suffix}`,
apiaryId,
colonyType: 'PRODUCTION_COLONY',
},
});
colonyIds.push(colony.id);
return colony;
}
/** Open or not: a rule task that exists at all. */
const taskCount = (trigger: AutomationTriggerType, relatedId: string) =>
prisma.task.count({
where: { sourceAutomationTrigger: trigger, relatedId },
});
const openTaskCount = (trigger: AutomationTriggerType, relatedId: string) =>
prisma.task.count({
where: {
sourceAutomationTrigger: trigger,
relatedId,
status: { in: ['OPEN', 'IN_PROGRESS'] },
},
});
/** Everything this file wrote, scoped by its own ids. */
async function cleanUp(): Promise<void> {
const byColony = {
where: { colonyId: { in: colonyIds } },
select: { id: true },
};
const recordIds = (
await Promise.all([
prisma.inspection.findMany(byColony),
prisma.treatment.findMany(byColony),
prisma.varroaMeasurement.findMany(byColony),
])
)
.flat()
.map((record) => record.id);
const relatedIds = [...colonyIds, ...stockItemIds];
const taskIds = (
await prisma.task.findMany({
where: { relatedId: { in: relatedIds } },
select: { id: true },
})
).map((task) => task.id);
const aggregateIds = [...recordIds, ...taskIds];
// Deliveries go with their event (ON DELETE CASCADE).
await prisma.domainEvent.deleteMany({
where: { aggregateId: { in: aggregateIds } },
});
await prisma.calendarSyncOutboxEntry.deleteMany({
where: { sourceId: { in: aggregateIds } },
});
// Rule tasks and the rules' own entries are written without a user.
await prisma.auditLog.deleteMany({
where: {
OR: [
{ userId },
{ entityType: 'Task', entityId: { in: taskIds } },
...relatedIds.map((relatedId) => ({
entityType: 'AutomationRule',
afterData: { path: ['relatedId'], equals: relatedId },
})),
],
},
});
await prisma.notification.deleteMany({
where: { relatedId: { in: relatedIds } },
});
await prisma.task.deleteMany({ where: { id: { in: taskIds } } });
await prisma.inspection.deleteMany({ where: byColony.where });
await prisma.treatment.deleteMany({ where: byColony.where });
await prisma.varroaMeasurement.deleteMany({ where: byColony.where });
await prisma.stockItem.deleteMany({ where: { id: { in: stockItemIds } } });
await prisma.colony.deleteMany({ where: { id: { in: colonyIds } } });
await prisma.apiary.deleteMany({ where: { id: apiaryId } });
await prisma.idempotencyRecord.deleteMany({ where: { userId } });
await prisma.refreshToken.deleteMany({ where: { userId } });
await prisma.user.deleteMany({ where: { id: userId } });
}
beforeAll(async () => {
app = await createTestApp();
prisma = app.get(PrismaService);
await prisma.user.create({
data: {
email,
passwordHash: await bcrypt.hash(password, 12),
name: 'Post Commit',
role: { connect: { key: 'ADMIN' } },
},
});
userId = (
await prisma.user.create({
data: {
email,
passwordHash: await bcrypt.hash(password, 12),
name: 'Post Commit',
role: { connect: { key: 'ADMIN' } },
},
})
).id;
token = (await login(app, email, password)).accessToken;
apiaryId = (
await prisma.apiary.create({ data: { name: `Post Commit ${suffix}` } })
@@ -59,12 +152,66 @@ describe('Post-commit side effects (e2e)', () => {
});
afterAll(async () => {
await cleanUp();
await app.close();
});
describe('when a side effect fails', () => {
let errorLog: jest.SpyInstance;
/** The error lines the automation's legacy path logged for one record. */
const automationFailuresFor = (entityType: string, entityId: string) =>
errorLog.mock.calls
.map(([message]) => String(message))
.filter((message) =>
message.startsWith(
`Automation rule evaluation failed for ${entityType} ${entityId}:`,
),
);
/**
* Every transaction still runs for real, but inside it the seeding of
* the automation rules (ensureDefaults → tx.automationRule.createMany)
* fails: the first write of every rule evaluation. A rule evaluated
* inside the request's own transaction would turn it into a 500.
*/
function failRuleSeedingInTransactions(): void {
type Work = (tx: Prisma.TransactionClient) => Promise<unknown>;
const runTransaction = prisma.$transaction.bind(prisma) as (
work: Work,
options?: unknown,
) => Promise<unknown>;
const failingRules = (rules: object) =>
new Proxy(rules, {
get: (target, property): unknown =>
property === 'createMany'
? () => Promise.reject(new Error('automation rules down'))
: Reflect.get(target, property),
});
const withFailingRuleSeeding = (tx: Prisma.TransactionClient) =>
new Proxy(tx, {
get: (target, property): unknown =>
property === 'automationRule'
? failingRules(target.automationRule)
: Reflect.get(target, property),
});
jest
.spyOn(prisma, '$transaction')
.mockImplementation((work: unknown, options?: unknown) =>
typeof work === 'function'
? runTransaction(
(tx) => (work as Work)(withFailingRuleSeeding(tx)),
options,
)
: runTransaction(work as Work, options),
);
}
beforeEach(() => {
// Automation: the task is created, then the warning fails.
errorLog = jest
.spyOn(Logger.prototype, 'error')
.mockImplementation(() => undefined);
// Automation: the warning cannot be written.
jest
.spyOn(app.get(NotificationsService), 'record')
.mockRejectedValue(new Error('notifications down'));
@@ -78,7 +225,7 @@ describe('Post-commit side effects (e2e)', () => {
.mockRejectedValue(new Error('webhook subscriptions down'));
});
it('still answers 201 for an inspection and replays it on a retry', async () => {
it('still answers 201 and replays on retry with the same Idempotency-Key (no side effect in the request)', async () => {
const colony = await createColony('PC-INSP');
const key = `post-commit-${suffix}-inspection`;
const body = {
@@ -88,40 +235,36 @@ describe('Post-commit side effects (e2e)', () => {
eggsPresent: false,
};
const first = await request(server())
.post('/api/v1/inspections')
.set(auth())
.set('Idempotency-Key', key)
.send(body)
.expect(201);
const retry = await request(server())
.post('/api/v1/inspections')
.set(auth())
.set('Idempotency-Key', key)
.send(body)
.expect(201);
const first = await post('/api/v1/inspections', body, key).expect(201);
const retry = await post('/api/v1/inspections', body, key).expect(201);
expect((retry.body as { id: string }).id).toBe(
(first.body as { id: string }).id,
);
const inspectionId = (first.body as { id: string }).id;
expect((retry.body as { id: string }).id).toBe(inspectionId);
expect(
await prisma.inspection.count({ where: { colonyId: colony.id } }),
).toBe(1);
// The rule still did its main job before the warning failed.
expect(
await prisma.task.count({
where: {
relatedId: colony.id,
sourceAutomationTrigger: 'QUEENLESS_NO_EGGS',
},
// The request only recorded its event - the replay not even that.
const events = await prisma.domainEvent.findMany({
where: { aggregateId: inspectionId },
});
expect(events).toEqual([
expect.objectContaining({
type: 'InspectionRecorded',
status: 'PENDING',
attempts: 0,
}),
).toBe(1);
]);
// No rule ran in the request, not even one that failed: the task
// follows when the handler runs.
expect(automationFailuresFor('Inspection', inspectionId)).toEqual([]);
expect(await taskCount('QUEENLESS_NO_EGGS', colony.id)).toBe(0);
expect(
await prisma.notification.count({ where: { relatedId: colony.id } }),
).toBe(0);
});
it('still answers 201 for a treatment when the rule evaluation fails', async () => {
jest
.spyOn(prisma.automationRule, 'upsert')
.mockRejectedValue(new Error('automation rules down'));
it('still answers 201 for a treatment when the rule evaluation would fail, and replays it', async () => {
failRuleSeedingInTransactions();
const colony = await createColony('PC-TREAT');
const key = `post-commit-${suffix}-treatment`;
const body = {
@@ -130,18 +273,8 @@ describe('Post-commit side effects (e2e)', () => {
product: 'Oxalsäure',
};
const first = await request(server())
.post('/api/v1/treatments')
.set(auth())
.set('Idempotency-Key', key)
.send(body)
.expect(201);
const retry = await request(server())
.post('/api/v1/treatments')
.set(auth())
.set('Idempotency-Key', key)
.send(body)
.expect(201);
const first = await post('/api/v1/treatments', body, key).expect(201);
const retry = await post('/api/v1/treatments', body, key).expect(201);
expect((retry.body as { id: string }).id).toBe(
(first.body as { id: string }).id,
@@ -152,8 +285,56 @@ describe('Post-commit side effects (e2e)', () => {
});
});
describe('when an automation handler fails', () => {
it('creates exactly one automation task when the handler fails once and is retried', async () => {
jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
const colonyId = (await createColony('PC-RETRY')).id;
jest
.spyOn(app.get(NotificationsService), 'record')
.mockRejectedValueOnce(new Error('notifications down'));
const { body } = (await post('/api/v1/inspections', {
colonyId,
date: '2026-07-01T00:00:00.000Z',
queenSeen: false,
eggsPresent: false,
}).expect(201)) as { body: { id: string } };
await drainDomainEvents(app, [body.id]);
// The warning failed, so the whole evaluation rolled back.
expect(await taskCount('QUEENLESS_NO_EGGS', colonyId)).toBe(0);
expect(
await prisma.notification.count({ where: { relatedId: colonyId } }),
).toBe(0);
// Due again now instead of after the 30 s backoff.
await prisma.$executeRaw`UPDATE "DomainEvent" SET "availableAt" = now() WHERE "aggregateId" = ${body.id}`;
await drainDomainEvents(app, [body.id]);
expect(await taskCount('QUEENLESS_NO_EGGS', colonyId)).toBe(1);
expect(
await prisma.notification.count({ where: { relatedId: colonyId } }),
).toBe(1);
const deliveries = await prisma.domainEventDelivery.findMany({
where: { event: { aggregateId: body.id } },
});
expect(
deliveries.find((d) => d.handler === 'automation.inspection-rules'),
).toMatchObject({ status: 'DONE', attempts: 2 });
// What already worked did not run again.
expect(
deliveries.find((d) => d.handler === 'webhooks.forward'),
).toMatchObject({ status: 'DONE', attempts: 1 });
await expect(
prisma.domainEvent.findFirstOrThrow({
where: { aggregateId: body.id },
}),
).resolves.toMatchObject({ status: 'DONE', attempts: 1 });
});
});
describe('automation tasks', () => {
it('leaves one open task when several triggers for the same colony arrive at once', async () => {
it('leaves one open reorder task when several triggers for the same stock item arrive at once', async () => {
const automation = app.get(AutomationService);
// The same object crossing the threshold on several pods/requests at
// once: every evaluation sees "no open task yet" unless checking and
@@ -167,24 +348,17 @@ describe('Post-commit side effects (e2e)', () => {
minThreshold: 5,
},
});
stockItemIds.push(item.id);
await Promise.all(
Array.from({ length: 8 }, () => automation.evaluateStockItem(item)),
);
expect(
await prisma.task.count({
where: {
relatedId: item.id,
sourceAutomationTrigger: 'STOCK_ITEM_LOW',
status: { in: ['OPEN', 'IN_PROGRESS'] },
},
}),
).toBe(1);
expect(await openTaskCount('STOCK_ITEM_LOW', item.id)).toBe(1);
});
it('leaves one open task when several measurements for the same colony arrive at once', async () => {
const colony = await createColony('PC-RACE-HTTP');
it('leaves one open task when several triggers for the same colony arrive at once', async () => {
const colony = await createColony('PC-RACE');
const measurement = {
colonyId: colony.id,
date: '2026-08-15T00:00:00.000Z',
@@ -194,25 +368,19 @@ describe('Post-commit side effects (e2e)', () => {
durationMinutes: 24 * 60,
};
await Promise.all(
const responses = await Promise.all(
Array.from({ length: 6 }, () =>
request(server())
.post('/api/v1/varroa-measurements')
.set(auth())
.send(measurement)
.expect(201),
post('/api/v1/varroa-measurements', measurement).expect(201),
),
);
// One worker each: the handlers evaluate the rule at the same moment.
await Promise.all(
responses.map(({ body }) =>
drainDomainEvents(app, [(body as { id: string }).id]),
),
);
expect(
await prisma.task.count({
where: {
relatedId: colony.id,
sourceAutomationTrigger: 'VARROA_THRESHOLD',
status: { in: ['OPEN', 'IN_PROGRESS'] },
},
}),
).toBe(1);
expect(await openTaskCount('VARROA_THRESHOLD', colony.id)).toBe(1);
});
});
});
+221 -2
View File
@@ -2,9 +2,11 @@ import { INestApplication } from '@nestjs/common';
import * as bcrypt from 'bcryptjs';
import request from 'supertest';
import { App } from 'supertest/types';
import { Prisma } from '../generated/prisma';
import { PrismaService } from '../src/prisma/prisma.service';
import { createTestApp } from './utils/create-test-app';
import { login } from './utils/api';
import { drainDomainEvents } from './utils/domain-events';
interface StockBody {
summary: {
@@ -13,7 +15,12 @@ interface StockBody {
consumed: number;
remaining: number;
};
movements: { type: string; quantity: number; notes: string | null }[];
movements: {
id: string;
type: string;
quantity: number;
notes: string | null;
}[];
}
interface LotBody {
@@ -27,6 +34,13 @@ interface BatchBody {
inputs: { quantity: number; rawMaterialLot: { id: string } }[];
}
/** A shared rule's config as it was before a test changed it. */
function restoredConfig(rule: {
config: Prisma.JsonValue;
}): Prisma.InputJsonValue | typeof Prisma.DbNull {
return rule.config === null ? Prisma.DbNull : rule.config;
}
/**
* Raw material lots (wax, propolis) and their consumption in production:
* creating a production batch books the inputs off the raw lots in the same
@@ -37,17 +51,29 @@ describe('Raw materials and production (e2e)', () => {
let app: INestApplication<App>;
let prisma: PrismaService;
const suffix = `${Date.now()}-${Math.round(Math.random() * 1e6)}`;
const startedAt = new Date();
const password = 'SuperSecret123!';
let token: string;
let userId: string;
let waxId: string;
let propolisId: string;
let productId: string;
let batchId: string;
// Tasks the RAW_MATERIAL_LOW handler created for this file.
const ruleTaskIds: string[] = [];
const server = () => app.getHttpServer();
const auth = (req: request.Test) =>
req.set('Authorization', `Bearer ${token}`);
/** The StockChanged events recorded for one movement or batch. */
function stockChangedOf(aggregateId: string) {
return prisma.domainEvent.findMany({
where: { type: 'StockChanged', aggregateId },
select: { aggregateType: true, payload: true, actorUserId: true },
});
}
async function stockOf(lotId: string): Promise<StockBody> {
const response = await auth(
request(server()).get(`/api/v1/raw-material-lots/${lotId}/stock`),
@@ -58,7 +84,7 @@ describe('Raw materials and production (e2e)', () => {
beforeAll(async () => {
app = await createTestApp();
prisma = app.get(PrismaService);
await prisma.user.create({
const user = await prisma.user.create({
data: {
email: `raw-beekeeper-${suffix}@example.com`,
passwordHash: await bcrypt.hash(password, 12),
@@ -66,6 +92,7 @@ describe('Raw materials and production (e2e)', () => {
role: { connect: { key: 'BEEKEEPER' } },
},
});
userId = user.id;
token = (await login(app, `raw-beekeeper-${suffix}@example.com`, password))
.accessToken;
@@ -96,6 +123,53 @@ describe('Raw materials and production (e2e)', () => {
});
afterAll(async () => {
// Only this file's rows, by its own ids - other e2e files share the
// database. An undefined id would drop a filter, hence the guards.
const lotIds = [waxId, propolisId].filter(Boolean);
const movementIds = (
await prisma.batchMovement.findMany({
where: { rawMaterialLotId: { in: lotIds } },
select: { id: true },
})
).map((movement) => movement.id);
const batchIds = [batchId].filter(Boolean);
// Rule tasks are written without a user: their TaskCreated, audit
// entries, warning and queued deliveries go by task id.
for (const taskId of ruleTaskIds) {
await prisma.auditLog.deleteMany({
where: {
entityType: 'AutomationRule',
afterData: { path: ['taskId'], equals: taskId },
},
});
await prisma.webhookDelivery.deleteMany({
where: { event: 'task.created', body: { contains: taskId } },
});
}
await prisma.calendarSyncOutboxEntry.deleteMany({
where: { sourceId: { in: ruleTaskIds } },
});
// Safe to scope by kind and time: RAW_MATERIAL_LOW is the only writer
// of GENERAL/PROPOLIS warnings, and only this file switches the rule on
// (default: off, no minimum) - so such a warning since this file
// started belongs to the task above.
if (ruleTaskIds.length > 0) {
await prisma.notification.deleteMany({
where: {
relatedType: 'GENERAL',
relatedId: 'PROPOLIS',
createdAt: { gte: startedAt },
},
});
}
const aggregateIds = [...batchIds, ...movementIds, ...ruleTaskIds];
await prisma.domainEvent.deleteMany({
where: { aggregateId: { in: aggregateIds } },
});
await prisma.auditLog.deleteMany({
where: { entityId: { in: [...lotIds, ...aggregateIds] } },
});
await prisma.task.deleteMany({ where: { id: { in: ruleTaskIds } } });
await app.close();
});
@@ -130,6 +204,26 @@ describe('Raw materials and production (e2e)', () => {
expect(
(wax.body as LotBody).usedIn.map((u) => u.productionBatch.id),
).toEqual([batchId]);
// The article for the shop, the consumed kinds for the minimum rule.
const events = await stockChangedOf(batchId);
expect(events).toEqual([
{
aggregateType: 'ProductionBatch',
payload: {
bottlingBatchIds: [],
productIds: [productId],
rawMaterialKinds: expect.arrayContaining([
'BEESWAX',
'PROPOLIS',
]) as unknown,
},
actorUserId: userId,
},
]);
expect(
(events[0].payload as { rawMaterialKinds: string[] }).rawMaterialKinds,
).toHaveLength(2);
});
it('refuses a production batch that needs more raw material than is left - and creates nothing', async () => {
@@ -163,6 +257,24 @@ describe('Raw materials and production (e2e)', () => {
});
});
it('a raw material withdrawal records StockChanged with the lot kind and no shop ids', async () => {
const [withdrawal] = await prisma.batchMovement.findMany({
where: { rawMaterialLotId: waxId, type: 'WITHDRAWAL' },
select: { id: true },
});
expect(await stockChangedOf(withdrawal.id)).toEqual([
{
aggregateType: 'BatchMovement',
payload: {
bottlingBatchIds: [],
productIds: [],
rawMaterialKinds: ['BEESWAX'],
},
actorUserId: userId,
},
]);
});
it('gives the raw material back when a mistaken production batch is deleted', async () => {
await auth(
request(server()).delete(`/api/v1/production-batches/${batchId}`),
@@ -174,6 +286,20 @@ describe('Raw materials and production (e2e)', () => {
true,
);
expect((await stockOf(propolisId)).summary.remaining).toBe(200);
// The give-back is a stock change of the article and both kinds too.
const events = await stockChangedOf(batchId);
expect(events).toHaveLength(2);
for (const event of events) {
expect(event.payload).toEqual({
bottlingBatchIds: [],
productIds: [productId],
rawMaterialKinds: expect.arrayContaining([
'BEESWAX',
'PROPOLIS',
]) as unknown,
});
}
});
it('refuses to correct a raw lot below what was already used or withdrawn', async () => {
@@ -236,4 +362,97 @@ describe('Raw materials and production (e2e)', () => {
request(server()).delete(`/api/v1/raw-material-lots/${waxId}`),
).expect(409);
});
it('creates the RAW_MATERIAL_LOW task only once its StockChanged event is dispatched', async () => {
// The rule is shared by every e2e file: switch it on with a minimum no
// test database reaches, and put it back right after.
const before = await prisma.automationRule.findUnique({
where: { triggerType: 'RAW_MATERIAL_LOW' },
});
const config = { minGrams: { PROPOLIS: 1_000_000_000_000 } };
// One open task per kind: a leftover from an aborted earlier run would
// keep the handler from creating a new one - close it first.
await prisma.task.updateMany({
where: {
sourceAutomationTrigger: 'RAW_MATERIAL_LOW',
relatedId: 'PROPOLIS',
status: { in: ['OPEN', 'IN_PROGRESS'] },
},
data: { status: 'DONE', completedAt: new Date() },
});
await prisma.automationRule.upsert({
where: { triggerType: 'RAW_MATERIAL_LOW' },
create: {
triggerType: 'RAW_MATERIAL_LOW',
active: true,
config,
actionTemplate: 'Rohstoff unter Mindestmenge: Nachschub einplanen.',
},
update: { active: true, config },
});
try {
const response = await auth(
request(server()).post(
`/api/v1/raw-material-lots/${propolisId}/withdrawals`,
),
)
.send({ quantity: 10, reason: 'PROBE' })
.expect(201);
// Newest first: the withdrawal just booked.
const [movement] = (response.body as StockBody).movements;
const [event] = await prisma.domainEvent.findMany({
where: { type: 'StockChanged', aggregateId: movement.id },
select: { correlationId: true },
});
const ruleTasksOfThisChange = async () =>
(
await prisma.domainEvent.findMany({
where: {
type: 'TaskCreated',
correlationId: event.correlationId,
},
select: { aggregateId: true },
})
).map((created) => created.aggregateId);
// The request itself creates no task - the handler does.
expect(await ruleTasksOfThisChange()).toEqual([]);
expect(
await prisma.task.count({
where: {
sourceAutomationTrigger: 'RAW_MATERIAL_LOW',
relatedId: 'PROPOLIS',
createdAt: { gte: startedAt },
},
}),
).toBe(0);
await drainDomainEvents(app, [movement.id]);
const taskIds = await ruleTasksOfThisChange();
ruleTaskIds.push(...taskIds);
expect(taskIds).toHaveLength(1);
expect(
await prisma.task.findUniqueOrThrow({ where: { id: taskIds[0] } }),
).toMatchObject({
sourceAutomationTrigger: 'RAW_MATERIAL_LOW',
relatedType: 'GENERAL',
relatedId: 'PROPOLIS',
status: 'OPEN',
});
} finally {
// Leave the rule as found: restored, or gone again if this test
// created it (ensureDefaults then recreates it with its defaults).
if (before === null) {
await prisma.automationRule.delete({
where: { triggerType: 'RAW_MATERIAL_LOW' },
});
} else {
await prisma.automationRule.update({
where: { triggerType: 'RAW_MATERIAL_LOW' },
data: { active: before.active, config: restoredConfig(before) },
});
}
}
});
});
+329
View File
@@ -0,0 +1,329 @@
import { randomBytes } from 'node:crypto';
import { Prisma } from '../generated/prisma';
import {
decryptMfaSecret,
encryptMfaSecret,
} from '../src/auth/mfa/mfa-crypto.util';
import { generateTotpSecret } from '../src/auth/mfa/totp.util';
import { gcmSeal, legacyJwtKey } from '../src/common/crypto/key-ring';
import { RekeyJob } from '../src/jobs/rekey.job';
import { runCli } from '../src/jobs/run-cli';
import { PrismaService } from '../src/prisma/prisma.service';
import {
openWebhookSecret,
sealWebhookSecret,
} from '../src/webhooks/webhook-secret';
type Form = 'legacy' | 'previous' | 'current' | 'plaintext';
type MfaForm = Exclude<Form, 'plaintext'>;
const MFA_FORMS: readonly MfaForm[] = ['legacy', 'previous', 'current'];
const WEBHOOK_FORMS: readonly Form[] = [
'legacy',
'plaintext',
'previous',
'current',
];
const PREVIOUS_KEYS = {
MFA_ENCRYPTION_KEY_PREVIOUS: `rekey-e2e-previous-mfa-${'p'.repeat(32)}`,
WEBHOOK_ENCRYPTION_KEY_PREVIOUS: `rekey-e2e-previous-webhook-${'q'.repeat(32)}`,
} as const;
/** How the code before the key ring sealed: AES-256-GCM under sha256(JWT_ACCESS_SECRET). */
function legacyCiphertext(plaintext: string): string {
const { iv, tag, ciphertext } = gcmSeal(
legacyJwtKey(),
Buffer.from(plaintext, 'utf8'),
);
return [iv, tag, ciphertext].map((part) => part.toString('base64')).join('.');
}
/** Seals with the key in `<variable>_PREVIOUS`, as if before the rotation. */
function sealedWithPrevious(variable: string, seal: () => string): string {
const current = process.env[variable];
process.env[variable] = process.env[`${variable}_PREVIOUS`];
try {
return seal();
} finally {
if (current === undefined) {
delete process.env[variable];
} else {
process.env[variable] = current;
}
}
}
function sealMfa(form: MfaForm, plaintext: string): string {
if (form === 'legacy') {
return legacyCiphertext(plaintext);
}
if (form === 'previous') {
return sealedWithPrevious('MFA_ENCRYPTION_KEY', () =>
encryptMfaSecret(plaintext),
);
}
return encryptMfaSecret(plaintext);
}
function sealWebhook(form: Form, plaintext: string): string {
if (form === 'legacy') {
return `enc:v1:${legacyCiphertext(plaintext)}`;
}
if (form === 'plaintext') {
return plaintext;
}
if (form === 'previous') {
return sealedWithPrevious('WEBHOOK_ENCRYPTION_KEY', () =>
sealWebhookSecret(plaintext),
);
}
return sealWebhookSecret(plaintext);
}
const keyIdOf = (stored: string): string => stored.split(':')[0];
/**
* The job as `node dist/cli.js rekey [--dry-run]` runs it: runCli with the
* real JobsModule against the shared test database. The job visits every
* row there, also those of e2e files running in parallel. So this file
* sets only *_PREVIOUS: the current keys stay the ones every e2e process
* has (the development keys from JWT_ACCESS_SECRET in .env.test, unless the
* shell sets real ones), and whatever this job reseals still opens there.
* Assertions on single rows use this file's rows; counts over the whole
* database are lower bounds.
*/
describe('rekey job (e2e)', () => {
const suffix = `${Date.now()}-${Math.round(Math.random() * 1e6)}`;
const testStart = new Date(Date.now() - 1000);
const savedEnv = Object.fromEntries(
Object.keys(PREVIOUS_KEYS).map((name) => [name, process.env[name]]),
);
let prisma: PrismaService;
const mfa = {} as Record<MfaForm, { id: string; plaintext: string }>;
const webhook = {} as Record<Form, { id: string; plaintext: string }>;
const created = { mfa: {}, webhook: {} } as {
mfa: Record<MfaForm, string>;
webhook: Record<Form, string>;
};
const extraUserIds: string[] = [];
const createUser = async (name: string, mfaSecret: string) => {
const user = await prisma.user.create({
data: {
email: `rekey-e2e-${name}-${suffix}@example.com`,
passwordHash: 'not-used',
name: `Rekey ${name}`,
mfaEnabled: true,
mfaSecret,
role: { connect: { key: 'READONLY' } },
},
});
return user.id;
};
/** This file's rows as stored now. */
const storedValues = async () => {
const users = await prisma.user.findMany({
where: { id: { in: MFA_FORMS.map((form) => mfa[form].id) } },
select: { id: true, mfaSecret: true },
});
const subscriptions = await prisma.webhookSubscription.findMany({
where: { id: { in: WEBHOOK_FORMS.map((form) => webhook[form].id) } },
select: { id: true, secret: true },
});
const userById = new Map(users.map((row) => [row.id, row.mfaSecret]));
const subscriptionById = new Map(
subscriptions.map((row) => [row.id, row.secret]),
);
return {
mfa: Object.fromEntries(
MFA_FORMS.map((form) => [form, userById.get(mfa[form].id)]),
) as Record<MfaForm, string>,
webhook: Object.fromEntries(
WEBHOOK_FORMS.map((form) => [
form,
subscriptionById.get(webhook[form].id),
]),
) as Record<Form, string>,
};
};
const rekeyRuns = () =>
prisma.jobRun.findMany({
where: { job: 'rekey', startedAt: { gte: testStart } },
orderBy: { startedAt: 'asc' },
});
beforeAll(async () => {
Object.assign(process.env, PREVIOUS_KEYS);
prisma = new PrismaService();
await prisma.$connect();
for (const form of MFA_FORMS) {
const plaintext = generateTotpSecret();
created.mfa[form] = sealMfa(form, plaintext);
mfa[form] = {
id: await createUser(form, created.mfa[form]),
plaintext,
};
}
for (const form of WEBHOOK_FORMS) {
const plaintext = randomBytes(32).toString('hex');
created.webhook[form] = sealWebhook(form, plaintext);
const subscription = await prisma.webhookSubscription.create({
data: {
url: 'https://receiver.example.com/rekey-e2e',
events: ['task.created'],
secret: created.webhook[form],
active: false,
},
});
webhook[form] = { id: subscription.id, plaintext };
}
});
afterAll(async () => {
const runs = await rekeyRuns();
const runIds = runs.map((run) => run.id);
await prisma.notification.deleteMany({
where: { relatedType: 'JobRun', relatedId: { in: runIds } },
});
await prisma.jobRun.deleteMany({ where: { id: { in: runIds } } });
const userIds = [
...MFA_FORMS.flatMap((form) => (mfa[form] ? [mfa[form].id] : [])),
...extraUserIds,
];
await prisma.notification.deleteMany({
where: { userId: { in: userIds } },
});
await prisma.user.deleteMany({ where: { id: { in: userIds } } });
await prisma.webhookSubscription.deleteMany({
where: {
id: {
in: WEBHOOK_FORMS.flatMap((form) =>
webhook[form] ? [webhook[form].id] : [],
),
},
},
});
await prisma.$disconnect();
for (const [name, value] of Object.entries(savedEnv)) {
if (value === undefined) {
delete process.env[name];
} else {
process.env[name] = value;
}
}
});
it('a second run rekeys nothing; dry-run writes nothing', async () => {
const currentMfaKeyId = keyIdOf(encryptMfaSecret('key-id-probe'));
const currentWebhookKeyId = keyIdOf(sealWebhookSecret('key-id-probe'));
// Dry-run: counts, opens every value, writes none.
expect(await runCli(['rekey', '--dry-run'])).toBe(0);
expect(await storedValues()).toEqual(created);
// Run: everything not sealed with the current key is resealed.
expect(await runCli(['rekey'])).toBe(0);
const afterFirst = await storedValues();
for (const form of ['legacy', 'previous'] as const) {
expect(keyIdOf(afterFirst.mfa[form])).toBe(currentMfaKeyId);
expect(afterFirst.mfa[form]).not.toBe(created.mfa[form]);
}
for (const form of ['legacy', 'plaintext', 'previous'] as const) {
expect(keyIdOf(afterFirst.webhook[form])).toBe(currentWebhookKeyId);
expect(afterFirst.webhook[form]).not.toBe(created.webhook[form]);
}
expect(afterFirst.mfa.current).toBe(created.mfa.current);
expect(afterFirst.webhook.current).toBe(created.webhook.current);
// Second run: a reseal would draw a fresh IV, so unchanged bytes prove
// that nothing was written.
expect(await runCli(['rekey'])).toBe(0);
expect(await storedValues()).toEqual(afterFirst);
const [dryRun, first, second, ...others] = await rekeyRuns();
expect(others).toHaveLength(0);
for (const run of [dryRun, first, second]) {
expect(run).toMatchObject({ status: 'SUCCEEDED', error: null });
}
const counts = (run: typeof first) => run.result as Record<string, number>;
expect(counts(dryRun)).toMatchObject({ dryRun: true, 'mfa.failed': 0 });
expect(counts(first)).toMatchObject({ dryRun: false, 'mfa.failed': 0 });
for (const run of [dryRun, first]) {
expect(counts(run)['mfa.rekeyed']).toBeGreaterThanOrEqual(2);
expect(counts(run)['webhook.rekeyed']).toBeGreaterThanOrEqual(2);
expect(counts(run)['webhook.plaintextSealed']).toBeGreaterThanOrEqual(1);
}
// No e2e file writes old-format MFA or sealed webhook secrets; others
// may add plaintext webhook secrets meanwhile, so that count stays open.
expect(counts(second)).toMatchObject({
dryRun: false,
'mfa.rekeyed': 0,
'mfa.plaintextSealed': 0,
'mfa.changedMeanwhile': 0,
'mfa.failed': 0,
'webhook.rekeyed': 0,
'webhook.failed': 0,
});
expect(counts(second)['mfa.alreadyCurrent']).toBeGreaterThanOrEqual(3);
expect(counts(second)['webhook.alreadyCurrent']).toBeGreaterThanOrEqual(4);
});
it('rekeyed values open to the original plaintext', async () => {
// The previous keys can go now. (JWT_ACCESS_SECRET cannot change here:
// it is also the source of the development keys.)
for (const name of Object.keys(PREVIOUS_KEYS)) {
delete process.env[name];
}
const stored = await storedValues();
for (const form of MFA_FORMS) {
expect(decryptMfaSecret(stored.mfa[form])).toBe(mfa[form].plaintext);
}
for (const form of WEBHOOK_FORMS) {
expect(openWebhookSecret(stored.webhook[form])).toBe(
webhook[form].plaintext,
);
}
});
it('skips a row changed since it was read and keeps the new secret', async () => {
const userId = await createUser(
'raced',
legacyCiphertext(generateTotpSecret()),
);
extraUserIds.push(userId);
const reenrolled = encryptMfaSecret(generateTotpSecret());
// Between the job's read and its write, the user sets MFA up anew -
// through the real database, so the SQL compare-and-swap is what has
// to keep the new secret.
const racing = {
user: {
findMany: (args: Prisma.UserFindManyArgs) => prisma.user.findMany(args),
updateMany: async (args: Prisma.UserUpdateManyArgs) => {
if (args.where?.id === userId) {
await prisma.user.update({
where: { id: userId },
data: { mfaSecret: reenrolled },
});
}
return prisma.user.updateMany(args);
},
},
webhookSubscription: prisma.webhookSubscription,
} as unknown as PrismaService;
const result = await new RekeyJob(racing).run([]);
expect(result['mfa.changedMeanwhile']).toBeGreaterThanOrEqual(1);
expect(result['mfa.failed']).toBe(0);
const after = await prisma.user.findUniqueOrThrow({
where: { id: userId },
select: { mfaSecret: true },
});
expect(after.mfaSecret).toBe(reenrolled);
});
});
+145 -1
View File
@@ -14,7 +14,7 @@ interface StockBody {
corrected: number;
remaining: number;
};
movements: { type: string; quantity: number }[];
movements: { id: string; type: string; quantity: number }[];
}
interface ShopResult {
@@ -35,6 +35,7 @@ describe('Stock ledger (e2e)', () => {
let prisma: PrismaService;
const suffix = `${Date.now()}-${Math.round(Math.random() * 1e6)}`;
const password = 'SuperSecret123!';
let beekeeperId: string;
let beekeeperToken: string;
let adminToken: string;
let writeKey: string;
@@ -48,6 +49,23 @@ describe('Stock ledger (e2e)', () => {
const asBeekeeper = (req: request.Test) =>
req.set('Authorization', `Bearer ${beekeeperToken}`);
/** The StockChanged events recorded for one movement, shop request or batch. */
function stockChangedOf(aggregateId: string) {
return prisma.domainEvent.findMany({
where: { type: 'StockChanged', aggregateId },
select: { aggregateType: true, payload: true, actorUserId: true },
});
}
async function shopRequestId(externalId: string): Promise<string> {
return (
await prisma.shopStockRequest.findUniqueOrThrow({
where: { externalId },
select: { id: true },
})
).id;
}
function book(token: string, requests: Record<string, unknown>[]) {
return request(server())
.post('/api/v1/shop-sync/movements')
@@ -68,6 +86,7 @@ describe('Stock ledger (e2e)', () => {
role: { connect: { key: 'BEEKEEPER' } },
},
});
beekeeperId = beekeeper.id;
await prisma.user.create({
data: {
email: `stock-admin-${suffix}@example.com`,
@@ -155,6 +174,34 @@ describe('Stock ledger (e2e)', () => {
});
afterAll(async () => {
// Events and audit entries of this file only, by its own ids - other
// e2e files share the database.
// Only ids that exist: an undefined id would drop the filter.
const batchIds = [bottlingId, newBatchId, oldBatchId].filter(Boolean);
const movementIds = (
await prisma.batchMovement.findMany({
where: {
OR: [
{ bottlingBatchId: { in: batchIds } },
{ productionBatchId: { in: batchIds } },
],
},
select: { id: true },
})
).map((movement) => movement.id);
const requestIds = (
await prisma.shopStockRequest.findMany({
where: { externalId: { endsWith: suffix } },
select: { id: true },
})
).map((shopRequest) => shopRequest.id);
const aggregateIds = [...batchIds, ...movementIds, ...requestIds];
await prisma.domainEvent.deleteMany({
where: { aggregateId: { in: aggregateIds } },
});
await prisma.auditLog.deleteMany({
where: { entityId: { in: aggregateIds } },
});
await app.close();
});
@@ -181,6 +228,48 @@ describe('Stock ledger (e2e)', () => {
.expect(400);
});
it('withdraw books the movement, the audit entry and StockChanged in one transaction', async () => {
// The withdrawal of 2 above - the refused one of 20 left no trace.
const movements = await prisma.batchMovement.findMany({
where: { bottlingBatchId: bottlingId },
select: { id: true, type: true, quantity: true },
});
expect(movements).toEqual([
expect.objectContaining({ type: 'WITHDRAWAL', quantity: -2 }),
]);
const [movement] = movements;
expect(
await prisma.auditLog.count({
where: { entityType: 'BatchMovement', entityId: movement.id },
}),
).toBe(1);
expect(await stockChangedOf(movement.id)).toEqual([
{
aggregateType: 'BatchMovement',
payload: {
bottlingBatchIds: [bottlingId],
productIds: [],
rawMaterialKinds: [],
},
actorUserId: beekeeperId,
},
]);
});
it('a production batch records StockChanged for its article', async () => {
expect(await stockChangedOf(newBatchId)).toEqual([
{
aggregateType: 'ProductionBatch',
payload: {
bottlingBatchIds: [],
productIds: [candleId],
rawMaterialKinds: [],
},
actorUserId: beekeeperId,
},
]);
});
it('books a shop sale once, even when it is sent twice', async () => {
const sale = {
externalId: `sale-honey-${suffix}`,
@@ -198,6 +287,19 @@ describe('Stock ledger (e2e)', () => {
const [duplicate] = (second.body as { results: ShopResult[] }).results;
expect(duplicate.status).toBe('duplicate');
expect(duplicate.remainingCount).toBe(5);
// One event for the booking - the repeat booked nothing.
expect(await stockChangedOf(await shopRequestId(sale.externalId))).toEqual([
{
aggregateType: 'ShopStockRequest',
payload: {
bottlingBatchIds: [bottlingId],
productIds: [],
rawMaterialKinds: [],
},
actorUserId: null,
},
]);
});
it('allocates an article sale to the batch with the earliest best-before date first', async () => {
@@ -253,6 +355,34 @@ describe('Stock ledger (e2e)', () => {
expect(tooMuch.status).toBe('rejected');
});
it('a shop request records one StockChanged per accepted booking', async () => {
const sale = await stockChangedOf(
await shopRequestId(`sale-candle-${suffix}`),
);
const returned = await stockChangedOf(
await shopRequestId(`return-candle-${suffix}`),
);
for (const events of [sale, returned]) {
expect(events).toEqual([
{
aggregateType: 'ShopStockRequest',
payload: {
bottlingBatchIds: [],
productIds: [candleId],
rawMaterialKinds: [],
},
actorUserId: null,
},
]);
}
// The rejected return was rolled back together with its event.
expect(
await prisma.shopStockRequest.count({
where: { externalId: `return-too-much-${suffix}` },
}),
).toBe(0);
});
it('rejects a bad request but still books the rest of the batch', async () => {
const response = await book(writeKey, [
{
@@ -315,6 +445,20 @@ describe('Stock ledger (e2e)', () => {
);
expect(attempts.filter((r) => r.status === 201)).toHaveLength(5);
expect(attempts.filter((r) => r.status === 400)).toHaveLength(3);
// One StockChanged per booked withdrawal, none for a refused one.
const withdrawals = await prisma.batchMovement.findMany({
where: { productionBatchId: newBatchId, type: 'WITHDRAWAL' },
select: { id: true },
});
expect(withdrawals).toHaveLength(5);
expect(
await prisma.domainEvent.count({
where: {
type: 'StockChanged',
aggregateId: { in: withdrawals.map((movement) => movement.id) },
},
}),
).toBe(5);
const stock = await asBeekeeper(
request(server()).get(`/api/v1/production-batches/${newBatchId}/stock`),
+237
View File
@@ -0,0 +1,237 @@
import { INestApplication } from '@nestjs/common';
import * as bcrypt from 'bcryptjs';
import request from 'supertest';
import { App } from 'supertest/types';
import { DomainEventDispatcher } from '../src/domain-events/domain-event-dispatcher';
import { PrismaService } from '../src/prisma/prisma.service';
import { createTestApp } from './utils/create-test-app';
import { login } from './utils/api';
interface FailedEventBody {
id: string;
failedHandlers: unknown[];
[key: string]: unknown;
}
interface FailedDeliveryBody {
id: string;
targetHost: string;
[key: string]: unknown;
}
interface OverviewBody {
jobs: unknown[];
domainEvents: { failed: number; oldestPendingAgeSeconds: number | null };
webhookDeliveries: { failed: number };
}
describe('System API (e2e)', () => {
let app: INestApplication<App>;
let prisma: PrismaService;
const suffix = `${Date.now()}-${Math.round(Math.random() * 1e6)}`;
const password = 'SuperSecret123!';
let adminToken: string;
let staffToken: string;
const server = () => app.getHttpServer();
const auth = (token: string) => ({ Authorization: `Bearer ${token}` });
beforeAll(async () => {
app = await createTestApp();
prisma = app.get(PrismaService);
const passwordHash = await bcrypt.hash(password, 12);
for (const [key, role] of [
['admin', 'ADMIN'],
['staff', 'STAFF'],
] as const) {
await prisma.user.create({
data: {
email: `system-${key}-${suffix}@example.com`,
passwordHash,
name: key,
role: { connect: { key: role } },
},
});
}
adminToken = (
await login(app, `system-admin-${suffix}@example.com`, password)
).accessToken;
staffToken = (
await login(app, `system-staff-${suffix}@example.com`, password)
).accessToken;
});
afterAll(async () => {
await app.close();
});
it('staff without system:read gets 403; API keys never reach /system', async () => {
await request(server())
.get('/api/v1/system/overview')
.set(auth(staffToken))
.expect(403);
await request(server())
.post('/api/v1/system/domain-events/x/retry')
.set(auth(staffToken))
.expect(403);
const created = await request(server())
.post('/api/v1/api-keys')
.set(auth(adminToken))
.send({ name: `System ${suffix}`, scopes: ['shop-sync:read'] })
.expect(201);
const { token } = created.body as { token: string };
await request(server())
.get('/api/v1/system/overview')
.set('Authorization', `Bearer ${token}`)
.expect(403);
await request(server())
.get('/api/v1/system/job-runs')
.set('Authorization', `Bearer ${token}`)
.expect(403);
});
it('shows the overview and validates the list limit', async () => {
const response = await request(server())
.get('/api/v1/system/overview')
.set(auth(adminToken))
.expect(200);
const body = response.body as OverviewBody;
expect(body.jobs).toHaveLength(5);
expect(typeof body.domainEvents.failed).toBe('number');
await request(server())
.get('/api/v1/system/job-runs?limit=0')
.set(auth(adminToken))
.expect(400);
await request(server())
.get('/api/v1/system/job-runs?limit=101')
.set(auth(adminToken))
.expect(400);
await request(server())
.get('/api/v1/system/job-runs?job=nope')
.set(auth(adminToken))
.expect(400);
await request(server())
.get('/api/v1/system/job-runs?job=cleanup&limit=5')
.set(auth(adminToken))
.expect(200);
});
it('admin retries a FAILED event and the next drain completes it', async () => {
const correlationId = `system-${suffix}`;
const event = await prisma.domainEvent.create({
data: {
type: 'test.system-retry',
aggregateType: 'SystemTest',
aggregateId: `agg-${suffix}`,
payload: { secret: 'must-not-leak' },
correlationId,
status: 'FAILED',
attempts: 8,
lastError: 'boom',
availableAt: new Date(Date.now() + 86_400_000),
},
});
const failed = await request(server())
.get('/api/v1/system/domain-events/failed?limit=100')
.set(auth(adminToken))
.expect(200);
const listed = (failed.body as FailedEventBody[]).find(
(e) => e.id === event.id,
);
expect(listed).toBeDefined();
expect(JSON.stringify(failed.body)).not.toContain('must-not-leak');
expect(listed).not.toHaveProperty('payload');
await request(server())
.post(`/api/v1/system/domain-events/${event.id}/retry`)
.set(auth(adminToken))
.expect(204);
const reset = await prisma.domainEvent.findUniqueOrThrow({
where: { id: event.id },
});
expect(reset).toMatchObject({
status: 'PENDING',
attempts: 0,
lastError: null,
});
expect(reset.availableAt.getTime()).toBeLessThanOrEqual(Date.now() + 5_000);
await expect(
prisma.auditLog.count({
where: { entityType: 'DomainEvent', entityId: event.id },
}),
).resolves.toBe(1);
await app
.get(DomainEventDispatcher)
.drain({ correlationIds: [correlationId] });
const done = await prisma.domainEvent.findUniqueOrThrow({
where: { id: event.id },
});
expect(done.status).toBe('DONE');
// DONE is not FAILED any more: a second retry is a conflict.
await request(server())
.post(`/api/v1/system/domain-events/${event.id}/retry`)
.set(auth(adminToken))
.expect(409);
await request(server())
.post('/api/v1/system/domain-events/does-not-exist/retry')
.set(auth(adminToken))
.expect(404);
});
it('lists a failed webhook delivery with host only and retries it', async () => {
const subscription = await prisma.webhookSubscription.create({
data: {
url: 'https://hooks.example.com/very/secret/path?token=abc',
events: ['harvest.created'],
secret: 'enc-secret',
},
});
const delivery = await prisma.webhookDelivery.create({
data: {
subscriptionId: subscription.id,
event: 'harvest.created',
body: '{"private":"record"}',
status: 'FAILED',
attempts: 8,
lastStatusCode: 500,
lastError: 'HTTP 500',
},
});
const response = await request(server())
.get('/api/v1/system/webhook-deliveries/failed?limit=100')
.set(auth(adminToken))
.expect(200);
const listed = (response.body as FailedDeliveryBody[]).find(
(d) => d.id === delivery.id,
);
expect(listed?.targetHost).toBe('hooks.example.com');
const raw = JSON.stringify(listed);
expect(raw).not.toMatch(/secret|token|private/);
await request(server())
.post(`/api/v1/system/webhook-deliveries/${delivery.id}/retry`)
.set(auth(adminToken))
.expect(204);
const reset = await prisma.webhookDelivery.findUniqueOrThrow({
where: { id: delivery.id },
});
expect(reset).toMatchObject({
status: 'PENDING',
attempts: 0,
lastError: null,
lastStatusCode: null,
});
await request(server())
.post(`/api/v1/system/webhook-deliveries/${delivery.id}/retry`)
.set(auth(adminToken))
.expect(409);
await prisma.webhookSubscription.delete({ where: { id: subscription.id } });
});
});
@@ -43,6 +43,18 @@ describe('TaskTemplates (e2e)', () => {
afterAll(async () => {
if (templateId) {
const generated = await prisma.task.findMany({
where: { sourceTemplateId: templateId },
select: { id: true },
});
const generatedIds = generated.map((task) => task.id);
// Written without a user, so not covered by the userId cleanup below.
await prisma.auditLog.deleteMany({
where: { entityType: 'Task', entityId: { in: generatedIds } },
});
await prisma.domainEvent.deleteMany({
where: { aggregateType: 'Task', aggregateId: { in: generatedIds } },
});
await prisma.task.deleteMany({ where: { sourceTemplateId: templateId } });
await prisma.taskTemplate.delete({ where: { id: templateId } });
}
@@ -88,15 +100,51 @@ describe('TaskTemplates (e2e)', () => {
.set('Authorization', `Bearer ${beekeeperToken}`)
.expect(200);
const generatedTask = await prisma.task.findFirst({
const generated = await prisma.task.findMany({
where: { sourceTemplateId: templateId },
});
expect(generatedTask).not.toBeNull();
expect(generated).toHaveLength(1);
const [generatedTask] = generated;
expect(generatedTask.createdByUserId).toBeNull();
const template = await prisma.taskTemplate.findUniqueOrThrow({
where: { id: templateId },
});
expect(template.nextDueDate.getTime()).toBeGreaterThan(yesterday.getTime());
// Same trail as any other task: an audit entry (by no user - the
// template made it) and TaskCreated for calendar and webhooks.
const audit = await prisma.auditLog.findMany({
where: { entityType: 'Task', entityId: generatedTask.id },
});
expect(audit).toEqual([
expect.objectContaining({ action: 'CREATE', userId: null }),
]);
const events = await prisma.domainEvent.findMany({
where: { aggregateType: 'Task', aggregateId: generatedTask.id },
});
expect(events).toEqual([
expect.objectContaining({
type: 'TaskCreated',
actorUserId: null,
payload: {
relatedType: 'GENERAL',
relatedId: null,
dueDate: generatedTask.dueDate?.toISOString().slice(0, 10),
},
}),
]);
});
it('creates nothing more for an occurrence already generated', async () => {
await request(app.getHttpServer())
.get('/api/v1/tasks')
.set('Authorization', `Bearer ${beekeeperToken}`)
.expect(200);
await expect(
prisma.task.count({ where: { sourceTemplateId: templateId } }),
).resolves.toBe(1);
});
it('rejects a recurrence emptied of a field it needs', () => {
+89 -4
View File
@@ -5,6 +5,7 @@ import { App } from 'supertest/types';
import { PrismaService } from '../src/prisma/prisma.service';
import { createTestApp } from './utils/create-test-app';
import { login } from './utils/api';
import { drainDomainEvents } from './utils/domain-events';
interface TaskBody {
id: string;
@@ -24,6 +25,13 @@ describe('Tasks (e2e)', () => {
let staffToken: string;
let readonlyToken: string;
let taskId: string;
const taskIds: string[] = [];
const eventsOf = (id: string, type: string) =>
prisma.domainEvent.findMany({
where: { aggregateType: 'Task', aggregateId: id, type },
include: { deliveries: true },
});
beforeAll(async () => {
app = await createTestApp();
@@ -53,9 +61,11 @@ describe('Tasks (e2e)', () => {
});
afterAll(async () => {
if (taskId) {
await prisma.task.delete({ where: { id: taskId } });
}
// Deliveries go with their event (ON DELETE CASCADE).
await prisma.domainEvent.deleteMany({
where: { aggregateType: 'Task', aggregateId: { in: taskIds } },
});
await prisma.task.deleteMany({ where: { id: { in: taskIds } } });
await prisma.refreshToken.deleteMany({
where: { userId: { in: [staffId, readonlyId] } },
});
@@ -84,9 +94,63 @@ describe('Tasks (e2e)', () => {
.expect(201);
const task = response.body as TaskBody;
taskId = task.id;
taskIds.push(taskId);
expect(task.status).toBe('OPEN');
});
it('POST /tasks leaves a TaskCreated event whose calendar and webhook deliveries are DONE after draining', async () => {
const response = await request(app.getHttpServer())
.post('/api/v1/tasks')
.set('Authorization', `Bearer ${staffToken}`)
.send({ title: 'Mit Termin', dueDate: '2027-05-01T00:00:00.000Z' })
.expect(201);
const id = (response.body as TaskBody).id;
taskIds.push(id);
// Recorded in the request's transaction, next to the audit entry.
const [recorded] = await eventsOf(id, 'TaskCreated');
expect(recorded).toMatchObject({
status: 'PENDING',
actorUserId: staffId,
payload: {
relatedType: 'GENERAL',
relatedId: null,
dueDate: '2027-05-01',
},
});
await expect(
prisma.auditLog.count({
where: {
entityType: 'Task',
entityId: id,
action: 'CREATE',
userId: staffId,
},
}),
).resolves.toBe(1);
await drainDomainEvents(app, [id]);
const [drained, ...more] = await eventsOf(id, 'TaskCreated');
expect(more).toHaveLength(0);
expect(drained.status).toBe('DONE');
expect(drained.deliveries).toEqual(
expect.arrayContaining([
expect.objectContaining({
handler: 'calendar-sync.task',
status: 'DONE',
}),
expect.objectContaining({
handler: 'webhooks.forward',
status: 'DONE',
}),
]),
);
expect(drained.deliveries.every((entry) => entry.status === 'DONE')).toBe(
true,
);
});
it('lists tasks filtered by status', async () => {
const response = await request(app.getHttpServer())
.get('/api/v1/tasks?status=OPEN')
@@ -121,6 +185,14 @@ describe('Tasks (e2e)', () => {
expect(auditEntry?.afterData).toEqual(
expect.objectContaining({ dueDate: null }),
);
const updates = await eventsOf(taskId, 'TaskUpdated');
expect(updates.map((event) => event.payload)).toEqual(
expect.arrayContaining([
{ status: 'OPEN', dueDate: '2026-08-01' },
{ status: 'OPEN', dueDate: null },
]),
);
expect(updates).toHaveLength(2);
});
it('rejects null for the required title', () => {
@@ -131,7 +203,7 @@ describe('Tasks (e2e)', () => {
.expect(400);
});
it('completes a task', async () => {
it('completes a task and records TaskCompleted once, also when completed again', async () => {
const response = await request(app.getHttpServer())
.post(`/api/v1/tasks/${taskId}/complete`)
.set('Authorization', `Bearer ${staffToken}`)
@@ -139,5 +211,18 @@ describe('Tasks (e2e)', () => {
const task = response.body as TaskBody;
expect(task.status).toBe('DONE');
expect(task.completedAt).not.toBeNull();
const repeated = await request(app.getHttpServer())
.post(`/api/v1/tasks/${taskId}/complete`)
.set('Authorization', `Bearer ${staffToken}`)
.expect(201);
expect((repeated.body as TaskBody).completedAt).toBe(task.completedAt);
const completions = await eventsOf(taskId, 'TaskCompleted');
expect(completions).toHaveLength(1);
expect(completions[0]).toMatchObject({
actorUserId: staffId,
payload: { relatedType: 'GENERAL', relatedId: null },
});
});
});
@@ -108,6 +108,55 @@ describe('Database-backed throttler (e2e)', () => {
expect(record.timeToExpire).toBeLessThanOrEqual(60);
});
it('never reports more seconds than the ttl or the block duration (sub-ms rounding)', async () => {
const ns = randomUUID();
await newApp(ns);
const storage = apps[apps.length - 1].get(PrismaThrottlerStorage);
// The column keeps milliseconds while now() has microseconds; rounding
// up by < 1 ms used to turn 60.0004 s into 61. About half of all
// timestamps round up, so 60 fresh keys catch it reliably.
for (let i = 0; i < 60; i += 1) {
const record = await storage.increment(`ms-${i}`, 60000, 0, 60000, 'd');
expect(record.timeToExpire).toBeGreaterThanOrEqual(59);
expect(record.timeToExpire).toBeLessThanOrEqual(60);
expect(record.isBlocked).toBe(true);
expect(record.timeToBlockExpire).toBeGreaterThanOrEqual(59);
expect(record.timeToBlockExpire).toBeLessThanOrEqual(60);
}
});
it('keeps the same bounds when a window restarts or a block starts on an existing row', async () => {
const ns = randomUUID();
await newApp(ns);
const storage = apps[apps.length - 1].get(PrismaThrottlerStorage);
const sleep = (ms: number): Promise<void> =>
new Promise((resolve) => setTimeout(resolve, ms));
for (let i = 0; i < 60; i += 1) {
// Restart branch: the 1 ms window is over, the row starts again at 1.
await storage.increment(`re-${i}`, 1, 10, 60000, 'd');
await sleep(5);
const restarted = await storage.increment(
`re-${i}`,
60000,
10,
60000,
'd',
);
expect(restarted.totalHits).toBe(1);
expect(restarted.timeToExpire).toBeGreaterThanOrEqual(59);
expect(restarted.timeToExpire).toBeLessThanOrEqual(60);
// Block branch: hits + 1 > limit on an open window sets blockedUntil.
await storage.increment(`bl-${i}`, 60000, 1, 60000, 'd');
const blocked = await storage.increment(`bl-${i}`, 60000, 1, 60000, 'd');
expect(blocked.isBlocked).toBe(true);
expect(blocked.timeToBlockExpire).toBeGreaterThanOrEqual(59);
expect(blocked.timeToBlockExpire).toBeLessThanOrEqual(60);
}
});
it('keeps hits while blocked and releases the block after it expired', async () => {
const ns = randomUUID();
await newApp(ns);
+289
View File
@@ -19,6 +19,11 @@ interface WithdrawalStatusBody {
product: string;
}
interface BulkTreatmentBody {
bulkId: string;
records: (TreatmentBody & { apiaryId: string; batchNumber: string })[];
}
describe('Treatments (e2e)', () => {
let app: INestApplication<App>;
let prisma: PrismaService;
@@ -74,6 +79,15 @@ describe('Treatments (e2e)', () => {
});
afterAll(async () => {
const treatmentIds = (
await prisma.treatment.findMany({
where: { colonyId },
select: { id: true },
})
).map((treatment) => treatment.id);
await prisma.domainEvent.deleteMany({
where: { aggregateType: 'Treatment', aggregateId: { in: treatmentIds } },
});
await prisma.treatment.deleteMany({ where: { colonyId } });
await prisma.colony.deleteMany({ where: { id: colonyId } });
await prisma.apiary.deleteMany({ where: { id: apiaryId } });
@@ -116,6 +130,30 @@ describe('Treatments (e2e)', () => {
expect(treatmentId).toEqual(expect.any(String));
});
it('wrote the audit entry and TreatmentRecorded together with the treatment', async () => {
expect(
await prisma.auditLog.count({
where: {
entityType: 'Treatment',
entityId: treatmentId,
action: 'CREATE',
userId: beekeeperId,
},
}),
).toBe(1);
const events = await prisma.domainEvent.findMany({
where: { aggregateType: 'Treatment', aggregateId: treatmentId },
});
expect(events).toHaveLength(1);
expect(events[0]).toEqual(
expect.objectContaining({
type: 'TreatmentRecorded',
actorUserId: beekeeperId,
payload: { colonyId },
}),
);
});
it('lists treatments for the colony', async () => {
const response = await request(app.getHttpServer())
.get(`/api/v1/colonies/${colonyId}/treatments`)
@@ -126,6 +164,14 @@ describe('Treatments (e2e)', () => {
});
it('clears start and end date on null and audits the edit', async () => {
const correctionWhere = {
aggregateType: 'Treatment',
aggregateId: treatmentId,
type: 'TreatmentCorrected',
} as const;
const correctionsBefore = await prisma.domainEvent.count({
where: correctionWhere,
});
await request(app.getHttpServer())
.patch(`/api/v1/treatments/${treatmentId}`)
.set('Authorization', `Bearer ${beekeeperToken}`)
@@ -152,6 +198,17 @@ describe('Treatments (e2e)', () => {
expect(auditEntry?.afterData).toEqual(
expect.objectContaining({ startDate: null, endDate: null }),
);
// Two edits in this test, one event each.
const corrections = await prisma.domainEvent.findMany({
where: correctionWhere,
});
expect(corrections).toHaveLength(correctionsBefore + 2);
expect(
corrections.every(
(event) =>
(event.payload as { colonyId: string }).colonyId === colonyId,
),
).toBe(true);
});
it.each(['date', 'product'])('rejects null for the required %s', (field) =>
@@ -350,4 +407,236 @@ describe('Treatments (e2e)', () => {
it('rejects a role without treatments:create', () =>
lastByProduct(staffToken, productName).expect(403));
});
describe('POST /treatments/bulk', () => {
const suffix = Date.now();
const shared = {
date: '2026-08-15',
product: 'Oxalsäure',
batchNumber: 'CH-1',
withdrawalPeriodDays: 0,
};
let secondApiaryId: string;
// Two colonies at the first apiary, one at the second.
let bulkColonyIds: string[];
let endedColonyId: string;
const postBulk = (token: string, body: object, idempotencyKey?: string) => {
const call = request(app.getHttpServer())
.post('/api/v1/treatments/bulk')
.set('Authorization', `Bearer ${token}`);
return (
idempotencyKey === undefined
? call
: call.set('Idempotency-Key', idempotencyKey)
).send(body);
};
const storedTreatments = () =>
prisma.treatment.count({
where: { colonyId: { in: [...bulkColonyIds, endedColonyId] } },
});
beforeAll(async () => {
const secondApiary = await prisma.apiary.create({
data: { name: `Stand B ${suffix}` },
});
secondApiaryId = secondApiary.id;
const colonyAt = async (
code: string,
atApiaryId: string,
status: 'ACTIVE' | 'WEAK' | 'DISSOLVED' = 'ACTIVE',
) =>
(
await prisma.colony.create({
data: {
code: `${code}-${suffix}`,
apiaryId: atApiaryId,
colonyType: 'PRODUCTION_COLONY',
status,
},
})
).id;
bulkColonyIds = [
await colonyAt('TB-1', apiaryId),
await colonyAt('TB-2', apiaryId, 'WEAK'),
await colonyAt('TB-3', secondApiaryId),
];
endedColonyId = await colonyAt('TB-X', apiaryId, 'DISSOLVED');
});
afterAll(async () => {
const colonyIds = [...bulkColonyIds, endedColonyId];
const treatmentIds = (
await prisma.treatment.findMany({
where: { colonyId: { in: colonyIds } },
select: { id: true },
})
).map((treatment) => treatment.id);
await prisma.domainEvent.deleteMany({
where: {
aggregateType: 'Treatment',
aggregateId: { in: treatmentIds },
},
});
await prisma.treatment.deleteMany({
where: { colonyId: { in: colonyIds } },
});
await prisma.colony.deleteMany({ where: { id: { in: colonyIds } } });
await prisma.apiary.deleteMany({ where: { id: secondApiaryId } });
await prisma.idempotencyRecord.deleteMany({
where: { userId: beekeeperId },
});
});
it("bulk creates one treatment per colony with the shared fields and each colony's apiary", async () => {
const response = await postBulk(beekeeperToken, {
...shared,
colonyIds: bulkColonyIds,
}).expect(201);
const body = response.body as BulkTreatmentBody;
expect(body.bulkId).toEqual(expect.any(String));
expect(body.records).toHaveLength(3);
expect(body.records.map((record) => record.colonyId)).toEqual(
bulkColonyIds,
);
expect(
body.records.every((record) => record.batchNumber === 'CH-1'),
).toBe(true);
expect(body.records.map((record) => record.apiaryId)).toEqual([
apiaryId,
apiaryId,
secondApiaryId,
]);
});
it('bulk writes one TreatmentRecorded event and one audit entry per record', async () => {
const response = await postBulk(beekeeperToken, {
...shared,
colonyIds: bulkColonyIds,
}).expect(201);
const { bulkId, records } = response.body as BulkTreatmentBody;
const ofThisBulk = {
type: 'TreatmentRecorded',
payload: { path: ['bulkId'], equals: bulkId },
};
expect(await prisma.domainEvent.count({ where: ofThisBulk })).toBe(3);
const events = await prisma.domainEvent.findMany({ where: ofThisBulk });
expect(
events
.map((event) => ({
aggregateId: event.aggregateId,
actorUserId: event.actorUserId,
payload: event.payload,
}))
.sort((a, b) => a.aggregateId.localeCompare(b.aggregateId)),
).toEqual(
records
.map((record) => ({
aggregateId: record.id,
actorUserId: beekeeperId,
payload: { colonyId: record.colonyId, bulkId },
}))
.sort((a, b) => a.aggregateId.localeCompare(b.aggregateId)),
);
expect(
await prisma.auditLog.count({
where: {
entityType: 'Treatment',
action: 'CREATE',
userId: beekeeperId,
entityId: { in: records.map((record) => record.id) },
},
}),
).toBe(3);
});
it('bulk rejects the whole batch when one colony has ended and names it', async () => {
const before = await storedTreatments();
const response = await postBulk(beekeeperToken, {
...shared,
colonyIds: [...bulkColonyIds, endedColonyId],
}).expect(400);
expect(
(response.body as { invalidColonyIds: string[] }).invalidColonyIds,
).toEqual([endedColonyId]);
expect(await storedTreatments()).toBe(before);
});
it.each([
[
'an unknown id',
() => [bulkColonyIds[0], `unknown-${suffix}`],
'Einige Völker sind nicht (mehr) erfassbar',
],
['no id', () => [], 'colonyIds braucht mindestens ein Volk'],
[
'101 ids',
() => [
...bulkColonyIds,
...Array.from({ length: 98 }, (_, index) => `cl-${suffix}-${index}`),
],
'colonyIds darf höchstens 100 Völker enthalten',
],
[
'a duplicate id',
() => [bulkColonyIds[0], bulkColonyIds[0]],
'colonyIds enthält ein Volk doppelt',
],
[
'an empty id',
() => [bulkColonyIds[0], ''],
'colonyIds enthält eine leere Volk-ID',
],
])(
'bulk rejects unknown ids, 0 or 101 ids and duplicates: %s',
async (_label, colonyIds, message) => {
const before = await storedTreatments();
const response = await postBulk(beekeeperToken, {
...shared,
colonyIds: colonyIds(),
}).expect(400);
expect(JSON.stringify(response.body)).toContain(message);
expect(await storedTreatments()).toBe(before);
},
);
it('bulk names an unknown id like an ended colony', async () => {
const unknownId = `unknown-${suffix}`;
const response = await postBulk(beekeeperToken, {
...shared,
colonyIds: [bulkColonyIds[0], unknownId],
}).expect(400);
expect(
(response.body as { invalidColonyIds: string[] }).invalidColonyIds,
).toEqual([unknownId]);
});
it('bulk replays with the same Idempotency-Key', async () => {
const key = `treatments-bulk-${suffix}`;
const body = { ...shared, colonyIds: bulkColonyIds };
const before = await storedTreatments();
const first = await postBulk(beekeeperToken, body, key).expect(201);
const replay = await postBulk(beekeeperToken, body, key).expect(201);
expect((replay.body as BulkTreatmentBody).bulkId).toBe(
(first.body as BulkTreatmentBody).bulkId,
);
expect(await storedTreatments()).toBe(before + bulkColonyIds.length);
});
it('bulk rejects a role without treatments:create', () =>
postBulk(staffToken, { ...shared, colonyIds: bulkColonyIds }).expect(
403,
));
});
});
+15
View File
@@ -0,0 +1,15 @@
import { INestApplicationContext } from '@nestjs/common';
import { WebhookDeliveryWorker } from '../../src/webhooks/webhook-delivery.worker';
/**
* Sends the due webhook deliveries of the given subscriptions - and nothing
* else, so e2e files running in parallel never send each other's
* deliveries. Without a worker, e2e tests call this after the request
* whose webhooks they want to assert. Returns how many were claimed.
*/
export function deliverWebhooks(
app: INestApplicationContext,
subscriptionIds: readonly string[],
): Promise<number> {
return app.get(WebhookDeliveryWorker).drain({ subscriptionIds });
}
+259
View File
@@ -12,6 +12,15 @@ interface VarroaMeasurementBody {
calculatedValue: number | null;
}
interface BulkVarroaBody {
bulkId: string;
records: (VarroaMeasurementBody & {
miteCount: number | null;
durationMinutes: number | null;
assessment: string | null;
})[];
}
describe('Varroa measurements (e2e)', () => {
let app: INestApplication<App>;
let prisma: PrismaService;
@@ -92,6 +101,19 @@ describe('Varroa measurements (e2e)', () => {
});
afterAll(async () => {
const measurementIds = (
await prisma.varroaMeasurement.findMany({
where: { colonyId },
select: { id: true },
})
).map((measurement) => measurement.id);
// Deliveries go with their event (ON DELETE CASCADE).
await prisma.domainEvent.deleteMany({
where: {
aggregateType: 'VarroaMeasurement',
aggregateId: { in: measurementIds },
},
});
await prisma.varroaMeasurement.deleteMany({ where: { colonyId } });
await prisma.colony.deleteMany({ where: { id: colonyId } });
await prisma.apiary.deleteMany({ where: { id: apiaryId } });
@@ -136,6 +158,30 @@ describe('Varroa measurements (e2e)', () => {
measurementId = created.id;
expect(measurementId).toEqual(expect.any(String));
expect(created.calculatedValue).toBe(4);
// Committed with the measurement and its audit entry; the threshold
// rule and the varroa.measured webhook follow from the event.
await expect(
prisma.auditLog.count({
where: {
entityType: 'VarroaMeasurement',
entityId: measurementId,
action: 'CREATE',
userId: beekeeperId,
},
}),
).resolves.toBe(1);
const events = await prisma.domainEvent.findMany({
where: { aggregateType: 'VarroaMeasurement', aggregateId: measurementId },
});
expect(events).toEqual([
expect.objectContaining({
type: 'VarroaMeasured',
actorUserId: beekeeperId,
payload: { colonyId },
status: 'PENDING',
}),
]);
});
it('recomputes mites/day when the count is corrected', async () => {
@@ -153,6 +199,28 @@ describe('Varroa measurements (e2e)', () => {
})
.expect(200);
expect((response.body as VarroaMeasurementBody).calculatedValue).toBe(10);
// A corrected count can cross the threshold: the rule re-evaluates it
// from VarroaCorrected.
await expect(
prisma.auditLog.count({
where: {
entityType: 'VarroaMeasurement',
entityId: measurementId,
action: 'UPDATE',
userId: beekeeperId,
},
}),
).resolves.toBe(1);
await expect(
prisma.domainEvent.findFirst({
where: { aggregateId: measurementId, type: 'VarroaCorrected' },
}),
).resolves.toMatchObject({
aggregateType: 'VarroaMeasurement',
actorUserId: beekeeperId,
payload: { colonyId },
});
});
it('lists measurements for the colony', async () => {
@@ -187,4 +255,195 @@ describe('Varroa measurements (e2e)', () => {
it('rejects a role without any varroa permission', () =>
thresholdBody(noVarroaToken).expect(403));
});
describe('POST /varroa-measurements/bulk', () => {
const suffix = Date.now();
const shared = {
date: '2026-09-10',
method: 'NATURAL_MITE_FALL',
durationMinutes: 4320,
notes: 'Windel nach Ameisensäure',
};
let bulkColonyIds: string[];
let endedColonyId: string;
const postBulk = (token: string, body: object) =>
request(app.getHttpServer())
.post('/api/v1/varroa-measurements/bulk')
.set('Authorization', `Bearer ${token}`)
.send(body);
const storedMeasurements = () =>
prisma.varroaMeasurement.count({
where: { colonyId: { in: [...bulkColonyIds, endedColonyId] } },
});
beforeAll(async () => {
const colonyAt = async (
code: string,
status: 'ACTIVE' | 'MERGED' = 'ACTIVE',
) =>
(
await prisma.colony.create({
data: {
code: `${code}-${suffix}`,
apiaryId,
colonyType: 'PRODUCTION_COLONY',
status,
},
})
).id;
bulkColonyIds = [await colonyAt('VB-1'), await colonyAt('VB-2')];
endedColonyId = await colonyAt('VB-X', 'MERGED');
});
afterAll(async () => {
const colonyIds = [...bulkColonyIds, endedColonyId];
const measurementIds = (
await prisma.varroaMeasurement.findMany({
where: { colonyId: { in: colonyIds } },
select: { id: true },
})
).map((measurement) => measurement.id);
await prisma.domainEvent.deleteMany({
where: {
aggregateType: 'VarroaMeasurement',
aggregateId: { in: measurementIds },
},
});
await prisma.varroaMeasurement.deleteMany({
where: { colonyId: { in: colonyIds } },
});
await prisma.colony.deleteMany({ where: { id: { in: colonyIds } } });
});
it('bulk derives mites per day per entry', async () => {
const response = await postBulk(beekeeperToken, {
...shared,
entries: [
{ colonyId: bulkColonyIds[0], miteCount: 30, assessment: 'hoch' },
{ colonyId: bulkColonyIds[1], miteCount: 6 },
],
}).expect(201);
const { bulkId, records } = response.body as BulkVarroaBody;
expect(
records.map((record) => ({
colonyId: record.colonyId,
miteCount: record.miteCount,
durationMinutes: record.durationMinutes,
calculatedValue: record.calculatedValue,
assessment: record.assessment,
})),
).toEqual([
{
colonyId: bulkColonyIds[0],
miteCount: 30,
durationMinutes: 4320,
calculatedValue: 10,
assessment: 'hoch',
},
{
colonyId: bulkColonyIds[1],
miteCount: 6,
durationMinutes: 4320,
calculatedValue: 2,
assessment: null,
},
]);
expect(
await prisma.domainEvent.count({
where: {
type: 'VarroaMeasured',
payload: { path: ['bulkId'], equals: bulkId },
},
}),
).toBe(2);
expect(
await prisma.auditLog.count({
where: {
entityType: 'VarroaMeasurement',
action: 'CREATE',
userId: beekeeperId,
entityId: { in: records.map((record) => record.id) },
},
}),
).toBe(2);
});
it('bulk rejects duplicate colonyId in entries', async () => {
const before = await storedMeasurements();
const response = await postBulk(beekeeperToken, {
...shared,
entries: [
{ colonyId: bulkColonyIds[0], miteCount: 3 },
{ colonyId: bulkColonyIds[0], miteCount: 4 },
],
}).expect(400);
expect(JSON.stringify(response.body)).toContain(
'entries enthält ein Volk doppelt',
);
expect(await storedMeasurements()).toBe(before);
});
it('bulk rejects the whole batch when one colony has ended and names it', async () => {
const before = await storedMeasurements();
const response = await postBulk(beekeeperToken, {
...shared,
entries: [
{ colonyId: bulkColonyIds[0], miteCount: 3 },
{ colonyId: endedColonyId, miteCount: 4 },
],
}).expect(400);
expect(
(response.body as { invalidColonyIds: string[] }).invalidColonyIds,
).toEqual([endedColonyId]);
expect(await storedMeasurements()).toBe(before);
});
it.each([
['no entry', {}, () => []],
[
'a shared mite count instead of one per entry',
{ miteCount: 5 },
() => [{ colonyId: bulkColonyIds[0] }],
],
[
'an invalid entry',
{},
() => [{ colonyId: bulkColonyIds[0], miteCount: -1 }],
],
])('bulk rejects %s', (_label, extra: object, entries: () => object[]) =>
postBulk(beekeeperToken, {
...shared,
...extra,
entries: entries(),
}).expect(400),
);
it('bulk rejects an empty colonyId in entries as invalid input, not as an unknown colony', async () => {
const response = await postBulk(beekeeperToken, {
...shared,
entries: [
{ colonyId: bulkColonyIds[0], miteCount: 3 },
{ colonyId: '', miteCount: 4 },
],
}).expect(400);
expect(JSON.stringify(response.body)).toContain(
'entries.1.colonyId enthält eine leere Volk-ID',
);
expect(response.body).not.toHaveProperty('invalidColonyIds');
});
it('bulk rejects a role without varroa:create', () =>
postBulk(staffToken, {
...shared,
entries: [{ colonyId: bulkColonyIds[0], miteCount: 1 }],
}).expect(403));
});
});
+450 -17
View File
@@ -5,8 +5,25 @@ import * as bcrypt from 'bcryptjs';
import request from 'supertest';
import { App } from 'supertest/types';
import { PrismaService } from '../src/prisma/prisma.service';
import { postWebhook, WebhookResponse } from '../src/webhooks/webhook-delivery';
import { createTestApp } from './utils/create-test-app';
import { login } from './utils/api';
import { drainDomainEvents } from './utils/domain-events';
import { deliverWebhooks } from './utils/webhooks';
// The real postWebhook by default; tests that need the loopback receiver
// below swap in postToLoopback (the real one refuses loopback by design).
jest.mock('../src/webhooks/webhook-delivery', () => {
const actual = jest.requireActual<
typeof import('../src/webhooks/webhook-delivery')
>('../src/webhooks/webhook-delivery');
return { ...actual, postWebhook: jest.fn(actual.postWebhook) };
});
const postWebhookMock = postWebhook as jest.MockedFunction<typeof postWebhook>;
const realPostWebhook = jest.requireActual<
typeof import('../src/webhooks/webhook-delivery')
>('../src/webhooks/webhook-delivery').postWebhook;
interface WebhookSubscriptionBody {
id: string;
@@ -16,27 +33,142 @@ interface WebhookSubscriptionBody {
active: boolean;
}
interface WebhookDeliveryBody {
id: string;
event: string;
status: string;
attempts: number;
lastStatusCode: number | null;
lastError: string | null;
createdAt: string;
deliveredAt: string | null;
}
interface ReceivedRequest {
body: string;
headers: http.IncomingHttpHeaders;
}
/**
* postWebhook without the connect-time address guard, for the loopback
* receiver of this file: same method, headers and body, real HTTP.
*/
function postToLoopback(
rawUrl: string,
headers: Record<string, string>,
body: string,
): Promise<WebhookResponse> {
return new Promise((resolve, reject) => {
const outgoing = http.request(
rawUrl,
{
method: 'POST',
headers: {
...headers,
'Content-Length': Buffer.byteLength(body).toString(),
},
},
(response) => {
response.resume();
resolve({ status: response.statusCode ?? 0 });
},
);
outgoing.on('error', reject);
outgoing.end(body);
});
}
function signatureOf(secret: string, received: ReceivedRequest): string {
const timestamp = String(received.headers['x-apicore-timestamp']);
const deliveryId = String(received.headers['x-apicore-delivery']);
const digest = crypto
.createHmac('sha256', secret)
.update(`${timestamp}.${deliveryId}.${received.body}`)
.digest('hex');
return `sha256=${digest}`;
}
describe('Webhooks (e2e)', () => {
let app: INestApplication<App>;
let prisma: PrismaService;
const adminEmail = `webhooks-e2e-admin-${Date.now()}@example.com`;
const staffEmail = `webhooks-e2e-staff-${Date.now()}@example.com`;
const suffix = `${Date.now()}-${Math.round(Math.random() * 1e6)}`;
const adminEmail = `webhooks-e2e-admin-${suffix}@example.com`;
const staffEmail = `webhooks-e2e-staff-${suffix}@example.com`;
const password = 'SuperSecret123!';
// Stored in plain like a pre-encryption row; openWebhookSecret reads it.
const loopbackSecret = crypto.randomBytes(32).toString('hex');
let adminId: string;
let staffId: string;
let adminAccessToken: string;
let staffAccessToken: string;
let apiaryId: string;
let subscriptionId: string;
let deliverySubscriptionId: string;
let blockedSubscriptionId: string;
let colonySubscriptionId: string;
let taskSubscriptionId: string;
const colonyIds: string[] = [];
const taskIds: string[] = [];
let receiverServer: http.Server;
let receiverUrl: string;
let receivedRequests: ReceivedRequest[] = [];
/** Delivery ids the receiver answers with 500 once. */
const failOnce = new Set<string>();
const received = (deliveryId: string): ReceivedRequest[] =>
receivedRequests.filter(
(entry) => entry.headers['x-apicore-delivery'] === deliveryId,
);
/** Creates a colony through the API; returns its id. */
async function createColony(): Promise<string> {
const response = await request(app.getHttpServer())
.post('/api/v1/colonies')
.set('Authorization', `Bearer ${adminAccessToken}`)
.send({
code: `WH-${suffix}-${colonyIds.length}`,
apiaryId,
colonyType: 'PRODUCTION_COLONY',
})
.expect(201);
const id = (response.body as { id: string }).id;
colonyIds.push(id);
return id;
}
/** Creates a task through the API; returns its id. */
async function createTask(title: string): Promise<string> {
const response = await request(app.getHttpServer())
.post('/api/v1/tasks')
.set('Authorization', `Bearer ${adminAccessToken}`)
.send({ title: `${title} ${suffix}` })
.expect(201);
const id = (response.body as { id: string }).id;
taskIds.push(id);
return id;
}
/** The delivery of `subscription` whose body is about `recordId`. */
function deliveryAbout(subscription: string, recordId: string) {
return prisma.webhookDelivery.findFirstOrThrow({
where: { subscriptionId: subscription, body: { contains: recordId } },
});
}
/** All deliveries of `subscription` for `event` about `recordId`. */
function deliveriesAbout(
subscription: string,
event: string,
recordId: string,
) {
return prisma.webhookDelivery.findMany({
where: {
subscriptionId: subscription,
event,
body: { contains: recordId },
},
});
}
beforeAll(async () => {
app = await createTestApp();
@@ -63,18 +195,23 @@ describe('Webhooks (e2e)', () => {
staffId = staff.id;
adminAccessToken = (await login(app, adminEmail, password)).accessToken;
staffAccessToken = (await login(app, staffEmail, password)).accessToken;
apiaryId = (
await prisma.apiary.create({ data: { name: `Webhook-Stand ${suffix}` } })
).id;
receivedRequests = [];
receiverServer = http.createServer((req, res) => {
const chunks: Buffer[] = [];
req.on('data', (chunk: Buffer) => chunks.push(chunk));
req.on('end', () => {
const deliveryId = String(req.headers['x-apicore-delivery']);
receivedRequests.push({
body: Buffer.concat(chunks).toString('utf-8'),
headers: req.headers,
});
res.writeHead(200);
res.end('ok');
const fail = failOnce.delete(deliveryId);
res.writeHead(fail ? 500 : 200);
res.end(fail ? 'receiver error body' : 'ok');
});
});
await new Promise<void>((resolve) =>
@@ -87,13 +224,33 @@ describe('Webhooks (e2e)', () => {
receiverUrl = `http://127.0.0.1:${address.port}/hook`;
});
afterEach(() => {
postWebhookMock.mockImplementation(realPostWebhook);
});
afterAll(async () => {
await new Promise<void>((resolve, reject) =>
receiverServer.close((error) => (error ? reject(error) : resolve())),
);
// Deliveries go with their subscription (ON DELETE CASCADE).
await prisma.webhookSubscription.deleteMany({
where: { id: { in: [subscriptionId, deliverySubscriptionId] } },
where: {
id: {
in: [
subscriptionId,
blockedSubscriptionId,
colonySubscriptionId,
taskSubscriptionId,
].filter(Boolean),
},
},
});
// Event deliveries go with their event (ON DELETE CASCADE).
await prisma.domainEvent.deleteMany({
where: { aggregateType: 'Task', aggregateId: { in: taskIds } },
});
await prisma.colony.deleteMany({ where: { id: { in: colonyIds } } });
await prisma.apiary.deleteMany({ where: { id: apiaryId } });
await prisma.task.deleteMany({
where: { createdByUserId: { in: [adminId, staffId] } },
});
@@ -142,6 +299,22 @@ describe('Webhooks (e2e)', () => {
expect(body.events).toEqual(['task.created']);
});
it('accepts the new event types', async () => {
await request(app.getHttpServer())
.patch(`/api/v1/webhooks/${subscriptionId}`)
.set('Authorization', `Bearer ${adminAccessToken}`)
.send({
events: [
'task.created',
'varroa.measured',
'treatment.created',
'feeding.created',
'stock.changed',
],
})
.expect(200);
});
it('excludes the secret from the list response', async () => {
const response = await request(app.getHttpServer())
.get('/api/v1/webhooks')
@@ -154,29 +327,289 @@ describe('Webhooks (e2e)', () => {
it('never delivers to a private address, even from a row that bypassed the create guard', async () => {
// Stands in for DNS rebinding or a tampered row: the stored URL points
// at a loopback receiver. The connection-time check must refuse it, and
// the request that fired the event must not wait for the receiver.
// Signing is covered by webhooks.service.spec.ts.
// at a loopback receiver. The connection-time check must refuse it.
const subscription = await prisma.webhookSubscription.create({
data: {
url: receiverUrl,
events: ['task.created'],
secret: crypto.randomBytes(32).toString('hex'),
secret: loopbackSecret,
createdByUserId: adminId,
},
});
deliverySubscriptionId = subscription.id;
blockedSubscriptionId = subscription.id;
await request(app.getHttpServer())
.post('/api/v1/tasks')
.set('Authorization', `Bearer ${adminAccessToken}`)
.send({ title: `Webhook-Test ${Date.now()}` })
.expect(201);
await new Promise((resolve) => setTimeout(resolve, 300));
const taskId = await createTask('Webhook-Test');
// The request recorded TaskCreated; its webhook handler queues the
// delivery. Nothing is sent yet.
await drainDomainEvents(app, [taskId]);
const queued = await deliveryAbout(blockedSubscriptionId, taskId);
expect(queued).toMatchObject({ status: 'PENDING', attempts: 0 });
await deliverWebhooks(app, [blockedSubscriptionId]);
expect(receivedRequests).toHaveLength(0);
const after = await deliveryAbout(blockedSubscriptionId, taskId);
expect(after).toMatchObject({
status: 'PENDING',
attempts: 1,
lastStatusCode: null,
});
expect(after.lastError).toContain('blocked address');
});
it('delivers task.created and task.completed once each, through the event and the queue', async () => {
postWebhookMock.mockImplementation(postToLoopback);
taskSubscriptionId = (
await prisma.webhookSubscription.create({
data: {
url: receiverUrl,
events: ['task.created', 'task.completed'],
secret: loopbackSecret,
createdByUserId: adminId,
},
})
).id;
const taskId = await createTask('Webhook-Aufgabe');
// Nothing is queued before the event is dispatched.
await expect(
deliveriesAbout(taskSubscriptionId, 'task.created', taskId),
).resolves.toHaveLength(0);
await drainDomainEvents(app, [taskId]);
await deliverWebhooks(app, [taskSubscriptionId]);
const [created, ...moreCreated] = await deliveriesAbout(
taskSubscriptionId,
'task.created',
taskId,
);
expect(moreCreated).toHaveLength(0);
expect(created).toMatchObject({ status: 'DELIVERED', lastStatusCode: 200 });
const [sentCreated, ...resentCreated] = received(created.id);
expect(resentCreated).toHaveLength(0);
expect(sentCreated.headers['x-apicore-event']).toBe('task.created');
expect(JSON.parse(sentCreated.body)).toMatchObject({
event: 'task.created',
data: { id: taskId, status: 'OPEN' },
});
// Completed twice: the second request announces nothing.
for (let i = 0; i < 2; i++) {
await request(app.getHttpServer())
.post(`/api/v1/tasks/${taskId}/complete`)
.set('Authorization', `Bearer ${adminAccessToken}`)
.expect(201);
}
await drainDomainEvents(app, [taskId]);
await deliverWebhooks(app, [taskSubscriptionId]);
const [completed, ...moreCompleted] = await deliveriesAbout(
taskSubscriptionId,
'task.completed',
taskId,
);
expect(moreCompleted).toHaveLength(0);
expect(completed).toMatchObject({
status: 'DELIVERED',
lastStatusCode: 200,
});
const [sentCompleted, ...resentCompleted] = received(completed.id);
expect(resentCompleted).toHaveLength(0);
expect(sentCompleted.headers['x-apicore-signature']).toBe(
signatureOf(loopbackSecret, sentCompleted),
);
expect(JSON.parse(sentCompleted.body)).toMatchObject({
event: 'task.completed',
data: { id: taskId, status: 'DONE' },
});
// Still the one task.created: completing sends no second one.
await expect(
deliveriesAbout(taskSubscriptionId, 'task.created', taskId),
).resolves.toHaveLength(1);
});
it('queues task.completed once for a PATCH into DONE, also when it is repeated', async () => {
const taskId = await createTask('Per Bearbeiten erledigt');
for (let i = 0; i < 2; i++) {
await request(app.getHttpServer())
.patch(`/api/v1/tasks/${taskId}`)
.set('Authorization', `Bearer ${adminAccessToken}`)
.send({ status: 'DONE' })
.expect(200);
}
await expect(
prisma.domainEvent.count({
where: { aggregateId: taskId, type: 'TaskCompleted' },
}),
).resolves.toBe(1);
await expect(
prisma.domainEvent.count({
where: { aggregateId: taskId, type: 'TaskUpdated' },
}),
).resolves.toBe(2);
await drainDomainEvents(app, [taskId]);
const completed = await deliveriesAbout(
taskSubscriptionId,
'task.completed',
taskId,
);
expect(completed).toHaveLength(1);
expect(JSON.parse(completed[0].body)).toMatchObject({
event: 'task.completed',
data: { id: taskId, status: 'DONE' },
});
});
it('delivers colony.created through the queue with a stable delivery id', async () => {
postWebhookMock.mockImplementation(postToLoopback);
colonySubscriptionId = (
await prisma.webhookSubscription.create({
data: {
url: receiverUrl,
events: ['colony.created'],
secret: loopbackSecret,
createdByUserId: adminId,
},
})
).id;
const colonyId = await createColony();
await deliverWebhooks(app, [colonySubscriptionId]);
const delivery = await deliveryAbout(colonySubscriptionId, colonyId);
const [sent, ...more] = received(delivery.id);
expect(more).toHaveLength(0);
expect(sent.headers['x-apicore-delivery']).toBe(delivery.id);
expect(sent.headers['x-apicore-event']).toBe('colony.created');
expect(sent.headers['x-apicore-signature']).toBe(
signatureOf(loopbackSecret, sent),
);
// The body stored at enqueue time, byte for byte.
expect(sent.body).toBe(delivery.body);
expect(JSON.parse(sent.body)).toMatchObject({
event: 'colony.created',
data: { id: colonyId, apiaryId },
});
expect(delivery).toMatchObject({
status: 'DELIVERED',
attempts: 0,
lastStatusCode: 200,
claimedAt: null,
});
expect(delivery.deliveredAt).toBeInstanceOf(Date);
});
it('retries a 500 with the same delivery id and then marks it DELIVERED', async () => {
postWebhookMock.mockImplementation(postToLoopback);
const colonyId = await createColony();
const queued = await deliveryAbout(colonySubscriptionId, colonyId);
failOnce.add(queued.id);
await deliverWebhooks(app, [colonySubscriptionId]);
const failed = await deliveryAbout(colonySubscriptionId, colonyId);
expect(failed).toMatchObject({
status: 'PENDING',
attempts: 1,
lastStatusCode: 500,
lastError: 'Receiver answered HTTP 500',
});
// Backoff by the database clock: the first retry waits 30 s.
const [{ waitMs }] = await prisma.$queryRaw<{ waitMs: number }[]>`
SELECT (EXTRACT(EPOCH FROM ("availableAt" - now())) * 1000)::float8 AS "waitMs"
FROM "WebhookDelivery" WHERE "id" = ${queued.id}`;
expect(waitMs).toBeGreaterThan(25_000);
expect(waitMs).toBeLessThanOrEqual(30_000);
// Not due yet: another run leaves it alone.
await deliverWebhooks(app, [colonySubscriptionId]);
expect(received(queued.id)).toHaveLength(1);
await prisma.$executeRaw`UPDATE "WebhookDelivery" SET "availableAt" = now() WHERE "id" = ${queued.id}`;
await deliverWebhooks(app, [colonySubscriptionId]);
const attempts = received(queued.id);
expect(attempts).toHaveLength(2);
expect(attempts[1].headers['x-apicore-delivery']).toBe(queued.id);
expect(attempts[1].body).toBe(attempts[0].body);
attempts.forEach((attempt) =>
expect(attempt.headers['x-apicore-signature']).toBe(
signatureOf(loopbackSecret, attempt),
),
);
const delivered = await deliveryAbout(colonySubscriptionId, colonyId);
expect(delivered).toMatchObject({
status: 'DELIVERED',
attempts: 1,
lastStatusCode: 200,
});
});
it('lists deliveries for admins and refuses staff', async () => {
// Every e2e file creates colonies: while the subscription is active,
// their colony.created deliveries pile up on it and could push this
// file's rows off the page. Deactivated, nothing new is queued.
await prisma.webhookSubscription.update({
where: { id: colonySubscriptionId },
data: { active: false },
});
const response = await request(app.getHttpServer())
.get(`/api/v1/webhooks/${colonySubscriptionId}/deliveries?limit=100`)
.set('Authorization', `Bearer ${adminAccessToken}`)
.expect(200);
const deliveries = response.body as WebhookDeliveryBody[];
const ours = await prisma.webhookDelivery.findMany({
where: {
subscriptionId: colonySubscriptionId,
OR: colonyIds.map((id) => ({ body: { contains: id } })),
},
});
expect(ours.length).toBeGreaterThanOrEqual(2);
for (const delivery of ours) {
expect(deliveries.map((entry) => entry.id)).toContain(delivery.id);
}
for (const entry of deliveries) {
expect(Object.keys(entry).sort()).toEqual(
[
'attempts',
'createdAt',
'deliveredAt',
'event',
'id',
'lastError',
'lastStatusCode',
'status',
].sort(),
);
}
// Newest first.
const createdAt = deliveries.map((entry) => Date.parse(entry.createdAt));
expect(createdAt).toEqual([...createdAt].sort((a, b) => b - a));
await request(app.getHttpServer())
.get(`/api/v1/webhooks/${colonySubscriptionId}/deliveries`)
.set('Authorization', `Bearer ${staffAccessToken}`)
.expect(403);
});
it.each(['0', '101', '1.5', 'abc'])(
'rejects limit=%s for the delivery list',
(limit) =>
request(app.getHttpServer())
.get(`/api/v1/webhooks/${colonySubscriptionId}/deliveries`)
.query({ limit })
.set('Authorization', `Bearer ${adminAccessToken}`)
.expect(400),
);
it('answers 404 for the deliveries of an unknown subscription', () =>
request(app.getHttpServer())
.get('/api/v1/webhooks/does-not-exist/deliveries')
.set('Authorization', `Bearer ${adminAccessToken}`)
.expect(404));
it.each(['url', 'events', 'active'])(
'rejects null for the required %s',
(field) =>
@@ -94,6 +94,9 @@ describe('Winter checks (e2e)', () => {
afterAll(async () => {
const colonyIds = [survivorId, lostId, pendingId];
await prisma.domainEvent.deleteMany({
where: { aggregateType: 'Colony', aggregateId: { in: colonyIds } },
});
await prisma.winterCheck.deleteMany({
where: { colonyId: { in: colonyIds } },
});
@@ -192,6 +195,18 @@ describe('Winter checks (e2e)', () => {
expect(colony.deactivatedAt?.toISOString()).toBe(
'2002-03-20T00:00:00.000Z',
);
const events = await prisma.domainEvent.findMany({
where: { aggregateId: lostId },
orderBy: { occurredAt: 'asc' },
});
expect(events.map((event) => event.type)).toEqual([
'ColonyStatusChanged',
'ColonyEnded',
]);
expect(events[1].payload).toEqual({ status: 'DECEASED' });
expect(
await prisma.domainEvent.count({ where: { aggregateId: survivorId } }),
).toBe(0);
const survivor = await prisma.colony.findUniqueOrThrow({
where: { id: survivorId },
});
@@ -15,6 +15,7 @@ import type { DomainEventEnvelope } from '../src/domain-events/domain-event-type
import { DomainEventsService } from '../src/domain-events/domain-events.service';
import { OnDomainEvent } from '../src/domain-events/on-domain-event.decorator';
import { PrismaService } from '../src/prisma/prisma.service';
import { WebhookDeliveryWorker } from '../src/webhooks/webhook-delivery.worker';
import { WorkerModule } from '../src/worker/worker.module';
import { DOMAIN_EVENT_POLL_MS } from '../src/worker/worker-runtime.service';
@@ -127,6 +128,7 @@ describe('Worker runtime (e2e)', () => {
let worker: TestingModule;
let prisma: PrismaService;
let processOutbox: jest.SpyInstance;
let deliverWebhooks: jest.SpyInstance;
let healthPort: number;
async function recordTaskDueInTx(): Promise<void> {
@@ -164,6 +166,13 @@ describe('Worker runtime (e2e)', () => {
processOutbox = jest
.spyOn(worker.get(CalendarSyncService), 'processOutbox')
.mockResolvedValue(undefined);
// Likewise the webhook loop (webhooks.e2e-spec.ts covers delivery): an
// unfiltered drain here would send the deliveries of parallel e2e files.
const webhookDeliveries = worker.get(WebhookDeliveryWorker);
deliverWebhooks = jest
.spyOn(webhookDeliveries, 'drain')
.mockResolvedValue(0);
jest.spyOn(webhookDeliveries, 'releaseStaleClaims').mockResolvedValue(0);
await worker.init();
// The first ticks run right after bootstrap; wait for the dispatcher's.
const dispatcher = worker.get(DomainEventDispatcher);
@@ -210,6 +219,19 @@ describe('Worker runtime (e2e)', () => {
);
});
it('wakes the webhook delivery loop on a webhook_delivery notification', async () => {
expect(deliverWebhooks).toHaveBeenCalled();
const before = deliverWebhooks.mock.calls.length;
await notifyWorker(prisma, WAKE_CHANNELS.webhookDeliveries);
await expectWithin(
WAKE_UP_WITHIN_MS,
() => deliverWebhooks.mock.calls.length > before,
true,
);
});
it('answers GET /health with 200 {"status":"ok"}', async () => {
await expect(getJson(healthPort, '/health')).resolves.toEqual({
status: 200,