feat: platform backbone and field capture, first part (checkpoint)
Platform backbone (plan 2026-10-13-plattform-rueckgrat), reviewed tasks: - run roles api/worker/job/all with shared startup checks (Task 1) - schema and migration for DomainEvent, deliveries, JobRun, ThrottlerHit (Task 2) - audit, notifications and domain events written in the same transaction (Task 3) - dispatcher with SKIP LOCKED claims, retries and stale release (Task 4) - worker building blocks: tick, LISTEN/NOTIFY wake-up, health (Task 5) - worker process and entry points (Task 6) - throttler counters in Postgres (Task 19) - separate keys per purpose with key ring (Task 20) - public origin page without exact location (Task 22) - shop keys split into read and write (Task 23) - Kustomize base, components, overlay encry, CI validation (Task 26) Field capture T3 (plan 2026-10-13-t3-erfassung-am-stand), reviewed tasks: - brood frames, read endpoints, apiary overview and offline pack - network basics, offline queue v3, navigation sheets, form building blocks - new inspection with last-time hints and weather, varroa per day, treatment prefill, apiary page with round - inspection photo (Task 10) is implemented, review pending Not rolled out. Remaining tasks follow in later commits. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
10cbe9bf2d
commit
c01a38835a
340 files changed
+25695
-1808
No files matched your search
@@ -95,6 +95,14 @@ describe('API keys (e2e)', () => {
|
||||
expect(raw).not.toContain('keyHash');
|
||||
});
|
||||
|
||||
it('refuses a new key with both shop scopes', async () => {
|
||||
const response = await createKey({
|
||||
name: `Combined ${suffix}`,
|
||||
scopes: ['shop-sync:read', 'shop-sync:write'],
|
||||
}).expect(400);
|
||||
expect(JSON.stringify(response.body)).toContain('getrennte Schlüssel');
|
||||
});
|
||||
|
||||
it('lets a key read exactly what its scopes allow', async () => {
|
||||
const { token } = (
|
||||
await createKey({ name: `Scope ${suffix}`, scopes: ['shop-sync:read'] })
|
||||
|
||||
@@ -10,6 +10,7 @@ interface ApiaryBody {
|
||||
id: string;
|
||||
status: string;
|
||||
notes: string | null;
|
||||
publicLocationLabel: string | null;
|
||||
}
|
||||
|
||||
describe('Apiaries (e2e)', () => {
|
||||
@@ -23,6 +24,11 @@ describe('Apiaries (e2e)', () => {
|
||||
let beekeeperToken: string;
|
||||
let readonlyToken: string;
|
||||
let apiaryId: string;
|
||||
const noColonyEmail = `apiaries-e2e-nocolony-${Date.now()}@example.com`;
|
||||
let noColonyRoleId: string;
|
||||
let noColonyUserId: string;
|
||||
let noColonyToken: string;
|
||||
let fieldApiaryId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await createTestApp();
|
||||
@@ -50,9 +56,45 @@ describe('Apiaries (e2e)', () => {
|
||||
|
||||
beekeeperToken = (await login(app, beekeeperEmail, password)).accessToken;
|
||||
readonlyToken = (await login(app, readonlyEmail, password)).accessToken;
|
||||
|
||||
const apiariesRead = await prisma.permission.findMany({
|
||||
where: { key: 'apiaries:read' },
|
||||
});
|
||||
const noColonyRole = await prisma.role.create({
|
||||
data: {
|
||||
key: `APIARIES_ONLY_${Date.now()}`,
|
||||
name: 'Nur Stände',
|
||||
permissions: {
|
||||
create: apiariesRead.map((permission) => ({
|
||||
permissionId: permission.id,
|
||||
})),
|
||||
},
|
||||
},
|
||||
});
|
||||
noColonyRoleId = noColonyRole.id;
|
||||
const noColonyUser = await prisma.user.create({
|
||||
data: {
|
||||
email: noColonyEmail,
|
||||
passwordHash,
|
||||
name: 'No colonies',
|
||||
roleId: noColonyRoleId,
|
||||
},
|
||||
});
|
||||
noColonyUserId = noColonyUser.id;
|
||||
noColonyToken = (await login(app, noColonyEmail, password)).accessToken;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (fieldApiaryId) {
|
||||
await prisma.task.deleteMany({
|
||||
where: { title: { startsWith: 'field-e2e-' } },
|
||||
});
|
||||
await prisma.colony.deleteMany({ where: { apiaryId: fieldApiaryId } });
|
||||
await prisma.apiary.delete({ where: { id: fieldApiaryId } });
|
||||
}
|
||||
await prisma.refreshToken.deleteMany({ where: { userId: noColonyUserId } });
|
||||
await prisma.user.delete({ where: { id: noColonyUserId } });
|
||||
await prisma.role.delete({ where: { id: noColonyRoleId } });
|
||||
if (apiaryId) {
|
||||
await prisma.apiary.delete({ where: { id: apiaryId } });
|
||||
}
|
||||
@@ -128,6 +170,24 @@ describe('Apiaries (e2e)', () => {
|
||||
.expect(409);
|
||||
});
|
||||
|
||||
it('lets publicLocationLabel be set, changed and cleared with null', async () => {
|
||||
const patch = (body: object) =>
|
||||
request(app.getHttpServer())
|
||||
.patch(`/api/v1/apiaries/${apiaryId}`)
|
||||
.set('Authorization', `Bearer ${beekeeperToken}`)
|
||||
.send(body);
|
||||
|
||||
const set = await patch({ publicLocationLabel: 'Nordhessen' }).expect(200);
|
||||
expect((set.body as ApiaryBody).publicLocationLabel).toBe('Nordhessen');
|
||||
const changed = await patch({ publicLocationLabel: 'Südhessen' }).expect(
|
||||
200,
|
||||
);
|
||||
expect((changed.body as ApiaryBody).publicLocationLabel).toBe('Südhessen');
|
||||
const cleared = await patch({ publicLocationLabel: null }).expect(200);
|
||||
expect((cleared.body as ApiaryBody).publicLocationLabel).toBeNull();
|
||||
await patch({ publicLocationLabel: 'x'.repeat(101) }).expect(400);
|
||||
});
|
||||
|
||||
it('refuses to deactivate an apiary that still holds a weak colony', async () => {
|
||||
const weakColony = await prisma.colony.create({
|
||||
data: {
|
||||
@@ -146,4 +206,120 @@ describe('Apiaries (e2e)', () => {
|
||||
await prisma.colony.delete({ where: { id: weakColony.id } });
|
||||
}
|
||||
});
|
||||
|
||||
describe('apiary field endpoints', () => {
|
||||
interface OverviewBody {
|
||||
apiaryId: string;
|
||||
rows: { code: string }[];
|
||||
}
|
||||
interface PackBody {
|
||||
version: number;
|
||||
apiary: Record<string, unknown>;
|
||||
tasks: { title: string }[] | null;
|
||||
}
|
||||
const codeSuffix = /-\d+$/;
|
||||
|
||||
beforeAll(async () => {
|
||||
const apiary = await prisma.apiary.create({
|
||||
data: {
|
||||
name: 'Feldstand',
|
||||
location: 'Wiese',
|
||||
latitude: 48.1,
|
||||
longitude: 11.5,
|
||||
address: 'Geheimweg 1',
|
||||
notes: 'geheim',
|
||||
},
|
||||
});
|
||||
fieldApiaryId = apiary.id;
|
||||
const suffix = Date.now();
|
||||
const colonies = await Promise.all(
|
||||
(
|
||||
[
|
||||
['V-10', 'ACTIVE'],
|
||||
['V-2', 'WEAK'],
|
||||
['V-3', 'DISSOLVED'],
|
||||
] as const
|
||||
).map(([code, status]) =>
|
||||
prisma.colony.create({
|
||||
data: {
|
||||
code: `${code}-${suffix}`,
|
||||
apiaryId: apiary.id,
|
||||
colonyType: 'PRODUCTION_COLONY',
|
||||
status,
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
await prisma.task.createMany({
|
||||
data: [
|
||||
{
|
||||
title: 'field-e2e-apiary',
|
||||
relatedType: 'APIARY',
|
||||
relatedId: apiary.id,
|
||||
},
|
||||
{
|
||||
title: 'field-e2e-colony',
|
||||
relatedType: 'COLONY',
|
||||
relatedId: colonies[0].id,
|
||||
},
|
||||
{
|
||||
title: 'field-e2e-done',
|
||||
relatedType: 'COLONY',
|
||||
relatedId: colonies[0].id,
|
||||
status: 'DONE',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('colony-overview lists living colonies only, sorted by code', async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.get(`/api/v1/apiaries/${fieldApiaryId}/colony-overview`)
|
||||
.set('Authorization', `Bearer ${beekeeperToken}`)
|
||||
.expect(200);
|
||||
const body = response.body as OverviewBody;
|
||||
expect(body.apiaryId).toBe(fieldApiaryId);
|
||||
expect(body.rows.map((row) => row.code.replace(codeSuffix, ''))).toEqual([
|
||||
'V-2',
|
||||
'V-10',
|
||||
]);
|
||||
});
|
||||
|
||||
it('offline-pack contains the open tasks of the apiary and its colonies', async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.get(`/api/v1/apiaries/${fieldApiaryId}/offline-pack`)
|
||||
.set('Authorization', `Bearer ${beekeeperToken}`)
|
||||
.expect(200);
|
||||
const body = response.body as PackBody;
|
||||
expect(body.version).toBe(1);
|
||||
expect(body.tasks?.map((task) => task.title).sort()).toEqual([
|
||||
'field-e2e-apiary',
|
||||
'field-e2e-colony',
|
||||
]);
|
||||
expect(Object.keys(body.apiary).sort()).toEqual([
|
||||
'id',
|
||||
'location',
|
||||
'name',
|
||||
'status',
|
||||
]);
|
||||
});
|
||||
|
||||
it('rejects both endpoints without colonies:read', async () => {
|
||||
for (const path of ['colony-overview', 'offline-pack']) {
|
||||
await request(app.getHttpServer())
|
||||
.get(`/api/v1/apiaries/${fieldApiaryId}/${path}`)
|
||||
.set('Authorization', `Bearer ${noColonyToken}`)
|
||||
.expect(403);
|
||||
}
|
||||
});
|
||||
|
||||
it('answers 404 for an unknown apiary', async () => {
|
||||
for (const path of ['colony-overview', 'offline-pack']) {
|
||||
await request(app.getHttpServer())
|
||||
.get(`/api/v1/apiaries/does-not-exist/${path}`)
|
||||
.set('Authorization', `Bearer ${beekeeperToken}`)
|
||||
.expect(404);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 { CalendarSyncService } from '../src/calendar-sync/calendar-sync.service';
|
||||
import { CalendarSyncSettingsService } from '../src/calendar-sync/calendar-sync-settings.service';
|
||||
import { GraphClientService } from '../src/calendar-sync/graph-client.service';
|
||||
import { withTimeout } from '../src/common/with-timeout';
|
||||
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';
|
||||
|
||||
/**
|
||||
* The M365 outbox against the real database, with Microsoft Graph replaced
|
||||
* by a recorder: several workers (pods, the cron and "Jetzt
|
||||
* synchronisieren") must never write the same entry twice, and an edit
|
||||
* that arrives while an entry is being synced must not get lost.
|
||||
* by a recorder: several workers (worker pods, each ticking and woken by
|
||||
* "Jetzt synchronisieren") must never write the same entry twice, and an
|
||||
* edit that arrives while an entry is being synced must not get lost. The
|
||||
* API itself only queues a pass; it has no worker loop.
|
||||
*/
|
||||
describe('Calendar sync outbox (e2e)', () => {
|
||||
let app: INestApplication<App>;
|
||||
@@ -134,11 +138,11 @@ describe('Calendar sync outbox (e2e)', () => {
|
||||
tasks.push(await queueTask(title));
|
||||
}
|
||||
|
||||
// Two pods' cron ticks plus a click on "Jetzt synchronisieren".
|
||||
// Three worker pods, ticking or woken by "Jetzt synchronisieren".
|
||||
await Promise.all([
|
||||
calendarSync.processOutbox(),
|
||||
calendarSync.processOutbox(),
|
||||
calendarSync.runNow(),
|
||||
calendarSync.processOutbox(),
|
||||
]);
|
||||
|
||||
for (const title of titles) {
|
||||
@@ -211,7 +215,7 @@ describe('Calendar sync outbox (e2e)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('retries a FAILED entry on "Jetzt synchronisieren", not on the cron tick', async () => {
|
||||
it('retries a FAILED entry after "Jetzt synchronisieren", not on a regular tick', async () => {
|
||||
const task = await queueTask('Failed before');
|
||||
await prisma.calendarSyncOutboxEntry.update({
|
||||
where: { sourceType_sourceId: { sourceType: 'TASK', sourceId: task.id } },
|
||||
@@ -222,7 +226,9 @@ describe('Calendar sync outbox (e2e)', () => {
|
||||
expect(createsFor('Failed before')).toHaveLength(0);
|
||||
expect((await entryFor(task.id)).status).toBe('FAILED');
|
||||
|
||||
await calendarSync.runNow();
|
||||
// The click, then the tick of the worker it woke.
|
||||
await calendarSync.requestRun();
|
||||
await calendarSync.processOutbox();
|
||||
|
||||
expect(createsFor('Failed before')).toHaveLength(1);
|
||||
const entry = await entryFor(task.id);
|
||||
@@ -234,9 +240,51 @@ describe('Calendar sync outbox (e2e)', () => {
|
||||
describe('API', () => {
|
||||
const auth = () => ({ Authorization: `Bearer ${token}` });
|
||||
|
||||
it('answers "Jetzt synchronisieren" with 202 while the pass still runs', async () => {
|
||||
const task = await queueTask('Started via API');
|
||||
const title = `Started via API ${suffix}`;
|
||||
it('answers "Jetzt synchronisieren" with 202, resets FAILED and wakes the worker - no Graph call from the API', async () => {
|
||||
const task = await queueTask('Requested via API');
|
||||
await prisma.calendarSyncOutboxEntry.update({
|
||||
where: {
|
||||
sourceType_sourceId: { sourceType: 'TASK', sourceId: task.id },
|
||||
},
|
||||
data: { status: 'FAILED', attempts: 5, lastError: 'Graph down' },
|
||||
});
|
||||
let wake: () => void = () => undefined;
|
||||
const woken = new Promise<void>((resolve) => {
|
||||
wake = resolve;
|
||||
});
|
||||
const worker = new PgListener({
|
||||
connectionString: process.env.DATABASE_URL ?? '',
|
||||
channels: [WAKE_CHANNELS.calendarSync],
|
||||
onNotify: () => wake(),
|
||||
logger: new Logger('CalendarSyncE2E'),
|
||||
});
|
||||
await worker.start();
|
||||
|
||||
try {
|
||||
const response = await request(app.getHttpServer())
|
||||
.post('/api/v1/calendar/sync/run-now')
|
||||
.set(auth())
|
||||
.expect(202);
|
||||
|
||||
// A pass is due: the client follows it through GET status.
|
||||
expect((response.body as { running: boolean }).running).toBe(true);
|
||||
await withTimeout(woken, 5_000, 'no calendar_sync wake-up');
|
||||
} finally {
|
||||
await worker.stop();
|
||||
}
|
||||
expect(await entryFor(task.id)).toMatchObject({
|
||||
status: 'PENDING',
|
||||
attempts: 0,
|
||||
lastError: null,
|
||||
claimedAt: null,
|
||||
});
|
||||
expect(createdEvents).toEqual([]);
|
||||
expect(updateEvent).not.toHaveBeenCalled();
|
||||
}, 15_000);
|
||||
|
||||
it('reports running in GET status while a worker holds an entry', async () => {
|
||||
const task = await queueTask('Held by a worker');
|
||||
const title = `Held by a worker ${suffix}`;
|
||||
let releaseCreate: () => void = () => undefined;
|
||||
jest.spyOn(graph, 'createEvent').mockImplementation(async (_g, input) => {
|
||||
createdEvents.push(input.title);
|
||||
@@ -248,24 +296,23 @@ describe('Calendar sync outbox (e2e)', () => {
|
||||
eventCounter += 1;
|
||||
return `event-${suffix}-${eventCounter}`;
|
||||
});
|
||||
const status = async () =>
|
||||
(
|
||||
await request(app.getHttpServer())
|
||||
.get('/api/v1/calendar/sync/status')
|
||||
.set(auth())
|
||||
.expect(200)
|
||||
).body as { running: boolean };
|
||||
|
||||
const response = await request(app.getHttpServer())
|
||||
.post('/api/v1/calendar/sync/run-now')
|
||||
.set(auth())
|
||||
.expect(202);
|
||||
|
||||
expect((response.body as { running: boolean }).running).toBe(true);
|
||||
await eventually(() => createsFor('Started via API').length === 1);
|
||||
expect((await entryFor(task.id)).status).toBe('PENDING');
|
||||
const whileRunning = await request(app.getHttpServer())
|
||||
.get('/api/v1/calendar/sync/status')
|
||||
.set(auth())
|
||||
.expect(200);
|
||||
expect((whileRunning.body as { running: boolean }).running).toBe(true);
|
||||
// A worker's tick, held inside the Graph call for this entry.
|
||||
const pass = calendarSync.processOutbox();
|
||||
await eventually(() => createsFor('Held by a worker').length === 1);
|
||||
expect((await status()).running).toBe(true);
|
||||
|
||||
releaseCreate();
|
||||
await eventually(async () => !(await calendarSync.getStatus()).running);
|
||||
await pass;
|
||||
|
||||
expect((await status()).running).toBe(false);
|
||||
expect((await entryFor(task.id)).status).toBe('SYNCED');
|
||||
}, 15_000);
|
||||
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { Client } from 'pg';
|
||||
import { dbSessionConfig } from '../src/prisma/db-session';
|
||||
import { PrismaService } from '../src/prisma/prisma.service';
|
||||
|
||||
/**
|
||||
* Prisma fills @default(now()) on the client as UTC wall clock, SQL now()
|
||||
* stores the session's wall clock - both in `timestamp without time zone`
|
||||
* columns. They only agree if every session runs in UTC, whatever the
|
||||
* server or database default is (the local test DB runs Europe/Berlin).
|
||||
*/
|
||||
describe('Database session time zone (e2e)', () => {
|
||||
let prisma: PrismaService;
|
||||
const aggregateId = `tz-${Date.now()}-${Math.round(Math.random() * 1e6)}`;
|
||||
|
||||
beforeAll(() => {
|
||||
prisma = new PrismaService();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await prisma.domainEvent.deleteMany({ where: { aggregateId } });
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
|
||||
it('runs a fresh Prisma connection in UTC', async () => {
|
||||
const [{ TimeZone }] = await prisma.$queryRaw<
|
||||
{ TimeZone: string }[]
|
||||
>`SHOW TIME ZONE`;
|
||||
|
||||
expect(TimeZone).toBe('UTC');
|
||||
});
|
||||
|
||||
it('writes a Prisma default timestamp and SQL now() on the same clock', async () => {
|
||||
const seconds = await prisma.$transaction(async (tx) => {
|
||||
const event = await tx.domainEvent.create({
|
||||
data: {
|
||||
type: 'TaskDue',
|
||||
aggregateType: 'Task',
|
||||
aggregateId,
|
||||
payload: { dueOn: '2026-10-13' },
|
||||
correlationId: aggregateId,
|
||||
},
|
||||
});
|
||||
const [{ s }] = await tx.$queryRaw<
|
||||
{ s: number }[]
|
||||
>`SELECT EXTRACT(EPOCH FROM now() - "occurredAt")::float AS s FROM "DomainEvent" WHERE "id" = ${event.id}`;
|
||||
return s;
|
||||
});
|
||||
|
||||
expect(Math.abs(seconds)).toBeLessThan(1);
|
||||
});
|
||||
|
||||
it('runs a raw pg connection with the shared session options in UTC (LISTEN client)', async () => {
|
||||
const client = new Client(dbSessionConfig(process.env.DATABASE_URL));
|
||||
await client.connect();
|
||||
try {
|
||||
const { rows } = await client.query<{ TimeZone: string }>(
|
||||
'SHOW TIME ZONE',
|
||||
);
|
||||
expect(rows[0].TimeZone).toBe('UTC');
|
||||
} finally {
|
||||
await client.end();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -189,4 +189,35 @@ describe('Documents (e2e)', () => {
|
||||
.set('Authorization', `Bearer ${beekeeperToken}`)
|
||||
.expect(204);
|
||||
});
|
||||
|
||||
it('replays an upload with the same Idempotency-Key', async () => {
|
||||
const key = `idem-documents-${Date.now()}-upload-0001`;
|
||||
const upload = () =>
|
||||
request(app.getHttpServer())
|
||||
.post('/api/v1/documents')
|
||||
.set('Authorization', `Bearer ${beekeeperToken}`)
|
||||
.set('Idempotency-Key', key)
|
||||
.field('entityType', 'COLONY')
|
||||
.field('entityId', colonyId)
|
||||
.attach('file', PNG_BYTES, {
|
||||
filename: 'idempotent.png',
|
||||
contentType: 'image/png',
|
||||
})
|
||||
.expect(201);
|
||||
const before = await prisma.document.count({
|
||||
where: { entityType: 'COLONY', entityId: colonyId },
|
||||
});
|
||||
|
||||
const first = await upload();
|
||||
const second = await upload();
|
||||
|
||||
expect((second.body as DocumentBody).id).toBe(
|
||||
(first.body as DocumentBody).id,
|
||||
);
|
||||
expect(
|
||||
await prisma.document.count({
|
||||
where: { entityType: 'COLONY', entityId: colonyId },
|
||||
}),
|
||||
).toBe(before + 1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,538 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { Prisma } from '../generated/prisma';
|
||||
import * as outboxClaims from '../src/common/outbox/outbox-claims';
|
||||
import { domainEventContext } from '../src/domain-events/domain-event-context';
|
||||
import {
|
||||
DispatchFilter,
|
||||
DomainEventDispatcher,
|
||||
} from '../src/domain-events/domain-event-dispatcher';
|
||||
import type { DomainEventEnvelope } from '../src/domain-events/domain-event-types';
|
||||
import { DomainEventsModule } from '../src/domain-events/domain-events.module';
|
||||
import { DomainEventsService } from '../src/domain-events/domain-events.service';
|
||||
import { OnDomainEvent } from '../src/domain-events/on-domain-event.decorator';
|
||||
import { PrismaModule } from '../src/prisma/prisma.module';
|
||||
import { PrismaService } from '../src/prisma/prisma.service';
|
||||
import { drainDomainEvents } from './utils/domain-events';
|
||||
|
||||
const ENTITY_TYPE = 'DispatcherE2E';
|
||||
|
||||
/** `entered` resolves when a handler reaches the gate; `open()` lets it on. */
|
||||
class Gate {
|
||||
private signalEntered!: () => void;
|
||||
private release!: () => void;
|
||||
readonly entered = new Promise<void>((resolve) => {
|
||||
this.signalEntered = resolve;
|
||||
});
|
||||
private readonly opened = new Promise<void>((resolve) => {
|
||||
this.release = resolve;
|
||||
});
|
||||
|
||||
pass(): Promise<void> {
|
||||
this.signalEntered();
|
||||
return this.opened;
|
||||
}
|
||||
|
||||
open(): void {
|
||||
this.release();
|
||||
}
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
/** True if `promise` has not settled within `ms`. */
|
||||
function isPending(promise: Promise<unknown>, ms = 300): Promise<boolean> {
|
||||
return Promise.race([promise.then(() => false), sleep(ms).then(() => true)]);
|
||||
}
|
||||
|
||||
/** Waits until a run reaches `gate`; fails if the run ends without it. */
|
||||
async function enteredBeforeDone(
|
||||
gate: Gate,
|
||||
run: Promise<number>,
|
||||
): Promise<void> {
|
||||
await Promise.race([
|
||||
gate.entered,
|
||||
run.then((claimed) => {
|
||||
throw new Error(`Run ended (${claimed} claimed) before the gate`);
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* TaskDue, QueenAged and InspectionOverdue have no real handlers in the
|
||||
* plan, so these test handlers are the only ones that react - and only to
|
||||
* this file's events, because every dispatch below is filtered by
|
||||
* correlation id.
|
||||
*/
|
||||
@Injectable()
|
||||
class DispatcherTestHandlers {
|
||||
readonly seen: { event: DomainEventEnvelope; correlationId?: string }[] = [];
|
||||
|
||||
@OnDomainEvent('TaskDue', 'test.counter')
|
||||
async count(
|
||||
event: DomainEventEnvelope,
|
||||
tx: Prisma.TransactionClient,
|
||||
): Promise<void> {
|
||||
this.seen.push({
|
||||
event,
|
||||
correlationId: domainEventContext.getStore()?.correlationId,
|
||||
});
|
||||
await tx.auditLog.create({
|
||||
data: {
|
||||
entityType: ENTITY_TYPE,
|
||||
entityId: event.aggregateId,
|
||||
action: 'CREATE',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@OnDomainEvent('QueenAged', 'test.failing')
|
||||
fail(): Promise<void> {
|
||||
return Promise.reject(new Error('boom'));
|
||||
}
|
||||
|
||||
/** Aggregates this instance ran 'test.gated' for, in order. */
|
||||
readonly gatedRuns: string[] = [];
|
||||
private readonly gates = new Map<string, Gate>();
|
||||
|
||||
/** Holds 'test.gated' for one aggregate inside its delivery transaction. */
|
||||
hold(aggregateId: string): Gate {
|
||||
const gate = new Gate();
|
||||
this.gates.set(aggregateId, gate);
|
||||
return gate;
|
||||
}
|
||||
|
||||
@OnDomainEvent('InspectionOverdue', 'test.gated')
|
||||
async gated(
|
||||
event: DomainEventEnvelope,
|
||||
tx: Prisma.TransactionClient,
|
||||
): Promise<void> {
|
||||
this.gatedRuns.push(event.aggregateId);
|
||||
await this.gates.get(event.aggregateId)?.pass();
|
||||
await tx.auditLog.create({
|
||||
data: {
|
||||
entityType: ENTITY_TYPE,
|
||||
entityId: event.aggregateId,
|
||||
action: 'CREATE',
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function createDispatcherContext(): Promise<TestingModule> {
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
imports: [PrismaModule, DomainEventsModule],
|
||||
providers: [DispatcherTestHandlers],
|
||||
}).compile();
|
||||
await moduleRef.init();
|
||||
return moduleRef;
|
||||
}
|
||||
|
||||
describe('DomainEventDispatcher (e2e)', () => {
|
||||
const suffix = `dispatch-${Date.now()}-${Math.round(Math.random() * 1e6)}`;
|
||||
let appA: TestingModule;
|
||||
let appB: TestingModule;
|
||||
let prisma: PrismaService;
|
||||
|
||||
/** Records n TaskDue events in one transaction; returns their filter. */
|
||||
async function recordTaskDue(
|
||||
n: number,
|
||||
group: string,
|
||||
): Promise<DispatchFilter> {
|
||||
const events = appA.get(DomainEventsService);
|
||||
await prisma.$transaction((tx) =>
|
||||
events.recordAll(
|
||||
tx,
|
||||
Array.from({ length: n }, (_, i) => ({
|
||||
type: 'TaskDue' as const,
|
||||
aggregateType: 'Task' as const,
|
||||
aggregateId: `${suffix}-${group}-${i}`,
|
||||
payload: { dueOn: '2026-10-13' },
|
||||
})),
|
||||
),
|
||||
);
|
||||
return filterFor(group);
|
||||
}
|
||||
|
||||
async function filterFor(group: string): Promise<DispatchFilter> {
|
||||
const rows = await prisma.domainEvent.findMany({
|
||||
where: { aggregateId: { startsWith: `${suffix}-${group}-` } },
|
||||
select: { correlationId: true },
|
||||
distinct: ['correlationId'],
|
||||
});
|
||||
return { correlationIds: rows.map((r) => r.correlationId) };
|
||||
}
|
||||
|
||||
async function waitForStatus(id: string, status: string): Promise<void> {
|
||||
for (let i = 0; i < 250; i++) {
|
||||
const event = await prisma.domainEvent.findUnique({
|
||||
where: { id },
|
||||
select: { status: true },
|
||||
});
|
||||
if (event?.status === status) {
|
||||
return;
|
||||
}
|
||||
await sleep(20);
|
||||
}
|
||||
throw new Error(`Domain event ${id} never reached ${status}`);
|
||||
}
|
||||
|
||||
function effects(prefix: string): Promise<number> {
|
||||
return prisma.auditLog.count({
|
||||
where: { entityType: ENTITY_TYPE, entityId: { startsWith: prefix } },
|
||||
});
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
[appA, appB] = await Promise.all([
|
||||
createDispatcherContext(),
|
||||
createDispatcherContext(),
|
||||
]);
|
||||
prisma = appA.get(PrismaService);
|
||||
});
|
||||
|
||||
afterEach(() => jest.restoreAllMocks());
|
||||
|
||||
afterAll(async () => {
|
||||
await prisma.auditLog.deleteMany({
|
||||
where: { entityType: ENTITY_TYPE, entityId: { startsWith: suffix } },
|
||||
});
|
||||
await prisma.domainEvent.deleteMany({
|
||||
where: { aggregateId: { startsWith: suffix } },
|
||||
});
|
||||
await Promise.all([appA.close(), appB.close()]);
|
||||
});
|
||||
|
||||
it('two dispatchers in parallel produce exactly one effect per event and handler', async () => {
|
||||
const filter = await recordTaskDue(20, 'parallel');
|
||||
|
||||
const [a, b] = await Promise.all([
|
||||
appA.get(DomainEventDispatcher).drain(filter),
|
||||
appB.get(DomainEventDispatcher).drain(filter),
|
||||
]);
|
||||
|
||||
expect(a + b).toBe(20);
|
||||
expect(await effects(`${suffix}-parallel-`)).toBe(20);
|
||||
const events = await prisma.domainEvent.findMany({
|
||||
where: { aggregateId: { startsWith: `${suffix}-parallel-` } },
|
||||
include: { deliveries: true },
|
||||
});
|
||||
expect(events).toHaveLength(20);
|
||||
for (const event of events) {
|
||||
expect(event).toMatchObject({
|
||||
status: 'DONE',
|
||||
attempts: 0,
|
||||
claimedAt: null,
|
||||
lastError: null,
|
||||
});
|
||||
expect(event.processedAt).toBeInstanceOf(Date);
|
||||
expect(event.deliveries).toEqual([
|
||||
expect.objectContaining({
|
||||
handler: 'test.counter',
|
||||
status: 'DONE',
|
||||
attempts: 1,
|
||||
}),
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
it('hands the handler the stored event and its correlation id', async () => {
|
||||
const filter = await recordTaskDue(1, 'envelope');
|
||||
const handlers = appA.get(DispatcherTestHandlers);
|
||||
|
||||
await appA.get(DomainEventDispatcher).drain(filter);
|
||||
|
||||
const seen = handlers.seen.find(
|
||||
(entry) => entry.event.aggregateId === `${suffix}-envelope-0`,
|
||||
);
|
||||
expect(seen?.correlationId).toBe(filter.correlationIds?.[0]);
|
||||
expect(seen?.event).toMatchObject({
|
||||
type: 'TaskDue',
|
||||
aggregateType: 'Task',
|
||||
payload: { dueOn: '2026-10-13' },
|
||||
actorUserId: null,
|
||||
correlationId: filter.correlationIds?.[0],
|
||||
attempts: 0,
|
||||
});
|
||||
expect(seen?.event.occurredAt).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
it('skips an event another worker holds locked instead of waiting for it', async () => {
|
||||
const filter = await recordTaskDue(2, 'locked');
|
||||
const { id } = await prisma.domainEvent.findFirstOrThrow({
|
||||
where: { aggregateId: `${suffix}-locked-0` },
|
||||
});
|
||||
const lock = new Gate();
|
||||
// Another worker in the middle of its claim statement.
|
||||
const holder = appB.get(PrismaService).$transaction(
|
||||
async (tx) => {
|
||||
await tx.$queryRaw`SELECT "id" FROM "DomainEvent" WHERE "id" = ${id} FOR UPDATE`;
|
||||
await lock.pass();
|
||||
},
|
||||
{ timeout: 15_000 },
|
||||
);
|
||||
await lock.entered;
|
||||
const dispatcher = appA.get(DomainEventDispatcher);
|
||||
|
||||
try {
|
||||
const run = dispatcher.runOnce(filter);
|
||||
// Generous window against slow CI; without SKIP LOCKED the claim
|
||||
// blocks until the lock opens, so it would still be pending here.
|
||||
expect(await isPending(run, 4_000)).toBe(false);
|
||||
await expect(run).resolves.toBe(1);
|
||||
expect(await effects(`${suffix}-locked-1`)).toBe(1);
|
||||
expect(await effects(`${suffix}-locked-0`)).toBe(0);
|
||||
} finally {
|
||||
lock.open();
|
||||
await holder;
|
||||
}
|
||||
await expect(dispatcher.runOnce(filter)).resolves.toBe(1);
|
||||
expect(await effects(`${suffix}-locked-0`)).toBe(1);
|
||||
}, 20_000);
|
||||
|
||||
it('a stale release that ends in FAILED also fails the open deliveries', async () => {
|
||||
const filter = await recordTaskDue(1, 'stale-failed');
|
||||
const { id } = await prisma.domainEvent.findFirstOrThrow({
|
||||
where: { aggregateId: `${suffix}-stale-failed-0` },
|
||||
});
|
||||
// Seven attempts used up, the worker of the eighth died mid-event.
|
||||
await prisma.$executeRaw`UPDATE "DomainEvent" SET "status" = 'PROCESSING', "attempts" = 7, "claimedAt" = now() - interval '6 minutes' WHERE "id" = ${id}`;
|
||||
await prisma.domainEventDelivery.create({
|
||||
data: { eventId: id, handler: 'test.counter', attempts: 7 },
|
||||
});
|
||||
|
||||
await expect(
|
||||
appA.get(DomainEventDispatcher).releaseStaleClaims(),
|
||||
).resolves.toBeGreaterThanOrEqual(1);
|
||||
|
||||
expect(
|
||||
await prisma.domainEvent.findUnique({
|
||||
where: { id },
|
||||
include: { deliveries: true },
|
||||
}),
|
||||
).toMatchObject({
|
||||
status: 'FAILED',
|
||||
attempts: 8,
|
||||
claimedAt: null,
|
||||
lastError: 'claim expired (worker restarted?)',
|
||||
deliveries: [{ handler: 'test.counter', status: 'FAILED', attempts: 7 }],
|
||||
});
|
||||
await expect(appA.get(DomainEventDispatcher).drain(filter)).resolves.toBe(
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
it('drains a backlog of 120 events in batches of at most 50', async () => {
|
||||
const filter = await recordTaskDue(120, 'backlog');
|
||||
const dispatcher = appA.get(DomainEventDispatcher);
|
||||
|
||||
const rounds: number[] = [];
|
||||
for (let i = 0; i < 4; i++) {
|
||||
rounds.push(await dispatcher.runOnce(filter));
|
||||
}
|
||||
|
||||
expect(rounds).toEqual([50, 50, 20, 0]);
|
||||
expect(await effects(`${suffix}-backlog-`)).toBe(120);
|
||||
});
|
||||
|
||||
it('drainDomainEvents processes only the events of the given root aggregates', async () => {
|
||||
await recordTaskDue(2, 'root');
|
||||
await recordTaskDue(1, 'other');
|
||||
|
||||
await drainDomainEvents(appA, [`${suffix}-root-0`]);
|
||||
|
||||
expect(await effects(`${suffix}-root-`)).toBe(2);
|
||||
expect(await effects(`${suffix}-other-`)).toBe(0);
|
||||
await drainDomainEvents(appA, [`${suffix}-other-0`]);
|
||||
expect(await effects(`${suffix}-other-`)).toBe(1);
|
||||
});
|
||||
|
||||
it('a stale-released event does not repeat a handler that already committed', async () => {
|
||||
const filter = await recordTaskDue(1, 'stale');
|
||||
const dispatcher = appA.get(DomainEventDispatcher);
|
||||
// The worker dies after the handler committed, before settling the event.
|
||||
jest
|
||||
.spyOn(outboxClaims, 'settleSuccess')
|
||||
.mockRejectedValueOnce(new Error('worker killed'));
|
||||
const logError = jest
|
||||
.spyOn(Logger.prototype, 'error')
|
||||
.mockImplementation(() => undefined);
|
||||
|
||||
await dispatcher.runOnce(filter);
|
||||
expect(logError).toHaveBeenCalledWith(
|
||||
expect.stringContaining('not settled: worker killed'),
|
||||
);
|
||||
|
||||
const id = (
|
||||
await prisma.domainEvent.findFirstOrThrow({
|
||||
where: { aggregateId: `${suffix}-stale-0` },
|
||||
})
|
||||
).id;
|
||||
expect(
|
||||
await prisma.domainEvent.findUnique({ where: { id } }),
|
||||
).toMatchObject({ status: 'PROCESSING' });
|
||||
expect(await effects(`${suffix}-stale-`)).toBe(1);
|
||||
|
||||
await prisma.$executeRaw`UPDATE "DomainEvent" SET "claimedAt" = now() - interval '6 minutes' WHERE "id" = ${id}`;
|
||||
await expect(
|
||||
dispatcher.releaseStaleClaims(),
|
||||
).resolves.toBeGreaterThanOrEqual(1);
|
||||
expect(
|
||||
await prisma.domainEvent.findUnique({ where: { id } }),
|
||||
).toMatchObject({
|
||||
status: 'PENDING',
|
||||
attempts: 1,
|
||||
claimedAt: null,
|
||||
lastError: 'claim expired (worker restarted?)',
|
||||
});
|
||||
|
||||
await appB.get(DomainEventDispatcher).drain(filter);
|
||||
|
||||
expect(await effects(`${suffix}-stale-`)).toBe(1);
|
||||
expect(
|
||||
await prisma.domainEvent.findUnique({
|
||||
where: { id },
|
||||
include: { deliveries: true },
|
||||
}),
|
||||
).toMatchObject({
|
||||
status: 'DONE',
|
||||
attempts: 1,
|
||||
deliveries: [{ handler: 'test.counter', status: 'DONE', attempts: 1 }],
|
||||
});
|
||||
});
|
||||
|
||||
it('a worker whose claim expired waits for the delivery lock and skips the handler the new owner committed', async () => {
|
||||
const events = appA.get(DomainEventsService);
|
||||
for (const i of [0, 1]) {
|
||||
await prisma.$transaction((tx) =>
|
||||
events.record(tx, {
|
||||
type: 'InspectionOverdue',
|
||||
aggregateType: 'Colony',
|
||||
aggregateId: `${suffix}-gated-${i}`,
|
||||
payload: { dueOn: '2026-10-13', lastInspectedOn: null },
|
||||
}),
|
||||
);
|
||||
}
|
||||
const filter = await filterFor('gated');
|
||||
const [e0, e1] = await Promise.all(
|
||||
[0, 1].map((i) =>
|
||||
prisma.domainEvent.findFirstOrThrow({
|
||||
where: { aggregateId: `${suffix}-gated-${i}` },
|
||||
}),
|
||||
),
|
||||
);
|
||||
// Worker A handles e0 before e1.
|
||||
await prisma.$executeRaw`UPDATE "DomainEvent" SET "occurredAt" = "occurredAt" - interval '1 second' WHERE "id" = ${e0.id}`;
|
||||
// e1's handler failed once before, so its delivery row exists: only
|
||||
// the row lock (SELECT ... FOR UPDATE), not the unique insert, can
|
||||
// make a second worker wait.
|
||||
await prisma.domainEventDelivery.create({
|
||||
data: {
|
||||
eventId: e1.id,
|
||||
handler: 'test.gated',
|
||||
attempts: 1,
|
||||
lastError: 'earlier failure',
|
||||
},
|
||||
});
|
||||
const handlersA = appA.get(DispatcherTestHandlers);
|
||||
const handlersB = appB.get(DispatcherTestHandlers);
|
||||
const gateA = handlersA.hold(e0.aggregateId);
|
||||
const gateB = handlersB.hold(e1.aggregateId);
|
||||
const logWarn = jest
|
||||
.spyOn(Logger.prototype, 'warn')
|
||||
.mockImplementation(() => undefined);
|
||||
|
||||
const runs: Promise<number>[] = [];
|
||||
try {
|
||||
// A claims both events and is held inside the handler for e0 ...
|
||||
const runA = appA.get(DomainEventDispatcher).runOnce(filter);
|
||||
runs.push(runA);
|
||||
await enteredBeforeDone(gateA, runA);
|
||||
// ... while its claim on e1 expires: B claims e1 and is held inside
|
||||
// e1's delivery transaction, the delivery row locked.
|
||||
await prisma.$executeRaw`UPDATE "DomainEvent" SET "claimedAt" = now() - interval '6 minutes' WHERE "id" = ${e1.id}`;
|
||||
await appB.get(DomainEventDispatcher).releaseStaleClaims();
|
||||
const runB = appB.get(DomainEventDispatcher).runOnce(filter);
|
||||
runs.push(runB);
|
||||
await enteredBeforeDone(gateB, runB);
|
||||
|
||||
// A finishes e0, moves on to e1 and has to wait for B's delivery row.
|
||||
gateA.open();
|
||||
await waitForStatus(e0.id, 'DONE');
|
||||
expect(await isPending(runA)).toBe(true);
|
||||
|
||||
// B commits; A then finds e1's delivery DONE and skips the handler.
|
||||
gateB.open();
|
||||
await expect(runB).resolves.toBe(1);
|
||||
await expect(runA).resolves.toBe(2);
|
||||
} finally {
|
||||
gateA.open();
|
||||
gateB.open();
|
||||
await Promise.allSettled(runs);
|
||||
}
|
||||
|
||||
expect(handlersA.gatedRuns).toEqual([e0.aggregateId]);
|
||||
expect(handlersB.gatedRuns).toEqual([e1.aggregateId]);
|
||||
expect(await effects(`${suffix}-gated-`)).toBe(2);
|
||||
const delivered = (attempts: number) => ({
|
||||
deliveries: [{ handler: 'test.gated', status: 'DONE', attempts }],
|
||||
});
|
||||
expect(
|
||||
await prisma.domainEvent.findMany({
|
||||
where: { id: { in: [e0.id, e1.id] } },
|
||||
include: { deliveries: true },
|
||||
orderBy: { aggregateId: 'asc' },
|
||||
}),
|
||||
).toMatchObject([
|
||||
{ id: e0.id, status: 'DONE', attempts: 0, ...delivered(1) },
|
||||
{ id: e1.id, status: 'DONE', attempts: 1, ...delivered(2) },
|
||||
]);
|
||||
// A's claim on e1 was gone, so B - the owner - settled it.
|
||||
expect(logWarn).toHaveBeenCalledWith(
|
||||
expect.stringContaining(`${e1.id} (InspectionOverdue): claim lost`),
|
||||
);
|
||||
});
|
||||
|
||||
it('schedules a failed event about 30 s ahead by the database clock', async () => {
|
||||
const events = appA.get(DomainEventsService);
|
||||
await prisma.$transaction((tx) =>
|
||||
events.record(tx, {
|
||||
type: 'QueenAged',
|
||||
aggregateType: 'Queen',
|
||||
aggregateId: `${suffix}-failing-0`,
|
||||
payload: { hatchYear: 2024, colonyId: null },
|
||||
}),
|
||||
);
|
||||
const filter = await filterFor('failing');
|
||||
|
||||
await expect(appA.get(DomainEventDispatcher).drain(filter)).resolves.toBe(
|
||||
1,
|
||||
);
|
||||
|
||||
const event = await prisma.domainEvent.findFirstOrThrow({
|
||||
where: { aggregateId: `${suffix}-failing-0` },
|
||||
include: { deliveries: true },
|
||||
});
|
||||
expect(event).toMatchObject({
|
||||
status: 'PENDING',
|
||||
attempts: 1,
|
||||
claimedAt: null,
|
||||
lastError: 'test.failing: boom',
|
||||
deliveries: [
|
||||
{
|
||||
handler: 'test.failing',
|
||||
status: 'PENDING',
|
||||
attempts: 1,
|
||||
lastError: 'boom',
|
||||
},
|
||||
],
|
||||
});
|
||||
const [{ s }] = await prisma.$queryRaw<
|
||||
{ s: number }[]
|
||||
>`SELECT EXTRACT(EPOCH FROM "availableAt" - now())::float AS s FROM "DomainEvent" WHERE id = ${event.id}`;
|
||||
expect(s).toBeGreaterThan(25);
|
||||
expect(s).toBeLessThanOrEqual(30.5);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import { INestApplication } from '@nestjs/common';
|
||||
import request from 'supertest';
|
||||
import { App } from 'supertest/types';
|
||||
import { PrismaThrottlerStorage } from '../src/common/throttler/prisma-throttler.storage';
|
||||
import { createTestApp } from './utils/create-test-app';
|
||||
|
||||
describe('Health (e2e)', () => {
|
||||
@@ -27,4 +28,34 @@ describe('Health (e2e)', () => {
|
||||
.expect(200)
|
||||
.expect({ status: 'ok' });
|
||||
});
|
||||
|
||||
describe('when the throttler storage is down', () => {
|
||||
afterEach(() => jest.restoreAllMocks());
|
||||
|
||||
it('GET /health stays 200 (liveness must not depend on the database)', async () => {
|
||||
jest
|
||||
.spyOn(app.get(PrismaThrottlerStorage), 'increment')
|
||||
.mockRejectedValue(new Error('db down'));
|
||||
|
||||
await request(app.getHttpServer()).get('/health').expect(200);
|
||||
});
|
||||
|
||||
it('GET /health/ready stays throttled (fails with the storage)', async () => {
|
||||
jest
|
||||
.spyOn(app.get(PrismaThrottlerStorage), 'increment')
|
||||
.mockRejectedValue(new Error('db down'));
|
||||
|
||||
await request(app.getHttpServer()).get('/health/ready').expect(500);
|
||||
});
|
||||
|
||||
it('other routes fail closed instead of being allowed', async () => {
|
||||
jest
|
||||
.spyOn(app.get(PrismaThrottlerStorage), 'increment')
|
||||
.mockRejectedValue(new Error('db down'));
|
||||
|
||||
await request(app.getHttpServer())
|
||||
.get('/api/v1/public/batches/NONE-x')
|
||||
.expect(500);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -9,6 +9,7 @@ import { login } from './utils/api';
|
||||
interface InspectionBody {
|
||||
id: string;
|
||||
colonyId: string;
|
||||
broodFrames?: number | null;
|
||||
}
|
||||
|
||||
interface TaskBody {
|
||||
@@ -142,4 +143,32 @@ describe('Inspections (e2e)', () => {
|
||||
taskId = task.id;
|
||||
expect(task.sourceInspectionId).toBe(inspectionId);
|
||||
});
|
||||
|
||||
it('stores, clears and validates broodFrames', async () => {
|
||||
const created = await request(app.getHttpServer())
|
||||
.post('/api/v1/inspections')
|
||||
.set('Authorization', `Bearer ${staffToken}`)
|
||||
.send({ colonyId, date: new Date().toISOString(), broodFrames: 4 })
|
||||
.expect(201);
|
||||
const id = (created.body as InspectionBody).id;
|
||||
expect((created.body as InspectionBody).broodFrames).toBe(4);
|
||||
|
||||
const cleared = await request(app.getHttpServer())
|
||||
.patch(`/api/v1/inspections/${id}`)
|
||||
.set('Authorization', `Bearer ${staffToken}`)
|
||||
.send({ broodFrames: null })
|
||||
.expect(200);
|
||||
expect((cleared.body as InspectionBody).broodFrames).toBeNull();
|
||||
|
||||
await request(app.getHttpServer())
|
||||
.patch(`/api/v1/inspections/${id}`)
|
||||
.set('Authorization', `Bearer ${staffToken}`)
|
||||
.send({ broodFrames: -1 })
|
||||
.expect(400);
|
||||
await request(app.getHttpServer())
|
||||
.patch(`/api/v1/inspections/${id}`)
|
||||
.set('Authorization', `Bearer ${staffToken}`)
|
||||
.send({ broodFrames: 'x' })
|
||||
.expect(400);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import { PrismaService } from '../src/prisma/prisma.service';
|
||||
|
||||
/**
|
||||
* Schema-level guarantees of the platform backbone migration that the Prisma
|
||||
* schema alone cannot express (UNLOGGED table, nullable unique dedupe key).
|
||||
*/
|
||||
describe('Platform backbone schema (e2e)', () => {
|
||||
let prisma: PrismaService;
|
||||
const suffix = `${Date.now()}-${Math.round(Math.random() * 1e6)}`;
|
||||
|
||||
beforeAll(() => {
|
||||
prisma = new PrismaService();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await prisma.domainEvent.deleteMany({
|
||||
where: { aggregateId: `t-${suffix}` },
|
||||
});
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
|
||||
it('creates ThrottlerHit as an UNLOGGED table', async () => {
|
||||
const [row] = await prisma.$queryRaw<
|
||||
{ relpersistence: string }[]
|
||||
>`SELECT relpersistence::text AS relpersistence FROM pg_class WHERE relname = 'ThrottlerHit'`;
|
||||
expect(row.relpersistence).toBe('u');
|
||||
});
|
||||
|
||||
it('keeps at most one event per dedupe key', async () => {
|
||||
const data = {
|
||||
type: 'TaskDue',
|
||||
aggregateType: 'Task',
|
||||
aggregateId: `t-${suffix}`,
|
||||
payload: {},
|
||||
correlationId: 'c',
|
||||
dedupeKey: `TaskDue:t-${suffix}:2026-10-13`,
|
||||
};
|
||||
expect(
|
||||
(
|
||||
await prisma.domainEvent.createMany({
|
||||
data: [data],
|
||||
skipDuplicates: true,
|
||||
})
|
||||
).count,
|
||||
).toBe(1);
|
||||
expect(
|
||||
(
|
||||
await prisma.domainEvent.createMany({
|
||||
data: [data],
|
||||
skipDuplicates: true,
|
||||
})
|
||||
).count,
|
||||
).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -11,7 +11,7 @@ interface PublicBatchBody {
|
||||
productName: string | null;
|
||||
honeyType: string | null;
|
||||
waterContent: number | null;
|
||||
apiaries: { name: string; location: string | null }[];
|
||||
origin: { regions: string[]; country: string };
|
||||
harvestDates: string[];
|
||||
}
|
||||
|
||||
@@ -49,7 +49,14 @@ describe('Public batch origin page (e2e)', () => {
|
||||
beekeeperToken = (await login(app, beekeeperEmail, password)).accessToken;
|
||||
|
||||
const apiary = await prisma.apiary.create({
|
||||
data: { name: apiaryName, location: 'Wald' },
|
||||
data: {
|
||||
name: apiaryName,
|
||||
location: 'Waldweg 3',
|
||||
address: 'Waldweg 3, 12345 Ort',
|
||||
latitude: 51.2,
|
||||
longitude: 9.4,
|
||||
publicLocationLabel: 'Nordhessen',
|
||||
},
|
||||
});
|
||||
apiaryId = apiary.id;
|
||||
const colony = await prisma.colony.create({
|
||||
@@ -127,14 +134,32 @@ describe('Public batch origin page (e2e)', () => {
|
||||
expect(body.productName).toBe('Sommerhonig 500g');
|
||||
expect(body.honeyType).toBe('Sommerhonig');
|
||||
expect(body.waterContent).toBe(16.8);
|
||||
expect(body.apiaries).toEqual([{ name: apiaryName, location: 'Wald' }]);
|
||||
expect(body.origin.regions).toEqual(['Nordhessen']);
|
||||
expect(body.origin.country).toEqual(expect.any(String));
|
||||
expect(body.harvestDates).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('returns 404 for an unknown lot', () => {
|
||||
return request(app.getHttpServer())
|
||||
.get('/api/v1/public/batches/does-not-exist')
|
||||
.expect(404);
|
||||
it('never leaks the apiary name, location, address or coordinates', async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.get(`/api/v1/public/batches/${lotNumber}`)
|
||||
.expect(200);
|
||||
const text = JSON.stringify(response.body);
|
||||
for (const secret of [apiaryName, 'Waldweg 3', '51.2', '9.4', 'Ort']) {
|
||||
expect(text).not.toContain(secret);
|
||||
}
|
||||
expect(response.body).not.toHaveProperty('apiaries');
|
||||
});
|
||||
|
||||
it('sends the public cache header on 200 and no-store on 404', async () => {
|
||||
const ok = await request(app.getHttpServer())
|
||||
.get(`/api/v1/public/batches/${lotNumber}`)
|
||||
.expect(200)
|
||||
.expect('Cache-Control', 'public, max-age=300, s-maxage=3600');
|
||||
expect(ok.headers['pragma']).toBeUndefined();
|
||||
await request(app.getHttpServer())
|
||||
.get('/api/v1/public/batches/NONE')
|
||||
.expect(404)
|
||||
.expect('Cache-Control', 'no-store');
|
||||
});
|
||||
|
||||
it('generates a QR code for the bottling batch that deep-links to the public page', async () => {
|
||||
|
||||
@@ -91,7 +91,7 @@ describe('Stock ledger (e2e)', () => {
|
||||
.expect(201);
|
||||
return (response.body as { token: string }).token;
|
||||
};
|
||||
writeKey = await createKey(['shop-sync:read', 'shop-sync:write']);
|
||||
writeKey = await createKey(['shop-sync:write']);
|
||||
readKey = await createKey(['shop-sync:read']);
|
||||
|
||||
// Honey chain: apiary -> harvest -> extraction -> honey batch -> bottling.
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { INestApplication } from '@nestjs/common';
|
||||
import request from 'supertest';
|
||||
import { App } from 'supertest/types';
|
||||
import { PrismaThrottlerStorage } from '../src/common/throttler/prisma-throttler.storage';
|
||||
import { PrismaService } from '../src/prisma/prisma.service';
|
||||
import { createTestApp } from './utils/create-test-app';
|
||||
|
||||
// Public batches allow 30 requests per minute and client network.
|
||||
const PUBLIC_BATCH_LIMIT = 30;
|
||||
const PARALLEL_REQUESTS = 40;
|
||||
|
||||
describe('Database-backed throttler (e2e)', () => {
|
||||
const suffix = randomUUID();
|
||||
const path = `/api/v1/public/batches/NONE-${suffix}`;
|
||||
const namespaces: string[] = [];
|
||||
const apps: INestApplication<App>[] = [];
|
||||
let prisma: PrismaService;
|
||||
|
||||
const newApp = async (
|
||||
throttlerNamespace: string,
|
||||
): Promise<INestApplication<App>> => {
|
||||
namespaces.push(throttlerNamespace);
|
||||
const app = await createTestApp({ throttlerNamespace });
|
||||
apps.push(app);
|
||||
prisma = app.get(PrismaService);
|
||||
return app;
|
||||
};
|
||||
|
||||
const status = async (app: INestApplication<App>): Promise<number> =>
|
||||
(await request(app.getHttpServer()).get(path)).status;
|
||||
|
||||
afterAll(async () => {
|
||||
for (const ns of namespaces) {
|
||||
await prisma.throttlerHit.deleteMany({
|
||||
where: { key: { startsWith: `${ns}:` } },
|
||||
});
|
||||
}
|
||||
for (const app of apps) await app.close();
|
||||
});
|
||||
|
||||
it('counts across two app instances sharing one namespace', async () => {
|
||||
const ns = randomUUID();
|
||||
const [a, b] = [await newApp(ns), await newApp(ns)];
|
||||
|
||||
for (let i = 0; i < PUBLIC_BATCH_LIMIT; i += 1) {
|
||||
await request((i % 2 ? a : b).getHttpServer())
|
||||
.get(path)
|
||||
.expect(404);
|
||||
}
|
||||
|
||||
await request(a.getHttpServer()).get(path).expect(429);
|
||||
await request(b.getHttpServer()).get(path).expect(429);
|
||||
});
|
||||
|
||||
it('40 parallel requests over two instances yield exactly 10 × 429', async () => {
|
||||
const ns = randomUUID();
|
||||
const [a, b] = [await newApp(ns), await newApp(ns)];
|
||||
|
||||
const statuses = await Promise.all(
|
||||
Array.from({ length: PARALLEL_REQUESTS }, (_, i) =>
|
||||
status(i % 2 ? a : b),
|
||||
),
|
||||
);
|
||||
|
||||
expect(statuses.filter((s) => s === 429)).toHaveLength(
|
||||
PARALLEL_REQUESTS - PUBLIC_BATCH_LIMIT,
|
||||
);
|
||||
expect(statuses.filter((s) => s === 404)).toHaveLength(PUBLIC_BATCH_LIMIT);
|
||||
});
|
||||
|
||||
it('separate namespaces never share counters', async () => {
|
||||
const [a, b] = [await newApp(randomUUID()), await newApp(randomUUID())];
|
||||
|
||||
for (let i = 0; i < PUBLIC_BATCH_LIMIT; i += 1) {
|
||||
await request(a.getHttpServer()).get(path).expect(404);
|
||||
}
|
||||
await request(a.getHttpServer()).get(path).expect(429);
|
||||
|
||||
await request(b.getHttpServer()).get(path).expect(404);
|
||||
});
|
||||
|
||||
it('an expired window starts again at 1', async () => {
|
||||
const ns = randomUUID();
|
||||
await newApp(ns);
|
||||
const storage = apps[apps.length - 1].get(PrismaThrottlerStorage);
|
||||
|
||||
for (let i = 0; i < 3; i += 1) {
|
||||
await storage.increment('window', 60000, 10, 60000, 'default');
|
||||
}
|
||||
// The window ends in the past, decided by the database clock.
|
||||
await prisma.$executeRaw`
|
||||
UPDATE "ThrottlerHit"
|
||||
SET "expiresAt" = now() - interval '1 second'
|
||||
WHERE "key" = ${`${ns}:window`}`;
|
||||
|
||||
const record = await storage.increment(
|
||||
'window',
|
||||
60000,
|
||||
10,
|
||||
60000,
|
||||
'default',
|
||||
);
|
||||
|
||||
expect(record.totalHits).toBe(1);
|
||||
expect(record.isBlocked).toBe(false);
|
||||
expect(record.timeToExpire).toBeGreaterThan(0);
|
||||
expect(record.timeToExpire).toBeLessThanOrEqual(60);
|
||||
});
|
||||
|
||||
it('keeps hits while blocked and releases the block after it expired', async () => {
|
||||
const ns = randomUUID();
|
||||
await newApp(ns);
|
||||
const storage = apps[apps.length - 1].get(PrismaThrottlerStorage);
|
||||
|
||||
await storage.increment('block', 60000, 1, 60000, 'default');
|
||||
const blocked = await storage.increment(
|
||||
'block',
|
||||
60000,
|
||||
1,
|
||||
60000,
|
||||
'default',
|
||||
);
|
||||
expect(blocked.isBlocked).toBe(true);
|
||||
expect(blocked.totalHits).toBe(2);
|
||||
expect(blocked.timeToBlockExpire).toBeGreaterThan(0);
|
||||
|
||||
const stillBlocked = await storage.increment(
|
||||
'block',
|
||||
60000,
|
||||
1,
|
||||
60000,
|
||||
'default',
|
||||
);
|
||||
expect(stillBlocked.isBlocked).toBe(true);
|
||||
expect(stillBlocked.totalHits).toBe(2);
|
||||
|
||||
await prisma.$executeRaw`
|
||||
UPDATE "ThrottlerHit"
|
||||
SET "blockedUntil" = now() - interval '1 second'
|
||||
WHERE "key" = ${`${ns}:block`}`;
|
||||
|
||||
const released = await storage.increment(
|
||||
'block',
|
||||
60000,
|
||||
1,
|
||||
60000,
|
||||
'default',
|
||||
);
|
||||
expect(released.isBlocked).toBe(false);
|
||||
expect(released.totalHits).toBe(1);
|
||||
expect(released.timeToBlockExpire).toBe(0);
|
||||
});
|
||||
|
||||
it('blocks the first hit when the limit is 0', async () => {
|
||||
const ns = randomUUID();
|
||||
await newApp(ns);
|
||||
const storage = apps[apps.length - 1].get(PrismaThrottlerStorage);
|
||||
|
||||
const first = await storage.increment('zero', 60000, 0, 60000, 'default');
|
||||
expect(first.isBlocked).toBe(true);
|
||||
expect(first.totalHits).toBe(1);
|
||||
|
||||
// Same after a window or block has expired.
|
||||
await prisma.$executeRaw`
|
||||
UPDATE "ThrottlerHit"
|
||||
SET "expiresAt" = now() - interval '1 second',
|
||||
"blockedUntil" = now() - interval '1 second'
|
||||
WHERE "key" = ${`${ns}:zero`}`;
|
||||
const again = await storage.increment('zero', 60000, 0, 60000, 'default');
|
||||
expect(again.isBlocked).toBe(true);
|
||||
expect(again.totalHits).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -254,4 +254,100 @@ describe('Treatments (e2e)', () => {
|
||||
.expect(400),
|
||||
);
|
||||
});
|
||||
|
||||
describe('GET /treatments/last-by-product', () => {
|
||||
const productName = `Oxuvar-${Date.now()}`;
|
||||
|
||||
const lastByProduct = (token: string, product?: string) =>
|
||||
request(app.getHttpServer())
|
||||
.get('/api/v1/treatments/last-by-product')
|
||||
.query(product === undefined ? {} : { product })
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
|
||||
beforeAll(async () => {
|
||||
await prisma.treatment.createMany({
|
||||
data: [
|
||||
{
|
||||
colonyId,
|
||||
userId: beekeeperId,
|
||||
date: new Date('2026-05-01T00:00:00.000Z'),
|
||||
product: productName,
|
||||
supplier: 'Alt GmbH',
|
||||
},
|
||||
{
|
||||
colonyId,
|
||||
userId: beekeeperId,
|
||||
date: new Date('2026-06-01T00:00:00.000Z'),
|
||||
product: productName,
|
||||
activeIngredient: 'Oxalsäure',
|
||||
withdrawalPeriodDays: 0,
|
||||
supplier: 'Neu GmbH',
|
||||
purchaseProof: 'RE-2026-17',
|
||||
prescribingVet: 'Dr. Imker',
|
||||
},
|
||||
{
|
||||
colonyId,
|
||||
userId: staffId,
|
||||
date: new Date('2026-07-01T00:00:00.000Z'),
|
||||
product: productName,
|
||||
supplier: 'Fremd GmbH',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("returns the caller's newest treatment with that product, case-insensitive", async () => {
|
||||
const response = await lastByProduct(
|
||||
beekeeperToken,
|
||||
` ${productName.toLowerCase()} `,
|
||||
).expect(200);
|
||||
const newest = await prisma.treatment.findFirstOrThrow({
|
||||
where: {
|
||||
userId: beekeeperId,
|
||||
product: productName,
|
||||
supplier: 'Neu GmbH',
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
expect(response.body).toEqual({
|
||||
treatment: {
|
||||
sourceTreatmentId: newest.id,
|
||||
sourceDate: '2026-06-01T00:00:00.000Z',
|
||||
product: productName,
|
||||
activeIngredient: 'Oxalsäure',
|
||||
withdrawalPeriodDays: 0,
|
||||
supplier: 'Neu GmbH',
|
||||
purchaseProof: 'RE-2026-17',
|
||||
prescribingVet: 'Dr. Imker',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("never returns another user's treatment", async () => {
|
||||
const response = await lastByProduct(beekeeperToken, productName).expect(
|
||||
200,
|
||||
);
|
||||
|
||||
expect(JSON.stringify(response.body)).not.toContain('Fremd GmbH');
|
||||
});
|
||||
|
||||
it('returns { treatment: null } without a match', async () => {
|
||||
const response = await lastByProduct(
|
||||
beekeeperToken,
|
||||
`Unbekannt-${Date.now()}`,
|
||||
).expect(200);
|
||||
|
||||
expect(response.body).toEqual({ treatment: null });
|
||||
});
|
||||
|
||||
it('rejects a request without product', () =>
|
||||
lastByProduct(beekeeperToken).expect(400));
|
||||
|
||||
it('rejects an empty product', () =>
|
||||
lastByProduct(beekeeperToken, ' ').expect(400));
|
||||
|
||||
it('rejects a role without treatments:create', () =>
|
||||
lastByProduct(staffToken, productName).expect(403));
|
||||
});
|
||||
});
|
||||
@@ -1,14 +1,30 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { INestApplication } from '@nestjs/common';
|
||||
import { NestExpressApplication } from '@nestjs/platform-express';
|
||||
import { App } from 'supertest/types';
|
||||
import { AppModule } from '../../src/app.module';
|
||||
import { configureApp } from '../../src/app.config';
|
||||
import { THROTTLER_NAMESPACE } from '../../src/common/throttler/prisma-throttler.storage';
|
||||
|
||||
export async function createTestApp(): Promise<INestApplication<App>> {
|
||||
export interface CreateTestAppOptions {
|
||||
/**
|
||||
* Rate-limit counter namespace. Defaults to a fresh random one so parallel
|
||||
* e2e files never throttle each other; pass the same value to two apps to
|
||||
* simulate two pods sharing one counter set.
|
||||
*/
|
||||
throttlerNamespace?: string;
|
||||
}
|
||||
|
||||
export async function createTestApp(
|
||||
options: CreateTestAppOptions = {},
|
||||
): Promise<INestApplication<App>> {
|
||||
const moduleFixture: TestingModule = await Test.createTestingModule({
|
||||
imports: [AppModule],
|
||||
}).compile();
|
||||
})
|
||||
.overrideProvider(THROTTLER_NAMESPACE)
|
||||
.useValue(options.throttlerNamespace ?? randomUUID())
|
||||
.compile();
|
||||
|
||||
const app = moduleFixture.createNestApplication<NestExpressApplication>();
|
||||
configureApp(app);
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { INestApplicationContext } from '@nestjs/common';
|
||||
import { DomainEventDispatcher } from '../../src/domain-events/domain-event-dispatcher';
|
||||
import { PrismaService } from '../../src/prisma/prisma.service';
|
||||
|
||||
/**
|
||||
* Runs the dispatcher for the events of the given root aggregates and every
|
||||
* follow-up event their handlers record (same correlation id) - and for
|
||||
* nothing else, so e2e files running in parallel never process each
|
||||
* other's events. Without a worker, e2e tests call this after the request
|
||||
* whose side effects they want to assert.
|
||||
*/
|
||||
export async function drainDomainEvents(
|
||||
app: INestApplicationContext,
|
||||
rootAggregateIds: readonly string[],
|
||||
): Promise<void> {
|
||||
const roots = await app.get(PrismaService).domainEvent.findMany({
|
||||
where: { aggregateId: { in: [...rootAggregateIds] } },
|
||||
select: { correlationId: true },
|
||||
distinct: ['correlationId'],
|
||||
});
|
||||
if (roots.length === 0) {
|
||||
return;
|
||||
}
|
||||
await app.get(DomainEventDispatcher).drain({
|
||||
correlationIds: roots.map((root) => root.correlationId),
|
||||
});
|
||||
}
|
||||
@@ -25,6 +25,9 @@ describe('Varroa measurements (e2e)', () => {
|
||||
let apiaryId: string;
|
||||
let colonyId: string;
|
||||
let measurementId: string;
|
||||
let noVarroaRoleId: string;
|
||||
let noVarroaUserId: string;
|
||||
let noVarroaToken: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await createTestApp();
|
||||
@@ -64,6 +67,28 @@ describe('Varroa measurements (e2e)', () => {
|
||||
},
|
||||
});
|
||||
colonyId = colony.id;
|
||||
|
||||
const noVarroaRole = await prisma.role.create({
|
||||
data: {
|
||||
key: `NO_VARROA_${Date.now()}`,
|
||||
name: 'Ohne Varroa',
|
||||
permissions: {
|
||||
create: [{ permission: { connect: { key: 'apiaries:read' } } }],
|
||||
},
|
||||
},
|
||||
});
|
||||
noVarroaRoleId = noVarroaRole.id;
|
||||
const noVarroaEmail = `varroa-e2e-novarroa-${Date.now()}@example.com`;
|
||||
const noVarroaUser = await prisma.user.create({
|
||||
data: {
|
||||
email: noVarroaEmail,
|
||||
passwordHash,
|
||||
name: 'Ohne Varroa',
|
||||
role: { connect: { id: noVarroaRoleId } },
|
||||
},
|
||||
});
|
||||
noVarroaUserId = noVarroaUser.id;
|
||||
noVarroaToken = (await login(app, noVarroaEmail, password)).accessToken;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
@@ -71,14 +96,15 @@ describe('Varroa measurements (e2e)', () => {
|
||||
await prisma.colony.deleteMany({ where: { id: colonyId } });
|
||||
await prisma.apiary.deleteMany({ where: { id: apiaryId } });
|
||||
await prisma.refreshToken.deleteMany({
|
||||
where: { userId: { in: [beekeeperId, staffId] } },
|
||||
where: { userId: { in: [beekeeperId, staffId, noVarroaUserId] } },
|
||||
});
|
||||
await prisma.auditLog.deleteMany({
|
||||
where: { userId: { in: [beekeeperId, staffId] } },
|
||||
where: { userId: { in: [beekeeperId, staffId, noVarroaUserId] } },
|
||||
});
|
||||
await prisma.user.deleteMany({
|
||||
where: { id: { in: [beekeeperId, staffId] } },
|
||||
where: { id: { in: [beekeeperId, staffId, noVarroaUserId] } },
|
||||
});
|
||||
await prisma.role.deleteMany({ where: { id: noVarroaRoleId } });
|
||||
await app.close();
|
||||
});
|
||||
|
||||
@@ -137,4 +163,28 @@ describe('Varroa measurements (e2e)', () => {
|
||||
const measurements = response.body as VarroaMeasurementBody[];
|
||||
expect(measurements.some((entry) => entry.id === measurementId)).toBe(true);
|
||||
});
|
||||
|
||||
describe('GET /varroa-measurements/threshold', () => {
|
||||
const thresholdBody = (token: string) =>
|
||||
request(app.getHttpServer())
|
||||
.get('/api/v1/varroa-measurements/threshold')
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
|
||||
it('returns the threshold shape and is not shadowed by :id', async () => {
|
||||
const response = await thresholdBody(staffToken).expect(200);
|
||||
|
||||
const body = response.body as { thresholdMitesPerDay: number | null };
|
||||
expect(Object.keys(body)).toEqual(['thresholdMitesPerDay']);
|
||||
expect(
|
||||
body.thresholdMitesPerDay === null ||
|
||||
typeof body.thresholdMitesPerDay === 'number',
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
// The shared rule is never written here (parallel e2e workers); the
|
||||
// inactive / custom value cases are covered by varroa-threshold.spec.ts.
|
||||
|
||||
it('rejects a role without any varroa permission', () =>
|
||||
thresholdBody(noVarroaToken).expect(403));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,219 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import * as http from 'node:http';
|
||||
import { AddressInfo } from 'node:net';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { Prisma } from '../generated/prisma';
|
||||
import { CalendarSyncService } from '../src/calendar-sync/calendar-sync.service';
|
||||
import { notifyWorker, WAKE_CHANNELS } from '../src/common/worker-wakeup';
|
||||
import { domainEventContext } from '../src/domain-events/domain-event-context';
|
||||
import {
|
||||
DispatchFilter,
|
||||
DomainEventDispatcher,
|
||||
} from '../src/domain-events/domain-event-dispatcher';
|
||||
import type { DomainEventEnvelope } from '../src/domain-events/domain-event-types';
|
||||
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 { WorkerModule } from '../src/worker/worker.module';
|
||||
import { DOMAIN_EVENT_POLL_MS } from '../src/worker/worker-runtime.service';
|
||||
|
||||
const ENTITY_TYPE = 'WorkerRuntimeE2E';
|
||||
/** Every event of this file carries it; the worker below sees no other. */
|
||||
const CORRELATION_ID = randomUUID();
|
||||
/** Well below the 5 s poll: only a NOTIFY wake-up can be this fast. */
|
||||
const WAKE_UP_WITHIN_MS = 1_500;
|
||||
|
||||
@Injectable()
|
||||
class CountingHandler {
|
||||
@OnDomainEvent('TaskDue', 'test.worker-counter')
|
||||
async count(
|
||||
event: DomainEventEnvelope,
|
||||
tx: Prisma.TransactionClient,
|
||||
): Promise<void> {
|
||||
await tx.auditLog.create({
|
||||
data: {
|
||||
entityType: ENTITY_TYPE,
|
||||
entityId: event.aggregateId,
|
||||
action: 'CREATE',
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The real dispatcher, limited to this file's correlation: e2e files run in
|
||||
* parallel against one database, and a worker draining all of it would
|
||||
* take over (and release the stale claims of) their events.
|
||||
*/
|
||||
@Injectable()
|
||||
class IsolatedDispatcher extends DomainEventDispatcher {
|
||||
override drain(
|
||||
_filter?: DispatchFilter,
|
||||
maxRounds?: number,
|
||||
signal?: AbortSignal,
|
||||
): Promise<number> {
|
||||
return super.drain({ correlationIds: [CORRELATION_ID] }, maxRounds, signal);
|
||||
}
|
||||
|
||||
override releaseStaleClaims(): Promise<number> {
|
||||
return Promise.resolve(0);
|
||||
}
|
||||
}
|
||||
|
||||
/** Assigning undefined would store the string "undefined". */
|
||||
function restoreEnv(name: string, value: string | undefined): void {
|
||||
if (value === undefined) {
|
||||
delete process.env[name];
|
||||
} else {
|
||||
process.env[name] = value;
|
||||
}
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function freePort(): Promise<number> {
|
||||
const server = http.createServer();
|
||||
await new Promise<void>((resolve) => server.listen(0, resolve));
|
||||
const { port } = server.address() as AddressInfo;
|
||||
await new Promise((resolve) => server.close(resolve));
|
||||
return port;
|
||||
}
|
||||
|
||||
function getJson(
|
||||
port: number,
|
||||
path: string,
|
||||
): Promise<{ status: number; body: unknown }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
http
|
||||
.get({ port, path }, (res) => {
|
||||
let raw = '';
|
||||
res.on('data', (chunk: Buffer) => (raw += chunk.toString()));
|
||||
res.on('end', () =>
|
||||
resolve({
|
||||
status: res.statusCode ?? 0,
|
||||
body: raw ? (JSON.parse(raw) as unknown) : undefined,
|
||||
}),
|
||||
);
|
||||
})
|
||||
.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
/** Polls `read` until it returns `expected`, for at most `ms`. */
|
||||
async function expectWithin<T>(
|
||||
ms: number,
|
||||
read: () => Promise<T> | T,
|
||||
expected: T,
|
||||
): Promise<void> {
|
||||
const deadline = Date.now() + ms;
|
||||
let actual = await read();
|
||||
while (actual !== expected && Date.now() < deadline) {
|
||||
await sleep(25);
|
||||
actual = await read();
|
||||
}
|
||||
expect(actual).toBe(expected);
|
||||
}
|
||||
|
||||
/**
|
||||
* The worker process as dist/worker.js starts it (WorkerModule, LISTEN,
|
||||
* loops, health endpoint) against the test database.
|
||||
*/
|
||||
describe('Worker runtime (e2e)', () => {
|
||||
const suffix = `worker-${Date.now()}-${Math.round(Math.random() * 1e6)}`;
|
||||
const originalHealthPort = process.env.WORKER_HEALTH_PORT;
|
||||
let worker: TestingModule;
|
||||
let prisma: PrismaService;
|
||||
let processOutbox: jest.SpyInstance;
|
||||
let healthPort: number;
|
||||
|
||||
async function recordTaskDueInTx(): Promise<void> {
|
||||
const events = worker.get(DomainEventsService);
|
||||
await domainEventContext.run({ correlationId: CORRELATION_ID }, () =>
|
||||
prisma.$transaction((tx) =>
|
||||
events.record(tx, {
|
||||
type: 'TaskDue',
|
||||
aggregateType: 'Task',
|
||||
aggregateId: `${suffix}-0`,
|
||||
payload: { dueOn: '2026-10-13' },
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const countAuditRows = () =>
|
||||
prisma.auditLog.count({
|
||||
where: { entityType: ENTITY_TYPE, entityId: { startsWith: suffix } },
|
||||
});
|
||||
|
||||
beforeAll(async () => {
|
||||
healthPort = await freePort();
|
||||
process.env.WORKER_HEALTH_PORT = String(healthPort);
|
||||
worker = await Test.createTestingModule({
|
||||
imports: [WorkerModule],
|
||||
providers: [CountingHandler],
|
||||
})
|
||||
.overrideProvider(DomainEventDispatcher)
|
||||
.useClass(IsolatedDispatcher)
|
||||
.compile();
|
||||
prisma = worker.get(PrismaService);
|
||||
// The outbox itself is covered by calendar-sync.e2e-spec.ts; here only
|
||||
// whether the loop runs and wakes up counts.
|
||||
processOutbox = jest
|
||||
.spyOn(worker.get(CalendarSyncService), 'processOutbox')
|
||||
.mockResolvedValue(undefined);
|
||||
await worker.init();
|
||||
// The first ticks run right after bootstrap; wait for the dispatcher's.
|
||||
const dispatcher = worker.get(DomainEventDispatcher);
|
||||
await expectWithin(5_000, () => dispatcher.lastTickAt !== null, true);
|
||||
}, 15_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await prisma.auditLog.deleteMany({
|
||||
where: { entityType: ENTITY_TYPE, entityId: { startsWith: suffix } },
|
||||
});
|
||||
await prisma.domainEvent.deleteMany({
|
||||
where: { aggregateId: { startsWith: suffix } },
|
||||
});
|
||||
await worker.close();
|
||||
restoreEnv('WORKER_HEALTH_PORT', originalHealthPort);
|
||||
});
|
||||
|
||||
it('wakes up on NOTIFY and processes a new event well before the 5 s poll', async () => {
|
||||
expect(WAKE_UP_WITHIN_MS).toBeLessThan(DOMAIN_EVENT_POLL_MS);
|
||||
|
||||
await recordTaskDueInTx();
|
||||
|
||||
await expectWithin(WAKE_UP_WITHIN_MS, countAuditRows, 1);
|
||||
const event = await prisma.domainEvent.findFirstOrThrow({
|
||||
where: { aggregateId: `${suffix}-0` },
|
||||
include: { deliveries: true },
|
||||
});
|
||||
expect(event).toMatchObject({
|
||||
status: 'DONE',
|
||||
deliveries: [{ handler: 'test.worker-counter', status: 'DONE' }],
|
||||
});
|
||||
});
|
||||
|
||||
it('wakes the calendar outbox loop on a calendar_sync notification', async () => {
|
||||
expect(processOutbox).toHaveBeenCalled();
|
||||
const before = processOutbox.mock.calls.length;
|
||||
|
||||
await notifyWorker(prisma, WAKE_CHANNELS.calendarSync);
|
||||
|
||||
await expectWithin(
|
||||
WAKE_UP_WITHIN_MS,
|
||||
() => processOutbox.mock.calls.length > before,
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('answers GET /health with 200 {"status":"ok"}', async () => {
|
||||
await expect(getJson(healthPort, '/health')).resolves.toEqual({
|
||||
status: 200,
|
||||
body: { status: 'ok' },
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user