import { BadRequestException, NotFoundException } from '@nestjs/common'; import { HoneyBatchesService } from './honey-batches.service'; import { PrismaService } from '../prisma/prisma.service'; import { AuditLogService } from '../audit/audit-log.service'; import { DomainEventsService } from '../domain-events/domain-events.service'; import { AutomationService } from '../automation/automation.service'; describe('HoneyBatchesService', () => { let service: HoneyBatchesService; let prisma: { honeyBatch: { findMany: jest.Mock; findUnique: jest.Mock; findUniqueOrThrow: jest.Mock; create: jest.Mock; update: jest.Mock; }; honeyMeasurement: { create: jest.Mock; findUnique: jest.Mock; update: jest.Mock; }; extraction: { findUnique: jest.Mock }; storageContainer: { findUnique: jest.Mock }; honeyBatchRelocation: { create: jest.Mock; findMany: jest.Mock }; honeyBatchContainerAllocation: { findMany: jest.Mock; findUnique: jest.Mock; upsert: jest.Mock; delete: jest.Mock; }; $transaction: jest.Mock; $queryRaw: jest.Mock; }; let auditLog: { record: jest.Mock }; let events: { record: jest.Mock }; let automation: { generateNumber: jest.Mock }; const extraction = { id: 'extraction-1', date: new Date(), room: 'Raum 1', harvests: [ { harvest: { id: 'harvest-1', date: new Date(), apiaryId: 'apiary-1', colonies: [{ colony: { id: 'colony-1', code: 'V-001' } }], }, }, ], }; const honeyBatch = { id: 'batch-1', batchNumber: 'HC-2026-001', extractionId: 'extraction-1', honeyType: 'Blütenhonig', amount: 20, status: 'RAW', measurements: [], extraction, }; beforeEach(() => { prisma = { honeyBatch: { findMany: jest.fn(), findUnique: jest.fn(), findUniqueOrThrow: jest.fn(), create: jest.fn(), update: jest.fn(), }, honeyMeasurement: { create: jest.fn(), findUnique: jest.fn(), update: jest.fn(), }, extraction: { findUnique: jest.fn() }, storageContainer: { findUnique: jest.fn() }, honeyBatchRelocation: { create: jest.fn(), findMany: jest.fn() }, honeyBatchContainerAllocation: { findMany: jest.fn().mockResolvedValue([]), findUnique: jest.fn(), upsert: jest.fn(), delete: jest.fn(), }, $transaction: jest.fn( async ( callback: (tx: typeof prisma) => Promise, ): Promise => callback(prisma), ), $queryRaw: jest.fn().mockResolvedValue([]), }; auditLog = { record: jest.fn() }; events = { record: jest.fn() }; automation = { generateNumber: jest.fn().mockResolvedValue(null) }; service = new HoneyBatchesService( prisma as unknown as PrismaService, auditLog as unknown as AuditLogService, automation as unknown as AutomationService, events as unknown as DomainEventsService, ); }); it('findById throws NotFoundException when missing', async () => { prisma.honeyBatch.findUnique.mockResolvedValue(null); await expect(service.findById('missing')).rejects.toBeInstanceOf( NotFoundException, ); }); describe('create', () => { const dto = { batchNumber: 'HC-2026-001', extractionId: 'extraction-1' }; beforeEach(() => { prisma.honeyBatch.create.mockResolvedValue(honeyBatch); prisma.honeyBatch.findUniqueOrThrow.mockResolvedValue(honeyBatch); prisma.honeyBatch.findMany.mockResolvedValue([]); prisma.extraction.findUnique.mockResolvedValue({ id: 'extraction-1', amount: null, }); }); it('stores the batch and returns it with relations and warnings', async () => { const result = await service.create(dto, 'user-1'); const [[createArg]] = prisma.honeyBatch.create.mock.calls as [ [{ data: { batchNumber: string; extractionId: string } }], ]; expect(createArg.data.batchNumber).toBe('HC-2026-001'); expect(createArg.data.extractionId).toBe('extraction-1'); expect(result).toEqual({ ...honeyBatch, warnings: [] }); }); it('writes the audit entry and HoneyBatchRecorded in the same transaction', async () => { await service.create(dto, 'user-1'); expect(auditLog.record).toHaveBeenCalledWith( expect.objectContaining({ entityType: 'HoneyBatch', action: 'CREATE', userId: 'user-1', }), prisma, ); expect(events.record).toHaveBeenCalledWith(prisma, { type: 'HoneyBatchRecorded', aggregateType: 'HoneyBatch', aggregateId: 'batch-1', payload: { extractionId: 'extraction-1' }, actorUserId: 'user-1', }); }); it('locks the extraction row before it writes and counts the batches', async () => { await service.create(dto, 'user-1'); expect(prisma.$queryRaw.mock.invocationCallOrder[0]).toBeLessThan( prisma.honeyBatch.create.mock.invocationCallOrder[0], ); expect(prisma.$queryRaw.mock.invocationCallOrder[0]).toBeLessThan( prisma.honeyBatch.findMany.mock.invocationCallOrder[0], ); }); it('warns when the batches exceed the extraction, but still answers', async () => { prisma.extraction.findUnique.mockResolvedValue({ id: 'extraction-1', amount: 10, }); prisma.honeyBatch.findMany.mockResolvedValue([ { id: 'batch-0', initialAmount: 6, amount: 6 }, { id: 'batch-1', initialAmount: null, amount: 8 }, ]); const result = await service.create(dto, 'user-1'); expect(result.warnings.map((warning) => warning.code)).toEqual([ 'BATCHES_EXCEED_EXTRACTION', ]); }); }); it('traceability flattens the extraction, harvest, and colony chain', async () => { prisma.honeyBatch.findUnique.mockResolvedValue(honeyBatch); const result = await service.traceability('batch-1'); expect(result.honeyBatch.batchNumber).toBe('HC-2026-001'); expect(result.extraction.id).toBe('extraction-1'); expect(result.harvests).toEqual([ { id: 'harvest-1', date: extraction.harvests[0].harvest.date, apiaryId: 'apiary-1', colonies: [{ id: 'colony-1', code: 'V-001' }], }, ]); }); describe('relocate', () => { beforeEach(() => { prisma.$transaction.mockImplementation( async ( callback: (tx: typeof prisma) => Promise, ): Promise => callback(prisma), ); }); it('throws NotFoundException when the target container does not exist', async () => { prisma.honeyBatch.findUnique.mockResolvedValue({ ...honeyBatch, storageContainerId: null, }); prisma.storageContainer.findUnique.mockResolvedValue(null); await expect( service.relocate( 'batch-1', { storageContainerId: 'missing' }, 'user-1', ), ).rejects.toBeInstanceOf(NotFoundException); }); it('records the relocation and updates the batch container', async () => { prisma.honeyBatch.findUnique .mockResolvedValueOnce({ ...honeyBatch, storageContainerId: 'container-old', }) .mockResolvedValueOnce({ ...honeyBatch, storageContainerId: 'container-new', }); prisma.storageContainer.findUnique.mockResolvedValue({ id: 'container-new', }); prisma.honeyBatchRelocation.create.mockResolvedValue({ id: 'relocation-1', honeyBatchId: 'batch-1', fromStorageContainerId: 'container-old', toStorageContainerId: 'container-new', }); await service.relocate( 'batch-1', { storageContainerId: 'container-new', notes: 'Umzug ins Lager 2' }, 'user-1', ); const [[createArg]] = prisma.honeyBatchRelocation.create.mock.calls as [ [ { data: { fromStorageContainerId: string | null; toStorageContainerId: string; }; }, ], ]; expect(createArg.data.fromStorageContainerId).toBe('container-old'); expect(createArg.data.toStorageContainerId).toBe('container-new'); const [[updateArg]] = prisma.honeyBatch.update.mock.calls as [ [{ data: { storageContainerId: string } }], ]; expect(updateArg.data.storageContainerId).toBe('container-new'); expect(auditLog.record).toHaveBeenCalledWith( expect.objectContaining({ entityType: 'HoneyBatchRelocation' }), ); }); }); describe('findRelocations', () => { it('returns the relocation history ordered by date', async () => { prisma.honeyBatch.findUnique.mockResolvedValue(honeyBatch); prisma.honeyBatchRelocation.findMany.mockResolvedValue([ { id: 'relocation-1', honeyBatchId: 'batch-1' }, ]); const result = await service.findRelocations('batch-1'); expect(result).toEqual([{ id: 'relocation-1', honeyBatchId: 'batch-1' }]); const [[findArg]] = prisma.honeyBatchRelocation.findMany.mock.calls as [ [{ where: { honeyBatchId: string } }], ]; expect(findArg.where.honeyBatchId).toBe('batch-1'); }); }); describe('setContainerAllocation', () => { it('throws NotFoundException when the container does not exist', async () => { prisma.honeyBatch.findUnique.mockResolvedValue(honeyBatch); prisma.storageContainer.findUnique.mockResolvedValue(null); await expect( service.setContainerAllocation( 'batch-1', 'missing', { amount: 25 }, 'user-1', ), ).rejects.toBeInstanceOf(NotFoundException); }); it('allows splitting the batch amount across multiple containers (125 kg / 5 x 25 kg)', async () => { prisma.honeyBatch.findUnique.mockResolvedValue({ ...honeyBatch, amount: 125, }); prisma.storageContainer.findUnique.mockResolvedValue({ id: 'bucket-5', }); prisma.honeyBatchContainerAllocation.findMany.mockResolvedValue([ { storageContainerId: 'bucket-1', amount: 25 }, { storageContainerId: 'bucket-2', amount: 25 }, { storageContainerId: 'bucket-3', amount: 25 }, { storageContainerId: 'bucket-4', amount: 25 }, ]); prisma.honeyBatchContainerAllocation.upsert.mockResolvedValue({ id: 'allocation-5', honeyBatchId: 'batch-1', storageContainerId: 'bucket-5', amount: 25, }); await service.setContainerAllocation( 'batch-1', 'bucket-5', { amount: 25 }, 'user-1', ); expect(prisma.honeyBatchContainerAllocation.upsert).toHaveBeenCalledWith({ where: { honeyBatchId_storageContainerId: { honeyBatchId: 'batch-1', storageContainerId: 'bucket-5', }, }, create: { honeyBatchId: 'batch-1', storageContainerId: 'bucket-5', amount: 25, }, update: { amount: 25 }, include: { storageContainer: true }, }); }); it('rejects an allocation that would exceed the batch amount', async () => { prisma.honeyBatch.findUnique.mockResolvedValue({ ...honeyBatch, amount: 100, }); prisma.storageContainer.findUnique.mockResolvedValue({ id: 'bucket-1' }); prisma.honeyBatchContainerAllocation.findMany.mockResolvedValue([ { storageContainerId: 'bucket-2', amount: 80 }, ]); await expect( service.setContainerAllocation( 'batch-1', 'bucket-1', { amount: 30 }, 'user-1', ), ).rejects.toBeInstanceOf(BadRequestException); expect( prisma.honeyBatchContainerAllocation.upsert, ).not.toHaveBeenCalled(); }); it('re-assigning the same container overwrites its own amount rather than double-counting it', async () => { prisma.honeyBatch.findUnique.mockResolvedValue({ ...honeyBatch, amount: 100, }); prisma.storageContainer.findUnique.mockResolvedValue({ id: 'bucket-1' }); // findMany excludes the container being set (storageContainerId: { not: ... }), // so its own prior amount never counts against itself here. prisma.honeyBatchContainerAllocation.findMany.mockResolvedValue([]); prisma.honeyBatchContainerAllocation.upsert.mockResolvedValue({ id: 'allocation-1', honeyBatchId: 'batch-1', storageContainerId: 'bucket-1', amount: 90, }); await expect( service.setContainerAllocation( 'batch-1', 'bucket-1', { amount: 90 }, 'user-1', ), ).resolves.toBeDefined(); }); }); describe('removeContainerAllocation', () => { it('throws NotFoundException when no allocation exists for that container', async () => { prisma.honeyBatch.findUnique.mockResolvedValue(honeyBatch); prisma.honeyBatchContainerAllocation.findUnique.mockResolvedValue(null); await expect( service.removeContainerAllocation('batch-1', 'bucket-1', 'user-1'), ).rejects.toBeInstanceOf(NotFoundException); }); it('deletes the allocation and records an audit entry', async () => { prisma.honeyBatch.findUnique.mockResolvedValue(honeyBatch); prisma.honeyBatchContainerAllocation.findUnique.mockResolvedValue({ id: 'allocation-1', honeyBatchId: 'batch-1', storageContainerId: 'bucket-1', amount: 25, }); await service.removeContainerAllocation('batch-1', 'bucket-1', 'user-1'); expect(prisma.honeyBatchContainerAllocation.delete).toHaveBeenCalledWith({ where: { id: 'allocation-1' }, }); expect(auditLog.record).toHaveBeenCalledWith( expect.objectContaining({ entityType: 'HoneyBatchContainerAllocation', action: 'DELETE', }), ); }); }); describe('update', () => { beforeEach(() => { prisma.honeyBatch.findUniqueOrThrow.mockResolvedValue(honeyBatch); prisma.honeyBatch.findMany.mockResolvedValue([]); prisma.extraction.findUnique.mockResolvedValue({ id: 'extraction-1', amount: null, }); }); it('locks the row first, reads "before" from the locked row and records HoneyBatchCorrected', async () => { prisma.honeyBatch.findUnique.mockResolvedValue(honeyBatch); const result = await service.update( 'batch-1', { honeyType: 'Waldhonig' }, 'user-1', ); // "Before" is the read after the last lock. const reads = prisma.honeyBatch.findUnique.mock.invocationCallOrder; expect(prisma.$queryRaw.mock.invocationCallOrder[1]).toBeLessThan( reads[reads.length - 1], ); expect(auditLog.record).toHaveBeenCalledWith( expect.objectContaining({ action: 'UPDATE', beforeData: honeyBatch, }), prisma, ); expect(events.record).toHaveBeenCalledWith(prisma, { type: 'HoneyBatchCorrected', aggregateType: 'HoneyBatch', aggregateId: 'batch-1', payload: { extractionId: 'extraction-1' }, actorUserId: 'user-1', }); expect(result.warnings).toEqual([]); }); it('locks the extraction before the batch and before counting the batches', async () => { prisma.honeyBatch.findUnique.mockResolvedValue(honeyBatch); await service.update('batch-1', { honeyType: 'Waldhonig' }, 'user-1'); // Extraction lock, then batch lock: two statements, both before the // update and the count of the batches. expect(prisma.$queryRaw).toHaveBeenCalledTimes(2); expect(prisma.$queryRaw.mock.invocationCallOrder[1]).toBeLessThan( prisma.honeyBatch.update.mock.invocationCallOrder[0], ); expect(prisma.$queryRaw.mock.invocationCallOrder[1]).toBeLessThan( prisma.honeyBatch.findMany.mock.invocationCallOrder[0], ); }); it('throws NotFoundException for an unknown batch', async () => { prisma.honeyBatch.findUnique.mockResolvedValue(null); await expect( service.update('missing', { honeyType: 'x' }, 'user-1'), ).rejects.toBeInstanceOf(NotFoundException); expect(events.record).not.toHaveBeenCalled(); }); it('records a relocation when an edit corrects the storage container', async () => { prisma.honeyBatch.findUnique.mockResolvedValue({ ...honeyBatch, storageContainerId: 'container-1', }); await service.update( 'batch-1', { storageContainerId: 'container-2' }, 'user-1', ); expect(prisma.honeyBatchRelocation.create).toHaveBeenCalledWith({ data: { honeyBatchId: 'batch-1', fromStorageContainerId: 'container-1', toStorageContainerId: 'container-2', userId: 'user-1', notes: 'Korrektur über Honigcharge bearbeiten', }, }); }); it('removes the container assignment on null without a relocation', async () => { prisma.honeyBatch.findUnique.mockResolvedValue({ ...honeyBatch, storageContainerId: 'container-1', }); await service.update( 'batch-1', { storageContainerId: null as never }, 'user-1', ); const [[updateArg]] = prisma.honeyBatch.update.mock.calls as [ [{ data: { storageContainerId?: string | null } }], ]; expect(updateArg.data.storageContainerId).toBeNull(); expect(prisma.honeyBatchRelocation.create).not.toHaveBeenCalled(); expect(auditLog.record).toHaveBeenCalledWith( expect.objectContaining({ entityType: 'HoneyBatch', entityId: 'batch-1', action: 'UPDATE', }), prisma, ); }); it('records no relocation when an edit leaves the container untouched', async () => { prisma.honeyBatch.findUnique.mockResolvedValue({ ...honeyBatch, storageContainerId: 'container-1', }); await service.update('batch-1', { honeyType: 'Waldhonig' }, 'user-1'); expect(prisma.honeyBatchRelocation.create).not.toHaveBeenCalled(); }); }); describe('prefill', () => { it('proposes the rest of the extraction with its honey type and water content', async () => { prisma.extraction.findUnique.mockResolvedValue({ id: 'extraction-1', amount: 42.5, waterContent: 17.2, harvests: [ { harvest: { honeyType: 'Bl\u00fctenhonig' } }, { harvest: { honeyType: 'Bl\u00fctenhonig' } }, ], }); prisma.honeyBatch.findMany.mockResolvedValue([ { id: 'batch-0', initialAmount: 20, amount: 20 }, { id: 'batch-9', initialAmount: null, amount: null }, ]); const result = await service.prefill('extraction-1'); expect(result).toEqual({ remainingKg: 22.5, honeyType: 'Bl\u00fctenhonig', waterContent: 17.2, warnings: [], }); }); it('proposes no amount when the extraction has none and never a negative one', async () => { prisma.extraction.findUnique.mockResolvedValue({ id: 'extraction-1', amount: null, waterContent: null, harvests: [{ harvest: { honeyType: null } }], }); prisma.honeyBatch.findMany.mockResolvedValue([]); const none = await service.prefill('extraction-1'); expect(none).toMatchObject({ remainingKg: null, honeyType: null, waterContent: null, }); prisma.extraction.findUnique.mockResolvedValue({ id: 'extraction-1', amount: 10, waterContent: null, harvests: [], }); prisma.honeyBatch.findMany.mockResolvedValue([ { id: 'batch-0', initialAmount: 12, amount: 12 }, ]); const over = await service.prefill('extraction-1'); expect(over.remainingKg).toBe(0); expect(over.warnings.map((warning) => warning.code)).toEqual([ 'BATCHES_EXCEED_EXTRACTION', ]); }); it('throws NotFoundException for an unknown extraction', async () => { prisma.extraction.findUnique.mockResolvedValue(null); await expect(service.prefill('missing')).rejects.toBeInstanceOf( NotFoundException, ); }); }); it('updateMeasurement corrects a reading and logs before/after', async () => { const before = { id: 'measurement-1', honeyBatchId: 'batch-1', waterContent: 22, }; prisma.honeyMeasurement.findUnique.mockResolvedValue(before); prisma.honeyMeasurement.update.mockResolvedValue({ ...before, waterContent: 17.5, }); prisma.honeyBatch.findUnique.mockResolvedValue(honeyBatch); await service.updateMeasurement( 'measurement-1', { waterContent: 17.5 }, 'user-1', ); expect(auditLog.record).toHaveBeenCalledWith( expect.objectContaining({ entityType: 'HoneyMeasurement', entityId: 'measurement-1', action: 'UPDATE', beforeData: before, }), ); }); it('updateMeasurement throws NotFoundException for an unknown reading', async () => { prisma.honeyMeasurement.findUnique.mockResolvedValue(null); await expect( service.updateMeasurement('missing', { waterContent: 17.5 }, 'user-1'), ).rejects.toBeInstanceOf(NotFoundException); }); });