- Weather history from Open-Meteo behind an adapter: worker loop phenology-weather with backoff, Retry-After, User-Agent, chunked writes and forecast-to-archive correction - Phenology: growing degree sum per DWD, phase from reports, GTS and calendar, calibration from previous years, daily job step, SeasonService reads the stored state - Honey chain: prefill for extractions and batches (proposals only), plausibility and withdrawal warnings, Extraction lock, chain events and batch.created webhook from HoneyBatchRecorded - Telegram base: schema, NotificationDelivery outbox, hardened bot client, startup check without crash, no-network-outside-loops guard - ARCHITECTURE.md: sections 11.11-11.13, assumptions 42-46, env table Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
667 lines
21 KiB
TypeScript
667 lines
21 KiB
TypeScript
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<unknown>,
|
|
): Promise<unknown> => 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<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('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);
|
|
});
|
|
});
|