Files
ApiCore/ApiCore-Backend/test/worker-runtime.e2e-spec.ts
T
Nick SzaunigandClaude Opus 5.5 c01a38835a 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]>
2026-10-13 17:57:54 +02:00

220 lines
6.9 KiB
TypeScript

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' },
});
});
});