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:
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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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 },
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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}` });
|
||||
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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)/)"
|
||||
]
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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) },
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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`),
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
));
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user