- 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]>
710 lines
22 KiB
TypeScript
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);
|
|
});
|
|
});
|
|
});
|