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:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-13 17:57:54 +02:00
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'] })
+176
View File
@@ -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);
}
});
});
});
+72 -25
View File
@@ -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);
});
});
+31
View File
@@ -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);
});
});
+32 -7
View File
@@ -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 () => {
+1 -1
View File
@@ -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.
+174
View File
@@ -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));
});
});
+18 -2
View File
@@ -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),
});
}
+53 -3
View File
@@ -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' },
});
});
});