Merge main checkpoint c01a388 into feat/erp-inventar-export
Brings the ERP inventory export up to date with the platform backbone and field capture work. Conflicts in ARCHITECTURE.md, the API key service and the API key settings tab resolved by keeping both sides: - read and write keys stay separate; erp-inventory:read is read-only and cannot be combined with a write scope - the failed-attempt lockout counts in the shared ThrottlerHit table so it holds across API pods - the UI offers only scopes the user holds and creates one scope per key Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
commit
53fab102f2
347 files changed
+26087
-2141
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,10 +1,11 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { INestApplication } from '@nestjs/common';
|
||||
import * as bcrypt from 'bcryptjs';
|
||||
import request from 'supertest';
|
||||
import { App } from 'supertest/types';
|
||||
import { PrismaService } from '../src/prisma/prisma.service';
|
||||
import { encodeCursor } from '../src/erp-export/erp-cursor';
|
||||
import { API_KEY_FAILURE_LIMIT } from '../src/api-keys/api-keys.service';
|
||||
import { API_KEY_FAILURE_LIMIT } from '../src/api-keys/failed-attempt-limiter';
|
||||
import { createTestApp } from './utils/create-test-app';
|
||||
import { login } from './utils/api';
|
||||
|
||||
@@ -46,6 +47,9 @@ describe('ERP-Inventar-Export (e2e)', () => {
|
||||
let app: INestApplication<App>;
|
||||
let prisma: PrismaService;
|
||||
const suffix = `${Date.now()}-${Math.round(Math.random() * 1e6)}`;
|
||||
// Eigene Zähler-Menge dieses Laufs; eine zweite Instanz mit derselben
|
||||
// Menge spielt einen zweiten api-Pod.
|
||||
const throttlerNamespace = randomUUID();
|
||||
const password = 'SuperSecret123!';
|
||||
let adminToken: string;
|
||||
let readonlyToken: string;
|
||||
@@ -104,7 +108,7 @@ describe('ERP-Inventar-Export (e2e)', () => {
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await createTestApp();
|
||||
app = await createTestApp({ throttlerNamespace });
|
||||
prisma = app.get(PrismaService);
|
||||
const passwordHash = await bcrypt.hash(password, 12);
|
||||
const owner = await prisma.user.create({
|
||||
@@ -128,7 +132,8 @@ describe('ERP-Inventar-Export (e2e)', () => {
|
||||
readonlyToken = (await login(app, `erp-ro-${suffix}@example.com`, password))
|
||||
.accessToken;
|
||||
erpKey = (await createKey(['erp-inventory:read'])).token;
|
||||
shopKey = (await createKey(['shop-sync:read', 'shop-sync:write'])).token;
|
||||
// Lesen und Buchen sind getrennte Schlüssel; zum Gegenprüfen reicht Lesen.
|
||||
shopKey = (await createKey(['shop-sync:read'])).token;
|
||||
|
||||
const apiary = await prisma.apiary.create({
|
||||
data: { name: `Waldrand ${suffix}`, notes: 'Schlüssel unter dem Stein' },
|
||||
@@ -279,6 +284,9 @@ describe('ERP-Inventar-Export (e2e)', () => {
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await prisma.throttlerHit.deleteMany({
|
||||
where: { key: { startsWith: `${throttlerNamespace}:` } },
|
||||
});
|
||||
await app.close();
|
||||
});
|
||||
|
||||
@@ -692,6 +700,34 @@ describe('ERP-Inventar-Export (e2e)', () => {
|
||||
.expect(200);
|
||||
});
|
||||
|
||||
it('zählt Fehlversuche über alle API-Pods gemeinsam', async () => {
|
||||
// Die API läuft mit mehreren Pods: ein Angreifer darf sein Budget
|
||||
// nicht vervielfachen, indem der Load-Balancer ihn verteilt.
|
||||
const secondPod = await createTestApp({ throttlerNamespace });
|
||||
try {
|
||||
const ip = '203.0.113.80';
|
||||
const wrong = `ack_abcd1234_${'V'.repeat(43)}`;
|
||||
const via = (pod: INestApplication<App>) =>
|
||||
request(pod.getHttpServer())
|
||||
.get(`${BASE}/produkte`)
|
||||
.set('Authorization', `Bearer ${wrong}`)
|
||||
.set('X-Forwarded-For', ip);
|
||||
for (let i = 0; i < API_KEY_FAILURE_LIMIT.maxFailures; i++) {
|
||||
await via(i % 2 ? secondPod : app).expect(401);
|
||||
}
|
||||
await via(app).expect(429);
|
||||
await via(secondPod).expect(429);
|
||||
// Ein gültiger Schlüssel kommt auf beiden Pods weiter durch.
|
||||
await request(secondPod.getHttpServer())
|
||||
.get(`${BASE}/produkte`)
|
||||
.set('Authorization', `Bearer ${erpKey}`)
|
||||
.set('X-Forwarded-For', ip)
|
||||
.expect(200);
|
||||
} finally {
|
||||
await secondPod.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('zählt fehlerhaft aufgebaute Schlüssel nicht', async () => {
|
||||
const ip = '203.0.113.70';
|
||||
for (let i = 0; i < API_KEY_FAILURE_LIMIT.maxFailures + 5; i++) {
|
||||
|
||||
@@ -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