Files
ApiCore/ApiCore-Backend/test/honey-chain.e2e-spec.ts
T
Nick SzaunigandClaude Opus 5.5 1971b2d9c4 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]>
2026-10-14 20:00:46 +02:00

710 lines
22 KiB
TypeScript

import { INestApplication } from '@nestjs/common';
import * as bcrypt from 'bcryptjs';
import request from 'supertest';
import { App } from 'supertest/types';
import { PrismaService } from '../src/prisma/prisma.service';
import { createTestApp } from './utils/create-test-app';
import { login } from './utils/api';
import { drainDomainEvents } from './utils/domain-events';
interface Warning {
code: string;
severity: string;
message: string;
refs: { type: string; id: string }[];
}
interface WithWarningsBody {
id: string;
warnings: Warning[];
}
interface ExtractionPrefillBody {
amountKg: number | null;
honeyType: string | null;
sources: { harvestId: string; date: string; netWeight: number | null }[];
warnings: Warning[];
}
interface HoneyBatchPrefillBody {
remainingKg: number | null;
honeyType: string | null;
waterContent: number | null;
warnings: Warning[];
}
/**
* The honey chain against a real PostgreSQL: the prefill proposals, the
* plausibility warnings of extraction and honey batch (never a 4xx), the
* withdrawal check at the extraction stage and the webhook batch.created
* that now comes from the event handler.
*/
describe('Honey chain (e2e)', () => {
let app: INestApplication<App>;
let prisma: PrismaService;
const suffix = `${Date.now()}-${Math.round(Math.random() * 1e6)}`;
const password = 'SuperSecret123!';
let apiaryId: string;
let adminId: string;
let staffId: string;
let readerId: string;
let roleId: string;
let subscriptionId: string;
const colonyIds: string[] = [];
const harvestIds: string[] = [];
const treatmentIds: string[] = [];
const extractionIds: string[] = [];
const honeyBatchIds: string[] = [];
const server = () => app.getHttpServer();
const asAdmin = { Authorization: '' };
const asStaff = { Authorization: '' };
async function harvestWith(
colonyId: string,
day: string,
netWeight: number | null,
honeyType: string | null,
): Promise<string> {
const harvest = await prisma.harvest.create({
data: {
apiaryId,
userId: adminId,
date: new Date(`${day}T00:00:00.000Z`),
netWeight,
honeyType,
},
});
await prisma.harvestColony.create({
data: { harvestId: harvest.id, colonyId },
});
harvestIds.push(harvest.id);
return harvest.id;
}
async function postExtraction(
harvests: string[],
amount?: number,
): Promise<WithWarningsBody> {
const response = await request(server())
.post('/api/v1/extractions')
.set(asAdmin)
.send({
date: '2026-08-01T00:00:00.000Z',
harvestIds: harvests,
amount,
})
.expect(201);
const body = response.body as WithWarningsBody;
extractionIds.push(body.id);
return body;
}
async function postBatch(
extractionId: string,
amount: number,
label: string,
): Promise<WithWarningsBody> {
const response = await request(server())
.post('/api/v1/honey-batches')
.set(asAdmin)
.send({
batchNumber: `HC-${label}-${suffix}`,
extractionId,
amount,
})
.expect(201);
const body = response.body as WithWarningsBody;
honeyBatchIds.push(body.id);
return body;
}
/** Deliveries of this file's subscription that carry the given batch. */
async function deliveriesOfBatch(batchId: string) {
const deliveries = await prisma.webhookDelivery.findMany({
where: { subscriptionId },
});
return deliveries.filter((delivery) => {
const sent = JSON.parse(delivery.body) as { data?: { id?: string } };
return sent.data?.id === batchId;
});
}
const conflictRowsOf = (harvestId: string, colonyId: string) =>
prisma.notification.findMany({
where: {
userId: readerId,
dedupeKey: {
startsWith: `withdrawal-conflict:${harvestId}:${colonyId}:`,
},
},
});
beforeAll(async () => {
app = await createTestApp();
prisma = app.get(PrismaService);
const passwordHash = await bcrypt.hash(password, 12);
const admin = await prisma.user.create({
data: {
email: `honey-chain-admin-${suffix}@example.com`,
passwordHash,
name: 'Honey chain admin',
role: { connect: { key: 'ADMIN' } },
},
});
adminId = admin.id;
const staff = await prisma.user.create({
data: {
email: `honey-chain-staff-${suffix}@example.com`,
passwordHash,
name: 'Honey chain staff',
role: { connect: { key: 'STAFF' } },
},
});
staffId = staff.id;
asAdmin.Authorization = `Bearer ${(await login(app, admin.email, password)).accessToken}`;
asStaff.Authorization = `Bearer ${(await login(app, staff.email, password)).accessToken}`;
const role = await prisma.role.create({
data: {
key: `HC_READER_${suffix}`,
name: 'Honigkette Leser',
permissions: {
create: ['harvests:read', 'treatments:read'].map((key) => ({
permission: { connect: { key } },
})),
},
},
});
roleId = role.id;
readerId = (
await prisma.user.create({
data: {
email: `honey-chain-reader-${suffix}@example.com`,
passwordHash,
name: 'Honey chain reader',
roleId,
},
})
).id;
apiaryId = (
await prisma.apiary.create({ data: { name: `Honigkette ${suffix}` } })
).id;
subscriptionId = (
await prisma.webhookSubscription.create({
data: {
url: 'https://hooks.example.com/honey-chain-e2e',
events: ['batch.created'],
secret: `honey-chain-${suffix}`,
createdByUserId: adminId,
},
})
).id;
});
afterAll(async () => {
await prisma.notification.deleteMany({
where: {
category: 'WITHDRAWAL',
relatedId: { in: [...harvestIds, ...colonyIds] },
},
});
const rootIds = [...extractionIds, ...honeyBatchIds, ...harvestIds];
const roots = await prisma.domainEvent.findMany({
where: { aggregateId: { in: rootIds } },
select: { correlationId: true },
});
await prisma.domainEvent.deleteMany({
where: {
OR: [
{ aggregateId: { in: rootIds } },
{ correlationId: { in: roots.map((root) => root.correlationId) } },
],
},
});
await prisma.webhookDelivery.deleteMany({ where: { subscriptionId } });
await prisma.webhookSubscription.deleteMany({
where: { id: subscriptionId },
});
await prisma.auditLog.deleteMany({
where: {
OR: [
{ userId: { in: [adminId, staffId, readerId] } },
{
entityId: {
in: [...extractionIds, ...honeyBatchIds, ...treatmentIds],
},
},
],
},
});
await prisma.honeyBatch.deleteMany({
where: { id: { in: honeyBatchIds } },
});
await prisma.extractionHarvest.deleteMany({
where: { extractionId: { in: extractionIds } },
});
await prisma.extraction.deleteMany({
where: { id: { in: extractionIds } },
});
await prisma.harvestColony.deleteMany({
where: { harvestId: { in: harvestIds } },
});
await prisma.harvest.deleteMany({ where: { id: { in: harvestIds } } });
await prisma.treatment.deleteMany({ where: { id: { in: treatmentIds } } });
await prisma.colony.deleteMany({ where: { id: { in: colonyIds } } });
await prisma.apiary.deleteMany({ where: { id: apiaryId } });
await prisma.refreshToken.deleteMany({
where: { userId: { in: [adminId, staffId, readerId] } },
});
await prisma.user.deleteMany({
where: { id: { in: [adminId, staffId, readerId] } },
});
await prisma.role.deleteMany({ where: { id: roleId } });
await app.close();
});
let colonyId: string;
beforeAll(async () => {
const colony = await prisma.colony.create({
data: {
code: `HC-${suffix}`,
apiaryId,
colonyType: 'PRODUCTION_COLONY',
},
});
colonyId = colony.id;
colonyIds.push(colony.id);
});
describe('prefill', () => {
let harvestA: string;
let harvestB: string;
let harvestC: string;
let harvestNoNet: string;
beforeAll(async () => {
harvestA = await harvestWith(colonyId, '2026-05-10', 20, 'Blütenhonig');
harvestB = await harvestWith(colonyId, '2026-05-12', 22.5, 'Blütenhonig');
harvestC = await harvestWith(colonyId, '2026-05-14', 5, 'Waldhonig');
harvestNoNet = await harvestWith(colonyId, '2026-05-16', null, null);
});
it('prefill sums the net weights of the chosen harvests and keeps a shared honey type', async () => {
const response = await request(server())
.get(`/api/v1/extractions/prefill?harvestIds=${harvestA},${harvestB}`)
.set(asAdmin)
.expect(200);
expect(response.body as ExtractionPrefillBody).toEqual({
amountKg: 42.5,
honeyType: 'Blütenhonig',
sources: [
{ harvestId: harvestA, date: '2026-05-10', netWeight: 20 },
{ harvestId: harvestB, date: '2026-05-12', netWeight: 22.5 },
],
warnings: [],
});
});
it('offers no honey type for different types and no amount when a net weight is missing', async () => {
const mixed = await request(server())
.get(`/api/v1/extractions/prefill?harvestIds=${harvestA},${harvestC}`)
.set(asAdmin)
.expect(200);
expect((mixed.body as ExtractionPrefillBody).honeyType).toBeNull();
expect((mixed.body as ExtractionPrefillBody).amountKg).toBe(25);
const missing = await request(server())
.get(
`/api/v1/extractions/prefill?harvestIds=${harvestA},${harvestNoNet}`,
)
.set(asAdmin)
.expect(200);
const body = missing.body as ExtractionPrefillBody;
expect(body.amountKg).toBeNull();
expect(body.warnings.map((warning) => warning.code)).toEqual([
'HARVEST_NET_MISSING',
]);
});
it('rejects unknown, missing and too many harvest ids, and a user without extractions:create', async () => {
await request(server())
.get(`/api/v1/extractions/prefill?harvestIds=${harvestA},nope`)
.set(asAdmin)
.expect(400);
await request(server())
.get('/api/v1/extractions/prefill')
.set(asAdmin)
.expect(400);
const tooMany = Array.from({ length: 51 }, (_, i) => `h${i}`).join(',');
await request(server())
.get(`/api/v1/extractions/prefill?harvestIds=${tooMany}`)
.set(asAdmin)
.expect(400);
await request(server())
.get(`/api/v1/extractions/prefill?harvestIds=${harvestA}`)
.set(asStaff)
.expect(403);
});
it('batch prefill proposes the rest of the extraction with its honey type and water content', async () => {
const extraction = await prisma.extraction.create({
data: {
date: new Date('2026-08-01T00:00:00.000Z'),
userId: adminId,
amount: 42.5,
waterContent: 17.2,
},
});
extractionIds.push(extraction.id);
await prisma.extractionHarvest.createMany({
data: [harvestA, harvestB].map((harvestId) => ({
extractionId: extraction.id,
harvestId,
})),
});
await prisma.honeyBatch.create({
data: {
batchNumber: `HC-PRE-1-${suffix}`,
extractionId: extraction.id,
amount: 20,
initialAmount: 20,
},
});
// A batch without a recorded amount does not count.
await prisma.honeyBatch.create({
data: {
batchNumber: `HC-PRE-2-${suffix}`,
extractionId: extraction.id,
},
});
honeyBatchIds.push(
...(
await prisma.honeyBatch.findMany({
where: { extractionId: extraction.id },
select: { id: true },
})
).map((batch) => batch.id),
);
const response = await request(server())
.get(`/api/v1/honey-batches/prefill?extractionId=${extraction.id}`)
.set(asAdmin)
.expect(200);
expect(response.body as HoneyBatchPrefillBody).toEqual({
remainingKg: 22.5,
honeyType: 'Blütenhonig',
waterContent: 17.2,
warnings: [],
});
await request(server())
.get('/api/v1/honey-batches/prefill?extractionId=nope')
.set(asAdmin)
.expect(404);
await request(server())
.get('/api/v1/honey-batches/prefill')
.set(asAdmin)
.expect(400);
await request(server())
.get(`/api/v1/honey-batches/prefill?extractionId=${extraction.id}`)
.set(asStaff)
.expect(403);
});
});
describe('plausibility warnings', () => {
let harvestA: string;
let harvestNoNet: string;
beforeAll(async () => {
harvestA = await harvestWith(colonyId, '2026-06-10', 20, null);
harvestNoNet = await harvestWith(colonyId, '2026-06-12', null, null);
});
it('extraction and batch answers carry both plausibility warnings, never 4xx', async () => {
// 30 kg out of 20 kg net.
const extraction = await postExtraction([harvestA], 30);
expect(extraction.warnings.map((warning) => warning.code)).toEqual([
'EXTRACTION_EXCEEDS_HARVEST',
]);
expect(extraction.warnings[0]).toMatchObject({
severity: 'WARNUNG',
refs: [{ type: 'Harvest', id: harvestA }],
});
// 25 + 10 kg of batches out of 30 kg.
const first = await postBatch(extraction.id, 25, 'W1');
expect(first.warnings).toEqual([]);
const second = await postBatch(extraction.id, 10, 'W2');
expect(second.warnings).toHaveLength(1);
expect(second.warnings[0]).toMatchObject({
code: 'BATCHES_EXCEED_EXTRACTION',
severity: 'WARNUNG',
});
// The write happened all the same.
expect(await prisma.honeyBatch.count({ where: { id: second.id } })).toBe(
1,
);
const patched = await request(server())
.patch(`/api/v1/honey-batches/${second.id}`)
.set(asAdmin)
.send({ honeyType: 'Blütenhonig' })
.expect(200);
expect(
(patched.body as WithWarningsBody).warnings.map((w) => w.code),
).toEqual(['BATCHES_EXCEED_EXTRACTION']);
const corrected = await request(server())
.patch(`/api/v1/extractions/${extraction.id}`)
.set(asAdmin)
.send({ amount: 20 })
.expect(200);
expect(
(corrected.body as WithWarningsBody).warnings.map((w) => w.code),
).toEqual([]);
const noNet = await request(server())
.patch(`/api/v1/extractions/${extraction.id}`)
.set(asAdmin)
.send({ harvestIds: [harvestA, harvestNoNet], amount: 500 })
.expect(200);
expect(
(noNet.body as WithWarningsBody).warnings.map((w) => w.code),
).toEqual(['HARVEST_NET_MISSING']);
});
it('records the events with audit entries on create and update', async () => {
const own = await postExtraction([harvestA], 5);
const id = own.id;
await request(server())
.patch(`/api/v1/extractions/${id}`)
.set(asAdmin)
.send({ amount: 6 })
.expect(200);
await request(server())
.patch(`/api/v1/extractions/${id}`)
.set(asAdmin)
.send({ harvestIds: [harvestA, harvestNoNet] })
.expect(200);
const events = await prisma.domainEvent.findMany({
where: { aggregateType: 'Extraction', aggregateId: id },
orderBy: { occurredAt: 'asc' },
});
expect(events.map((event) => event.type)).toEqual([
'ExtractionRecorded',
'ExtractionCorrected',
'ExtractionCorrected',
]);
expect(events[2].payload).toEqual({
harvestIds: expect.arrayContaining([
harvestA,
harvestNoNet,
]) as string[],
});
expect(
await prisma.auditLog.count({
where: { entityType: 'Extraction', entityId: id },
}),
).toBe(3);
});
});
describe('withdrawal at the extraction stage', () => {
it('an extraction of a harvest inside a withdrawal window warns but notifies no second time', async () => {
const colony = await prisma.colony.create({
data: {
code: `HC-W-${suffix}`,
apiaryId,
colonyType: 'PRODUCTION_COLONY',
},
});
colonyIds.push(colony.id);
// 2026-07-10 + 20 days: harvestable again from 2026-07-30.
const treatment = await prisma.treatment.create({
data: {
colonyId: colony.id,
userId: adminId,
date: new Date('2026-07-10T00:00:00.000Z'),
product: 'Oxuvar',
withdrawalPeriodDays: 20,
},
});
treatmentIds.push(treatment.id);
const harvestResponse = await request(server())
.post('/api/v1/harvests')
.set(asAdmin)
.send({
apiaryId,
date: '2026-07-20T00:00:00.000Z',
colonyIds: [colony.id],
netWeight: 12,
})
.expect(201);
const harvestId = (harvestResponse.body as WithWarningsBody).id;
harvestIds.push(harvestId);
await drainDomainEvents(app, [harvestId]);
expect(await conflictRowsOf(harvestId, colony.id)).toHaveLength(1);
const extraction = await postExtraction([harvestId], 10);
expect(extraction.warnings).toHaveLength(1);
expect(extraction.warnings[0]).toMatchObject({
code: 'WITHDRAWAL_ACTIVE',
severity: 'WARNUNG',
});
expect(extraction.warnings[0].refs).toEqual(
expect.arrayContaining([
{ type: 'Colony', id: colony.id },
{ type: 'Treatment', id: treatment.id },
{ type: 'Harvest', id: harvestId },
]),
);
await drainDomainEvents(app, [extraction.id]);
await drainDomainEvents(app, [extraction.id]);
expect(await conflictRowsOf(harvestId, colony.id)).toHaveLength(1);
});
it('the prefill already warns about a harvest inside a withdrawal window', async () => {
const colony = await prisma.colony.create({
data: {
code: `HC-P-${suffix}`,
apiaryId,
colonyType: 'PRODUCTION_COLONY',
},
});
colonyIds.push(colony.id);
const treatment = await prisma.treatment.create({
data: {
colonyId: colony.id,
userId: adminId,
date: new Date('2026-07-10T00:00:00.000Z'),
product: 'Oxuvar',
withdrawalPeriodDays: 20,
},
});
treatmentIds.push(treatment.id);
const harvestId = await harvestWith(colony.id, '2026-07-20', 12, null);
const response = await request(server())
.get(`/api/v1/extractions/prefill?harvestIds=${harvestId}`)
.set(asAdmin)
.expect(200);
const body = response.body as ExtractionPrefillBody;
expect(body.warnings.map((warning) => warning.code)).toEqual([
'WITHDRAWAL_ACTIVE',
]);
// Proposals only: nothing was written or notified.
expect(await conflictRowsOf(harvestId, colony.id)).toHaveLength(0);
});
it('an extraction notifies a conflict the harvest did not (harvest entered without an event)', async () => {
const colony = await prisma.colony.create({
data: {
code: `HC-X-${suffix}`,
apiaryId,
colonyType: 'PRODUCTION_COLONY',
},
});
colonyIds.push(colony.id);
const treatment = await prisma.treatment.create({
data: {
colonyId: colony.id,
userId: adminId,
date: new Date('2026-07-10T00:00:00.000Z'),
product: 'Oxuvar',
withdrawalPeriodDays: 20,
},
});
treatmentIds.push(treatment.id);
const harvestId = await harvestWith(colony.id, '2026-07-20', 12, null);
expect(await conflictRowsOf(harvestId, colony.id)).toHaveLength(0);
const extraction = await postExtraction([harvestId], 10);
await drainDomainEvents(app, [extraction.id]);
await drainDomainEvents(app, [extraction.id]);
const rows = await conflictRowsOf(harvestId, colony.id);
expect(rows).toHaveLength(1);
expect(rows[0]).toMatchObject({
category: 'WITHDRAWAL',
relatedType: 'Harvest',
relatedId: harvestId,
});
});
});
describe('webhook batch.created', () => {
let batchId: string;
it('a new honey batch delivers batch.created through the handler', async () => {
const harvestId = await harvestWith(colonyId, '2026-09-01', 8, null);
const extraction = await postExtraction([harvestId], 8);
const batch = await postBatch(extraction.id, 8, 'WH');
batchId = batch.id;
// The request itself queued nothing.
expect(await deliveriesOfBatch(batchId)).toHaveLength(0);
const events = await prisma.domainEvent.findMany({
where: { aggregateType: 'HoneyBatch', aggregateId: batchId },
});
expect(events.map((event) => event.type)).toEqual(['HoneyBatchRecorded']);
expect(events[0].payload).toEqual({ extractionId: extraction.id });
await drainDomainEvents(app, [batchId]);
await drainDomainEvents(app, [batchId]);
const deliveries = await deliveriesOfBatch(batchId);
expect(deliveries).toHaveLength(1);
expect(deliveries[0].event).toBe('batch.created');
});
it('the batch.created payload has the same shape as before (honeyBatchInclude)', async () => {
const [delivery] = await deliveriesOfBatch(batchId);
const sent = JSON.parse(delivery.body) as {
event: string;
data: Record<string, unknown>;
};
const response = await request(server())
.get(`/api/v1/honey-batches/${batchId}`)
.set(asAdmin)
.expect(200);
expect(sent.event).toBe('batch.created');
expect(sent.data).toEqual(response.body);
expect(Object.keys(sent.data).sort()).toEqual(
expect.arrayContaining([
'measurements',
'storageContainer',
'extraction',
'batchNumber',
]),
);
});
it('a correction records HoneyBatchCorrected and sends no second webhook', async () => {
await request(server())
.patch(`/api/v1/honey-batches/${batchId}`)
.set(asAdmin)
.send({ honeyType: 'Sommertracht' })
.expect(200);
await drainDomainEvents(app, [batchId]);
const events = await prisma.domainEvent.findMany({
where: { aggregateType: 'HoneyBatch', aggregateId: batchId },
orderBy: { occurredAt: 'asc' },
});
expect(events.map((event) => event.type)).toEqual([
'HoneyBatchRecorded',
'HoneyBatchCorrected',
]);
expect(await deliveriesOfBatch(batchId)).toHaveLength(1);
});
});
});