Files
ApiCore/ApiCore-Backend/src/domain-events/domain-events.service.spec.ts
T
Nick SzaunigandClaude Opus 5.5 1dc39f7658
Backend Image / build-and-push (push) Successful in 6m51s
Frontend Image / build-and-push (push) Successful in 18m9s
feat: Sprachnotizen übernehmen, melden und abschreiben (T7 L2 Tasks 8, 9, 11)
Task 8: POST /inspections nimmt voiceCaptureId; die Notiz wird zuletzt
in derselben Transaktion bestätigt (jeder offene Status, Ruling 1),
Inhalt, Audio und Claim gelöscht; fremde oder geschlossene Notiz 409
VOICE_CAPTURE_GONE, anderes Volk 409 VOICE_CAPTURE_COLONY_MISMATCH;
Wiederholung mit gleichem Schlüssel idempotent.

Task 9: Ereignisse VoiceCaptureReady/VoiceCaptureFailed (nur IDs und
Endcodes), Meldungen „Sprachnotiz für … ist fertig“ bzw. Fehlerhinweis
mit nächstem Schritt, Betreiberhinweis bei abgeschalteter Auswertung;
Handler auch im Worker registriert (sonst gingen die Ereignisse
ungehört durch); keine Weiterleitung an Webhooks.

Task 11: Job voice-transcribe mit Phasen fetch (Audio nur im Speicher
entschlüsselt in ein RAM-Arbeitsverzeichnis 0700/0600) und ingest
(Ergebnis gegen Claim geprüft, Transkript versiegelt, Audio gelöscht,
Auswertung geweckt; Stille/zu lang enden FAILED); Ereignisse genau
einmal in derselben Transaktion. Aufbewahrung: Audio 24 h, Inhalt bis
contentExpiresAt, deaktivierte Besitzer, Zeilen 90 Tage; im täglichen
Cleanup inkl. leerer Job-Läufe (Ruling 10).

Gates: Backend lint, typecheck, 4471 Unit-Tests, build; e2e 103 Suites
(einziger Fehler: Wetter-Test gegen echte API).

Refs #27

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 10:38:14 +02:00

300 lines
9.1 KiB
TypeScript

import { DomainEventsService } from './domain-events.service';
import { domainEventContext } from './domain-event-context';
import {
AnyNewDomainEvent,
DOMAIN_EVENT_TYPES,
dailyDedupeKey,
NewDomainEvent,
utcDay,
} from './domain-event-types';
type CreateManyArg = {
data: {
type: string;
aggregateId: string;
actorUserId: string | null;
correlationId: string;
dedupeKey: string | null;
payload: unknown;
}[];
skipDuplicates: boolean;
};
describe('DomainEventsService', () => {
let service: DomainEventsService;
let tx: {
domainEvent: { createMany: jest.Mock };
$executeRaw: jest.Mock;
};
const event: NewDomainEvent<'InspectionRecorded'> = {
type: 'InspectionRecorded',
aggregateType: 'Inspection',
aggregateId: 'i1',
payload: { colonyId: 'c1' },
};
const daily: NewDomainEvent<'TaskDue'> = {
type: 'TaskDue',
aggregateType: 'Task',
aggregateId: 't1',
payload: { dueOn: '2026-10-13' },
dedupeKey: dailyDedupeKey('TaskDue', 't1', '2026-10-13'),
};
function createManyArg(call = 0): CreateManyArg {
const calls = tx.domainEvent.createMany.mock.calls as [CreateManyArg][];
return calls[call][0];
}
beforeEach(() => {
tx = {
domainEvent: {
createMany: jest
.fn()
.mockImplementation(({ data }: CreateManyArg) =>
Promise.resolve({ count: data.length }),
),
},
$executeRaw: jest.fn().mockResolvedValue(1),
};
service = new DomainEventsService();
});
it('records the event and the wake-up on the same transaction client', async () => {
await service.record(tx as never, {
type: 'InspectionRecorded',
aggregateType: 'Inspection',
aggregateId: 'i1',
payload: { colonyId: 'c1' },
actorUserId: 'u1',
});
expect(tx.domainEvent.createMany).toHaveBeenCalledWith({
data: [
expect.objectContaining({
type: 'InspectionRecorded',
aggregateId: 'i1',
actorUserId: 'u1',
correlationId: expect.any(String) as unknown,
}),
],
skipDuplicates: true,
});
expect(tx.$executeRaw).toHaveBeenCalledTimes(1);
});
it("wakes the dispatcher with pg_notify('domain_event', '') - never data in the channel", async () => {
await service.record(tx as never, event);
const [[strings, ...values]] = tx.$executeRaw.mock.calls as [
[TemplateStringsArray, ...unknown[]],
];
expect(strings.join('$')).toBe("SELECT pg_notify($, '')");
expect(values).toEqual(['domain_event']);
});
it('refuses the root PrismaService (must run inside the business transaction)', async () => {
const prismaServiceMock = {
...tx,
$connect: jest.fn(),
$transaction: jest.fn(),
};
await expect(
service.record(prismaServiceMock as never, event),
).rejects.toThrow(
'DomainEventsService.record needs the transaction client',
);
expect(tx.domainEvent.createMany).not.toHaveBeenCalled();
});
it('names recordAll when recordAll gets the root client', async () => {
const prismaServiceMock = { ...tx, $connect: jest.fn() };
await expect(
service.recordAll(prismaServiceMock as never, [event]),
).rejects.toThrow(
'DomainEventsService.recordAll needs the transaction client',
);
expect(tx.domainEvent.createMany).not.toHaveBeenCalled();
});
it('records through a transaction client that can open nested transactions (Prisma 7)', async () => {
const nestingTx = { ...tx, $transaction: jest.fn() };
await service.record(nestingTx as never, event);
expect(tx.domainEvent.createMany).toHaveBeenCalledTimes(1);
});
it('inherits the correlation id of the event being handled', async () => {
await domainEventContext.run({ correlationId: 'corr-1' }, () =>
service.record(tx as never, event),
);
expect(createManyArg().data[0].correlationId).toBe('corr-1');
});
it('starts a new correlation id per call outside a handler, shared by all events of the call', async () => {
await service.recordAll(tx as never, [event, daily]);
await service.record(tx as never, event);
const [first, second] = createManyArg(0).data;
expect(first.correlationId).toBe(second.correlationId);
expect(createManyArg(1).data[0].correlationId).not.toBe(
first.correlationId,
);
});
it('stores missing actor and dedupe key as null and keeps the payload as given', async () => {
await service.recordAll(tx as never, [event, daily]);
const [plain, withKey] = createManyArg().data;
expect(plain.actorUserId).toBeNull();
expect(plain.dedupeKey).toBeNull();
expect(plain.payload).toEqual({ colonyId: 'c1' });
expect(withKey.dedupeKey).toBe('TaskDue:t1:2026-10-13');
});
it('wakes the worker only when something was inserted', async () => {
tx.domainEvent.createMany.mockResolvedValue({ count: 0 });
await expect(service.recordAll(tx as never, [daily])).resolves.toBe(0);
expect(tx.$executeRaw).not.toHaveBeenCalled();
});
it('returns the number of inserted events', async () => {
tx.domainEvent.createMany.mockResolvedValue({ count: 1 });
await expect(service.recordAll(tx as never, [event, daily])).resolves.toBe(
1,
);
expect(tx.$executeRaw).toHaveBeenCalledTimes(1);
});
it('does nothing for an empty list', async () => {
await expect(service.recordAll(tx as never, [])).resolves.toBe(0);
expect(tx.domainEvent.createMany).not.toHaveBeenCalled();
expect(tx.$executeRaw).not.toHaveBeenCalled();
});
});
describe('domain event catalog', () => {
it('lists every event type exactly once', () => {
expect(DOMAIN_EVENT_TYPES).toHaveLength(41);
expect(new Set(DOMAIN_EVENT_TYPES).size).toBe(DOMAIN_EVENT_TYPES.length);
expect(DOMAIN_EVENT_TYPES).toEqual(
expect.arrayContaining([
'InspectionRecorded',
'StockChanged',
'TaskDue',
'InspectionOverdue',
'ColonySplit',
'QueenAssigned',
'ExtractionRecorded',
'ExtractionCorrected',
'HoneyBatchRecorded',
'HoneyBatchCorrected',
'PhenologyReported',
'PhenologyCorrected',
'PhenologyRecomputeRequested',
'SeasonPhaseChanged',
'WinterCheckRecorded',
'ColonyCreated',
'ColonyMoved',
'ApiaryChanged',
'TskReferenceDayReached',
'HiveChanged',
'VoiceCaptureReady',
'VoiceCaptureFailed',
]),
);
});
it('builds the daily dedupe key as "<type>:<aggregateId>:<YYYY-MM-DD>"', () => {
expect(dailyDedupeKey('QueenAged', 'q1', '2026-10-13')).toBe(
'QueenAged:q1:2026-10-13',
);
});
it.each(['2026-10-13T00:00:00.000Z', '13.10.2026', '2026-1-3', ''])(
'refuses %p as day - a timestamp would make every run a new event',
(day) => {
expect(() => dailyDedupeKey('TaskDue', 't1', day)).toThrow(
'Daily dedupe key needs a YYYY-MM-DD day',
);
},
);
it.each([
['2027-05-01T00:00:00.000Z', '2027-05-01'],
// Late evening in Berlin is still the same UTC day ...
['2026-10-13T23:30:00+02:00', '2026-10-13'],
// ... and shortly after midnight in Berlin the UTC day before.
['2026-10-14T01:30:00+02:00', '2026-10-13'],
])('utcDay(%s) is %s', (instant, day) => {
expect(utcDay(new Date(instant))).toBe(day);
});
it('utcDay yields a day dailyDedupeKey accepts', () => {
const day = utcDay(new Date('2026-10-13T08:15:00.000Z'));
expect(dailyDedupeKey('TaskDue', 't1', day)).toBe('TaskDue:t1:2026-10-13');
});
});
// The @ts-expect-error lines are checked by `npm run typecheck`: if the
// type stopped refusing the mismatch, the unused directive fails it.
describe('domain event typing', () => {
const tx = {
domainEvent: { createMany: jest.fn().mockResolvedValue({ count: 0 }) },
};
const service = new DomainEventsService();
it('ties every event type to its payload in recordAll', async () => {
const recorded = service.recordAll(tx as never, [
{
type: 'TaskDue',
aggregateType: 'Task',
aggregateId: 't1',
payload: { dueOn: '2026-10-13' },
},
{
type: 'TaskDue',
aggregateType: 'Task',
aggregateId: 't1',
// @ts-expect-error - TaskDue carries { dueOn }, not { colonyId }
payload: { colonyId: 'c1' },
},
]);
await expect(recorded).resolves.toBe(0);
});
it('refuses a stored event value whose payload belongs to another type', () => {
const misfit = {
type: 'TaskDue',
aggregateType: 'Task',
aggregateId: 't1',
payload: { colonyId: 'c1' },
} as const;
// @ts-expect-error - TaskDue carries { dueOn }, not { colonyId }
const wrong: AnyNewDomainEvent = misfit;
expect(wrong.type).toBe('TaskDue');
});
it('keeps record<T> checking the payload against the literal type', async () => {
await service.record(tx as never, {
type: 'TaskDue',
aggregateType: 'Task',
aggregateId: 't1',
// @ts-expect-error - TaskDue carries { dueOn }, not { colonyId }
payload: { colonyId: 'c1' },
});
expect(tx.domainEvent.createMany).toHaveBeenCalled();
});
});