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 "::"', () => { 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 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(); }); });