feat: T4b/T4c wave 8 - weather history, phenology, honey chain, Telegram base
- 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]>
This commit is contained in:
1 parent
22382d53a2
commit
1971b2d9c4
90 files changed
+8431
-155
No files matched your search
@@ -2,7 +2,7 @@ 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 { WebhooksService } from '../webhooks/webhooks.service';
|
||||
import { DomainEventsService } from '../domain-events/domain-events.service';
|
||||
import { AutomationService } from '../automation/automation.service';
|
||||
|
||||
describe('HoneyBatchesService', () => {
|
||||
@@ -11,6 +11,7 @@ describe('HoneyBatchesService', () => {
|
||||
honeyBatch: {
|
||||
findMany: jest.Mock;
|
||||
findUnique: jest.Mock;
|
||||
findUniqueOrThrow: jest.Mock;
|
||||
create: jest.Mock;
|
||||
update: jest.Mock;
|
||||
};
|
||||
@@ -19,6 +20,7 @@ describe('HoneyBatchesService', () => {
|
||||
findUnique: jest.Mock;
|
||||
update: jest.Mock;
|
||||
};
|
||||
extraction: { findUnique: jest.Mock };
|
||||
storageContainer: { findUnique: jest.Mock };
|
||||
honeyBatchRelocation: { create: jest.Mock; findMany: jest.Mock };
|
||||
honeyBatchContainerAllocation: {
|
||||
@@ -28,8 +30,11 @@ describe('HoneyBatchesService', () => {
|
||||
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',
|
||||
@@ -49,6 +54,7 @@ describe('HoneyBatchesService', () => {
|
||||
const honeyBatch = {
|
||||
id: 'batch-1',
|
||||
batchNumber: 'HC-2026-001',
|
||||
extractionId: 'extraction-1',
|
||||
honeyType: 'Blütenhonig',
|
||||
amount: 20,
|
||||
status: 'RAW',
|
||||
@@ -61,6 +67,7 @@ describe('HoneyBatchesService', () => {
|
||||
honeyBatch: {
|
||||
findMany: jest.fn(),
|
||||
findUnique: jest.fn(),
|
||||
findUniqueOrThrow: jest.fn(),
|
||||
create: jest.fn(),
|
||||
update: jest.fn(),
|
||||
},
|
||||
@@ -69,6 +76,7 @@ describe('HoneyBatchesService', () => {
|
||||
findUnique: jest.fn(),
|
||||
update: jest.fn(),
|
||||
},
|
||||
extraction: { findUnique: jest.fn() },
|
||||
storageContainer: { findUnique: jest.fn() },
|
||||
honeyBatchRelocation: { create: jest.fn(), findMany: jest.fn() },
|
||||
honeyBatchContainerAllocation: {
|
||||
@@ -77,16 +85,21 @@ describe('HoneyBatchesService', () => {
|
||||
upsert: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
},
|
||||
$transaction: jest.fn(),
|
||||
$transaction: jest.fn(
|
||||
async (
|
||||
callback: (tx: typeof prisma) => Promise<unknown>,
|
||||
): Promise<unknown> => callback(prisma),
|
||||
),
|
||||
$queryRaw: jest.fn().mockResolvedValue([]),
|
||||
};
|
||||
auditLog = { record: jest.fn() };
|
||||
const webhooks = { dispatch: jest.fn().mockResolvedValue(undefined) };
|
||||
const automation = { generateNumber: jest.fn().mockResolvedValue(null) };
|
||||
events = { record: jest.fn() };
|
||||
automation = { generateNumber: jest.fn().mockResolvedValue(null) };
|
||||
service = new HoneyBatchesService(
|
||||
prisma as unknown as PrismaService,
|
||||
auditLog as unknown as AuditLogService,
|
||||
webhooks as unknown as WebhooksService,
|
||||
automation as unknown as AutomationService,
|
||||
events as unknown as DomainEventsService,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -97,20 +110,77 @@ describe('HoneyBatchesService', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('create stores the batch and returns it with relations', async () => {
|
||||
prisma.honeyBatch.create.mockResolvedValue(honeyBatch);
|
||||
prisma.honeyBatch.findUnique.mockResolvedValue(honeyBatch);
|
||||
describe('create', () => {
|
||||
const dto = { batchNumber: 'HC-2026-001', extractionId: 'extraction-1' };
|
||||
|
||||
await service.create(
|
||||
{ batchNumber: 'HC-2026-001', extractionId: 'extraction-1' },
|
||||
'user-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,
|
||||
});
|
||||
});
|
||||
|
||||
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');
|
||||
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 () => {
|
||||
@@ -365,11 +435,68 @@ describe('HoneyBatchesService', () => {
|
||||
|
||||
describe('update', () => {
|
||||
beforeEach(() => {
|
||||
prisma.$transaction.mockImplementation(
|
||||
async (
|
||||
callback: (tx: typeof prisma) => Promise<unknown>,
|
||||
): Promise<unknown> => callback(prisma),
|
||||
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 () => {
|
||||
@@ -418,6 +545,7 @@ describe('HoneyBatchesService', () => {
|
||||
entityId: 'batch-1',
|
||||
action: 'UPDATE',
|
||||
}),
|
||||
prisma,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -433,6 +561,72 @@ describe('HoneyBatchesService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
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',
|
||||
|
||||
Reference in new issue
Block a user