Files
ApiCore/ApiCore-Backend/src/honey-batches/honey-batches.service.spec.ts
T
Nick SzaunigandClaude Opus 5.5 fab7f7e12f feat: T4 wave 10 – bottling quality, phenology and honey chain UI, Telegram link/receive and ops
- Bottling quality: water content above the legal limit (strict >) answers
  409 CONFIRMATION_REQUIRED bound to the measured value; prefill for jar
  size, best-before (shelfLifeMonths, month-end clamping) and water content;
  impossible dates give 400; water-content checks in src/common/quality.
- Frontend phenology: "Entwicklung melden" sheet, IndexedDB v4 queue,
  season header on the apiary page, confirmed revoke for all apiaries.
- Frontend honey chain: "Vorschlag aus …" hints with explicit take-over,
  prefill status note, movement ledger with withdraw/correct dialogs.
- Telegram: link tokens (hashed, single use), private-chat receiver with
  lease and offset CAS, bounded poison-update skip, Retry-After, pending
  deliveries skipped on disconnect; system API for deliveries and retry;
  worker-only secret wiring and manifest policy tests.
- ARCHITECTURE.md §7, §9, §10, §11.11–11.13, Annahmen 51–55.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-14 22:47:17 +02:00

999 lines
32 KiB
TypeScript

import {
BadRequestException,
ConflictException,
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';
import { HoneyBatchLedgerService } from './honey-batch-ledger.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;
findMany: jest.Mock;
findUnique: jest.Mock;
update: jest.Mock;
};
automationRule: { findUnique: 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 };
let ledger: {
bookIn: jest.Mock;
setInitialIn: jest.Mock;
clearIn: jest.Mock;
movements: jest.Mock;
movementView: 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(),
findMany: jest.fn().mockResolvedValue([]),
findUnique: jest.fn(),
update: jest.fn(),
},
// No rule row: the built-in water content limits apply.
automationRule: { findUnique: jest.fn().mockResolvedValue(null) },
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<unknown>,
): Promise<unknown> => callback(prisma),
),
$queryRaw: jest.fn().mockResolvedValue([]),
};
auditLog = { record: jest.fn() };
events = { record: jest.fn() };
automation = { generateNumber: jest.fn().mockResolvedValue(null) };
ledger = {
bookIn: jest.fn().mockResolvedValue({ amountKg: 20, movementId: 'm1' }),
setInitialIn: jest.fn().mockResolvedValue(undefined),
clearIn: jest.fn().mockResolvedValue(undefined),
movements: jest.fn().mockResolvedValue([]),
movementView: jest.fn(),
};
service = new HoneyBatchesService(
prisma as unknown as PrismaService,
auditLog as unknown as AuditLogService,
automation as unknown as AutomationService,
events as unknown as DomainEventsService,
ledger as unknown as HoneyBatchLedgerService,
);
});
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: 8, amount: 8 },
]);
const result = await service.create(dto, 'user-1');
expect(result.warnings.map((warning) => warning.code)).toEqual([
'BATCHES_EXCEED_EXTRACTION',
]);
});
it('create writes amount and initialAmount together, through the ledger', async () => {
await service.create({ ...dto, amount: 20 }, 'user-1');
const [[createArg]] = prisma.honeyBatch.create.mock.calls as [
[{ data: Record<string, unknown> }],
];
expect(createArg.data).not.toHaveProperty('amount');
expect(createArg.data).not.toHaveProperty('initialAmount');
expect(ledger.setInitialIn).toHaveBeenCalledWith(prisma, 'batch-1', 20);
});
it('the audit entry of a new batch shows the amount the ledger set', async () => {
const withAmount = { ...honeyBatch, amount: 20, initialAmount: 20 };
prisma.honeyBatch.findUniqueOrThrow.mockResolvedValue(withAmount);
await service.create({ ...dto, amount: 20 }, 'user-1');
expect(auditLog.record).toHaveBeenCalledWith(
expect.objectContaining({ action: 'CREATE', afterData: withAmount }),
prisma,
);
expect(ledger.setInitialIn.mock.invocationCallOrder[0]).toBeLessThan(
prisma.honeyBatch.findUniqueOrThrow.mock.invocationCallOrder[0],
);
});
it('create without an amount books nothing', async () => {
await service.create(dto, 'user-1');
expect(ledger.setInitialIn).not.toHaveBeenCalled();
});
it('a batch without initialAmount does not count against the extraction', 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).toEqual([]);
});
});
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<unknown>,
): Promise<unknown> => 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('update of the amount (movement book)', () => {
const tracked = { ...honeyBatch, initialAmount: 25, amount: 20 };
beforeEach(() => {
prisma.honeyBatch.findUniqueOrThrow.mockResolvedValue(tracked);
prisma.honeyBatch.findUnique.mockResolvedValue(tracked);
prisma.honeyBatch.findMany.mockResolvedValue([]);
prisma.extraction.findUnique.mockResolvedValue({
id: 'extraction-1',
amount: null,
});
});
function dataOfUpdate(): Record<string, unknown> {
const [[arg]] = prisma.honeyBatch.update.mock.calls as [
[{ data: Record<string, unknown> }],
];
return arg.data;
}
it('update never passes amount to Prisma, even when the dto carries it', async () => {
await service.update(
'batch-1',
{ amount: 22, honeyType: 'Waldhonig' },
'user-1',
);
expect(dataOfUpdate()).toEqual({ honeyType: 'Waldhonig' });
});
it('books a CORRECTION for the difference new minus current', async () => {
await service.update('batch-1', { amount: 22.5 }, 'user-1');
expect(ledger.bookIn).toHaveBeenCalledWith(prisma, {
honeyBatchId: 'batch-1',
deltaKg: 2.5,
type: 'CORRECTION',
actingUserId: 'user-1',
});
});
it('rounds the new amount to the gram before it takes the difference', async () => {
await service.update('batch-1', { amount: 22.5004 }, 'user-1');
expect(ledger.bookIn).toHaveBeenCalledWith(
prisma,
expect.objectContaining({ deltaKg: 2.5 }),
);
});
it('answers 409 for a batch with an initial amount but no rest', async () => {
prisma.honeyBatch.findUnique.mockResolvedValue({
...honeyBatch,
initialAmount: 25,
amount: null,
});
await expect(
service.update('batch-1', { amount: 22 }, 'user-1'),
).rejects.toBeInstanceOf(ConflictException);
expect(ledger.bookIn).not.toHaveBeenCalled();
});
it('books nothing when the amount stays the same', async () => {
await service.update('batch-1', { amount: 20 }, 'user-1');
expect(ledger.bookIn).not.toHaveBeenCalled();
});
it('reads the current rest under the batch lock, then books', async () => {
await service.update('batch-1', { amount: 22 }, 'user-1');
const reads = prisma.honeyBatch.findUnique.mock.invocationCallOrder;
expect(prisma.$queryRaw.mock.invocationCallOrder[1]).toBeLessThan(
reads[reads.length - 1],
);
expect(reads[reads.length - 1]).toBeLessThan(
ledger.bookIn.mock.invocationCallOrder[0],
);
});
it('sets the first amount of a batch that had none without a booking', async () => {
const untracked = { ...honeyBatch, initialAmount: null, amount: null };
prisma.honeyBatch.findUnique.mockResolvedValue(untracked);
await service.update('batch-1', { amount: 12 }, 'user-1');
expect(ledger.setInitialIn).toHaveBeenCalledWith(prisma, 'batch-1', 12);
expect(ledger.bookIn).not.toHaveBeenCalled();
});
it('amount null goes to the ledger, which refuses it once bookings exist', async () => {
await service.update('batch-1', { amount: null as never }, 'user-1');
expect(ledger.clearIn).toHaveBeenCalledWith(prisma, 'batch-1');
expect(prisma.honeyBatch.update).not.toHaveBeenCalled();
});
it('passes the 400 of the ledger on', async () => {
ledger.clearIn.mockRejectedValue(new BadRequestException('x'));
await expect(
service.update('batch-1', { amount: null as never }, 'user-1'),
).rejects.toBeInstanceOf(BadRequestException);
expect(events.record).not.toHaveBeenCalled();
});
});
describe('createMovement', () => {
const tracked = { ...honeyBatch, initialAmount: 25, amount: 20 };
beforeEach(() => {
prisma.honeyBatch.findUnique.mockResolvedValue(tracked);
prisma.honeyBatch.findUniqueOrThrow.mockResolvedValue(tracked);
ledger.movementView.mockResolvedValue({
id: 'm1',
type: 'WITHDRAWAL',
quantityGrams: -1000,
});
});
it('books a withdrawal with its reason, with audit and event in the transaction', async () => {
const result = await service.createMovement(
'batch-1',
{ type: 'WITHDRAWAL', deltaKg: -1, reason: 'EIGENBEDARF' },
'user-1',
);
expect(ledger.bookIn).toHaveBeenCalledWith(prisma, {
honeyBatchId: 'batch-1',
deltaKg: -1,
type: 'WITHDRAWAL',
reason: 'EIGENBEDARF',
notes: null,
actingUserId: 'user-1',
});
expect(auditLog.record).toHaveBeenCalledWith(
expect.objectContaining({
entityType: 'HoneyBatch',
entityId: 'batch-1',
action: 'UPDATE',
}),
prisma,
);
expect(events.record).toHaveBeenCalledWith(
prisma,
expect.objectContaining({
type: 'HoneyBatchCorrected',
aggregateId: 'batch-1',
}),
);
expect(result).toEqual(
expect.objectContaining({ id: 'm1', quantityGrams: -1000 }),
);
});
it('a withdrawal needs a reason and a negative amount', async () => {
await expect(
service.createMovement(
'batch-1',
{ type: 'WITHDRAWAL', deltaKg: -1 },
'user-1',
),
).rejects.toThrow(new BadRequestException('Bitte einen Grund angeben.'));
await expect(
service.createMovement(
'batch-1',
{ type: 'WITHDRAWAL', deltaKg: 1, reason: 'BRUCH' },
'user-1',
),
).rejects.toThrow(
new BadRequestException('Eine Entnahme braucht eine negative Menge.'),
);
expect(ledger.bookIn).not.toHaveBeenCalled();
});
it('a correction may carry a reason and stores it', async () => {
await service.createMovement(
'batch-1',
{ type: 'CORRECTION', deltaKg: 1, reason: 'SONSTIGES' },
'user-1',
);
expect(ledger.bookIn).toHaveBeenCalledWith(
prisma,
expect.objectContaining({ type: 'CORRECTION', reason: 'SONSTIGES' }),
);
});
it('refuses a batch whose amount is not recorded', async () => {
ledger.bookIn.mockResolvedValue({ amountKg: null, movementId: null });
await expect(
service.createMovement(
'batch-1',
{ type: 'CORRECTION', deltaKg: 1 },
'user-1',
),
).rejects.toThrow(
new BadRequestException(
'Die Menge dieser Honigcharge ist nicht erfasst. Bitte zuerst die Menge eintragen.',
),
);
expect(events.record).not.toHaveBeenCalled();
});
it('answers 404 for an unknown batch', async () => {
prisma.honeyBatch.findUnique.mockResolvedValue(null);
await expect(
service.createMovement(
'x',
{ type: 'CORRECTION', deltaKg: 1 },
'user-1',
),
).rejects.toBeInstanceOf(NotFoundException);
});
});
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);
});
describe('water content warnings on a measurement', () => {
function measured(newest: number) {
prisma.honeyBatch.findUnique.mockResolvedValue({
...honeyBatch,
honeyType: null,
waterContent: null,
});
prisma.honeyMeasurement.create.mockResolvedValue({ id: 'm1' });
prisma.honeyMeasurement.findMany.mockResolvedValue([
{ waterContent: newest, measuredAt: new Date('2026-08-01') },
]);
}
it('addMeasurement above the legal limit answers a warning and never refuses', async () => {
measured(21);
const result = await service.addMeasurement(
'batch-1',
{ waterContent: 21, measuredAt: '2026-08-01T10:00:00.000Z' },
'user-1',
);
expect(result.warnings.map((w) => w.code)).toEqual([
'WATER_CONTENT_ABOVE_LEGAL',
]);
expect(prisma.honeyMeasurement.create).toHaveBeenCalledTimes(1);
});
it('a good measurement answers no warning', async () => {
measured(16.5);
const result = await service.addMeasurement(
'batch-1',
{ waterContent: 16.5, measuredAt: '2026-08-01T10:00:00.000Z' },
'user-1',
);
expect(result.warnings).toEqual([]);
});
});
});