import { ConflictException, NotFoundException } from '@nestjs/common'; import { ApiariesService } from './apiaries.service'; import { PrismaService } from '../prisma/prisma.service'; import { AuditLogService } from '../audit/audit-log.service'; import { DomainEventsService } from '../domain-events/domain-events.service'; import { expectOnlyIdPayloads } from '../domain-events/herd-payload.spec-helper'; describe('ApiariesService', () => { let service: ApiariesService; let order: string[]; let tx: { $queryRaw: jest.Mock; apiary: { findUnique: jest.Mock; create: jest.Mock; update: jest.Mock }; colony: { count: jest.Mock }; }; let prisma: { apiary: { findMany: jest.Mock; findUnique: jest.Mock }; $transaction: jest.Mock; }; let auditLog: { record: jest.Mock }; let events: { record: jest.Mock }; const existingApiary = { id: 'apiary-1', name: 'Heimstand', status: 'ACTIVE', deactivatedAt: null, address: 'Weg 1', location: 'Garten', latitude: 48.1, longitude: 11.5, notes: null, }; const changes = (): string[] => ( events.record.mock.calls as [unknown, { payload: { change: string } }][] ).map(([, event]) => event.payload.change); beforeEach(() => { order = []; tx = { $queryRaw: jest.fn().mockImplementation(() => { order.push('lock'); return Promise.resolve([{ id: 'apiary-1' }]); }), apiary: { findUnique: jest.fn().mockImplementation(() => { order.push('read'); return Promise.resolve(existingApiary); }), create: jest.fn(), update: jest.fn(), }, colony: { count: jest.fn().mockResolvedValue(0) }, }; prisma = { apiary: { findMany: jest.fn(), findUnique: jest.fn() }, $transaction: jest.fn((callback: (txArg: typeof tx) => unknown) => callback(tx), ), }; auditLog = { record: jest.fn().mockResolvedValue(undefined) }; events = { record: jest.fn().mockResolvedValue(undefined) }; service = new ApiariesService( prisma as unknown as PrismaService, auditLog as unknown as AuditLogService, events as unknown as DomainEventsService, ); }); // Whatever a test recorded, the herd payloads carry only ids. afterEach(() => { expectOnlyIdPayloads(events.record.mock.calls); }); it('findById throws NotFoundException when missing', async () => { prisma.apiary.findUnique.mockResolvedValue(null); await expect(service.findById('missing')).rejects.toBeInstanceOf( NotFoundException, ); }); it('create writes record, audit and ApiaryChanged CREATED in one transaction', async () => { tx.apiary.create.mockResolvedValue(existingApiary); const result = await service.create({ name: 'Heimstand' }, 'user-1'); expect(result).toEqual(existingApiary); expect(prisma.$transaction).toHaveBeenCalledTimes(1); expect(auditLog.record).toHaveBeenCalledWith( expect.objectContaining({ entityType: 'Apiary', action: 'CREATE', userId: 'user-1', }), tx, ); expect(events.record).toHaveBeenCalledWith(tx, { type: 'ApiaryChanged', aggregateType: 'Apiary', aggregateId: 'apiary-1', actorUserId: 'user-1', payload: { change: 'CREATED' }, }); }); it('a failing audit write rolls back the apiary change', async () => { tx.apiary.create.mockResolvedValue(existingApiary); auditLog.record.mockRejectedValue(new Error('audit down')); await expect( service.create({ name: 'Heimstand' }, 'user-1'), ).rejects.toThrow('audit down'); // Record, audit and event share one transaction callback: its rejection // is what rolls the record back. expect(events.record).not.toHaveBeenCalled(); expect(prisma.$transaction).toHaveBeenCalledTimes(1); }); it('update reads before after locking the Apiary row', async () => { tx.apiary.update.mockResolvedValue(existingApiary); await service.update('apiary-1', { notes: 'neu' }, 'user-1'); expect(order).toEqual(['lock', 'read']); expect(auditLog.record).toHaveBeenCalledWith( expect.objectContaining({ action: 'UPDATE', beforeData: existingApiary, }), tx, ); }); it('update of a missing apiary answers 404', async () => { tx.apiary.findUnique.mockResolvedValue(null); await expect( service.update('missing', { notes: 'x' }, 'user-1'), ).rejects.toBeInstanceOf(NotFoundException); expect(tx.apiary.update).not.toHaveBeenCalled(); }); it.each([ [{ address: 'Neuer Weg 2' }], [{ location: 'Wiese' }], [{ latitude: 50 }], [{ longitude: 12 }], ])('update records LOCATION_CHANGED for %j', async (dto) => { tx.apiary.update.mockResolvedValue({ ...existingApiary, ...dto }); await service.update('apiary-1', dto, 'user-1'); expect(changes()).toEqual(['LOCATION_CHANGED']); }); it('update records LOCATION_CHANGED only when address, location, latitude or longitude changed', async () => { tx.apiary.update.mockResolvedValue({ ...existingApiary, notes: 'neu' }); await service.update( 'apiary-1', { name: 'Anders', notes: 'neu' }, 'user-1', ); // The same value sent again is no change either. tx.apiary.update.mockResolvedValue(existingApiary); await service.update('apiary-1', { address: 'Weg 1' }, 'user-1'); expect(events.record).not.toHaveBeenCalled(); }); it('deactivate counts living colonies inside the transaction and records DEACTIVATED', async () => { tx.apiary.update.mockResolvedValue({ ...existingApiary, status: 'INACTIVE', }); const result = await service.deactivate('apiary-1', 'user-1'); expect(result.status).toBe('INACTIVE'); expect(order).toEqual(['lock', 'read']); expect(tx.colony.count).toHaveBeenCalledWith({ where: { apiaryId: 'apiary-1', status: { notIn: ['DISSOLVED', 'SOLD', 'DECEASED', 'MERGED'] }, }, }); expect(auditLog.record).toHaveBeenCalledWith( expect.objectContaining({ entityType: 'Apiary', action: 'DEACTIVATE' }), tx, ); expect(changes()).toEqual(['DEACTIVATED']); }); it('deactivate throws ConflictException when active colonies remain', async () => { tx.colony.count.mockResolvedValue(2); await expect( service.deactivate('apiary-1', 'user-1'), ).rejects.toBeInstanceOf(ConflictException); expect(tx.apiary.update).not.toHaveBeenCalled(); expect(events.record).not.toHaveBeenCalled(); }); it('reactivate throws ConflictException when already active', async () => { await expect( service.reactivate('apiary-1', 'user-1'), ).rejects.toBeInstanceOf(ConflictException); expect(events.record).not.toHaveBeenCalled(); }); it('reactivate records REACTIVATED and clears deactivatedAt', async () => { tx.apiary.findUnique.mockResolvedValue({ ...existingApiary, status: 'INACTIVE', deactivatedAt: new Date(), }); tx.apiary.update.mockResolvedValue(existingApiary); const result = await service.reactivate('apiary-1', 'user-1'); expect(result.status).toBe('ACTIVE'); expect(tx.apiary.update).toHaveBeenCalledWith({ where: { id: 'apiary-1' }, data: { status: 'ACTIVE', deactivatedAt: null }, }); expect(auditLog.record).toHaveBeenCalledWith( expect.objectContaining({ entityType: 'Apiary', action: 'UPDATE' }), tx, ); expect(changes()).toEqual(['REACTIVATED']); }); });