import { BadRequestException, NotFoundException } from '@nestjs/common'; import { BottlingService } from './bottling.service'; import { PrismaService } from '../prisma/prisma.service'; import { AuditLogService } from '../audit/audit-log.service'; import { WebhooksService } from '../webhooks/webhooks.service'; import { AutomationService } from '../automation/automation.service'; describe('BottlingService', () => { let service: BottlingService; let prisma: { honeyBatch: { findUnique: jest.Mock; update: jest.Mock }; product: { findUnique: jest.Mock }; bottlingBatch: { findUnique: jest.Mock; findMany: jest.Mock; create: jest.Mock; update: jest.Mock; }; $transaction: jest.Mock; }; let auditLog: { record: jest.Mock }; let tx: { bottlingBatch: { create: jest.Mock; update: jest.Mock; delete: jest.Mock }; honeyBatch: { update: jest.Mock }; }; let webhooks: { dispatch: jest.Mock }; beforeEach(() => { tx = { bottlingBatch: { create: jest.fn(), update: jest.fn(), delete: jest.fn(), }, honeyBatch: { update: jest.fn() }, }; prisma = { honeyBatch: { findUnique: jest.fn(), update: jest.fn() }, product: { findUnique: jest.fn() }, bottlingBatch: { findUnique: jest.fn(), findMany: jest.fn(), create: jest.fn(), update: jest.fn(), }, $transaction: jest.fn((callback: (txArg: typeof tx) => unknown) => callback(tx), ), }; auditLog = { record: jest.fn() }; webhooks = { dispatch: jest.fn().mockResolvedValue(undefined) }; const automation = { generateNumber: jest.fn().mockResolvedValue(null) }; service = new BottlingService( prisma as unknown as PrismaService, auditLog as unknown as AuditLogService, webhooks as unknown as WebhooksService, automation as unknown as AutomationService, ); }); it('throws NotFoundException when the honey batch does not exist', async () => { prisma.honeyBatch.findUnique.mockResolvedValueOnce(null); await expect( service.create( { honeyBatchId: 'missing', date: '2026-01-01', jarSize: 500, jarCount: 10, bestBeforeDate: '2027-01-01', lotNumber: 'L-1', }, 'user-1', ), ).rejects.toBeInstanceOf(NotFoundException); }); it('rejects when the bottled amount exceeds the available honey batch amount', async () => { prisma.honeyBatch.findUnique.mockResolvedValueOnce({ id: 'h1', amount: 5, }); await expect( service.create( { honeyBatchId: 'h1', date: '2026-01-01', jarSize: 500, jarCount: 20, bestBeforeDate: '2027-01-01', lotNumber: 'L-1', }, 'user-1', ), ).rejects.toBeInstanceOf(BadRequestException); }); it('creates a bottling batch and reduces the honey batch amount transactionally', async () => { prisma.honeyBatch.findUnique.mockResolvedValueOnce({ id: 'h1', amount: 25, }); tx.bottlingBatch.create.mockResolvedValueOnce({ id: 'b1', totalAmount: 25, }); prisma.bottlingBatch.findUnique.mockResolvedValueOnce({ id: 'b1', totalAmount: 25, }); const result = await service.create( { honeyBatchId: 'h1', date: '2026-01-01', jarSize: 500, jarCount: 50, bestBeforeDate: '2027-01-01', lotNumber: 'L-1', }, 'user-1', ); expect(result).toEqual({ id: 'b1', totalAmount: 25 }); expect(tx.honeyBatch.update).toHaveBeenCalledWith({ where: { id: 'h1' }, data: { amount: 0, status: 'BOTTLED' }, }); expect(auditLog.record).toHaveBeenCalledWith( expect.objectContaining({ entityType: 'BottlingBatch', action: 'CREATE', }), ); }); it('update writes an audit log entry with before and after data', async () => { const before = { id: 'b1', lotNumber: 'L-1', honeyBatchId: 'h1', honeyBatch: { id: 'h1', amount: 10 }, jarSize: 500, jarCount: 10, totalAmount: 5, }; const updated = { ...before, lotNumber: 'L-2' }; prisma.bottlingBatch.findUnique .mockResolvedValueOnce(before) .mockResolvedValueOnce(updated); const result = await service.update('b1', { lotNumber: 'L-2' }, 'user-1'); expect(result).toEqual(updated); expect(auditLog.record).toHaveBeenCalledWith( expect.objectContaining({ entityType: 'BottlingBatch', entityId: 'b1', action: 'UPDATE', beforeData: before, afterData: updated, }), ); }); it('rejects updating to a product that does not exist', async () => { prisma.bottlingBatch.findUnique.mockResolvedValueOnce({ id: 'b1' }); prisma.product.findUnique.mockResolvedValueOnce(null); await expect( service.update('b1', { productId: 'missing' }, 'user-1'), ).rejects.toBeInstanceOf(NotFoundException); }); it('clears the product assignment when productId is null', async () => { const before = { id: 'b1', honeyBatchId: 'h1', honeyBatch: { id: 'h1', amount: 10 }, jarSize: 500, jarCount: 10, totalAmount: 5, productId: 'p1', }; prisma.bottlingBatch.findUnique .mockResolvedValueOnce(before) .mockResolvedValueOnce({ ...before, productId: null }); await service.update('b1', { productId: null }, 'user-1'); expect(prisma.product.findUnique).not.toHaveBeenCalled(); const [updateArgs] = tx.bottlingBatch.update.mock.calls as [ [{ where: { id: string }; data: { productId: string | null } }], ]; expect(updateArgs[0].where).toEqual({ id: 'b1' }); expect(updateArgs[0].data.productId).toBeNull(); }); it('releases the previous amount before re-reserving a corrected jar count', async () => { const before = { id: 'b1', honeyBatchId: 'h1', honeyBatch: { id: 'h1', amount: 0 }, jarSize: 500, jarCount: 20, totalAmount: 10, }; prisma.bottlingBatch.findUnique .mockResolvedValueOnce(before) .mockResolvedValueOnce({ ...before, jarCount: 10, totalAmount: 5 }); await service.update('b1', { jarCount: 10 }, 'user-1'); const [updateArgs] = tx.bottlingBatch.update.mock.calls as [ [{ data: { jarCount: number; totalAmount: number } }], ]; expect(updateArgs[0].data.jarCount).toBe(10); expect(updateArgs[0].data.totalAmount).toBe(5); expect(tx.honeyBatch.update).toHaveBeenCalledWith({ where: { id: 'h1' }, data: { amount: 5 }, }); }); it('rejects a corrected amount that exceeds the honey batch capacity', async () => { prisma.bottlingBatch.findUnique.mockResolvedValueOnce({ id: 'b1', honeyBatchId: 'h1', honeyBatch: { id: 'h1', amount: 0 }, jarSize: 500, jarCount: 10, totalAmount: 5, }); await expect( service.update('b1', { jarCount: 40 }, 'user-1'), ).rejects.toBeInstanceOf(BadRequestException); }); it('moves the amount between honey batches when the honey batch changes', async () => { const before = { id: 'b1', honeyBatchId: 'h1', honeyBatch: { id: 'h1', amount: 2 }, jarSize: 500, jarCount: 10, totalAmount: 5, }; prisma.bottlingBatch.findUnique .mockResolvedValueOnce(before) .mockResolvedValueOnce({ ...before, honeyBatchId: 'h2' }); prisma.honeyBatch.findUnique.mockResolvedValueOnce({ id: 'h2', amount: 8 }); await service.update('b1', { honeyBatchId: 'h2' }, 'user-1'); expect(tx.honeyBatch.update).toHaveBeenCalledWith({ where: { id: 'h1' }, data: { amount: 7 }, }); expect(tx.honeyBatch.update).toHaveBeenCalledWith({ where: { id: 'h2' }, data: { amount: 3, status: 'BOTTLED' }, }); }); it('remove gives the amount back and records a DELETE audit entry', async () => { const before = { id: 'b1', honeyBatchId: 'h1', honeyBatch: { id: 'h1', amount: 2 }, totalAmount: 5, }; prisma.bottlingBatch.findUnique.mockResolvedValueOnce(before); const result = await service.remove('b1', 'user-1'); expect(result).toEqual({ id: 'b1' }); expect(tx.bottlingBatch.delete).toHaveBeenCalledWith({ where: { id: 'b1' }, }); expect(tx.honeyBatch.update).toHaveBeenCalledWith({ where: { id: 'h1' }, data: { amount: 7 }, }); expect(auditLog.record).toHaveBeenCalledWith( expect.objectContaining({ entityType: 'BottlingBatch', entityId: 'b1', action: 'DELETE', beforeData: before, }), ); expect(webhooks.dispatch).toHaveBeenCalledWith( 'batch.updated', before.honeyBatch, ); }); it('remove throws NotFoundException for an unknown bottling batch', async () => { prisma.bottlingBatch.findUnique.mockResolvedValueOnce(null); await expect(service.remove('missing', 'user-1')).rejects.toBeInstanceOf( NotFoundException, ); }); });