Files
ApiCore/ApiCore-Backend/src/domain-events/handler-contract.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

210 lines
6.7 KiB
TypeScript

import { existsSync, readdirSync, readFileSync } from 'node:fs';
import { dirname, join, resolve, sep } from 'node:path';
/**
* Guard for the handler contract (Review Focus 2): a domain event handler
* runs inside its delivery transaction and may be repeated after a crash,
* so it only writes to the database. Network work belongs to an outbox of
* its own (calendar sync, WebhooksService.enqueueDeliveries) that a worker
* loop drains. A static source scan:
* - every @OnDomainEvent handler lives in a `*.handlers.ts` file, and
* - a handler file imports no network client: no http/https/fetch library,
* no `fetch(` call, nothing from graph-client or webhook-delivery, and no
* src module that itself makes network calls (a fetch wrapper).
*/
const SRC = join(__dirname, '..');
const DECORATOR_FILE = join(
SRC,
'domain-events',
'on-domain-event.decorator.ts',
);
interface SourceFile {
path: string;
source: string;
}
const NETWORK_PACKAGES = new Set([
'http',
'https',
'http2',
'net',
'tls',
'dgram',
'undici',
'axios',
'node-fetch',
'cross-fetch',
'got',
'ky',
'superagent',
]);
const NETWORK_MODULE_NAME = /graph-client|webhook-delivery/;
const FETCH_CALL = /\bfetch\s*\(/;
const IMPORT_SPECIFIER =
/(?:\bfrom\s*|\bimport\s*\(\s*|\bimport\s+|\brequire\s*\(\s*)['"]([^'"]+)['"]/g;
function importsOf(source: string): string[] {
return [...source.matchAll(IMPORT_SPECIFIER)].map((match) => match[1]);
}
function isNetworkPackage(specifier: string): boolean {
return NETWORK_PACKAGES.has(specifier.replace(/^node:/, ''));
}
/** A module that talks to the network itself. */
function makesNetworkCalls(file: SourceFile): boolean {
return (
FETCH_CALL.test(file.source) ||
importsOf(file.source).some(isNetworkPackage)
);
}
function resolveImport(from: string, specifier: string): string | undefined {
if (!specifier.startsWith('.')) {
return undefined;
}
const base = resolve(dirname(from), specifier);
return [`${base}.ts`, join(base, 'index.ts')].find(existsSync) ?? base;
}
/** Files declaring @OnDomainEvent handlers outside a `*.handlers.ts` file. */
function misplacedHandlerFiles(files: readonly SourceFile[]): string[] {
return files
.filter(
(file) =>
file.path !== DECORATOR_FILE &&
!file.path.endsWith('.spec.ts') &&
file.source.includes('@OnDomainEvent(') &&
!file.path.endsWith('.handlers.ts'),
)
.map((file) => file.path);
}
/** What a handler file must not use; empty when it keeps the contract. */
function networkViolations(
handlerFile: SourceFile,
networkModules: ReadonlySet<string>,
): string[] {
const violations = importsOf(handlerFile.source).filter((specifier) => {
const resolved = resolveImport(handlerFile.path, specifier);
return (
isNetworkPackage(specifier) ||
NETWORK_MODULE_NAME.test(specifier) ||
(resolved !== undefined && networkModules.has(resolved))
);
});
return FETCH_CALL.test(handlerFile.source)
? [...violations, 'fetch(']
: violations;
}
function sourceFiles(): SourceFile[] {
return readdirSync(SRC, { recursive: true, encoding: 'utf8' })
.filter((file) => file.endsWith('.ts'))
.map((file) => join(SRC, file))
.map((path) => ({ path, source: readFileSync(path, 'utf8') }));
}
function networkModulesOf(files: readonly SourceFile[]): Set<string> {
return new Set(
files
.filter((file) => !file.path.endsWith('.spec.ts'))
.filter(makesNetworkCalls)
.map((file) => file.path),
);
}
describe('domain event handler contract', () => {
const files = sourceFiles();
const networkModules = networkModulesOf(files);
it('every @OnDomainEvent handler lives in a *.handlers.ts file', () => {
expect(misplacedHandlerFiles(files)).toEqual([]);
});
it('handler files never import network clients', () => {
for (const file of files.filter((f) => f.path.endsWith('.handlers.ts'))) {
expect({
file: file.path,
violations: networkViolations(file, networkModules),
}).toEqual({
file: file.path,
violations: [],
});
}
});
it('knows the network modules of the code base (fetch wrappers included)', () => {
const relative = [...networkModules].map((path) =>
path
.slice(SRC.length + 1)
.split(sep)
.join('/'),
);
expect(relative).toEqual(
expect.arrayContaining([
'calendar-sync/graph-client.service.ts',
'webhooks/webhook-delivery.ts',
'weather/weather.service.ts',
'common/password/password-policy.ts',
]),
);
expect(relative).not.toContain('webhooks/webhooks.service.ts');
});
describe('with planted violations', () => {
const handlerPath = join(SRC, 'colonies', 'colonies.handlers.ts');
it('flags a handler declared outside a *.handlers.ts file', () => {
const planted = {
path: join(SRC, 'colonies', 'colonies.service.ts'),
source: "@OnDomainEvent('TaskDue', 'colonies.due')\n",
};
expect(misplacedHandlerFiles([planted])).toEqual([planted.path]);
expect(
misplacedHandlerFiles([
{ path: handlerPath, source: planted.source },
{
path: `${planted.path.slice(0, -3)}.spec.ts`,
source: planted.source,
},
{ path: DECORATOR_FILE, source: planted.source },
]),
).toEqual([]);
});
it.each([
"import https from 'https';",
"import { request } from 'http';",
"import { get } from 'node:https';",
"const http = require('node:http');",
"import { fetch as undiciFetch } from 'undici';",
"import axios from 'axios';",
'const response = await fetch(url);',
"import { GraphClientService } from '../calendar-sync/graph-client.service';",
"import { postWebhook } from '../webhooks/webhook-delivery';",
"import { WebhookDeliveryWorker } from '../webhooks/webhook-delivery.worker';",
"import { WeatherService } from '../weather/weather.service';",
"const { checkPassword } = await import('../common/password/password-policy');",
])('flags %s', (line) => {
expect(
networkViolations({ path: handlerPath, source: line }, networkModules),
).not.toEqual([]);
});
it.each([
"import { WebhooksService } from '../webhooks/webhooks.service';",
"import { Prisma } from '../../generated/prisma';",
"import type { DomainEventEnvelope } from '../domain-events/domain-event-types';",
'const prefetched = await tx.task.findMany();',
"const scheme = 'https';",
])('lets a database-only line pass: %s', (line) => {
expect(
networkViolations({ path: handlerPath, source: line }, networkModules),
).toEqual([]);
});
});
});