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]>
300 lines
9.1 KiB
TypeScript
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();
|
|
});
|
|
});
|