feat: T4b/T4c wave 8 - weather history, phenology, honey chain, Telegram base
- Weather history from Open-Meteo behind an adapter: worker loop phenology-weather with backoff, Retry-After, User-Agent, chunked writes and forecast-to-archive correction - Phenology: growing degree sum per DWD, phase from reports, GTS and calendar, calibration from previous years, daily job step, SeasonService reads the stored state - Honey chain: prefill for extractions and batches (proposals only), plausibility and withdrawal warnings, Extraction lock, chain events and batch.created webhook from HoneyBatchRecorded - Telegram base: schema, NotificationDelivery outbox, hardened bot client, startup check without crash, no-network-outside-loops guard - ARCHITECTURE.md: sections 11.11-11.13, assumptions 42-46, env table Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
22382d53a2
commit
1971b2d9c4
90 files changed
+8431
-155
No files matched your search
@@ -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
|
||||
|
||||
Reference in new issue
Block a user