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:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-14 20:00:46 +02:00
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',