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

@@ -5,8 +5,10 @@ import { AutopilotStep } from '../src/autopilot/autopilot.step';
import { SuggestionsExpiryStep } from '../src/autopilot/suggestions-expiry.step';
import { AutopilotService } from '../src/autopilot/autopilot.service';
import { JobsModule } from '../src/jobs/jobs.module';
import { PhenologyStep } from '../src/phenology/phenology.step';
import { TimeEventsStep } from '../src/jobs/steps/time-events.step';
import { PrismaService } from '../src/prisma/prisma.service';
import { JobPartialFailure } from '../src/system/job-run.service';
const d = (day: string): Date => new Date(`${day}T00:00:00.000Z`);
@@ -30,6 +32,7 @@ describe('daily-evaluation (e2e)', () => {
let app: INestApplicationContext;
let prisma: PrismaService;
let step: TimeEventsStep;
let phenologyStep: PhenologyStep;
let autopilotStep: AutopilotStep;
let expiryStep: SuggestionsExpiryStep;
const ids: Record<string, string> = {};
@@ -56,6 +59,7 @@ describe('daily-evaluation (e2e)', () => {
app = await moduleRef.init();
prisma = app.get(PrismaService);
step = app.get(TimeEventsStep);
phenologyStep = app.get(PhenologyStep);
expiryStep = app.get(SuggestionsExpiryStep);
// The real AutopilotStep, but on the colonies of this file only: other
// files share the database and must not be reconciled on a 2001 day.
@@ -71,6 +75,7 @@ describe('daily-evaluation (e2e)', () => {
}),
},
automationRule: prisma.automationRule,
apiaryPhenology: prisma.apiaryPhenology,
$transaction: prisma.$transaction.bind(prisma) as unknown,
} as unknown as PrismaService,
app.get(AutopilotService),
@@ -340,6 +345,119 @@ describe('daily-evaluation (e2e)', () => {
expect(await eventsOf(ids['queen:reserve'])).toEqual([]);
});
describe('phenology step', () => {
const DAY = '2001-04-30';
let weatherApiaryId = '';
beforeAll(async () => {
const apiary = await prisma.apiary.create({
data: {
name: `Daily Evaluation Weather ${suffix}`,
latitude: 48.1,
longitude: 9.9,
},
});
weatherApiaryId = apiary.id;
// 10 degrees every day from January 1st to April 29th: GTS 965.
const days: Prisma.ApiaryWeatherDayCreateManyInput[] = [];
for (
let t = d('2001-01-01').getTime();
t <= d('2001-04-29').getTime();
t += 86_400_000
) {
days.push({
apiaryId: apiary.id,
day: new Date(t),
meanTempC: 10,
source: 'archive',
});
}
await prisma.apiaryWeatherDay.createMany({ data: days });
await prisma.phenologyReport.create({
data: {
apiaryId: apiary.id,
phase: 'HOLUNDER',
date: d('2001-04-29'),
userId: ids.user,
},
});
});
afterAll(async () => {
await prisma.domainEvent.deleteMany({
where: { aggregateId: weatherApiaryId },
});
await prisma.phenologyReport.deleteMany({
where: { apiaryId: weatherApiaryId },
});
// Rows of 2001 would feed the season of the other tests of this file.
await prisma.apiaryPhenology.deleteMany({ where: { year: 2001 } });
await prisma.apiary.deleteMany({ where: { id: weatherApiaryId } });
});
// Other files create and delete apiaries concurrently: an apiary that
// vanishes mid-run fails the step, the apiaries of this file do not.
const runStep = () =>
phenologyStep.run(DAY).catch((error: unknown) => {
if (!(error instanceof JobPartialFailure)) {
throw error;
}
return error.result;
});
it('the phenology step writes ApiaryPhenology for every active apiary', async () => {
const result = await runStep();
expect(Number(result.phenologyApiaries)).toBeGreaterThanOrEqual(2);
const rows = await prisma.apiaryPhenology.findMany({
where: { apiaryId: { in: [ids.apiary, weatherApiaryId] }, year: 2001 },
orderBy: { apiaryId: 'asc' },
});
expect(rows).toHaveLength(2);
const byApiary = new Map(rows.map((row) => [row.apiaryId, row]));
// Calendar only (no coordinates): APFEL from April 28th.
expect(byApiary.get(ids.apiary)).toMatchObject({
gts: null,
effectivePhase: 'APFEL',
phaseSource: 'CALENDAR',
segment: 'SCHWARMZEIT',
});
// A report for HOLUNDER beats GTS and calendar.
expect(byApiary.get(weatherApiaryId)).toMatchObject({
gts: 965,
gtsThroughDay: d('2001-04-29'),
estimatedPhase: 'APFEL',
reportedPhase: 'HOLUNDER',
effectivePhase: 'HOLUNDER',
phaseSource: 'REPORT',
segment: 'SCHWARMZEIT',
});
});
it('records the phase change once per apiary and day', async () => {
await runStep();
await runStep();
expect(await eventsOf(weatherApiaryId)).toEqual([
{
type: 'SeasonPhaseChanged',
aggregateType: 'Apiary',
payload: {
apiaryId: weatherApiaryId,
phase: 'HOLUNDER',
segment: 'SCHWARMZEIT',
previousPhase: 'APFEL',
previousSegment: 'SCHWARMZEIT',
source: 'REPORT',
},
dedupeKey: `SeasonPhaseChanged:${weatherApiaryId}:${DAY}`,
actorUserId: null,
},
]);
expect(await eventsOf(ids.apiary)).toEqual([]);
});
});
describe('autopilot and suggestions-expiry steps', () => {
/** Living colony, inspected 2001-05-03 and measured 2001-05-10. */
async function lateColony(name: string): Promise<string> {
@@ -9,6 +9,7 @@ import { login } from './utils/api';
interface ExtractionBody {
id: string;
harvests: { harvestId: string }[];
warnings: { code: string }[];
}
describe('Extractions (e2e)', () => {
@@ -113,6 +114,10 @@ describe('Extractions (e2e)', () => {
const extraction = response.body as ExtractionBody;
extractionId = extraction.id;
expect(extraction.harvests).toHaveLength(1);
// The harvest has no net weight: a hint, not an error.
expect(extraction.warnings.map((warning) => warning.code)).toEqual([
'HARVEST_NET_MISSING',
]);
});
it('returns the extraction with its source harvests on lookup', async () => {
@@ -11,6 +11,7 @@ interface HoneyBatchBody {
batchNumber: string;
measurements: { id: string }[];
storageContainerId: string | null;
warnings: { code: string }[];
}
interface RelocationBody {
@@ -163,6 +164,8 @@ describe('Honey batches (e2e)', () => {
const honeyBatch = response.body as HoneyBatchBody;
honeyBatchId = honeyBatch.id;
expect(honeyBatch.batchNumber).toEqual(expect.stringContaining('HC-'));
// The answer carries the warnings; nothing to warn about here.
expect(honeyBatch.warnings).toEqual([]);
});
it('adds a water content measurement to the batch', async () => {
@@ -0,0 +1,709 @@
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);
});
});
});
@@ -0,0 +1,326 @@
import { Prisma } from '../generated/prisma';
import {
deferClaim,
claimBatch,
releaseStaleClaims,
settleSkipped,
settleSuccess,
} from '../src/common/outbox/outbox-claims';
import { PrismaService } from '../src/prisma/prisma.service';
interface Claimed {
id: string;
claimedAt: Date;
}
/**
* Schema-level guarantees of the T4 Telegram migration and the outbox
* helpers on NotificationDelivery: the target CHECK, the poll-state
* singleton, cascade with the user, compare-and-set settling.
*/
describe('Telegram schema and delivery outbox (e2e)', () => {
let prisma: PrismaService;
const suffix = `${Date.now()}-${Math.round(Math.random() * 1e6)}`;
let userId: string;
let notificationId: string;
const chatBase = BigInt(9_000_000_000) + BigInt(Date.now() % 1_000_000);
beforeAll(async () => {
prisma = new PrismaService();
const user = await prisma.user.create({
data: {
email: `tg-schema-${suffix}@example.com`,
passwordHash: 'x',
name: 'Telegram Schema',
role: { connect: { key: 'READONLY' } },
},
});
userId = user.id;
const notification = await prisma.notification.create({
data: {
userId,
priority: 'INFO',
category: 'SYSTEM',
message: 'm',
},
});
notificationId = notification.id;
});
afterAll(async () => {
await prisma.user.deleteMany({ where: { id: userId } });
await prisma.$disconnect();
});
afterEach(async () => {
await prisma.notificationDelivery.deleteMany({
where: { OR: [{ userId }, { chatId: { gte: chatBase } }] },
});
});
it('has exactly one TelegramPollState row, created by the migration', async () => {
const rows = await prisma.telegramPollState.findMany();
expect(rows).toHaveLength(1);
expect(rows[0]).toMatchObject({ id: 'singleton', updateOffset: 0n });
});
describe('NotificationDelivery target CHECK', () => {
const base = { channel: 'TELEGRAM' } as const;
it('accepts a notification for a user', async () => {
await expect(
prisma.notificationDelivery.create({
data: { ...base, userId, notificationId },
}),
).resolves.toMatchObject({ status: 'PENDING', attempts: 0 });
});
it('accepts a system reply for a chat', async () => {
await expect(
prisma.notificationDelivery.create({
data: { ...base, chatId: chatBase, templateCode: 'HELP' },
}),
).resolves.toMatchObject({ status: 'PENDING' });
});
it.each([
['an empty row', {}],
['notification without user', { notificationId: 'n' }],
['template without chat', { templateCode: 'HELP' }],
['chat without template', { chatId: chatBase }],
[
'notification and template',
{
userId: 'u',
notificationId: 'n',
templateCode: 'HELP',
chatId: chatBase,
},
],
])('rejects %s', async (_name, extra) => {
const insert = prisma.$executeRaw`INSERT INTO "NotificationDelivery" ("id","channel","userId","chatId","notificationId","templateCode")
VALUES (${`chk-${suffix}-${Math.random()}`}, 'TELEGRAM',
${(extra as { userId?: string }).userId ?? null},
${(extra as { chatId?: bigint }).chatId ?? null},
${(extra as { notificationId?: string }).notificationId ?? null},
${(extra as { templateCode?: string }).templateCode ?? null})`;
await expect(insert).rejects.toThrow();
});
});
it('allows only the singleton row in TelegramPollState', async () => {
await expect(
prisma.$executeRaw`INSERT INTO "TelegramPollState" ("id") VALUES (${`other-${suffix}`})`,
).rejects.toThrow();
});
it('creates at most one delivery per notification and channel', async () => {
await prisma.notificationDelivery.create({
data: { channel: 'TELEGRAM', userId, notificationId },
});
await expect(
prisma.notificationDelivery.create({
data: { channel: 'TELEGRAM', userId, notificationId },
}),
).rejects.toThrow();
});
it('indexes NotificationDelivery by notificationId', async () => {
const rows = await prisma.$queryRaw<{ indexdef: string }[]>`
SELECT indexdef FROM pg_indexes
WHERE tablename = 'NotificationDelivery' AND indexdef LIKE '%("notificationId")%'`;
expect(rows.length).toBeGreaterThanOrEqual(1);
});
describe('outbox helpers', () => {
async function claimOne(): Promise<Claimed> {
const created = await prisma.notificationDelivery.create({
data: { channel: 'TELEGRAM', userId, notificationId },
});
const [claimed] = await claimBatch<Claimed>(
prisma,
'NotificationDelivery',
{
limit: 1,
filter: Prisma.sql`"id" = ${created.id}`,
orderBy: 'createdAt',
},
);
expect(claimed.id).toBe(created.id);
return claimed;
}
it('settleSkipped needs the matching claim and records the reason', async () => {
const claimed = await claimOne();
await expect(
settleSkipped(
prisma,
'NotificationDelivery',
claimed.id,
new Date(claimed.claimedAt.getTime() + 1),
'nope',
),
).resolves.toBe(false);
await expect(
settleSkipped(
prisma,
'NotificationDelivery',
claimed.id,
claimed.claimedAt,
'chat gone',
),
).resolves.toBe(true);
await expect(
prisma.notificationDelivery.findUnique({ where: { id: claimed.id } }),
).resolves.toMatchObject({
status: 'SKIPPED',
lastError: 'chat gone',
claimedAt: null,
attempts: 0,
});
});
it('deferClaim puts the entry back for later without counting an attempt', async () => {
const claimed = await claimOne();
await expect(
deferClaim(
prisma,
'NotificationDelivery',
claimed.id,
new Date(0),
1000,
),
).resolves.toBe(false);
await expect(
deferClaim(
prisma,
'NotificationDelivery',
claimed.id,
claimed.claimedAt,
60_000,
),
).resolves.toBe(true);
const row = await prisma.notificationDelivery.findUniqueOrThrow({
where: { id: claimed.id },
});
expect(row).toMatchObject({
status: 'PENDING',
attempts: 0,
claimedAt: null,
});
const [{ ahead }] = await prisma.$queryRaw<{ ahead: number }[]>`
SELECT EXTRACT(EPOCH FROM ("availableAt" - now()))::float8 AS ahead
FROM "NotificationDelivery" WHERE "id" = ${claimed.id}`;
expect(ahead).toBeGreaterThan(50);
expect(ahead).toBeLessThanOrEqual(61);
});
it('settleSuccess marks DELIVERED with deliveredAt', async () => {
const claimed = await claimOne();
await expect(
settleSuccess(
prisma,
'NotificationDelivery',
claimed.id,
claimed.claimedAt,
'DELIVERED',
),
).resolves.toBe(true);
const row = await prisma.notificationDelivery.findUniqueOrThrow({
where: { id: claimed.id },
});
expect(row.status).toBe('DELIVERED');
expect(row.deliveredAt).not.toBeNull();
});
it('the stale release works on NotificationDelivery', async () => {
const claimed = await claimOne();
await prisma.$executeRaw`UPDATE "NotificationDelivery" SET "claimedAt" = now() - interval '1 day' WHERE "id" = ${claimed.id}`;
expect(
await releaseStaleClaims(prisma, 'NotificationDelivery'),
).toBeGreaterThanOrEqual(1);
await expect(
prisma.notificationDelivery.findUniqueOrThrow({
where: { id: claimed.id },
}),
).resolves.toMatchObject({ status: 'PENDING', attempts: 1 });
});
});
it('deleting the user removes the chat binding, tokens and deliveries', async () => {
const other = await prisma.user.create({
data: {
email: `tg-cascade-${suffix}@example.com`,
passwordHash: 'x',
name: 'Cascade',
role: { connect: { key: 'READONLY' } },
},
});
const note = await prisma.notification.create({
data: {
userId: other.id,
priority: 'INFO',
category: 'SYSTEM',
message: 'm',
},
});
await prisma.telegramLink.create({
data: { userId: other.id, chatId: chatBase + 1n },
});
await prisma.telegramLinkToken.create({
data: {
userId: other.id,
tokenHash: `hash-${suffix}`,
expiresAt: new Date(Date.now() + 60_000),
},
});
await prisma.notificationDelivery.create({
data: { channel: 'TELEGRAM', userId: other.id, notificationId: note.id },
});
await prisma.user.delete({ where: { id: other.id } });
expect(
await prisma.telegramLink.count({ where: { userId: other.id } }),
).toBe(0);
expect(
await prisma.telegramLinkToken.count({ where: { userId: other.id } }),
).toBe(0);
expect(
await prisma.notificationDelivery.count({ where: { userId: other.id } }),
).toBe(0);
});
it('one chat belongs to exactly one user', async () => {
const link = await prisma.telegramLink.create({
data: { userId, chatId: chatBase + 2n },
});
const second = await prisma.user.create({
data: {
email: `tg-chat-${suffix}@example.com`,
passwordHash: 'x',
name: 'Second',
role: { connect: { key: 'READONLY' } },
},
});
try {
await expect(
prisma.telegramLink.create({
data: { userId: second.id, chatId: link.chatId },
}),
).rejects.toThrow();
} finally {
await prisma.telegramLink.deleteMany({ where: { userId } });
await prisma.user.delete({ where: { id: second.id } });
}
});
});
@@ -0,0 +1,173 @@
import { Test, TestingModule } from '@nestjs/testing';
import {
addDays,
berlinToday,
daysBetween,
dayToDate,
} from '../src/common/dates';
import { PrismaModule } from '../src/prisma/prisma.module';
import { PrismaService } from '../src/prisma/prisma.service';
import { RECENT_WINDOW_DAYS } from '../src/weather/history/weather-history.config';
import { WeatherHistoryModule } from '../src/weather/history/weather-history.module';
import { WeatherHistoryService } from '../src/weather/history/weather-history.service';
// Two stands on one rounded point (61.23 / 5.31) and one on a point that fails.
// Other e2e files share the database, so nothing here counts requests or rows
// of other stands.
const GOOD = { lat: '61.23', lon: '5.31' };
const BAD = { lat: '62.11', lon: '6.12' };
function json(body: unknown): Response {
return new Response(JSON.stringify(body), { status: 200 });
}
function days(from: string, to: string): string[] {
const out: string[] = [];
for (let day = from; day <= to; day = addDays(day, 1)) out.push(day);
return out;
}
describe('Weather history (e2e)', () => {
const today = berlinToday();
const yesterday = addDays(today, -1);
const januaryFirst = `${today.slice(0, 4)}-01-01`;
const expectedDays = Math.max(0, daysBetween(januaryFirst, yesterday) + 1);
const suffix = `${Date.now()}`;
let moduleRef: TestingModule;
let prisma: PrismaService;
let service: WeatherHistoryService;
let fetchMock: jest.SpiedFunction<typeof fetch>;
let apiaryIds: string[];
let badApiaryId: string;
const ownCalls = (point: { lat: string; lon: string }): URL[] =>
fetchMock.mock.calls
.map(([url]) => url as URL)
.filter(
(url) =>
url.searchParams.get('latitude') === point.lat &&
url.searchParams.get('longitude') === point.lon,
);
beforeAll(async () => {
moduleRef = await Test.createTestingModule({
imports: [PrismaModule, WeatherHistoryModule],
}).compile();
await moduleRef.init();
prisma = moduleRef.get(PrismaService);
service = moduleRef.get(WeatherHistoryService);
const a = await prisma.apiary.create({
data: {
name: `Weather history A ${suffix}`,
latitude: 61.2345,
longitude: 5.3109,
},
});
const b = await prisma.apiary.create({
data: {
name: `Weather history B ${suffix}`,
latitude: 61.2349,
longitude: 5.3101,
},
});
const bad = await prisma.apiary.create({
data: {
name: `Weather history bad ${suffix}`,
latitude: 62.1149,
longitude: 6.1201,
},
});
apiaryIds = [a.id, b.id];
badApiaryId = bad.id;
});
afterAll(async () => {
await prisma.apiary.deleteMany({
where: { id: { in: [...apiaryIds, badApiaryId] } },
});
await moduleRef.close();
});
beforeEach(() => {
fetchMock = jest.spyOn(global, 'fetch').mockImplementation((input) => {
const url = input as URL;
const lat = url.searchParams.get('latitude');
if (lat === BAD.lat) {
return Promise.resolve(new Response('nope', { status: 500 }));
}
if (lat !== GOOD.lat) {
// Stands of other e2e files: no data.
return Promise.resolve(
json({ daily: { time: [], temperature_2m_mean: [] } }),
);
}
const archive = url.searchParams.has('start_date');
const time = archive
? days(
url.searchParams.get('start_date') as string,
url.searchParams.get('end_date') as string,
)
: days(addDays(today, -92), today);
return Promise.resolve(
json({
daily: { time, temperature_2m_mean: time.map(() => 10.5) },
}),
);
});
});
afterEach(() => {
fetchMock.mockRestore();
});
it('fetches missing days once, with rounded coordinates, and writes nothing on an error', async () => {
const controller = new AbortController();
const result = await service.syncOnce(today, controller.signal);
// The failing point wrote nothing and counted as failed.
expect(result.failedPoints).toBeGreaterThanOrEqual(1);
expect(
await prisma.apiaryWeatherDay.count({ where: { apiaryId: badApiaryId } }),
).toBe(0);
// One archive and one forecast request for the shared rounded point.
const calls = ownCalls(GOOD);
expect(calls.length).toBe(expectedDays > 0 ? 2 : 0);
for (const url of calls) {
expect(url.searchParams.get('latitude')).toBe('61.23');
expect(url.searchParams.get('longitude')).toBe('5.31');
expect(url.searchParams.get('daily')).toBe('temperature_2m_mean');
expect(url.searchParams.get('timezone')).toBe('Europe/Berlin');
}
// Both stands of the point carry every day from January 1st to yesterday.
const recentStart = addDays(today, -RECENT_WINDOW_DAYS);
for (const apiaryId of apiaryIds) {
const rows = await prisma.apiaryWeatherDay.findMany({
where: { apiaryId },
orderBy: { day: 'asc' },
});
expect(rows).toHaveLength(expectedDays);
for (const row of rows) {
const day = row.day.toISOString().slice(0, 10);
expect(row.meanTempC).toBe(10.5);
expect(row.source).toBe(day >= recentStart ? 'forecast' : 'archive');
expect(day >= januaryFirst && day <= yesterday).toBe(true);
}
}
if (expectedDays > 0) {
expect(
await prisma.apiaryWeatherDay.count({
where: { apiaryId: apiaryIds[0], day: dayToDate(yesterday) },
}),
).toBe(1);
}
});
it('asks for nothing when every day is already stored', async () => {
await service.syncOnce(today, new AbortController().signal);
expect(ownCalls(GOOD)).toHaveLength(0);
});
});
@@ -15,6 +15,7 @@ import type { DomainEventEnvelope } from '../src/domain-events/domain-event-type
import { DomainEventsService } from '../src/domain-events/domain-events.service';
import { OnDomainEvent } from '../src/domain-events/on-domain-event.decorator';
import { PrismaService } from '../src/prisma/prisma.service';
import { WEATHER_HISTORY_SOURCE } from '../src/weather/history/weather-history-source';
import { WebhookDeliveryWorker } from '../src/webhooks/webhook-delivery.worker';
import { WorkerModule } from '../src/worker/worker.module';
import { DOMAIN_EVENT_POLL_MS } from '../src/worker/worker-runtime.service';
@@ -159,6 +160,10 @@ describe('Worker runtime (e2e)', () => {
})
.overrideProvider(DomainEventDispatcher)
.useClass(IsolatedDispatcher)
// The phenology-weather loop ticks right after bootstrap: no real
// network call in tests (weather-history.e2e-spec.ts covers the sync).
.overrideProvider(WEATHER_HISTORY_SOURCE)
.useValue({ name: 'test', dailyMeans: () => Promise.resolve([]) })
.compile();
prisma = worker.get(PrismaService);
// The outbox itself is covered by calendar-sync.e2e-spec.ts; here only