A bottling batch could only be corrected in four of its seven fields after creation: honey batch, jar size and jar count were frozen at creation time, an assigned product could not be cleared again, and there was no way to remove a record entered by mistake at all. A mistyped jar count is a data-entry error, not a fact of production, so it has to stay correctable. Backend: - UpdateBottlingBatchDto now covers every field. `productId: null` explicitly clears the product assignment; omitting it leaves it alone. - BottlingService.update rebalances the affected honey batch amounts: the previously bottled amount is given back before the corrected amount is taken again, so a correction never double-charges the source batch. Moving a batch to a different honey batch credits the old one and debits the new one in the same transaction. - New DELETE /bottling-batches/:id gives the bottled amount back to the honey batch and keeps the full record in the audit log (action DELETE, beforeData). The honey batch status is deliberately left untouched - the status it held before being bottled is not recoverable here. - New permission bottling:delete, checked server-side. The catalog sync grants it to system roles on the next boot. Frontend: - The edit dialog exposes all fields, offers "Kein Produkt" to clear the assignment, keeps a discontinued product selectable so opening the dialog cannot silently drop it, and shows backend errors inline. - The form is mounted only while the dialog is open, so a cancelled edit never leaks its values into the next one. - New delete button behind a confirmation that names the amount credited back and warns that the public origin page for the lot number stops resolving. - Edit and delete are additionally hidden without the matching permission. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
308 lines
9.0 KiB
TypeScript
308 lines
9.0 KiB
TypeScript
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,
|
|
);
|
|
});
|
|
});
|