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]>
220 lines
6.9 KiB
TypeScript
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' },
|
|
});
|
|
});
|
|
});
|