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
@@ -108,3 +108,28 @@ ENABLE_SWAGGER=false
|
||||
# APICORE_ROLE=api
|
||||
# Port des Health-Endpunkts des Worker-Prozesses.
|
||||
# WORKER_HEALTH_PORT=3002
|
||||
|
||||
# Wetterquelle fuer die Pflanzenentwicklung (Gradtagszahl) und die
|
||||
# Wetteranzeige. Einzige Quelle ist die freie Open-Meteo-API (bewusst
|
||||
# uebernommenes Lizenzrisiko, nicht-kommerzielle Bedingungen); der Adapter
|
||||
# (src/weather/history) erlaubt einen Wechsel. Nur https:-URLs; ein
|
||||
# unbekannter Quellenname oder eine http:-URL stoppt den Start. Der Schluessel
|
||||
# ist optional, gehoert in ein Secret und steht nie in Logs oder Fehlermeldungen.
|
||||
# WEATHER_HISTORY_SOURCE=open-meteo
|
||||
# OPEN_METEO_FORECAST_URL=https://api.open-meteo.com/v1/forecast
|
||||
# OPEN_METEO_ARCHIVE_URL=https://archive-api.open-meteo.com/v1/archive
|
||||
# OPEN_METEO_API_KEY=
|
||||
# Optional: Kontakt (E-Mail oder URL) fuer den User-Agent-Header der
|
||||
# Wetteranfragen ("ApiCore/<Version> (+<Kontakt>)"); ohne Wert
|
||||
# "ApiCore/<Version> (self-hosted beekeeping app)".
|
||||
# WEATHER_USER_AGENT_CONTACT=
|
||||
|
||||
# Telegram-Erinnerungen (optional, T4c). TELEGRAM_BOT_USERNAME (ohne @, 5-32
|
||||
# Zeichen: Buchstaben, Ziffern, Unterstrich) steht in API und Worker und
|
||||
# erzeugt den Deep Link. TELEGRAM_BOT_TOKEN (BotFather) gehoert NUR dem Worker,
|
||||
# im Cluster in das eigene Secret apicore-telegram; der API-Pod darf es nicht
|
||||
# einbinden. Ein fehlerhaftes Token stoppt nichts: Telegram bleibt aus, der
|
||||
# Worker loggt einen Fehler (nie den Wert). Bei BotFather Gruppen ausschalten
|
||||
# (/setjoingroups) und keinen Webhook setzen.
|
||||
# TELEGRAM_BOT_USERNAME=
|
||||
# TELEGRAM_BOT_TOKEN=
|
||||
@@ -0,0 +1,111 @@
|
||||
-- T4 migration 11/n (part Telegram and delivery): chat binding, single-use
|
||||
-- link tokens, the delivery outbox and the receiver's poll state. All four
|
||||
-- tables are excluded from the backup (MODELS_EXCLUDED_FROM_BACKUP).
|
||||
-- NotificationDelivery carries either a notification for a user or a bot
|
||||
-- system reply for a chat, never both (CHECK below), and at most one
|
||||
-- delivery per notification and channel. TelegramPollState holds exactly one
|
||||
-- row (CHECK below), created here.
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "NotificationChannel" AS ENUM ('TELEGRAM');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "NotificationDeliveryStatus" AS ENUM ('PENDING', 'PROCESSING', 'DELIVERED', 'SKIPPED', 'FAILED');
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "TelegramLink" (
|
||||
"userId" TEXT NOT NULL,
|
||||
"chatId" BIGINT NOT NULL,
|
||||
"telegramFirstName" TEXT,
|
||||
"linkedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "TelegramLink_pkey" PRIMARY KEY ("userId")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "TelegramLinkToken" (
|
||||
"id" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"tokenHash" TEXT NOT NULL,
|
||||
"expiresAt" TIMESTAMP(3) NOT NULL,
|
||||
"consumedAt" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "TelegramLinkToken_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "NotificationDelivery" (
|
||||
"id" TEXT NOT NULL,
|
||||
"channel" "NotificationChannel" NOT NULL,
|
||||
"userId" TEXT,
|
||||
"chatId" BIGINT,
|
||||
"notificationId" TEXT,
|
||||
"templateCode" TEXT,
|
||||
"status" "NotificationDeliveryStatus" NOT NULL DEFAULT 'PENDING',
|
||||
"attempts" INTEGER NOT NULL DEFAULT 0,
|
||||
"availableAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"claimedAt" TIMESTAMP(3),
|
||||
"lastStatusCode" INTEGER,
|
||||
"lastError" TEXT,
|
||||
"deliveredAt" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "NotificationDelivery_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "TelegramPollState" (
|
||||
"id" TEXT NOT NULL DEFAULT 'singleton',
|
||||
"updateOffset" BIGINT NOT NULL DEFAULT 0,
|
||||
"leaseOwner" TEXT,
|
||||
"leaseUntil" TIMESTAMP(3),
|
||||
"lastPollAt" TIMESTAMP(3),
|
||||
"lastError" TEXT,
|
||||
|
||||
CONSTRAINT "TelegramPollState_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "TelegramLink_chatId_key" ON "TelegramLink"("chatId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "TelegramLinkToken_tokenHash_key" ON "TelegramLinkToken"("tokenHash");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "TelegramLinkToken_userId_idx" ON "TelegramLinkToken"("userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "NotificationDelivery_status_availableAt_idx" ON "NotificationDelivery"("status", "availableAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "NotificationDelivery_userId_createdAt_idx" ON "NotificationDelivery"("userId", "createdAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "NotificationDelivery_notificationId_idx" ON "NotificationDelivery"("notificationId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "NotificationDelivery_notificationId_channel_key" ON "NotificationDelivery"("notificationId", "channel");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "TelegramLink" ADD CONSTRAINT "TelegramLink_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "TelegramLinkToken" ADD CONSTRAINT "TelegramLinkToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "NotificationDelivery" ADD CONSTRAINT "NotificationDelivery_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "NotificationDelivery" ADD CONSTRAINT "NotificationDelivery_notificationId_fkey" FOREIGN KEY ("notificationId") REFERENCES "Notification"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- A delivery is either a notification for a user (rendered at send time) or a
|
||||
-- system reply for a chat (rendered from templateCode).
|
||||
ALTER TABLE "NotificationDelivery" ADD CONSTRAINT "NotificationDelivery_target_check" CHECK (
|
||||
("notificationId" IS NOT NULL AND "userId" IS NOT NULL AND "templateCode" IS NULL AND "chatId" IS NULL)
|
||||
OR ("notificationId" IS NULL AND "templateCode" IS NOT NULL AND "chatId" IS NOT NULL)
|
||||
);
|
||||
|
||||
-- The receiver's single state row.
|
||||
ALTER TABLE "TelegramPollState" ADD CONSTRAINT "TelegramPollState_singleton_check" CHECK ("id" = 'singleton');
|
||||
INSERT INTO "TelegramPollState" ("id") VALUES ('singleton');
|
||||
@@ -127,6 +127,9 @@ model User {
|
||||
rawMaterialLots RawMaterialLot[]
|
||||
notifications Notification[]
|
||||
notificationPreference NotificationPreference?
|
||||
telegramLink TelegramLink?
|
||||
telegramLinkTokens TelegramLinkToken[]
|
||||
notificationDeliveries NotificationDelivery[]
|
||||
queenEvaluations QueenEvaluation[]
|
||||
webhookSubscriptions WebhookSubscription[]
|
||||
documents Document[]
|
||||
@@ -1408,19 +1411,20 @@ enum NotificationCategory {
|
||||
// beim Schreiben). requiredPermissions = Rechte des Empfaengers plus Rechte
|
||||
// des Inhalts; dedupeKey = "<praefix>:<userId>".
|
||||
model Notification {
|
||||
id String @id @default(cuid())
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
priority NotificationPriority
|
||||
category NotificationCategory
|
||||
message String
|
||||
relatedType String?
|
||||
relatedId String?
|
||||
link String?
|
||||
requiredPermissions String[] @default([])
|
||||
dedupeKey String? @unique
|
||||
read Boolean @default(false)
|
||||
createdAt DateTime @default(now())
|
||||
requiredPermissions String[] @default([])
|
||||
dedupeKey String? @unique
|
||||
read Boolean @default(false)
|
||||
createdAt DateTime @default(now())
|
||||
deliveries NotificationDelivery[]
|
||||
|
||||
@@index([userId, read])
|
||||
@@index([priority])
|
||||
@@ -1441,6 +1445,78 @@ model NotificationPreference {
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
enum NotificationChannel {
|
||||
TELEGRAM
|
||||
}
|
||||
|
||||
enum NotificationDeliveryStatus {
|
||||
PENDING
|
||||
PROCESSING
|
||||
DELIVERED
|
||||
SKIPPED
|
||||
FAILED
|
||||
}
|
||||
|
||||
// Chat binding of a user to this deployment's Telegram bot (T4c). One private
|
||||
// chat belongs to exactly one user.
|
||||
model TelegramLink {
|
||||
userId String @id
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
chatId BigInt @unique
|
||||
telegramFirstName String?
|
||||
linkedAt DateTime @default(now())
|
||||
}
|
||||
|
||||
// Single-use link token, valid for 10 minutes; only the SHA-256 is stored.
|
||||
model TelegramLinkToken {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
tokenHash String @unique
|
||||
expiresAt DateTime
|
||||
consumedAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([userId])
|
||||
}
|
||||
|
||||
// Outbox like WebhookDelivery. The text is not stored: the sender renders it
|
||||
// from the Notification or the templateCode. CHECK (migration): exactly one of
|
||||
// (notificationId with userId) or (templateCode with chatId).
|
||||
model NotificationDelivery {
|
||||
id String @id @default(cuid())
|
||||
channel NotificationChannel
|
||||
userId String?
|
||||
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
chatId BigInt?
|
||||
notificationId String?
|
||||
notification Notification? @relation(fields: [notificationId], references: [id], onDelete: Cascade)
|
||||
templateCode String?
|
||||
status NotificationDeliveryStatus @default(PENDING)
|
||||
attempts Int @default(0)
|
||||
availableAt DateTime @default(now())
|
||||
claimedAt DateTime?
|
||||
lastStatusCode Int?
|
||||
lastError String?
|
||||
deliveredAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@unique([notificationId, channel])
|
||||
@@index([status, availableAt])
|
||||
@@index([userId, createdAt])
|
||||
@@index([notificationId])
|
||||
}
|
||||
|
||||
// One row: update offset and poll lease of the Telegram receiver.
|
||||
model TelegramPollState {
|
||||
id String @id @default("singleton")
|
||||
updateOffset BigInt @default(0)
|
||||
leaseOwner String?
|
||||
leaseUntil DateTime?
|
||||
lastPollAt DateTime?
|
||||
lastError String?
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Webhooks (Abschnitt 27) – v1.x Modul 28
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -28,6 +28,7 @@ import { DashboardModule } from './dashboard/dashboard.module';
|
||||
import { SearchModule } from './search/search.module';
|
||||
import { ExportModule } from './export/export.module';
|
||||
import { QrModule } from './qr/qr.module';
|
||||
import { PhenologyModule } from './phenology/phenology.module';
|
||||
import { RemindersModule } from './reminders/reminders.module';
|
||||
import { ProductsModule } from './products/products.module';
|
||||
import { BottlingModule } from './bottling/bottling.module';
|
||||
@@ -114,6 +115,7 @@ import { IdempotencyInterceptor } from './common/idempotency/idempotency.interce
|
||||
SuggestionsModule,
|
||||
CleanupModule,
|
||||
AutopilotModule,
|
||||
PhenologyModule,
|
||||
WithdrawalModule,
|
||||
RemindersModule,
|
||||
LabelTemplatesModule,
|
||||
|
||||
@@ -18,6 +18,7 @@ describe('NextStepsService', () => {
|
||||
treatment: { findMany: jest.Mock };
|
||||
automationRule: { findUnique: jest.Mock; findMany: jest.Mock };
|
||||
apiary: { findMany: jest.Mock };
|
||||
apiaryPhenology: { findMany: jest.Mock };
|
||||
};
|
||||
let permissionResolver: { getPermissions: jest.Mock };
|
||||
|
||||
@@ -70,6 +71,7 @@ describe('NextStepsService', () => {
|
||||
findMany: jest.fn().mockResolvedValue([]),
|
||||
},
|
||||
apiary: { findMany: jest.fn().mockResolvedValue([]) },
|
||||
apiaryPhenology: { findMany: jest.fn().mockResolvedValue([]) },
|
||||
};
|
||||
permissionResolver = { getPermissions: jest.fn() };
|
||||
service = new NextStepsService(
|
||||
|
||||
@@ -1486,6 +1486,26 @@ describe('BackupService', () => {
|
||||
expect(MODELS_EXCLUDED_FROM_BACKUP).not.toHaveProperty('PhenologyReport');
|
||||
});
|
||||
|
||||
it('excludes the Telegram models and the delivery queue from the backup, each with a reason', async () => {
|
||||
const bundle = await service.exportAll();
|
||||
|
||||
for (const [model, key] of [
|
||||
['TelegramLink', 'telegramLink'],
|
||||
['TelegramLinkToken', 'telegramLinkToken'],
|
||||
['NotificationDelivery', 'notificationDelivery'],
|
||||
['TelegramPollState', 'telegramPollState'],
|
||||
] as const) {
|
||||
expect(bundle.data).not.toHaveProperty(key);
|
||||
expect(MODELS_EXCLUDED_FROM_BACKUP[model]).toEqual(
|
||||
expect.stringMatching(/.{30,}/),
|
||||
);
|
||||
}
|
||||
expect(MODELS_EXCLUDED_FROM_BACKUP.TelegramLink).toMatch(/re-link/);
|
||||
expect(MODELS_EXCLUDED_FROM_BACKUP.NotificationDelivery).toMatch(
|
||||
/resend old messages/,
|
||||
);
|
||||
});
|
||||
|
||||
it('restores phenology reports after the apiaries, remapping a user that does not exist', async () => {
|
||||
await importData({
|
||||
apiary: [{ id: 'apiary-1' }],
|
||||
|
||||
@@ -146,6 +146,12 @@ export const MODELS_EXCLUDED_FROM_BACKUP: Readonly<
|
||||
ThrottlerHit: 'Rate-limit counters (UNLOGGED), valid for seconds to minutes.',
|
||||
ApiaryPhenology: 'Derived season state, recomputed by daily-evaluation.',
|
||||
ApiaryWeatherDay: 'Weather cache, refetched by the worker.',
|
||||
TelegramLink:
|
||||
"Chat binding to this deployment's bot - users re-link after a restore (like ApiKey).",
|
||||
TelegramLinkToken: 'Single-use link tokens, valid for minutes.',
|
||||
NotificationDelivery:
|
||||
'Delivery queue - restoring it would resend old messages.',
|
||||
TelegramPollState: 'Update offset and poll lease of this deployment.',
|
||||
};
|
||||
|
||||
const DOCUMENT_ENTITY_TYPES = new Set<string>(
|
||||
|
||||
@@ -49,7 +49,6 @@ describe('legacy after-commit paths', () => {
|
||||
it('only the modules without an event still dispatch webhooks directly', () => {
|
||||
expect(filesCalling('webhooks.dispatch(')).toEqual([
|
||||
'src/colonies/colonies.service.ts',
|
||||
'src/honey-batches/honey-batches.service.ts',
|
||||
'src/queens/queens.service.ts',
|
||||
'src/storage/storage.service.ts',
|
||||
]);
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
||||
import { dirname, join, resolve, sep } from 'node:path';
|
||||
|
||||
/**
|
||||
* Guard for outbound calls: the network is only reached from the worker
|
||||
* loops' own directories. A static source scan (like handler-contract.spec):
|
||||
* - weather hosts appear only in src/weather/, api.telegram.org only in
|
||||
* src/telegram/,
|
||||
* - `fetch` (call, reference or globalThis.fetch) and network packages
|
||||
* (http, https, undici, axios, got, node-fetch, with or without `node:`)
|
||||
* appear only in src/weather/, src/telegram/ and the older files that
|
||||
* predate this guard,
|
||||
* - a *.handlers.ts file or a file under src/jobs/ imports no network
|
||||
* package and no module that itself reaches the network - checked on its
|
||||
* own, whatever the fetch rule says.
|
||||
*/
|
||||
const SRC = join(__dirname, '..');
|
||||
|
||||
interface SourceFile {
|
||||
path: string;
|
||||
source: string;
|
||||
}
|
||||
|
||||
const WEATHER_HOSTS = /open-meteo\.com/i;
|
||||
const TELEGRAM_HOST = /api\.telegram\.org/i;
|
||||
const FETCH_USE = /(?<![\w$.])fetch\b(?!\s*:)|\bglobalThis\s*\.\s*fetch\b/;
|
||||
const NETWORK_PACKAGES = new Set([
|
||||
'http',
|
||||
'https',
|
||||
'undici',
|
||||
'axios',
|
||||
'got',
|
||||
'node-fetch',
|
||||
]);
|
||||
const IMPORT_SPECIFIER =
|
||||
/(?:\bfrom\s*|\bimport\s*\(\s*|\bimport\s+|\brequire\s*\(\s*)['"]([^'"]+)['"]/g;
|
||||
|
||||
const ALLOWED_DIRECTORIES = ['weather/', 'telegram/'];
|
||||
// Files that reach the network and predate the guard.
|
||||
const ALLOWED_FILES = [
|
||||
'calendar-sync/graph-client.service.ts',
|
||||
'common/password/password-policy.ts',
|
||||
'webhooks/webhook-delivery.ts',
|
||||
'worker/worker-health.ts',
|
||||
'worker/worker-health-server.service.ts',
|
||||
];
|
||||
|
||||
function relativePath(path: string): string {
|
||||
return path
|
||||
.slice(SRC.length + 1)
|
||||
.split(sep)
|
||||
.join('/');
|
||||
}
|
||||
|
||||
/** Source without comments, so prose like "fetch the data" does not count. */
|
||||
function withoutComments(source: string): string {
|
||||
return source.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '');
|
||||
}
|
||||
|
||||
function sourceFiles(): SourceFile[] {
|
||||
return readdirSync(SRC, { recursive: true, encoding: 'utf8' })
|
||||
.filter((file) => file.endsWith('.ts') && !file.endsWith('.spec.ts'))
|
||||
.map((file) => {
|
||||
const path = join(SRC, file);
|
||||
return {
|
||||
path: relativePath(path),
|
||||
source: readFileSync(path, 'utf8'),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function isNetworkPackage(specifier: string): boolean {
|
||||
return NETWORK_PACKAGES.has(specifier.replace(/^node:/, ''));
|
||||
}
|
||||
|
||||
function importsOf(source: string): string[] {
|
||||
return [...source.matchAll(IMPORT_SPECIFIER)].map((match) => match[1]);
|
||||
}
|
||||
|
||||
function inAllowedPlace(path: string): boolean {
|
||||
return (
|
||||
ALLOWED_DIRECTORIES.some((directory) => path.startsWith(directory)) ||
|
||||
ALLOWED_FILES.includes(path)
|
||||
);
|
||||
}
|
||||
|
||||
function reachesNetwork(file: SourceFile): boolean {
|
||||
return (
|
||||
FETCH_USE.test(file.source) || importsOf(file.source).some(isNetworkPackage)
|
||||
);
|
||||
}
|
||||
|
||||
function isHandlerOrJob(path: string): boolean {
|
||||
return path.endsWith('.handlers.ts') || path.startsWith('jobs/');
|
||||
}
|
||||
|
||||
/** The src-relative path a relative import points to, if it is one of `known`. */
|
||||
function resolveImport(
|
||||
from: string,
|
||||
specifier: string,
|
||||
known: ReadonlySet<string>,
|
||||
): string | undefined {
|
||||
if (!specifier.startsWith('.')) {
|
||||
return undefined;
|
||||
}
|
||||
const base = resolve(dirname(join(SRC, from)), specifier);
|
||||
return [`${base}.ts`, join(base, 'index.ts')]
|
||||
.map(relativePath)
|
||||
.find((candidate) => known.has(candidate));
|
||||
}
|
||||
|
||||
/** Where the code breaks the rule; empty when it keeps it. */
|
||||
function violations(rawFiles: readonly SourceFile[]): string[] {
|
||||
const files = rawFiles.map(({ path, source }) => ({
|
||||
path,
|
||||
source: withoutComments(source),
|
||||
}));
|
||||
const networkFiles = new Set(
|
||||
files.filter(reachesNetwork).map((file) => file.path),
|
||||
);
|
||||
return files.flatMap(({ path, source }) => {
|
||||
const found: string[] = [];
|
||||
if (WEATHER_HOSTS.test(source) && !path.startsWith('weather/')) {
|
||||
found.push(`${path}: weather host outside src/weather/`);
|
||||
}
|
||||
if (TELEGRAM_HOST.test(source) && !path.startsWith('telegram/')) {
|
||||
found.push(`${path}: api.telegram.org outside src/telegram/`);
|
||||
}
|
||||
if (FETCH_USE.test(source) && !inAllowedPlace(path)) {
|
||||
found.push(`${path}: fetch outside the allowed places`);
|
||||
}
|
||||
const specifiers = importsOf(source);
|
||||
if (specifiers.some(isNetworkPackage) && !inAllowedPlace(path)) {
|
||||
found.push(`${path}: network package import outside the allowed places`);
|
||||
}
|
||||
if (isHandlerOrJob(path)) {
|
||||
if (FETCH_USE.test(source)) {
|
||||
found.push(`${path}: fetch in a handler or job`);
|
||||
}
|
||||
if (specifiers.some(isNetworkPackage)) {
|
||||
found.push(`${path}: network package imported by a handler or job`);
|
||||
}
|
||||
if (
|
||||
specifiers.some(
|
||||
(specifier) =>
|
||||
resolveImport(path, specifier, networkFiles) !== undefined,
|
||||
)
|
||||
) {
|
||||
found.push(
|
||||
`${path}: handler or job imports a module that reaches the network`,
|
||||
);
|
||||
}
|
||||
}
|
||||
return found;
|
||||
});
|
||||
}
|
||||
|
||||
describe('network calls stay inside their loop directories', () => {
|
||||
it('network calls stay inside their loop directories', () => {
|
||||
expect(violations(sourceFiles())).toEqual([]);
|
||||
});
|
||||
|
||||
it('knows the real network files (the scan is not vacuous)', () => {
|
||||
const found = sourceFiles()
|
||||
.map(({ path, source }) => ({ path, source: withoutComments(source) }))
|
||||
.filter(reachesNetwork)
|
||||
.map((file) => file.path);
|
||||
|
||||
expect(found).toEqual(
|
||||
expect.arrayContaining([
|
||||
...ALLOWED_FILES,
|
||||
'weather/weather.service.ts',
|
||||
'telegram/telegram-client.ts',
|
||||
]),
|
||||
);
|
||||
expect(found.every(inAllowedPlace)).toBe(true);
|
||||
expect(existsSync(join(SRC, 'jobs'))).toBe(true);
|
||||
});
|
||||
|
||||
describe('with planted violations', () => {
|
||||
it.each([
|
||||
['colonies/colonies.service.ts', 'await fetch(url);'],
|
||||
['colonies/colonies.service.ts', 'const f = fetch;'],
|
||||
['colonies/colonies.service.ts', 'await globalThis.fetch(url);'],
|
||||
['colonies/colonies.service.ts', "import https from 'node:https';"],
|
||||
['colonies/colonies.service.ts', "import http from 'http';"],
|
||||
['colonies/colonies.service.ts', "import { fetch } from 'undici';"],
|
||||
['colonies/colonies.service.ts', "import axios from 'axios';"],
|
||||
['colonies/colonies.service.ts', "import got from 'got';"],
|
||||
['colonies/colonies.service.ts', "import fetch from 'node-fetch';"],
|
||||
['colonies/colonies.service.ts', "const h = require('node:http');"],
|
||||
['colonies/colonies.handlers.ts', 'await fetch(url);'],
|
||||
['jobs/daily-evaluation.job.ts', 'await fetch(url);'],
|
||||
['jobs/steps/phenology.step.ts', "import { get } from 'node:https';"],
|
||||
['colonies/colonies.handlers.ts', "import axios from 'axios';"],
|
||||
[
|
||||
'notifications/notifications.service.ts',
|
||||
"const u = 'https://api.telegram.org/bot';",
|
||||
],
|
||||
[
|
||||
'notifications/notifications.service.ts',
|
||||
"const u = 'https://api.open-meteo.com/v1';",
|
||||
],
|
||||
[
|
||||
'telegram/telegram-client.ts',
|
||||
"const u = 'https://api.open-meteo.com/v1';",
|
||||
],
|
||||
[
|
||||
'weather/weather.service.ts',
|
||||
"const u = 'https://api.telegram.org/bot';",
|
||||
],
|
||||
])('flags %s: %s', (path, source) => {
|
||||
expect(violations([{ path, source }])).not.toEqual([]);
|
||||
});
|
||||
|
||||
it('flags a handler or job importing a module that reaches the network, even without fetch of its own', () => {
|
||||
const client = {
|
||||
path: 'weather/weather.service.ts',
|
||||
source: 'await fetch(url);',
|
||||
};
|
||||
for (const path of [
|
||||
'colonies/colonies.handlers.ts',
|
||||
'jobs/daily-evaluation-steps.ts',
|
||||
]) {
|
||||
const importer = {
|
||||
path,
|
||||
source:
|
||||
"import { WeatherService } from '../weather/weather.service';",
|
||||
};
|
||||
expect(violations([client, importer])).toEqual([
|
||||
`${path}: handler or job imports a module that reaches the network`,
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
'weather/weather.service.ts',
|
||||
"await fetch('https://api.open-meteo.com/v1');",
|
||||
],
|
||||
[
|
||||
'telegram/telegram-client.ts',
|
||||
"await fetch('https://api.telegram.org/bot');",
|
||||
],
|
||||
['calendar-sync/graph-client.service.ts', 'await fetch(url);'],
|
||||
['webhooks/webhook-delivery.ts', "import * as https from 'node:https';"],
|
||||
['colonies/colonies.service.ts', 'const prefetched = refetch + fetched;'],
|
||||
['colonies/colonies.service.ts', '// fetch the data later\nconst a = 1;'],
|
||||
['colonies/colonies.service.ts', "const scheme = 'https';"],
|
||||
['colonies/colonies.service.ts', 'const o = { fetch: 1 };'],
|
||||
])('lets %s pass: %s', (path, source) => {
|
||||
expect(violations([{ path, source }])).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,165 @@
|
||||
import { Prisma } from '../../../generated/prisma';
|
||||
import { deferClaim, settleSkipped, settleSuccess } from './outbox-claims';
|
||||
|
||||
function fakeDb(count = 1) {
|
||||
return { $executeRaw: jest.fn().mockResolvedValue(count) };
|
||||
}
|
||||
|
||||
function sqlOf(db: ReturnType<typeof fakeDb>): Prisma.Sql {
|
||||
return (db.$executeRaw.mock.calls as [Prisma.Sql][])[0][0];
|
||||
}
|
||||
|
||||
describe('NotificationDelivery outbox helpers', () => {
|
||||
const claimedAt = new Date('2026-10-15T10:00:00.000Z');
|
||||
|
||||
it('settleSuccess settles NotificationDelivery as DELIVERED into deliveredAt', async () => {
|
||||
const db = fakeDb();
|
||||
|
||||
await expect(
|
||||
settleSuccess(
|
||||
db as never,
|
||||
'NotificationDelivery',
|
||||
'd1',
|
||||
claimedAt,
|
||||
'DELIVERED',
|
||||
),
|
||||
).resolves.toBe(true);
|
||||
|
||||
const query = sqlOf(db);
|
||||
expect(query.sql).toContain('UPDATE "NotificationDelivery"');
|
||||
expect(query.sql).toContain('"deliveredAt" = now()');
|
||||
expect(query.sql).toContain('"NotificationDeliveryStatus"');
|
||||
});
|
||||
|
||||
it('settleSuccess refuses DONE for NotificationDelivery', async () => {
|
||||
await expect(
|
||||
settleSuccess(
|
||||
fakeDb() as never,
|
||||
'NotificationDelivery',
|
||||
'd1',
|
||||
claimedAt,
|
||||
'DONE',
|
||||
),
|
||||
).rejects.toThrow('NotificationDelivery settles as DELIVERED, not DONE');
|
||||
});
|
||||
|
||||
it('settleSkipped is compare-and-set on id and claimedAt and keeps attempts', async () => {
|
||||
const db = fakeDb();
|
||||
|
||||
await expect(
|
||||
settleSkipped(
|
||||
db as never,
|
||||
'NotificationDelivery',
|
||||
'd1',
|
||||
claimedAt,
|
||||
'chat gone',
|
||||
),
|
||||
).resolves.toBe(true);
|
||||
|
||||
const query = sqlOf(db);
|
||||
expect(query.sql).toContain('UPDATE "NotificationDelivery"');
|
||||
expect(query.sql).toContain(`CAST(? AS "NotificationDeliveryStatus")`);
|
||||
expect(query.sql).toContain(
|
||||
`WHERE "id" = ? AND "status" = 'PROCESSING' AND "claimedAt" = ?`,
|
||||
);
|
||||
expect(query.sql).not.toContain('"attempts"');
|
||||
expect(query.values).toEqual(['SKIPPED', 'chat gone', 'd1', claimedAt]);
|
||||
});
|
||||
|
||||
it('settleSkipped reports a lost claim', async () => {
|
||||
await expect(
|
||||
settleSkipped(
|
||||
fakeDb(0) as never,
|
||||
'NotificationDelivery',
|
||||
'd1',
|
||||
claimedAt,
|
||||
'x',
|
||||
),
|
||||
).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('deferClaim puts the entry back to PENDING after the delay by the database clock, attempts unchanged, compare-and-set', async () => {
|
||||
const db = fakeDb();
|
||||
|
||||
await expect(
|
||||
deferClaim(
|
||||
db as never,
|
||||
'NotificationDelivery',
|
||||
'd1',
|
||||
claimedAt,
|
||||
15 * 60_000,
|
||||
),
|
||||
).resolves.toBe(true);
|
||||
|
||||
const query = sqlOf(db);
|
||||
expect(query.sql).toContain('UPDATE "NotificationDelivery"');
|
||||
expect(query.sql).toContain(`"status" = 'PENDING'`);
|
||||
expect(query.sql).toContain(
|
||||
`"availableAt" = now() + (? * interval '1 millisecond')`,
|
||||
);
|
||||
expect(query.sql).toContain('"claimedAt" = NULL');
|
||||
expect(query.sql).not.toContain('"attempts"');
|
||||
expect(query.sql).toContain(
|
||||
`WHERE "id" = ? AND "status" = 'PROCESSING' AND "claimedAt" = ?`,
|
||||
);
|
||||
expect(query.values).toEqual([900_000, 'd1', claimedAt]);
|
||||
});
|
||||
|
||||
it('deferClaim reports a lost claim and rejects a negative or non-integer delay', async () => {
|
||||
await expect(
|
||||
deferClaim(
|
||||
fakeDb(0) as never,
|
||||
'NotificationDelivery',
|
||||
'd1',
|
||||
claimedAt,
|
||||
1,
|
||||
),
|
||||
).resolves.toBe(false);
|
||||
await expect(
|
||||
deferClaim(
|
||||
fakeDb() as never,
|
||||
'NotificationDelivery',
|
||||
'd1',
|
||||
claimedAt,
|
||||
-1,
|
||||
),
|
||||
).rejects.toThrow(RangeError);
|
||||
await expect(
|
||||
deferClaim(
|
||||
fakeDb() as never,
|
||||
'NotificationDelivery',
|
||||
'd1',
|
||||
claimedAt,
|
||||
1.5,
|
||||
),
|
||||
).rejects.toThrow(RangeError);
|
||||
});
|
||||
|
||||
it('deferClaim caps the delay at 24 hours', async () => {
|
||||
const db = fakeDb();
|
||||
|
||||
await deferClaim(
|
||||
db as never,
|
||||
'NotificationDelivery',
|
||||
'd1',
|
||||
claimedAt,
|
||||
30 * 24 * 3_600_000,
|
||||
);
|
||||
|
||||
expect(sqlOf(db).values[0]).toBe(24 * 3_600_000);
|
||||
});
|
||||
|
||||
it('settleSkipped truncates the reason to 200 characters', async () => {
|
||||
const db = fakeDb();
|
||||
|
||||
await settleSkipped(
|
||||
db as never,
|
||||
'NotificationDelivery',
|
||||
'd1',
|
||||
claimedAt,
|
||||
'r'.repeat(500),
|
||||
);
|
||||
|
||||
expect(sqlOf(db).values[1]).toBe('r'.repeat(200));
|
||||
});
|
||||
});
|
||||
@@ -11,7 +11,8 @@ import { MAX_ATTEMPTS, retryDelayMs, STALE_CLAIM_MS } from '../retry-policy';
|
||||
* comparison and calculation runs in SQL against now() - the database
|
||||
* clock - never against a JS Date.
|
||||
*/
|
||||
export type OutboxTable = 'DomainEvent' | 'WebhookDelivery';
|
||||
export type OutboxTable =
|
||||
'DomainEvent' | 'WebhookDelivery' | 'NotificationDelivery';
|
||||
|
||||
export type OutboxOrderColumn = 'occurredAt' | 'createdAt';
|
||||
|
||||
@@ -29,6 +30,9 @@ interface OutboxTableSql {
|
||||
successStatus: 'DONE' | 'DELIVERED';
|
||||
}
|
||||
|
||||
/** Outbox tables whose entries can be put back or skipped by the sender. */
|
||||
export type DeliveryOutboxTable = 'NotificationDelivery';
|
||||
|
||||
/**
|
||||
* Table, enum and column names cannot be bound as parameters. They come
|
||||
* only from these closed maps - never from caller input - which is what
|
||||
@@ -47,6 +51,12 @@ const OUTBOX_TABLES: Readonly<Record<OutboxTable, OutboxTableSql>> = {
|
||||
settledAtColumn: Prisma.raw('"deliveredAt"'),
|
||||
successStatus: 'DELIVERED',
|
||||
},
|
||||
NotificationDelivery: {
|
||||
table: Prisma.raw('"NotificationDelivery"'),
|
||||
statusType: Prisma.raw('"NotificationDeliveryStatus"'),
|
||||
settledAtColumn: Prisma.raw('"deliveredAt"'),
|
||||
successStatus: 'DELIVERED',
|
||||
},
|
||||
};
|
||||
|
||||
const ORDER_COLUMNS: Readonly<Record<OutboxOrderColumn, Prisma.Sql>> = {
|
||||
@@ -54,6 +64,10 @@ const ORDER_COLUMNS: Readonly<Record<OutboxOrderColumn, Prisma.Sql>> = {
|
||||
createdAt: Prisma.raw('"createdAt"'),
|
||||
};
|
||||
|
||||
/** Longest wait deferClaim allows: a day. */
|
||||
const MAX_DEFER_MS = 24 * 60 * 60 * 1000;
|
||||
const MAX_SKIP_REASON_CHARS = 200;
|
||||
|
||||
const STALE_CLAIM_ERROR = 'claim expired (worker restarted?)';
|
||||
|
||||
function outboxTable(table: OutboxTable): OutboxTableSql {
|
||||
@@ -206,3 +220,56 @@ export async function settleFailure(
|
||||
WHERE "id" = ${id} AND "status" = 'PROCESSING' AND "claimedAt" = ${claimedAt}`);
|
||||
return outcome;
|
||||
}
|
||||
|
||||
/**
|
||||
* Puts a claimed entry back to PENDING, due after `delayMs` by the database
|
||||
* clock, without counting an attempt: the entry did not fail, it has to wait
|
||||
* (rate limit from the receiver, quiet period, paused bot); the delay is
|
||||
* capped at 24 hours. Compare-and-set
|
||||
* on id + claimedAt; false if the claim was lost.
|
||||
*/
|
||||
export async function deferClaim(
|
||||
db: DbClient,
|
||||
table: DeliveryOutboxTable,
|
||||
id: string,
|
||||
claimedAt: Date,
|
||||
delayMs: number,
|
||||
): Promise<boolean> {
|
||||
if (!Number.isInteger(delayMs) || delayMs < 0) {
|
||||
throw new RangeError(
|
||||
`Defer delay must be a non-negative integer, got ${delayMs}`,
|
||||
);
|
||||
}
|
||||
const { table: name } = outboxTable(table);
|
||||
const cappedDelayMs = Math.min(delayMs, MAX_DEFER_MS);
|
||||
const count = await db.$executeRaw(Prisma.sql`
|
||||
UPDATE ${name}
|
||||
SET "status" = 'PENDING',
|
||||
"claimedAt" = NULL,
|
||||
"availableAt" = now() + (${cappedDelayMs} * interval '1 millisecond')
|
||||
WHERE "id" = ${id} AND "status" = 'PROCESSING' AND "claimedAt" = ${claimedAt}`);
|
||||
return count === 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Settles a claimed entry as SKIPPED: it will never be sent (the recipient
|
||||
* unlinked, the message went stale). `reason` lands in lastError and must
|
||||
* not contain personal data or secrets; it is cut to 200 characters. Compare-and-set on id + claimedAt;
|
||||
* false if the claim was lost.
|
||||
*/
|
||||
export async function settleSkipped(
|
||||
db: DbClient,
|
||||
table: DeliveryOutboxTable,
|
||||
id: string,
|
||||
claimedAt: Date,
|
||||
reason: string,
|
||||
): Promise<boolean> {
|
||||
const { table: name, statusType } = outboxTable(table);
|
||||
const count = await db.$executeRaw(Prisma.sql`
|
||||
UPDATE ${name}
|
||||
SET "status" = CAST(${'SKIPPED'} AS ${statusType}),
|
||||
"claimedAt" = NULL,
|
||||
"lastError" = ${reason.slice(0, MAX_SKIP_REASON_CHARS)}
|
||||
WHERE "id" = ${id} AND "status" = 'PROCESSING' AND "claimedAt" = ${claimedAt}`);
|
||||
return count === 1;
|
||||
}
|
||||
@@ -5,6 +5,7 @@ export const WAKE_CHANNELS = {
|
||||
domainEvents: 'domain_event',
|
||||
calendarSync: 'calendar_sync',
|
||||
webhookDeliveries: 'webhook_delivery',
|
||||
notificationDeliveries: 'notification_delivery',
|
||||
} as const;
|
||||
|
||||
export type WakeChannel = (typeof WAKE_CHANNELS)[keyof typeof WAKE_CHANNELS];
|
||||
|
||||
@@ -39,7 +39,7 @@ const NETWORK_PACKAGES = new Set([
|
||||
'ky',
|
||||
'superagent',
|
||||
]);
|
||||
const NETWORK_MODULE_NAME = /graph-client|webhook-delivery/;
|
||||
const NETWORK_MODULE_NAME = /graph-client|webhook-delivery|telegram-client/;
|
||||
const FETCH_CALL = /\bfetch\s*\(/;
|
||||
const IMPORT_SPECIFIER =
|
||||
/(?:\bfrom\s*|\bimport\s*\(\s*|\bimport\s+|\brequire\s*\(\s*)['"]([^'"]+)['"]/g;
|
||||
@@ -147,6 +147,7 @@ describe('domain event handler contract', () => {
|
||||
'calendar-sync/graph-client.service.ts',
|
||||
'webhooks/webhook-delivery.ts',
|
||||
'weather/weather.service.ts',
|
||||
'telegram/telegram-client.ts',
|
||||
'common/password/password-policy.ts',
|
||||
]),
|
||||
);
|
||||
@@ -187,6 +188,7 @@ describe('domain event handler contract', () => {
|
||||
"import { postWebhook } from '../webhooks/webhook-delivery';",
|
||||
"import { WebhookDeliveryWorker } from '../webhooks/webhook-delivery.worker';",
|
||||
"import { WeatherService } from '../weather/weather.service';",
|
||||
"import { TelegramClient } from '../telegram/telegram-client';",
|
||||
"const { checkPassword } = await import('../common/password/password-policy');",
|
||||
])('flags %s', (line) => {
|
||||
expect(
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { plainToInstance } from 'class-transformer';
|
||||
import { validate } from 'class-validator';
|
||||
import {
|
||||
ExtractionPrefillQuery,
|
||||
MAX_PREFILL_HARVESTS,
|
||||
} from './extraction-prefill.query';
|
||||
|
||||
async function check(query: Record<string, unknown>) {
|
||||
const dto = plainToInstance(ExtractionPrefillQuery, query);
|
||||
const errors = await validate(dto, {
|
||||
whitelist: true,
|
||||
forbidNonWhitelisted: true,
|
||||
});
|
||||
return {
|
||||
dto,
|
||||
messages: errors.flatMap((error) => Object.values(error.constraints ?? {})),
|
||||
};
|
||||
}
|
||||
|
||||
describe('ExtractionPrefillQuery', () => {
|
||||
it('splits the comma-separated ids and drops empty segments', async () => {
|
||||
const { dto, messages } = await check({ harvestIds: 'ha,hb,' });
|
||||
|
||||
expect(messages).toEqual([]);
|
||||
expect(dto.harvestIds).toEqual(['ha', 'hb']);
|
||||
});
|
||||
|
||||
it('accepts a repeated query parameter', async () => {
|
||||
const { dto, messages } = await check({ harvestIds: ['ha', 'hb'] });
|
||||
|
||||
expect(messages).toEqual([]);
|
||||
expect(dto.harvestIds).toEqual(['ha', 'hb']);
|
||||
});
|
||||
|
||||
it('requires at least one id', async () => {
|
||||
expect((await check({})).messages).not.toEqual([]);
|
||||
expect((await check({ harvestIds: ',' })).messages).toContain(
|
||||
'harvestIds darf nicht leer sein',
|
||||
);
|
||||
});
|
||||
|
||||
it('allows at most 50 ids', async () => {
|
||||
const ids = Array.from(
|
||||
{ length: MAX_PREFILL_HARVESTS + 1 },
|
||||
(_, i) => `h${i}`,
|
||||
);
|
||||
|
||||
expect((await check({ harvestIds: ids.join(',') })).messages).toContain(
|
||||
`harvestIds darf höchstens ${MAX_PREFILL_HARVESTS} Ernte-IDs enthalten`,
|
||||
);
|
||||
expect(
|
||||
(await check({ harvestIds: ids.slice(0, 50).join(',') })).messages,
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('rejects duplicates and ids with odd characters', async () => {
|
||||
expect((await check({ harvestIds: 'ha,ha' })).messages).toContain(
|
||||
'harvestIds enthält eine Ernte doppelt',
|
||||
);
|
||||
expect((await check({ harvestIds: 'ha,h b' })).messages).toContain(
|
||||
'harvestIds enthält eine ungültige Ernte-ID',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { Transform, TransformFnParams } from 'class-transformer';
|
||||
import {
|
||||
ArrayMaxSize,
|
||||
ArrayMinSize,
|
||||
ArrayUnique,
|
||||
IsArray,
|
||||
IsString,
|
||||
Matches,
|
||||
MaxLength,
|
||||
} from 'class-validator';
|
||||
|
||||
export const MAX_PREFILL_HARVESTS = 50;
|
||||
const MAX_ID_LENGTH = 64;
|
||||
const IDS_MESSAGE = 'harvestIds enthält eine ungültige Ernte-ID';
|
||||
|
||||
// "a,b" and the repeated form harvestIds=a&harvestIds=b both become a list;
|
||||
// empty segments carry no id and are dropped.
|
||||
function toIdList({ value }: TransformFnParams): unknown {
|
||||
const parts: unknown =
|
||||
typeof value === 'string' ? value.split(',') : (value as unknown);
|
||||
return Array.isArray(parts) ? parts.filter((part) => part !== '') : parts;
|
||||
}
|
||||
|
||||
export class ExtractionPrefillQuery {
|
||||
@ApiProperty({
|
||||
type: String,
|
||||
description: `Kommagetrennte Ernte-IDs, 1 bis ${MAX_PREFILL_HARVESTS}`,
|
||||
example: 'harvest-a,harvest-b',
|
||||
})
|
||||
@Transform(toIdList)
|
||||
@IsArray({ message: IDS_MESSAGE })
|
||||
@ArrayMinSize(1, { message: 'harvestIds darf nicht leer sein' })
|
||||
@ArrayMaxSize(MAX_PREFILL_HARVESTS, {
|
||||
message: `harvestIds darf höchstens ${MAX_PREFILL_HARVESTS} Ernte-IDs enthalten`,
|
||||
})
|
||||
@ArrayUnique({ message: 'harvestIds enthält eine Ernte doppelt' })
|
||||
@IsString({ each: true, message: IDS_MESSAGE })
|
||||
@MaxLength(MAX_ID_LENGTH, { each: true, message: IDS_MESSAGE })
|
||||
@Matches(/^[A-Za-z0-9_-]+$/, { each: true, message: IDS_MESSAGE })
|
||||
harvestIds!: string[];
|
||||
}
|
||||
@@ -1,9 +1,18 @@
|
||||
import { Body, Controller, Get, Param, Patch, Post } from '@nestjs/common';
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
} from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
||||
import { RequirePermission } from '../auth/decorators/require-permission.decorator';
|
||||
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||
import type { AuthenticatedUser } from '../auth/types/authenticated-user';
|
||||
import { CreateExtractionDto } from './dto/create-extraction.dto';
|
||||
import { ExtractionPrefillQuery } from './dto/extraction-prefill.query';
|
||||
import { UpdateExtractionDto } from './dto/update-extraction.dto';
|
||||
import { ExtractionsService } from './extractions.service';
|
||||
|
||||
@@ -19,6 +28,15 @@ export class ExtractionsController {
|
||||
return this.extractionsService.findAll();
|
||||
}
|
||||
|
||||
// Before ':id', so 'prefill' is not read as an id. Needs the create
|
||||
// permission, not read: it offers the same data the create flow needs, and
|
||||
// POST answers it anyway (ruling, Task 28 review).
|
||||
@RequirePermission('extractions:create')
|
||||
@Get('prefill')
|
||||
prefill(@Query() query: ExtractionPrefillQuery) {
|
||||
return this.extractionsService.prefill(query.harvestIds);
|
||||
}
|
||||
|
||||
@RequirePermission('extractions:read')
|
||||
@Get(':id')
|
||||
findOne(@Param('id') id: string) {
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { WithdrawalModule } from '../withdrawal/withdrawal.module';
|
||||
import { ExtractionsController } from './extractions.controller';
|
||||
import { ExtractionsService } from './extractions.service';
|
||||
|
||||
@Module({
|
||||
imports: [WithdrawalModule],
|
||||
controllers: [ExtractionsController],
|
||||
providers: [ExtractionsService],
|
||||
exports: [ExtractionsService],
|
||||
|
||||
@@ -2,32 +2,59 @@ import { BadRequestException, NotFoundException } from '@nestjs/common';
|
||||
import { ExtractionsService } from './extractions.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { AuditLogService } from '../audit/audit-log.service';
|
||||
import { DomainEventsService } from '../domain-events/domain-events.service';
|
||||
import { WithdrawalCheckService } from '../withdrawal/withdrawal-check.service';
|
||||
|
||||
const entry = (harvestId: string, netWeight: number | null) => ({
|
||||
harvestId,
|
||||
harvest: { id: harvestId, netWeight },
|
||||
});
|
||||
|
||||
describe('ExtractionsService', () => {
|
||||
let service: ExtractionsService;
|
||||
let prisma: {
|
||||
extraction: { findUnique: jest.Mock; update: jest.Mock };
|
||||
extraction: { findUnique: jest.Mock };
|
||||
harvest: { findMany: jest.Mock };
|
||||
$transaction: jest.Mock;
|
||||
};
|
||||
let auditLog: { record: jest.Mock };
|
||||
let events: { record: jest.Mock };
|
||||
let withdrawalCheck: {
|
||||
conflictsForExtraction: jest.Mock;
|
||||
conflictsForHarvests: jest.Mock;
|
||||
warningsFor: jest.Mock;
|
||||
};
|
||||
let tx: {
|
||||
extraction: { create: jest.Mock; update: jest.Mock };
|
||||
extraction: {
|
||||
create: jest.Mock;
|
||||
update: jest.Mock;
|
||||
findUnique: jest.Mock;
|
||||
findUniqueOrThrow: jest.Mock;
|
||||
};
|
||||
extractionHarvest: { createMany: jest.Mock; deleteMany: jest.Mock };
|
||||
$queryRaw: jest.Mock;
|
||||
};
|
||||
|
||||
const extraction = { id: 'extraction-1', date: new Date() };
|
||||
const extraction = {
|
||||
id: 'extraction-1',
|
||||
date: new Date(),
|
||||
amount: null as number | null,
|
||||
harvests: [entry('harvest-1', 20), entry('harvest-2', 22.5)],
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
tx = {
|
||||
extraction: {
|
||||
create: jest.fn().mockResolvedValue(extraction),
|
||||
update: jest.fn(),
|
||||
findUnique: jest.fn().mockResolvedValue(extraction),
|
||||
findUniqueOrThrow: jest.fn().mockResolvedValue(extraction),
|
||||
},
|
||||
extractionHarvest: { createMany: jest.fn(), deleteMany: jest.fn() },
|
||||
$queryRaw: jest.fn().mockResolvedValue([]),
|
||||
};
|
||||
prisma = {
|
||||
extraction: { findUnique: jest.fn(), update: jest.fn() },
|
||||
extraction: { findUnique: jest.fn() },
|
||||
harvest: {
|
||||
findMany: jest
|
||||
.fn()
|
||||
@@ -38,9 +65,17 @@ describe('ExtractionsService', () => {
|
||||
),
|
||||
};
|
||||
auditLog = { record: jest.fn() };
|
||||
events = { record: jest.fn() };
|
||||
withdrawalCheck = {
|
||||
conflictsForExtraction: jest.fn().mockResolvedValue([]),
|
||||
conflictsForHarvests: jest.fn().mockResolvedValue([]),
|
||||
warningsFor: jest.fn().mockResolvedValue([]),
|
||||
};
|
||||
service = new ExtractionsService(
|
||||
prisma as unknown as PrismaService,
|
||||
auditLog as unknown as AuditLogService,
|
||||
events as unknown as DomainEventsService,
|
||||
withdrawalCheck as unknown as WithdrawalCheckService,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -52,8 +87,6 @@ describe('ExtractionsService', () => {
|
||||
});
|
||||
|
||||
it('create links every harvest to the extraction inside a transaction', async () => {
|
||||
prisma.extraction.findUnique.mockResolvedValue(extraction);
|
||||
|
||||
await service.create(
|
||||
{ date: '2026-07-05', harvestIds: ['harvest-1', 'harvest-2'] },
|
||||
'user-1',
|
||||
@@ -68,6 +101,61 @@ describe('ExtractionsService', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('create writes the audit entry and ExtractionRecorded in the same transaction', async () => {
|
||||
await service.create(
|
||||
{ date: '2026-07-05', harvestIds: ['harvest-1', 'harvest-2'] },
|
||||
'user-1',
|
||||
);
|
||||
|
||||
expect(auditLog.record).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
entityType: 'Extraction',
|
||||
entityId: 'extraction-1',
|
||||
action: 'CREATE',
|
||||
userId: 'user-1',
|
||||
}),
|
||||
tx,
|
||||
);
|
||||
expect(events.record).toHaveBeenCalledWith(tx, {
|
||||
type: 'ExtractionRecorded',
|
||||
aggregateType: 'Extraction',
|
||||
aggregateId: 'extraction-1',
|
||||
payload: { harvestIds: ['harvest-1', 'harvest-2'] },
|
||||
actorUserId: 'user-1',
|
||||
});
|
||||
});
|
||||
|
||||
it('create answers the extraction with withdrawal and plausibility warnings, never an error', async () => {
|
||||
const conflict = { harvestId: 'harvest-1' };
|
||||
withdrawalCheck.conflictsForExtraction.mockResolvedValue([conflict]);
|
||||
withdrawalCheck.warningsFor.mockResolvedValue([
|
||||
{ code: 'WITHDRAWAL_ACTIVE' },
|
||||
]);
|
||||
tx.extraction.findUniqueOrThrow.mockResolvedValue({
|
||||
...extraction,
|
||||
amount: 50,
|
||||
});
|
||||
|
||||
const result = await service.create(
|
||||
{
|
||||
date: '2026-07-05',
|
||||
harvestIds: ['harvest-1', 'harvest-2'],
|
||||
amount: 50,
|
||||
},
|
||||
'user-1',
|
||||
);
|
||||
|
||||
expect(withdrawalCheck.conflictsForExtraction).toHaveBeenCalledWith(
|
||||
tx,
|
||||
'extraction-1',
|
||||
);
|
||||
expect(withdrawalCheck.warningsFor).toHaveBeenCalledWith(tx, [conflict]);
|
||||
expect(result.warnings.map((warning) => warning.code)).toEqual([
|
||||
'WITHDRAWAL_ACTIVE',
|
||||
'EXTRACTION_EXCEEDS_HARVEST',
|
||||
]);
|
||||
});
|
||||
|
||||
it('create rejects an unknown harvest id instead of failing on the DB foreign-key constraint', async () => {
|
||||
prisma.harvest.findMany.mockResolvedValue([{ id: 'harvest-1' }]);
|
||||
|
||||
@@ -82,9 +170,8 @@ describe('ExtractionsService', () => {
|
||||
|
||||
it('update writes an audit log entry with before and after data', async () => {
|
||||
const updated = { ...extraction, notes: 'geändert' };
|
||||
prisma.extraction.findUnique
|
||||
.mockResolvedValueOnce(extraction)
|
||||
.mockResolvedValueOnce(updated);
|
||||
tx.extraction.findUnique.mockResolvedValueOnce(extraction);
|
||||
tx.extraction.findUniqueOrThrow.mockResolvedValueOnce(updated);
|
||||
|
||||
const result = await service.update(
|
||||
'extraction-1',
|
||||
@@ -92,7 +179,7 @@ describe('ExtractionsService', () => {
|
||||
'user-1',
|
||||
);
|
||||
|
||||
expect(result).toEqual(updated);
|
||||
expect(result).toEqual({ ...updated, warnings: [] });
|
||||
expect(auditLog.record).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
entityType: 'Extraction',
|
||||
@@ -101,13 +188,49 @@ describe('ExtractionsService', () => {
|
||||
beforeData: extraction,
|
||||
afterData: updated,
|
||||
}),
|
||||
tx,
|
||||
);
|
||||
expect(tx.extractionHarvest.deleteMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('update replaces the whole harvest set when harvestIds is given', async () => {
|
||||
prisma.extraction.findUnique.mockResolvedValue(extraction);
|
||||
it('update locks the row first and takes "before" from the locked row', async () => {
|
||||
const locked = { ...extraction, notes: 'gesperrt gelesen' };
|
||||
tx.extraction.findUnique.mockResolvedValue(locked);
|
||||
|
||||
await service.update('extraction-1', { notes: 'neu' }, 'user-1');
|
||||
|
||||
expect(tx.$queryRaw).toHaveBeenCalledTimes(1);
|
||||
expect(tx.$queryRaw.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
tx.extraction.findUnique.mock.invocationCallOrder[0],
|
||||
);
|
||||
expect(auditLog.record).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ beforeData: locked }),
|
||||
tx,
|
||||
);
|
||||
});
|
||||
|
||||
it('update throws NotFoundException for an unknown extraction', async () => {
|
||||
tx.extraction.findUnique.mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
service.update('missing', { notes: 'x' }, 'user-1'),
|
||||
).rejects.toBeInstanceOf(NotFoundException);
|
||||
expect(events.record).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('update records ExtractionCorrected with the harvests after the change', async () => {
|
||||
await service.update('extraction-1', { notes: 'x' }, 'user-1');
|
||||
|
||||
expect(events.record).toHaveBeenCalledWith(tx, {
|
||||
type: 'ExtractionCorrected',
|
||||
aggregateType: 'Extraction',
|
||||
aggregateId: 'extraction-1',
|
||||
payload: { harvestIds: ['harvest-1', 'harvest-2'] },
|
||||
actorUserId: 'user-1',
|
||||
});
|
||||
});
|
||||
|
||||
it('update replaces the whole harvest set when harvestIds is given', async () => {
|
||||
await service.update(
|
||||
'extraction-1',
|
||||
{ harvestIds: ['harvest-1', 'harvest-2'] },
|
||||
@@ -127,7 +250,6 @@ describe('ExtractionsService', () => {
|
||||
});
|
||||
|
||||
it('update rejects an unknown harvest id before touching the database', async () => {
|
||||
prisma.extraction.findUnique.mockResolvedValue(extraction);
|
||||
prisma.harvest.findMany.mockResolvedValue([{ id: 'harvest-1' }]);
|
||||
|
||||
await expect(
|
||||
@@ -139,4 +261,100 @@ describe('ExtractionsService', () => {
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(prisma.$transaction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe('prefill', () => {
|
||||
const harvestRow = (
|
||||
id: string,
|
||||
netWeight: number | null,
|
||||
honeyType: string | null,
|
||||
) => ({
|
||||
id,
|
||||
date: new Date('2026-07-05T00:00:00.000Z'),
|
||||
netWeight,
|
||||
honeyType,
|
||||
});
|
||||
|
||||
it('sums the net weights and keeps a shared honey type', async () => {
|
||||
prisma.harvest.findMany.mockResolvedValue([
|
||||
harvestRow('harvest-1', 20, 'Blütenhonig'),
|
||||
harvestRow('harvest-2', 22.5, 'Blütenhonig'),
|
||||
]);
|
||||
|
||||
const result = await service.prefill(['harvest-1', 'harvest-2']);
|
||||
|
||||
expect(result).toEqual({
|
||||
amountKg: 42.5,
|
||||
honeyType: 'Blütenhonig',
|
||||
sources: [
|
||||
{ harvestId: 'harvest-1', date: '2026-07-05', netWeight: 20 },
|
||||
{ harvestId: 'harvest-2', date: '2026-07-05', netWeight: 22.5 },
|
||||
],
|
||||
warnings: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('warns before saving when a chosen harvest lies in a withdrawal window', async () => {
|
||||
const conflict = { harvestId: 'harvest-1' };
|
||||
prisma.harvest.findMany.mockResolvedValue([
|
||||
harvestRow('harvest-1', 20, null),
|
||||
]);
|
||||
withdrawalCheck.conflictsForHarvests.mockResolvedValue([conflict]);
|
||||
withdrawalCheck.warningsFor.mockResolvedValue([
|
||||
{ code: 'WITHDRAWAL_ACTIVE' },
|
||||
]);
|
||||
|
||||
const result = await service.prefill(['harvest-1']);
|
||||
|
||||
expect(withdrawalCheck.conflictsForHarvests).toHaveBeenCalledWith(
|
||||
prisma,
|
||||
['harvest-1'],
|
||||
);
|
||||
expect(withdrawalCheck.warningsFor).toHaveBeenCalledWith(prisma, [
|
||||
conflict,
|
||||
]);
|
||||
expect(result.warnings.map((warning) => warning.code)).toEqual([
|
||||
'WITHDRAWAL_ACTIVE',
|
||||
]);
|
||||
});
|
||||
|
||||
it('offers no honey type when the harvests differ or one has none', async () => {
|
||||
prisma.harvest.findMany.mockResolvedValue([
|
||||
harvestRow('harvest-1', 20, 'Blütenhonig'),
|
||||
harvestRow('harvest-2', 22.5, 'Waldhonig'),
|
||||
]);
|
||||
const differing = await service.prefill(['harvest-1', 'harvest-2']);
|
||||
expect(differing.honeyType).toBeNull();
|
||||
|
||||
prisma.harvest.findMany.mockResolvedValue([
|
||||
harvestRow('harvest-1', 20, 'Blütenhonig'),
|
||||
harvestRow('harvest-2', 22.5, null),
|
||||
]);
|
||||
const partly = await service.prefill(['harvest-1', 'harvest-2']);
|
||||
expect(partly.honeyType).toBeNull();
|
||||
});
|
||||
|
||||
it('offers no amount when a net weight is missing and hints instead', async () => {
|
||||
prisma.harvest.findMany.mockResolvedValue([
|
||||
harvestRow('harvest-1', 20, null),
|
||||
harvestRow('harvest-2', null, null),
|
||||
]);
|
||||
|
||||
const result = await service.prefill(['harvest-1', 'harvest-2']);
|
||||
|
||||
expect(result.amountKg).toBeNull();
|
||||
expect(result.warnings.map((warning) => warning.code)).toEqual([
|
||||
'HARVEST_NET_MISSING',
|
||||
]);
|
||||
});
|
||||
|
||||
it('rejects an unknown harvest id', async () => {
|
||||
prisma.harvest.findMany.mockResolvedValue([
|
||||
harvestRow('harvest-1', 1, null),
|
||||
]);
|
||||
|
||||
await expect(
|
||||
service.prefill(['harvest-1', 'missing-harvest']),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -3,8 +3,23 @@ import {
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { AuditLogService } from '../audit/audit-log.service';
|
||||
import { DomainEventsService } from '../domain-events/domain-events.service';
|
||||
import { berlinDayOf } from '../common/dates';
|
||||
import {
|
||||
withWarnings,
|
||||
type PlausibilityWarning,
|
||||
} from '../common/warnings/plausibility-warning';
|
||||
import {
|
||||
extractionWarnings,
|
||||
roundKg,
|
||||
sharedHoneyType,
|
||||
} from '../honey-batches/honey-plausibility';
|
||||
import { DbClient } from '../prisma/db-client';
|
||||
import { lockRowForUpdate } from '../prisma/row-lock';
|
||||
import { WithdrawalCheckService } from '../withdrawal/withdrawal-check.service';
|
||||
import { CreateExtractionDto } from './dto/create-extraction.dto';
|
||||
import { UpdateExtractionDto } from './dto/update-extraction.dto';
|
||||
|
||||
@@ -16,11 +31,37 @@ const extractionInclude = {
|
||||
},
|
||||
} as const;
|
||||
|
||||
const PROPOSAL =
|
||||
'Vorschlag, keine Messung \u2013 nie stillschweigend \u00fcbernehmen';
|
||||
|
||||
export class ExtractionPrefillSource {
|
||||
@ApiProperty() harvestId!: string;
|
||||
@ApiProperty({ description: 'Berliner Tag, JJJJ-MM-TT' }) date!: string;
|
||||
@ApiProperty({ type: Number, nullable: true }) netWeight!: number | null;
|
||||
}
|
||||
|
||||
/** What the extraction form may offer from the chosen harvests (spec 7.6). */
|
||||
export class ExtractionPrefill {
|
||||
@ApiProperty({ type: Number, nullable: true, description: PROPOSAL })
|
||||
amountKg!: number | null;
|
||||
|
||||
@ApiProperty({ type: String, nullable: true, description: PROPOSAL })
|
||||
honeyType!: string | null;
|
||||
|
||||
@ApiProperty({ type: [ExtractionPrefillSource] })
|
||||
sources!: ExtractionPrefillSource[];
|
||||
|
||||
@ApiProperty({ type: 'array', items: { type: 'object' } })
|
||||
warnings!: PlausibilityWarning[];
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ExtractionsService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly auditLog: AuditLogService,
|
||||
private readonly events: DomainEventsService,
|
||||
private readonly withdrawalCheck: WithdrawalCheckService,
|
||||
) {}
|
||||
|
||||
async findAll() {
|
||||
@@ -55,9 +96,42 @@ export class ExtractionsService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The extraction as the response body plus its warnings: a harvest inside
|
||||
* a withdrawal window and the amount against the net weight of the
|
||||
* harvests. Warnings inform, they never block (Annahme 10). The
|
||||
* withdrawal warning goes to everyone who records the extraction, whether
|
||||
* or not they may read treatments (ruling: food safety beats the read
|
||||
* split); the text names only colony, product and end day.
|
||||
*/
|
||||
private async withAllWarnings<
|
||||
T extends {
|
||||
id: string;
|
||||
amount: number | null;
|
||||
harvests: { harvestId: string; harvest: { netWeight: number | null } }[];
|
||||
},
|
||||
>(db: DbClient, extraction: T) {
|
||||
const conflicts = await this.withdrawalCheck.conflictsForExtraction(
|
||||
db,
|
||||
extraction.id,
|
||||
);
|
||||
return withWarnings(extraction, [
|
||||
...(await this.withdrawalCheck.warningsFor(db, conflicts)),
|
||||
...extractionWarnings(
|
||||
extraction.amount,
|
||||
extraction.harvests.map((entry) => ({
|
||||
id: entry.harvestId,
|
||||
netWeight: entry.harvest.netWeight,
|
||||
})),
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
async create(dto: CreateExtractionDto, actingUserId: string) {
|
||||
await this.assertHarvestsExist(dto.harvestIds);
|
||||
const extraction = await this.prisma.$transaction(async (tx) => {
|
||||
// Extraction, harvest links, audit entry and ExtractionRecorded commit
|
||||
// together; the withdrawal notification follows from the event.
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
const created = await tx.extraction.create({
|
||||
data: {
|
||||
date: new Date(dto.date),
|
||||
@@ -75,26 +149,48 @@ export class ExtractionsService {
|
||||
harvestId,
|
||||
})),
|
||||
});
|
||||
return created;
|
||||
await this.auditLog.record(
|
||||
{
|
||||
entityType: 'Extraction',
|
||||
entityId: created.id,
|
||||
action: 'CREATE',
|
||||
userId: actingUserId,
|
||||
afterData: created,
|
||||
},
|
||||
tx,
|
||||
);
|
||||
await this.events.record(tx, {
|
||||
type: 'ExtractionRecorded',
|
||||
aggregateType: 'Extraction',
|
||||
aggregateId: created.id,
|
||||
payload: { harvestIds: dto.harvestIds },
|
||||
actorUserId: actingUserId,
|
||||
});
|
||||
const extraction = await tx.extraction.findUniqueOrThrow({
|
||||
where: { id: created.id },
|
||||
include: extractionInclude,
|
||||
});
|
||||
return this.withAllWarnings(tx, extraction);
|
||||
});
|
||||
await this.auditLog.record({
|
||||
entityType: 'Extraction',
|
||||
entityId: extraction.id,
|
||||
action: 'CREATE',
|
||||
userId: actingUserId,
|
||||
afterData: extraction,
|
||||
});
|
||||
return this.findById(extraction.id);
|
||||
}
|
||||
|
||||
async update(id: string, dto: UpdateExtractionDto, actingUserId: string) {
|
||||
const before = await this.findById(id);
|
||||
const { harvestIds, ...scalarFields } = dto;
|
||||
if (harvestIds) {
|
||||
await this.assertHarvestsExist(harvestIds);
|
||||
}
|
||||
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
// "Before" is read under the row lock, not before the transaction: a
|
||||
// concurrent edit cannot slip in between.
|
||||
await lockRowForUpdate(tx, 'Extraction', id);
|
||||
const before = await tx.extraction.findUnique({
|
||||
where: { id },
|
||||
include: extractionInclude,
|
||||
});
|
||||
if (!before) {
|
||||
throw new NotFoundException('Extraction not found');
|
||||
}
|
||||
await tx.extraction.update({
|
||||
where: { id },
|
||||
data: {
|
||||
@@ -113,17 +209,85 @@ export class ExtractionsService {
|
||||
})),
|
||||
});
|
||||
}
|
||||
const extraction = await tx.extraction.findUniqueOrThrow({
|
||||
where: { id },
|
||||
include: extractionInclude,
|
||||
});
|
||||
await this.auditLog.record(
|
||||
{
|
||||
entityType: 'Extraction',
|
||||
entityId: id,
|
||||
action: 'UPDATE',
|
||||
userId: actingUserId,
|
||||
beforeData: before,
|
||||
afterData: extraction,
|
||||
},
|
||||
tx,
|
||||
);
|
||||
await this.events.record(tx, {
|
||||
type: 'ExtractionCorrected',
|
||||
aggregateType: 'Extraction',
|
||||
aggregateId: id,
|
||||
payload: {
|
||||
harvestIds: extraction.harvests.map((entry) => entry.harvestId),
|
||||
},
|
||||
actorUserId: actingUserId,
|
||||
});
|
||||
return this.withAllWarnings(tx, extraction);
|
||||
});
|
||||
}
|
||||
|
||||
const extraction = await this.findById(id);
|
||||
await this.auditLog.record({
|
||||
entityType: 'Extraction',
|
||||
entityId: id,
|
||||
action: 'UPDATE',
|
||||
userId: actingUserId,
|
||||
beforeData: before,
|
||||
afterData: extraction,
|
||||
/**
|
||||
* What the form may offer for the chosen harvests (spec 7.6). The amount is
|
||||
* a measurement and so only a proposal: the sum of the net weights, and
|
||||
* only when every one is known. Read only, no lock.
|
||||
*/
|
||||
async prefill(harvestIds: readonly string[]): Promise<ExtractionPrefill> {
|
||||
const ids = [...new Set(harvestIds)];
|
||||
const harvests = await this.prisma.harvest.findMany({
|
||||
where: { id: { in: ids } },
|
||||
select: { id: true, date: true, netWeight: true, honeyType: true },
|
||||
orderBy: [{ date: 'asc' }, { id: 'asc' }],
|
||||
});
|
||||
return extraction;
|
||||
const found = new Set(harvests.map((harvest) => harvest.id));
|
||||
const missing = ids.filter((id) => !found.has(id));
|
||||
if (missing.length > 0) {
|
||||
throw new BadRequestException(
|
||||
`Unknown harvest id(s): ${missing.join(', ')}`,
|
||||
);
|
||||
}
|
||||
const conflicts = await this.withdrawalCheck.conflictsForHarvests(
|
||||
this.prisma,
|
||||
ids,
|
||||
);
|
||||
const allNetKnown = harvests.every((harvest) => harvest.netWeight !== null);
|
||||
return {
|
||||
amountKg:
|
||||
allNetKnown && harvests.length > 0
|
||||
? roundKg(
|
||||
harvests.reduce(
|
||||
(sum, harvest) => sum + (harvest.netWeight ?? 0),
|
||||
0,
|
||||
),
|
||||
)
|
||||
: null,
|
||||
honeyType: sharedHoneyType(harvests),
|
||||
sources: harvests.map((harvest) => ({
|
||||
harvestId: harvest.id,
|
||||
date: berlinDayOf(harvest.date),
|
||||
netWeight: harvest.netWeight,
|
||||
})),
|
||||
// The same warnings the save will answer, so the form can warn first.
|
||||
warnings: [
|
||||
...(await this.withdrawalCheck.warningsFor(this.prisma, conflicts)),
|
||||
...extractionWarnings(
|
||||
null,
|
||||
harvests.map((harvest) => ({
|
||||
id: harvest.id,
|
||||
netWeight: harvest.netWeight,
|
||||
})),
|
||||
),
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsString, Matches, MaxLength } from 'class-validator';
|
||||
|
||||
const MAX_ID_LENGTH = 64;
|
||||
|
||||
export class HoneyBatchPrefillQuery {
|
||||
@ApiProperty({ description: 'Die Schleuderung, aus der die Charge entsteht' })
|
||||
@IsString()
|
||||
@MaxLength(MAX_ID_LENGTH)
|
||||
@Matches(/^[A-Za-z0-9_-]+$/)
|
||||
extractionId!: string;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* The honey batch as the API and the webhook `batch.created` carry it. One
|
||||
* definition: the service answers with it and the webhook handler loads the
|
||||
* batch with it, so both payloads keep the same shape.
|
||||
*/
|
||||
export const honeyBatchInclude = {
|
||||
measurements: true,
|
||||
storageContainer: true,
|
||||
extraction: {
|
||||
include: {
|
||||
harvests: {
|
||||
include: {
|
||||
harvest: { include: { colonies: { include: { colony: true } } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
@@ -20,6 +20,7 @@ import { CreateHoneyMeasurementDto } from './dto/create-honey-measurement.dto';
|
||||
import { UpdateHoneyMeasurementDto } from './dto/update-honey-measurement.dto';
|
||||
import { RelocateHoneyBatchDto } from './dto/relocate-honey-batch.dto';
|
||||
import { SetHoneyBatchContainerAllocationDto } from './dto/set-honey-batch-container-allocation.dto';
|
||||
import { HoneyBatchPrefillQuery } from './dto/honey-batch-prefill.query';
|
||||
import { HoneyBatchesService } from './honey-batches.service';
|
||||
|
||||
@ApiTags('honey-batches')
|
||||
@@ -34,6 +35,15 @@ export class HoneyBatchesController {
|
||||
return this.honeyBatchesService.findAll({ status });
|
||||
}
|
||||
|
||||
// Before ':id', so 'prefill' is not read as an id. Needs the create
|
||||
// permission, not read: it offers the same data the create flow needs, and
|
||||
// POST answers it anyway (ruling, Task 28 review).
|
||||
@RequirePermission('honey-batches:create')
|
||||
@Get('prefill')
|
||||
prefill(@Query() query: HoneyBatchPrefillQuery) {
|
||||
return this.honeyBatchesService.prefill(query.extractionId);
|
||||
}
|
||||
|
||||
@RequirePermission('honey-batches:read')
|
||||
@Get(':id')
|
||||
findOne(@Param('id') id: string) {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { BadRequestException, NotFoundException } from '@nestjs/common';
|
||||
import { HoneyBatchesService } from './honey-batches.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { AuditLogService } from '../audit/audit-log.service';
|
||||
import { WebhooksService } from '../webhooks/webhooks.service';
|
||||
import { DomainEventsService } from '../domain-events/domain-events.service';
|
||||
import { AutomationService } from '../automation/automation.service';
|
||||
|
||||
describe('HoneyBatchesService', () => {
|
||||
@@ -11,6 +11,7 @@ describe('HoneyBatchesService', () => {
|
||||
honeyBatch: {
|
||||
findMany: jest.Mock;
|
||||
findUnique: jest.Mock;
|
||||
findUniqueOrThrow: jest.Mock;
|
||||
create: jest.Mock;
|
||||
update: jest.Mock;
|
||||
};
|
||||
@@ -19,6 +20,7 @@ describe('HoneyBatchesService', () => {
|
||||
findUnique: jest.Mock;
|
||||
update: jest.Mock;
|
||||
};
|
||||
extraction: { findUnique: jest.Mock };
|
||||
storageContainer: { findUnique: jest.Mock };
|
||||
honeyBatchRelocation: { create: jest.Mock; findMany: jest.Mock };
|
||||
honeyBatchContainerAllocation: {
|
||||
@@ -28,8 +30,11 @@ describe('HoneyBatchesService', () => {
|
||||
delete: jest.Mock;
|
||||
};
|
||||
$transaction: jest.Mock;
|
||||
$queryRaw: jest.Mock;
|
||||
};
|
||||
let auditLog: { record: jest.Mock };
|
||||
let events: { record: jest.Mock };
|
||||
let automation: { generateNumber: jest.Mock };
|
||||
|
||||
const extraction = {
|
||||
id: 'extraction-1',
|
||||
@@ -49,6 +54,7 @@ describe('HoneyBatchesService', () => {
|
||||
const honeyBatch = {
|
||||
id: 'batch-1',
|
||||
batchNumber: 'HC-2026-001',
|
||||
extractionId: 'extraction-1',
|
||||
honeyType: 'Blütenhonig',
|
||||
amount: 20,
|
||||
status: 'RAW',
|
||||
@@ -61,6 +67,7 @@ describe('HoneyBatchesService', () => {
|
||||
honeyBatch: {
|
||||
findMany: jest.fn(),
|
||||
findUnique: jest.fn(),
|
||||
findUniqueOrThrow: jest.fn(),
|
||||
create: jest.fn(),
|
||||
update: jest.fn(),
|
||||
},
|
||||
@@ -69,6 +76,7 @@ describe('HoneyBatchesService', () => {
|
||||
findUnique: jest.fn(),
|
||||
update: jest.fn(),
|
||||
},
|
||||
extraction: { findUnique: jest.fn() },
|
||||
storageContainer: { findUnique: jest.fn() },
|
||||
honeyBatchRelocation: { create: jest.fn(), findMany: jest.fn() },
|
||||
honeyBatchContainerAllocation: {
|
||||
@@ -77,16 +85,21 @@ describe('HoneyBatchesService', () => {
|
||||
upsert: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
},
|
||||
$transaction: jest.fn(),
|
||||
$transaction: jest.fn(
|
||||
async (
|
||||
callback: (tx: typeof prisma) => Promise<unknown>,
|
||||
): Promise<unknown> => callback(prisma),
|
||||
),
|
||||
$queryRaw: jest.fn().mockResolvedValue([]),
|
||||
};
|
||||
auditLog = { record: jest.fn() };
|
||||
const webhooks = { dispatch: jest.fn().mockResolvedValue(undefined) };
|
||||
const automation = { generateNumber: jest.fn().mockResolvedValue(null) };
|
||||
events = { record: jest.fn() };
|
||||
automation = { generateNumber: jest.fn().mockResolvedValue(null) };
|
||||
service = new HoneyBatchesService(
|
||||
prisma as unknown as PrismaService,
|
||||
auditLog as unknown as AuditLogService,
|
||||
webhooks as unknown as WebhooksService,
|
||||
automation as unknown as AutomationService,
|
||||
events as unknown as DomainEventsService,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -97,20 +110,77 @@ describe('HoneyBatchesService', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('create stores the batch and returns it with relations', async () => {
|
||||
prisma.honeyBatch.create.mockResolvedValue(honeyBatch);
|
||||
prisma.honeyBatch.findUnique.mockResolvedValue(honeyBatch);
|
||||
describe('create', () => {
|
||||
const dto = { batchNumber: 'HC-2026-001', extractionId: 'extraction-1' };
|
||||
|
||||
await service.create(
|
||||
{ batchNumber: 'HC-2026-001', extractionId: 'extraction-1' },
|
||||
'user-1',
|
||||
);
|
||||
beforeEach(() => {
|
||||
prisma.honeyBatch.create.mockResolvedValue(honeyBatch);
|
||||
prisma.honeyBatch.findUniqueOrThrow.mockResolvedValue(honeyBatch);
|
||||
prisma.honeyBatch.findMany.mockResolvedValue([]);
|
||||
prisma.extraction.findUnique.mockResolvedValue({
|
||||
id: 'extraction-1',
|
||||
amount: null,
|
||||
});
|
||||
});
|
||||
|
||||
const [[createArg]] = prisma.honeyBatch.create.mock.calls as [
|
||||
[{ data: { batchNumber: string; extractionId: string } }],
|
||||
];
|
||||
expect(createArg.data.batchNumber).toBe('HC-2026-001');
|
||||
expect(createArg.data.extractionId).toBe('extraction-1');
|
||||
it('stores the batch and returns it with relations and warnings', async () => {
|
||||
const result = await service.create(dto, 'user-1');
|
||||
|
||||
const [[createArg]] = prisma.honeyBatch.create.mock.calls as [
|
||||
[{ data: { batchNumber: string; extractionId: string } }],
|
||||
];
|
||||
expect(createArg.data.batchNumber).toBe('HC-2026-001');
|
||||
expect(createArg.data.extractionId).toBe('extraction-1');
|
||||
expect(result).toEqual({ ...honeyBatch, warnings: [] });
|
||||
});
|
||||
|
||||
it('writes the audit entry and HoneyBatchRecorded in the same transaction', async () => {
|
||||
await service.create(dto, 'user-1');
|
||||
|
||||
expect(auditLog.record).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
entityType: 'HoneyBatch',
|
||||
action: 'CREATE',
|
||||
userId: 'user-1',
|
||||
}),
|
||||
prisma,
|
||||
);
|
||||
expect(events.record).toHaveBeenCalledWith(prisma, {
|
||||
type: 'HoneyBatchRecorded',
|
||||
aggregateType: 'HoneyBatch',
|
||||
aggregateId: 'batch-1',
|
||||
payload: { extractionId: 'extraction-1' },
|
||||
actorUserId: 'user-1',
|
||||
});
|
||||
});
|
||||
|
||||
it('locks the extraction row before it writes and counts the batches', async () => {
|
||||
await service.create(dto, 'user-1');
|
||||
|
||||
expect(prisma.$queryRaw.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
prisma.honeyBatch.create.mock.invocationCallOrder[0],
|
||||
);
|
||||
expect(prisma.$queryRaw.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
prisma.honeyBatch.findMany.mock.invocationCallOrder[0],
|
||||
);
|
||||
});
|
||||
|
||||
it('warns when the batches exceed the extraction, but still answers', async () => {
|
||||
prisma.extraction.findUnique.mockResolvedValue({
|
||||
id: 'extraction-1',
|
||||
amount: 10,
|
||||
});
|
||||
prisma.honeyBatch.findMany.mockResolvedValue([
|
||||
{ id: 'batch-0', initialAmount: 6, amount: 6 },
|
||||
{ id: 'batch-1', initialAmount: null, amount: 8 },
|
||||
]);
|
||||
|
||||
const result = await service.create(dto, 'user-1');
|
||||
|
||||
expect(result.warnings.map((warning) => warning.code)).toEqual([
|
||||
'BATCHES_EXCEED_EXTRACTION',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
it('traceability flattens the extraction, harvest, and colony chain', async () => {
|
||||
@@ -365,11 +435,68 @@ describe('HoneyBatchesService', () => {
|
||||
|
||||
describe('update', () => {
|
||||
beforeEach(() => {
|
||||
prisma.$transaction.mockImplementation(
|
||||
async (
|
||||
callback: (tx: typeof prisma) => Promise<unknown>,
|
||||
): Promise<unknown> => callback(prisma),
|
||||
prisma.honeyBatch.findUniqueOrThrow.mockResolvedValue(honeyBatch);
|
||||
prisma.honeyBatch.findMany.mockResolvedValue([]);
|
||||
prisma.extraction.findUnique.mockResolvedValue({
|
||||
id: 'extraction-1',
|
||||
amount: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('locks the row first, reads "before" from the locked row and records HoneyBatchCorrected', async () => {
|
||||
prisma.honeyBatch.findUnique.mockResolvedValue(honeyBatch);
|
||||
|
||||
const result = await service.update(
|
||||
'batch-1',
|
||||
{ honeyType: 'Waldhonig' },
|
||||
'user-1',
|
||||
);
|
||||
|
||||
// "Before" is the read after the last lock.
|
||||
const reads = prisma.honeyBatch.findUnique.mock.invocationCallOrder;
|
||||
expect(prisma.$queryRaw.mock.invocationCallOrder[1]).toBeLessThan(
|
||||
reads[reads.length - 1],
|
||||
);
|
||||
expect(auditLog.record).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
action: 'UPDATE',
|
||||
beforeData: honeyBatch,
|
||||
}),
|
||||
prisma,
|
||||
);
|
||||
expect(events.record).toHaveBeenCalledWith(prisma, {
|
||||
type: 'HoneyBatchCorrected',
|
||||
aggregateType: 'HoneyBatch',
|
||||
aggregateId: 'batch-1',
|
||||
payload: { extractionId: 'extraction-1' },
|
||||
actorUserId: 'user-1',
|
||||
});
|
||||
expect(result.warnings).toEqual([]);
|
||||
});
|
||||
|
||||
it('locks the extraction before the batch and before counting the batches', async () => {
|
||||
prisma.honeyBatch.findUnique.mockResolvedValue(honeyBatch);
|
||||
|
||||
await service.update('batch-1', { honeyType: 'Waldhonig' }, 'user-1');
|
||||
|
||||
// Extraction lock, then batch lock: two statements, both before the
|
||||
// update and the count of the batches.
|
||||
expect(prisma.$queryRaw).toHaveBeenCalledTimes(2);
|
||||
expect(prisma.$queryRaw.mock.invocationCallOrder[1]).toBeLessThan(
|
||||
prisma.honeyBatch.update.mock.invocationCallOrder[0],
|
||||
);
|
||||
expect(prisma.$queryRaw.mock.invocationCallOrder[1]).toBeLessThan(
|
||||
prisma.honeyBatch.findMany.mock.invocationCallOrder[0],
|
||||
);
|
||||
});
|
||||
|
||||
it('throws NotFoundException for an unknown batch', async () => {
|
||||
prisma.honeyBatch.findUnique.mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
service.update('missing', { honeyType: 'x' }, 'user-1'),
|
||||
).rejects.toBeInstanceOf(NotFoundException);
|
||||
expect(events.record).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('records a relocation when an edit corrects the storage container', async () => {
|
||||
@@ -418,6 +545,7 @@ describe('HoneyBatchesService', () => {
|
||||
entityId: 'batch-1',
|
||||
action: 'UPDATE',
|
||||
}),
|
||||
prisma,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -433,6 +561,72 @@ describe('HoneyBatchesService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('prefill', () => {
|
||||
it('proposes the rest of the extraction with its honey type and water content', async () => {
|
||||
prisma.extraction.findUnique.mockResolvedValue({
|
||||
id: 'extraction-1',
|
||||
amount: 42.5,
|
||||
waterContent: 17.2,
|
||||
harvests: [
|
||||
{ harvest: { honeyType: 'Bl\u00fctenhonig' } },
|
||||
{ harvest: { honeyType: 'Bl\u00fctenhonig' } },
|
||||
],
|
||||
});
|
||||
prisma.honeyBatch.findMany.mockResolvedValue([
|
||||
{ id: 'batch-0', initialAmount: 20, amount: 20 },
|
||||
{ id: 'batch-9', initialAmount: null, amount: null },
|
||||
]);
|
||||
|
||||
const result = await service.prefill('extraction-1');
|
||||
|
||||
expect(result).toEqual({
|
||||
remainingKg: 22.5,
|
||||
honeyType: 'Bl\u00fctenhonig',
|
||||
waterContent: 17.2,
|
||||
warnings: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('proposes no amount when the extraction has none and never a negative one', async () => {
|
||||
prisma.extraction.findUnique.mockResolvedValue({
|
||||
id: 'extraction-1',
|
||||
amount: null,
|
||||
waterContent: null,
|
||||
harvests: [{ harvest: { honeyType: null } }],
|
||||
});
|
||||
prisma.honeyBatch.findMany.mockResolvedValue([]);
|
||||
const none = await service.prefill('extraction-1');
|
||||
expect(none).toMatchObject({
|
||||
remainingKg: null,
|
||||
honeyType: null,
|
||||
waterContent: null,
|
||||
});
|
||||
|
||||
prisma.extraction.findUnique.mockResolvedValue({
|
||||
id: 'extraction-1',
|
||||
amount: 10,
|
||||
waterContent: null,
|
||||
harvests: [],
|
||||
});
|
||||
prisma.honeyBatch.findMany.mockResolvedValue([
|
||||
{ id: 'batch-0', initialAmount: 12, amount: 12 },
|
||||
]);
|
||||
const over = await service.prefill('extraction-1');
|
||||
expect(over.remainingKg).toBe(0);
|
||||
expect(over.warnings.map((warning) => warning.code)).toEqual([
|
||||
'BATCHES_EXCEED_EXTRACTION',
|
||||
]);
|
||||
});
|
||||
|
||||
it('throws NotFoundException for an unknown extraction', async () => {
|
||||
prisma.extraction.findUnique.mockResolvedValue(null);
|
||||
|
||||
await expect(service.prefill('missing')).rejects.toBeInstanceOf(
|
||||
NotFoundException,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('updateMeasurement corrects a reading and logs before/after', async () => {
|
||||
const before = {
|
||||
id: 'measurement-1',
|
||||
|
||||
@@ -1,12 +1,20 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { AuditLogService } from '../audit/audit-log.service';
|
||||
import { WebhooksService } from '../webhooks/webhooks.service';
|
||||
import { AutomationService } from '../automation/automation.service';
|
||||
import { DomainEventsService } from '../domain-events/domain-events.service';
|
||||
import { DbClient } from '../prisma/db-client';
|
||||
import { lockRowForUpdate, lockRowsForUpdate } from '../prisma/row-lock';
|
||||
import {
|
||||
withWarnings,
|
||||
type PlausibilityWarning,
|
||||
} from '../common/warnings/plausibility-warning';
|
||||
import { HoneyBatchStatus } from '../../generated/prisma';
|
||||
import { CreateHoneyBatchDto } from './dto/create-honey-batch.dto';
|
||||
import { UpdateHoneyBatchDto } from './dto/update-honey-batch.dto';
|
||||
@@ -14,32 +22,43 @@ import { CreateHoneyMeasurementDto } from './dto/create-honey-measurement.dto';
|
||||
import { UpdateHoneyMeasurementDto } from './dto/update-honey-measurement.dto';
|
||||
import { RelocateHoneyBatchDto } from './dto/relocate-honey-batch.dto';
|
||||
import { SetHoneyBatchContainerAllocationDto } from './dto/set-honey-batch-container-allocation.dto';
|
||||
|
||||
const honeyBatchInclude = {
|
||||
measurements: true,
|
||||
storageContainer: true,
|
||||
extraction: {
|
||||
include: {
|
||||
harvests: {
|
||||
include: {
|
||||
harvest: { include: { colonies: { include: { colony: true } } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
import { honeyBatchInclude } from './honey-batch-include';
|
||||
import {
|
||||
batchWarnings,
|
||||
roundKg,
|
||||
sharedHoneyType,
|
||||
sumInitialAmounts,
|
||||
} from './honey-plausibility';
|
||||
|
||||
export interface HoneyBatchFilter {
|
||||
status?: HoneyBatchStatus;
|
||||
}
|
||||
|
||||
const PROPOSAL =
|
||||
'Vorschlag, keine Messung \u2013 nie stillschweigend \u00fcbernehmen';
|
||||
|
||||
/** What the honey batch form may offer for an extraction (spec 7.6). */
|
||||
export class HoneyBatchPrefill {
|
||||
@ApiProperty({ type: Number, nullable: true, description: PROPOSAL })
|
||||
remainingKg!: number | null;
|
||||
|
||||
@ApiProperty({ type: String, nullable: true, description: PROPOSAL })
|
||||
honeyType!: string | null;
|
||||
|
||||
@ApiProperty({ type: Number, nullable: true, description: PROPOSAL })
|
||||
waterContent!: number | null;
|
||||
|
||||
@ApiProperty({ type: 'array', items: { type: 'object' } })
|
||||
warnings!: PlausibilityWarning[];
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class HoneyBatchesService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly auditLog: AuditLogService,
|
||||
private readonly webhooks: WebhooksService,
|
||||
private readonly automation: AutomationService,
|
||||
private readonly events: DomainEventsService,
|
||||
) {}
|
||||
|
||||
async findAll(filter: HoneyBatchFilter = {}) {
|
||||
@@ -61,6 +80,38 @@ export class HoneyBatchesService {
|
||||
return honeyBatch;
|
||||
}
|
||||
|
||||
/**
|
||||
* The batches of one extraction with the amount they started with. A batch
|
||||
* the ledger does not know yet (no initialAmount) counts with its current
|
||||
* amount: it is the best known start, and the rest can only be lower.
|
||||
*/
|
||||
private async batchesOf(db: DbClient, extractionId: string) {
|
||||
const batches = await db.honeyBatch.findMany({
|
||||
where: { extractionId },
|
||||
select: { id: true, initialAmount: true, amount: true },
|
||||
orderBy: { id: 'asc' },
|
||||
});
|
||||
return batches.map((batch) => ({
|
||||
id: batch.id,
|
||||
initialAmount: batch.initialAmount ?? batch.amount,
|
||||
}));
|
||||
}
|
||||
|
||||
/** The batches of the extraction against its amount. Never blocks. */
|
||||
private async warningsFor(
|
||||
db: DbClient,
|
||||
extractionId: string,
|
||||
): Promise<PlausibilityWarning[]> {
|
||||
const extraction = await db.extraction.findUnique({
|
||||
where: { id: extractionId },
|
||||
select: { id: true, amount: true },
|
||||
});
|
||||
if (extraction === null) {
|
||||
return [];
|
||||
}
|
||||
return batchWarnings(extraction, await this.batchesOf(db, extractionId));
|
||||
}
|
||||
|
||||
async create(dto: CreateHoneyBatchDto, actingUserId: string) {
|
||||
const batchNumber =
|
||||
dto.batchNumber ??
|
||||
@@ -70,34 +121,83 @@ export class HoneyBatchesService {
|
||||
'Chargennummer erforderlich (oder Automatisierung "Chargennummer-Format" aktivieren)',
|
||||
);
|
||||
}
|
||||
const honeyBatch = await this.prisma.honeyBatch.create({
|
||||
data: { ...dto, batchNumber },
|
||||
// Batch, audit entry and HoneyBatchRecorded commit together; the webhook
|
||||
// batch.created follows from the event.
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
// The extraction first: batches of one extraction are counted against
|
||||
// its amount, so two of them must not be written side by side.
|
||||
await lockRowForUpdate(tx, 'Extraction', dto.extractionId);
|
||||
const honeyBatch = await tx.honeyBatch.create({
|
||||
data: { ...dto, batchNumber },
|
||||
});
|
||||
await this.auditLog.record(
|
||||
{
|
||||
entityType: 'HoneyBatch',
|
||||
entityId: honeyBatch.id,
|
||||
action: 'CREATE',
|
||||
userId: actingUserId,
|
||||
afterData: honeyBatch,
|
||||
},
|
||||
tx,
|
||||
);
|
||||
await this.events.record(tx, {
|
||||
type: 'HoneyBatchRecorded',
|
||||
aggregateType: 'HoneyBatch',
|
||||
aggregateId: honeyBatch.id,
|
||||
payload: { extractionId: honeyBatch.extractionId },
|
||||
actorUserId: actingUserId,
|
||||
});
|
||||
const created = await tx.honeyBatch.findUniqueOrThrow({
|
||||
where: { id: honeyBatch.id },
|
||||
include: honeyBatchInclude,
|
||||
});
|
||||
return withWarnings(
|
||||
created,
|
||||
await this.warningsFor(tx, created.extractionId),
|
||||
);
|
||||
});
|
||||
await this.auditLog.record({
|
||||
entityType: 'HoneyBatch',
|
||||
entityId: honeyBatch.id,
|
||||
action: 'CREATE',
|
||||
userId: actingUserId,
|
||||
afterData: honeyBatch,
|
||||
});
|
||||
const created = await this.findById(honeyBatch.id);
|
||||
await this.webhooks.dispatch('batch.created', created);
|
||||
return created;
|
||||
}
|
||||
|
||||
async update(id: string, dto: UpdateHoneyBatchDto, actingUserId: string) {
|
||||
const before = await this.findById(id);
|
||||
// A relocation is a move into a container. null only removes the
|
||||
// assignment - a batch may stand in no container, as it may be created
|
||||
// without one - so it is recorded by the audit entry below, not as a
|
||||
// relocation (which always needs a target container).
|
||||
const movedToContainerId =
|
||||
typeof dto.storageContainerId === 'string' &&
|
||||
dto.storageContainerId !== before.storageContainerId
|
||||
? dto.storageContainerId
|
||||
: null;
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
// Lock order Extraction -> HoneyBatch. The extraction ids (current and
|
||||
// a new one) come from an unlocked peek; the locked re-read below
|
||||
// proves the peek still holds.
|
||||
const peek = await tx.honeyBatch.findUnique({
|
||||
where: { id },
|
||||
select: { extractionId: true },
|
||||
});
|
||||
if (!peek) {
|
||||
throw new NotFoundException('Honey batch not found');
|
||||
}
|
||||
await lockRowsForUpdate(tx, 'Extraction', [
|
||||
...new Set([peek.extractionId, dto.extractionId ?? peek.extractionId]),
|
||||
]);
|
||||
// "Before" is read under the row lock, not before the transaction: a
|
||||
// concurrent edit cannot slip in between.
|
||||
await lockRowForUpdate(tx, 'HoneyBatch', id);
|
||||
const before = await tx.honeyBatch.findUnique({
|
||||
where: { id },
|
||||
include: honeyBatchInclude,
|
||||
});
|
||||
if (!before) {
|
||||
throw new NotFoundException('Honey batch not found');
|
||||
}
|
||||
if (before.extractionId !== peek.extractionId) {
|
||||
throw new ConflictException(
|
||||
'Die Honigcharge wurde gerade ge\u00e4ndert. Bitte erneut versuchen.',
|
||||
);
|
||||
}
|
||||
// A relocation is a move into a container. null only removes the
|
||||
// assignment - a batch may stand in no container, as it may be created
|
||||
// without one - so it is recorded by the audit entry below, not as a
|
||||
// relocation (which always needs a target container).
|
||||
const movedToContainerId =
|
||||
typeof dto.storageContainerId === 'string' &&
|
||||
dto.storageContainerId !== before.storageContainerId
|
||||
? dto.storageContainerId
|
||||
: null;
|
||||
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
await tx.honeyBatch.update({
|
||||
where: { id },
|
||||
data: { ...dto },
|
||||
@@ -116,17 +216,69 @@ export class HoneyBatchesService {
|
||||
},
|
||||
});
|
||||
}
|
||||
const updated = await tx.honeyBatch.findUniqueOrThrow({
|
||||
where: { id },
|
||||
include: honeyBatchInclude,
|
||||
});
|
||||
await this.auditLog.record(
|
||||
{
|
||||
entityType: 'HoneyBatch',
|
||||
entityId: id,
|
||||
action: 'UPDATE',
|
||||
userId: actingUserId,
|
||||
beforeData: before,
|
||||
afterData: updated,
|
||||
},
|
||||
tx,
|
||||
);
|
||||
await this.events.record(tx, {
|
||||
type: 'HoneyBatchCorrected',
|
||||
aggregateType: 'HoneyBatch',
|
||||
aggregateId: id,
|
||||
payload: { extractionId: updated.extractionId },
|
||||
actorUserId: actingUserId,
|
||||
});
|
||||
return withWarnings(
|
||||
updated,
|
||||
await this.warningsFor(tx, updated.extractionId),
|
||||
);
|
||||
});
|
||||
const updated = await this.findById(id);
|
||||
await this.auditLog.record({
|
||||
entityType: 'HoneyBatch',
|
||||
entityId: id,
|
||||
action: 'UPDATE',
|
||||
userId: actingUserId,
|
||||
beforeData: before,
|
||||
afterData: updated,
|
||||
}
|
||||
|
||||
/**
|
||||
* What the form may offer for a new batch of the extraction (spec 7.6): the
|
||||
* rest of its amount after the existing batches, and its honey type (the
|
||||
* one its harvests share) and water content. Proposals, never filled in
|
||||
* silently. Read only, no lock.
|
||||
*/
|
||||
async prefill(extractionId: string): Promise<HoneyBatchPrefill> {
|
||||
const extraction = await this.prisma.extraction.findUnique({
|
||||
where: { id: extractionId },
|
||||
select: {
|
||||
id: true,
|
||||
amount: true,
|
||||
waterContent: true,
|
||||
harvests: { select: { harvest: { select: { honeyType: true } } } },
|
||||
},
|
||||
});
|
||||
return updated;
|
||||
if (!extraction) {
|
||||
throw new NotFoundException('Extraction not found');
|
||||
}
|
||||
const batches = await this.batchesOf(this.prisma, extractionId);
|
||||
return {
|
||||
remainingKg:
|
||||
extraction.amount === null
|
||||
? null
|
||||
: Math.max(
|
||||
0,
|
||||
roundKg(extraction.amount - sumInitialAmounts(batches)),
|
||||
),
|
||||
honeyType: sharedHoneyType(
|
||||
extraction.harvests.map((entry) => entry.harvest),
|
||||
),
|
||||
waterContent: extraction.waterContent,
|
||||
warnings: batchWarnings(extraction, batches),
|
||||
};
|
||||
}
|
||||
|
||||
async updateMeasurement(
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import {
|
||||
batchWarnings,
|
||||
extractionWarnings,
|
||||
sharedHoneyType,
|
||||
} from './honey-plausibility';
|
||||
|
||||
describe('extractionWarnings', () => {
|
||||
it('warns when the extraction exceeds the known net harvest', () => {
|
||||
const warnings = extractionWarnings(50, [
|
||||
{ id: 'h1', netWeight: 20 },
|
||||
{ id: 'h2', netWeight: 22.5 },
|
||||
]);
|
||||
|
||||
expect(warnings).toEqual([
|
||||
{
|
||||
code: 'EXTRACTION_EXCEEDS_HARVEST',
|
||||
severity: 'WARNUNG',
|
||||
message:
|
||||
'Die Schleudermenge (50 kg) liegt über dem Netto der Ernten (42,5 kg).',
|
||||
refs: [
|
||||
{ type: 'Harvest', id: 'h1' },
|
||||
{ type: 'Harvest', id: 'h2' },
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('stays silent when the extraction does not exceed the net harvest', () => {
|
||||
expect(extractionWarnings(42.5, [{ id: 'h1', netWeight: 42.5 }])).toEqual(
|
||||
[],
|
||||
);
|
||||
expect(extractionWarnings(10, [{ id: 'h1', netWeight: 42.5 }])).toEqual([]);
|
||||
});
|
||||
|
||||
it('hints when a net weight is missing and does not compare then', () => {
|
||||
const warnings = extractionWarnings(500, [
|
||||
{ id: 'h1', netWeight: 20 },
|
||||
{ id: 'h2', netWeight: null },
|
||||
]);
|
||||
|
||||
expect(warnings).toEqual([
|
||||
{
|
||||
code: 'HARVEST_NET_MISSING',
|
||||
severity: 'HINWEIS',
|
||||
message:
|
||||
'Bei einer Ernte fehlt das Nettogewicht. Die Menge wurde nicht geprüft.',
|
||||
refs: [{ type: 'Harvest', id: 'h2' }],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('says how many harvests lack the net weight', () => {
|
||||
const [warning] = extractionWarnings(null, [
|
||||
{ id: 'h1', netWeight: null },
|
||||
{ id: 'h2', netWeight: null },
|
||||
]);
|
||||
|
||||
expect(warning.message).toBe(
|
||||
'Bei 2 Ernten fehlt das Nettogewicht. Die Menge wurde nicht geprüft.',
|
||||
);
|
||||
});
|
||||
|
||||
it('has nothing to compare without an amount or without harvests', () => {
|
||||
expect(extractionWarnings(null, [{ id: 'h1', netWeight: 5 }])).toEqual([]);
|
||||
expect(extractionWarnings(5, [])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('batchWarnings', () => {
|
||||
it('warns when the initial amounts of the batches exceed the extraction', () => {
|
||||
const warnings = batchWarnings({ id: 'e1', amount: 40 }, [
|
||||
{ id: 'b1', initialAmount: 25 },
|
||||
{ id: 'b2', initialAmount: 20.5 },
|
||||
]);
|
||||
|
||||
expect(warnings).toEqual([
|
||||
{
|
||||
code: 'BATCHES_EXCEED_EXTRACTION',
|
||||
severity: 'WARNUNG',
|
||||
message:
|
||||
'Die Chargen (45,5 kg) übersteigen die Schleudermenge (40 kg).',
|
||||
refs: [
|
||||
{ type: 'Extraction', id: 'e1' },
|
||||
{ type: 'HoneyBatch', id: 'b1' },
|
||||
{ type: 'HoneyBatch', id: 'b2' },
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not count batches without an initial amount and ignores an unknown extraction amount', () => {
|
||||
expect(
|
||||
batchWarnings({ id: 'e1', amount: 40 }, [
|
||||
{ id: 'b1', initialAmount: 40 },
|
||||
{ id: 'b2', initialAmount: null },
|
||||
]),
|
||||
).toEqual([]);
|
||||
expect(
|
||||
batchWarnings({ id: 'e1', amount: null }, [
|
||||
{ id: 'b1', initialAmount: 400 },
|
||||
]),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('tolerates float noise at the limit', () => {
|
||||
expect(
|
||||
batchWarnings({ id: 'e1', amount: 0.3 }, [
|
||||
{ id: 'b1', initialAmount: 0.1 },
|
||||
{ id: 'b2', initialAmount: 0.2 },
|
||||
]),
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sharedHoneyType', () => {
|
||||
it('keeps the type all harvests share and nothing otherwise', () => {
|
||||
expect(
|
||||
sharedHoneyType([{ honeyType: 'Linde' }, { honeyType: 'Linde' }]),
|
||||
).toBe('Linde');
|
||||
expect(
|
||||
sharedHoneyType([{ honeyType: 'Linde' }, { honeyType: 'Raps' }]),
|
||||
).toBeNull();
|
||||
expect(
|
||||
sharedHoneyType([{ honeyType: 'Linde' }, { honeyType: null }]),
|
||||
).toBeNull();
|
||||
expect(sharedHoneyType([])).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,112 @@
|
||||
import { formatNumberDe } from '../suggestions/texts/format-number';
|
||||
import {
|
||||
WARNING_SEVERITY,
|
||||
type PlausibilityWarning,
|
||||
} from '../common/warnings/plausibility-warning';
|
||||
|
||||
/** Half a gram in kg: the tolerance of the honey ledger (spec 4.6). */
|
||||
const TOLERANCE_KG = 0.0005;
|
||||
const MAX_FRACTION_DIGITS = 3;
|
||||
|
||||
const kgText = (value: number): string =>
|
||||
`${formatNumberDe(value, MAX_FRACTION_DIGITS)} kg`;
|
||||
|
||||
/** The honey type all harvests share; null when they differ or one has none. */
|
||||
export function sharedHoneyType(
|
||||
harvests: readonly { honeyType: string | null }[],
|
||||
): string | null {
|
||||
const [first] = harvests;
|
||||
if (first === undefined || first.honeyType === null) {
|
||||
return null;
|
||||
}
|
||||
return harvests.every((harvest) => harvest.honeyType === first.honeyType)
|
||||
? first.honeyType
|
||||
: null;
|
||||
}
|
||||
|
||||
/** kg on a gram grid, so sums of floats compare and print cleanly. */
|
||||
export function roundKg(value: number): number {
|
||||
return Math.round(value * 1000) / 1000;
|
||||
}
|
||||
|
||||
/**
|
||||
* The extraction (Schleuderung) against the net weight of its harvests
|
||||
* (spec 7.6). The amount is compared only when every net weight is known;
|
||||
* a missing one is a hint instead. Never blocks.
|
||||
*/
|
||||
export function extractionWarnings(
|
||||
amountKg: number | null,
|
||||
harvests: readonly { id: string; netWeight: number | null }[],
|
||||
): PlausibilityWarning[] {
|
||||
const missing = harvests.filter((harvest) => harvest.netWeight === null);
|
||||
if (missing.length > 0) {
|
||||
return [
|
||||
{
|
||||
code: 'HARVEST_NET_MISSING',
|
||||
severity: WARNING_SEVERITY.HARVEST_NET_MISSING,
|
||||
message:
|
||||
missing.length === 1
|
||||
? 'Bei einer Ernte fehlt das Nettogewicht. Die Menge wurde nicht geprüft.'
|
||||
: `Bei ${missing.length} Ernten fehlt das Nettogewicht. Die Menge wurde nicht geprüft.`,
|
||||
refs: missing.map((harvest) => ({ type: 'Harvest', id: harvest.id })),
|
||||
},
|
||||
];
|
||||
}
|
||||
if (amountKg === null || harvests.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const netKg = roundKg(
|
||||
harvests.reduce((sum, harvest) => sum + (harvest.netWeight ?? 0), 0),
|
||||
);
|
||||
if (amountKg <= netKg + TOLERANCE_KG) {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
{
|
||||
code: 'EXTRACTION_EXCEEDS_HARVEST',
|
||||
severity: WARNING_SEVERITY.EXTRACTION_EXCEEDS_HARVEST,
|
||||
message: `Die Schleudermenge (${kgText(amountKg)}) liegt über dem Netto der Ernten (${kgText(netKg)}).`,
|
||||
refs: harvests.map((harvest) => ({ type: 'Harvest', id: harvest.id })),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/** Σ initial amount of the batches; a batch without one does not count. */
|
||||
export function sumInitialAmounts(
|
||||
batches: readonly { initialAmount: number | null }[],
|
||||
): number {
|
||||
return roundKg(
|
||||
batches.reduce((sum, batch) => sum + (batch.initialAmount ?? 0), 0),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The honey batches of one extraction against its amount (spec 7.6): their
|
||||
* initial amounts together must not exceed what was extracted. Nothing to
|
||||
* compare without an extraction amount. Never blocks.
|
||||
*/
|
||||
export function batchWarnings(
|
||||
extraction: { id: string; amount: number | null },
|
||||
batches: readonly { id: string; initialAmount: number | null }[],
|
||||
): PlausibilityWarning[] {
|
||||
if (extraction.amount === null) {
|
||||
return [];
|
||||
}
|
||||
const total = sumInitialAmounts(batches);
|
||||
if (total <= extraction.amount + TOLERANCE_KG) {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
{
|
||||
code: 'BATCHES_EXCEED_EXTRACTION',
|
||||
severity: WARNING_SEVERITY.BATCHES_EXCEED_EXTRACTION,
|
||||
message: `Die Chargen (${kgText(total)}) übersteigen die Schleudermenge (${kgText(extraction.amount)}).`,
|
||||
refs: [
|
||||
{ type: 'Extraction', id: extraction.id },
|
||||
...batches
|
||||
.filter((batch) => batch.initialAmount !== null)
|
||||
.map((batch) => ({ type: 'HoneyBatch', id: batch.id })),
|
||||
],
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { IdempotencyCleanupService } from '../common/idempotency/idempotency-cleanup.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { JobRunService } from '../system/job-run.service';
|
||||
import { WEATHER_RETENTION_YEARS } from '../weather/history/weather-history.config';
|
||||
import {
|
||||
CleanupJob,
|
||||
DOMAIN_EVENT_RETENTION_DAYS,
|
||||
@@ -59,6 +60,16 @@ describe('CleanupJob', () => {
|
||||
expect(JOB_RUN_STALE_HOURS).toBe(6);
|
||||
});
|
||||
|
||||
it('cleanup deletes weather days before January 1st four years back', async () => {
|
||||
await job.run();
|
||||
|
||||
const { sql, values } = statementOn('ApiaryWeatherDay');
|
||||
expect(sql).toContain('"day" <');
|
||||
expect(sql).toContain('Europe/Berlin');
|
||||
expect(sql).toContain('make_date');
|
||||
expect(values).toEqual([WEATHER_RETENTION_YEARS]);
|
||||
});
|
||||
|
||||
it('cleanup removes only expired rows and reports counts', async () => {
|
||||
const result = await job.run();
|
||||
|
||||
@@ -76,6 +87,7 @@ describe('CleanupJob', () => {
|
||||
'webhookDeliveries.failed': 14,
|
||||
'jobRuns.aborted': 1,
|
||||
'jobRuns.deleted': 15,
|
||||
weatherDays: 16,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common';
|
||||
import { IdempotencyCleanupService } from '../common/idempotency/idempotency-cleanup.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { JobRunService } from '../system/job-run.service';
|
||||
import { WEATHER_RETENTION_YEARS } from '../weather/history/weather-history.config';
|
||||
import { Job, JobResult } from './job';
|
||||
import { JobName } from './job-names';
|
||||
|
||||
@@ -63,6 +64,12 @@ export class CleanupJob implements Job {
|
||||
const jobRunsDeleted = await this.prisma.$executeRaw`
|
||||
DELETE FROM "JobRun"
|
||||
WHERE "startedAt" < now() - (${JOB_RUN_RETENTION_DAYS} * interval '1 day')`;
|
||||
// Daily means before January 1st of (year - 4): the season needs the
|
||||
// current year only, the rest is a short history.
|
||||
const weatherDays = await this.prisma.$executeRaw`
|
||||
DELETE FROM "ApiaryWeatherDay"
|
||||
WHERE "day" < make_date(
|
||||
extract(year FROM now() AT TIME ZONE 'Europe/Berlin')::int - ${WEATHER_RETENTION_YEARS}, 1, 1)`;
|
||||
return {
|
||||
idempotencyRecords,
|
||||
consumedTokens,
|
||||
@@ -73,6 +80,7 @@ export class CleanupJob implements Job {
|
||||
'webhookDeliveries.failed': webhookDeliveriesFailed,
|
||||
'jobRuns.aborted': jobRunsAborted,
|
||||
'jobRuns.deleted': jobRunsDeleted,
|
||||
weatherDays,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { JobPartialFailure } from '../system/job-run.service';
|
||||
import { DailyEvaluationJob } from './daily-evaluation.job';
|
||||
import type { AutopilotStep } from '../autopilot/autopilot.step';
|
||||
import type { SuggestionsExpiryStep } from '../autopilot/suggestions-expiry.step';
|
||||
import type { PhenologyStep } from '../phenology/phenology.step';
|
||||
import type { DigestStep } from '../reminders/digest.step';
|
||||
import type { TimeEventsStep } from './steps/time-events.step';
|
||||
|
||||
@@ -14,11 +15,13 @@ describe('DailyEvaluationJob', () => {
|
||||
VarroaMeasurementDue: 0,
|
||||
InspectionOverdue: 0,
|
||||
};
|
||||
const PHENOLOGY_COUNTS = { phenologyApiaries: 2, seasonChanges: 1 };
|
||||
const AUTOPILOT_COUNTS = {
|
||||
autopilotColonies: 4,
|
||||
suggestionsOpened: 2,
|
||||
suggestionsResolved: 1,
|
||||
};
|
||||
let phenology: { name: 'phenology'; run: jest.Mock };
|
||||
let timeEvents: { name: 'time-events'; run: jest.Mock };
|
||||
let autopilot: { name: 'autopilot'; run: jest.Mock };
|
||||
let expiry: { name: 'suggestions-expiry'; run: jest.Mock };
|
||||
@@ -27,6 +30,8 @@ describe('DailyEvaluationJob', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers({ now: new Date('2026-06-10T02:30:00.000Z') });
|
||||
phenology = { name: 'phenology', run: jest.fn() };
|
||||
phenology.run.mockResolvedValue(PHENOLOGY_COUNTS);
|
||||
timeEvents = { name: 'time-events', run: jest.fn() };
|
||||
timeEvents.run.mockResolvedValue(COUNTS);
|
||||
autopilot = { name: 'autopilot', run: jest.fn() };
|
||||
@@ -36,6 +41,7 @@ describe('DailyEvaluationJob', () => {
|
||||
digest = { name: 'digest', run: jest.fn() };
|
||||
digest.run.mockResolvedValue({ digestsWritten: 5 });
|
||||
job = new DailyEvaluationJob(
|
||||
phenology as unknown as PhenologyStep,
|
||||
timeEvents as unknown as TimeEventsStep,
|
||||
autopilot as unknown as AutopilotStep,
|
||||
expiry as unknown as SuggestionsExpiryStep,
|
||||
@@ -54,20 +60,25 @@ describe('DailyEvaluationJob', () => {
|
||||
|
||||
it('runs the steps for the Berlin day of the run and reports their counters', async () => {
|
||||
expect(await job.run()).toEqual({
|
||||
...PHENOLOGY_COUNTS,
|
||||
...COUNTS,
|
||||
...AUTOPILOT_COUNTS,
|
||||
suggestionsExpired: 3,
|
||||
digestsWritten: 5,
|
||||
});
|
||||
expect(phenology.run).toHaveBeenCalledWith('2026-06-10');
|
||||
expect(timeEvents.run).toHaveBeenCalledWith('2026-06-10');
|
||||
expect(autopilot.run).toHaveBeenCalledWith('2026-06-10');
|
||||
expect(expiry.run).toHaveBeenCalledWith('2026-06-10');
|
||||
expect(digest.run).toHaveBeenCalledWith('2026-06-10');
|
||||
});
|
||||
|
||||
it('runs time-events, autopilot, suggestions-expiry and digest in that order', async () => {
|
||||
it('runs phenology, time-events, autopilot, suggestions-expiry and digest in that order', async () => {
|
||||
await job.run();
|
||||
|
||||
expect(phenology.run.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
timeEvents.run.mock.invocationCallOrder[0],
|
||||
);
|
||||
expect(timeEvents.run.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
autopilot.run.mock.invocationCallOrder[0],
|
||||
);
|
||||
@@ -95,6 +106,24 @@ describe('DailyEvaluationJob', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('a failing phenology step does not stop the others', async () => {
|
||||
jest.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
|
||||
phenology.run.mockRejectedValue(
|
||||
new JobPartialFailure(PHENOLOGY_COUNTS, ['1 apiary']),
|
||||
);
|
||||
|
||||
const failure = await job.run().then(
|
||||
() => null,
|
||||
(error: unknown) => error,
|
||||
);
|
||||
|
||||
expect(timeEvents.run).toHaveBeenCalled();
|
||||
expect(failure).toMatchObject({
|
||||
failedSteps: ['phenology'],
|
||||
result: { phenologyApiaries: 2, seasonChanges: 1, digestsWritten: 5 },
|
||||
});
|
||||
});
|
||||
|
||||
it('a failing autopilot step does not stop the expiry step', async () => {
|
||||
jest.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
|
||||
autopilot.run.mockRejectedValue(
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Injectable, Logger } from '@nestjs/common';
|
||||
import { AutopilotStep } from '../autopilot/autopilot.step';
|
||||
import { SuggestionsExpiryStep } from '../autopilot/suggestions-expiry.step';
|
||||
import { berlinToday } from '../common/dates';
|
||||
import { PhenologyStep } from '../phenology/phenology.step';
|
||||
import { DigestStep } from '../reminders/digest.step';
|
||||
import { JobPartialFailure } from '../system/job-run.service';
|
||||
import {
|
||||
@@ -25,6 +26,7 @@ export class DailyEvaluationJob implements Job {
|
||||
private readonly logger = new Logger(DailyEvaluationJob.name);
|
||||
|
||||
constructor(
|
||||
private readonly phenology: PhenologyStep,
|
||||
private readonly timeEvents: TimeEventsStep,
|
||||
private readonly autopilot: AutopilotStep,
|
||||
private readonly suggestionsExpiry: SuggestionsExpiryStep,
|
||||
@@ -34,6 +36,7 @@ export class DailyEvaluationJob implements Job {
|
||||
/** Evaluates the current Berlin day. */
|
||||
async run(): Promise<JobResult> {
|
||||
const steps: DailyEvaluationStep[] = [
|
||||
this.phenology,
|
||||
this.timeEvents,
|
||||
this.autopilot,
|
||||
this.suggestionsExpiry,
|
||||
|
||||
@@ -87,6 +87,8 @@ function fakeDb(rows: FakeRows): FakeDb {
|
||||
})),
|
||||
),
|
||||
},
|
||||
// No stored phenology: every apiary gets the calendar state.
|
||||
apiaryPhenology: { findMany: jest.fn().mockResolvedValue([]) },
|
||||
automationRule: {
|
||||
findUnique: jest.fn(({ where }: { where: { triggerType: string } }) => {
|
||||
const config = rows.rules?.[where.triggerType];
|
||||
|
||||
@@ -7,6 +7,7 @@ import { BackupModule } from '../backup/backup.module';
|
||||
import { IdempotencyCleanupService } from '../common/idempotency/idempotency-cleanup.service';
|
||||
import { DomainEventsModule } from '../domain-events/domain-events.module';
|
||||
import { NotificationsModule } from '../notifications/notifications.module';
|
||||
import { PhenologyModule } from '../phenology/phenology.module';
|
||||
import { PrismaModule } from '../prisma/prisma.module';
|
||||
import { RemindersModule } from '../reminders/reminders.module';
|
||||
import { SeasonModule } from '../season/season.module';
|
||||
@@ -50,6 +51,7 @@ const JOB_CLASSES = [
|
||||
SystemModule,
|
||||
SeasonModule,
|
||||
AutopilotModule,
|
||||
PhenologyModule,
|
||||
RemindersModule,
|
||||
],
|
||||
providers: [
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import { grasslandTemperatureSum } from './gts';
|
||||
import type { DailyMean } from '../season/season-types';
|
||||
|
||||
function daysOf(
|
||||
year: number,
|
||||
from: string,
|
||||
count: number,
|
||||
meanTempC: number | null,
|
||||
): DailyMean[] {
|
||||
const start = Date.parse(`${year}-${from}T00:00:00.000Z`);
|
||||
return Array.from({ length: count }, (_, i) => ({
|
||||
day: new Date(start + i * 86_400_000).toISOString().slice(0, 10),
|
||||
meanTempC,
|
||||
}));
|
||||
}
|
||||
|
||||
describe('grasslandTemperatureSum', () => {
|
||||
it('weights January 0.5, February 0.75 and March 1', () => {
|
||||
const days = [
|
||||
...daysOf(2026, '01-01', 1, 10),
|
||||
...daysOf(2026, '02-01', 1, 10),
|
||||
...daysOf(2026, '03-01', 1, 10),
|
||||
];
|
||||
|
||||
const january = grasslandTemperatureSum(days, 2026, '2026-01-01', 1);
|
||||
const february = grasslandTemperatureSum(days, 2026, '2026-02-01', 1);
|
||||
const march = grasslandTemperatureSum(days, 2026, '2026-03-01', 1);
|
||||
|
||||
expect(january.gts).toBe(5);
|
||||
// The days in between are missing, not zero: only the three count.
|
||||
expect(february.gts).toBe(12.5);
|
||||
expect(march.gts).toBe(22.5);
|
||||
});
|
||||
|
||||
it('negative daily means count 0', () => {
|
||||
const result = grasslandTemperatureSum(
|
||||
[...daysOf(2026, '03-01', 2, -4), ...daysOf(2026, '03-03', 1, 3)],
|
||||
2026,
|
||||
'2026-03-03',
|
||||
1,
|
||||
);
|
||||
|
||||
expect(result.gts).toBe(3);
|
||||
});
|
||||
|
||||
it('is unusable with more than 10 % missing days', () => {
|
||||
const full = daysOf(2026, '01-01', 90, 5);
|
||||
const gappy = full.map((d, i) => (i < 10 ? { ...d, meanTempC: null } : d));
|
||||
const edge = full.map((d, i) => (i < 9 ? { ...d, meanTempC: null } : d));
|
||||
|
||||
const ok = grasslandTemperatureSum(full, 2026, '2026-03-31', 0.1);
|
||||
const bad = grasslandTemperatureSum(gappy, 2026, '2026-03-31', 0.1);
|
||||
|
||||
expect(ok).toMatchObject({
|
||||
expectedDays: 90,
|
||||
missingDays: 0,
|
||||
usable: true,
|
||||
});
|
||||
expect(bad).toMatchObject({
|
||||
expectedDays: 90,
|
||||
missingDays: 10,
|
||||
usable: false,
|
||||
});
|
||||
expect(grasslandTemperatureSum(edge, 2026, '2026-03-31', 0.1).usable).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('reaches 200 °C on the expected day', () => {
|
||||
// January 31 x 10 x 0.5 = 155; each February day adds 7.5: 200 on Feb 6.
|
||||
const days = [
|
||||
...daysOf(2026, '01-01', 31, 10),
|
||||
...daysOf(2026, '02-01', 10, 10),
|
||||
];
|
||||
|
||||
expect(grasslandTemperatureSum(days, 2026, '2026-02-05', 0.1).gts).toBe(
|
||||
192.5,
|
||||
);
|
||||
expect(grasslandTemperatureSum(days, 2026, '2026-02-06', 0.1).gts).toBe(
|
||||
200,
|
||||
);
|
||||
});
|
||||
|
||||
it('reports the last day with data and nothing before the year starts', () => {
|
||||
const days = daysOf(2026, '01-01', 10, 4);
|
||||
|
||||
expect(
|
||||
grasslandTemperatureSum(days, 2026, '2026-01-20', 1).throughDay,
|
||||
).toBe('2026-01-10');
|
||||
expect(grasslandTemperatureSum([], 2026, '2025-12-31', 1)).toEqual({
|
||||
gts: 0,
|
||||
throughDay: null,
|
||||
expectedDays: 0,
|
||||
missingDays: 0,
|
||||
usable: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('counts February 28th and March 1st with their own weights', () => {
|
||||
const days = [
|
||||
{ day: '2026-02-28', meanTempC: 10 },
|
||||
{ day: '2026-03-01', meanTempC: 10 },
|
||||
];
|
||||
|
||||
const result = grasslandTemperatureSum(days, 2026, '2026-03-01', 1);
|
||||
|
||||
// 28 + 31 + 1 = 60 days from January 1st; 10 x 0.75 + 10 x 1.
|
||||
expect(result).toMatchObject({ gts: 17.5, expectedDays: 60 });
|
||||
});
|
||||
|
||||
it('knows February 29th in a leap year', () => {
|
||||
const days = [
|
||||
{ day: '2028-02-28', meanTempC: 10 },
|
||||
{ day: '2028-02-29', meanTempC: 10 },
|
||||
{ day: '2028-03-01', meanTempC: 10 },
|
||||
];
|
||||
|
||||
const result = grasslandTemperatureSum(days, 2028, '2028-03-01', 1);
|
||||
|
||||
expect(result).toMatchObject({ gts: 25, expectedDays: 61 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
import { addDays } from '../common/dates';
|
||||
import type { DailyMean } from '../season/season-types';
|
||||
|
||||
/** Month weights of the grassland temperature sum (DWD): January 0.5, February 0.75. */
|
||||
export const GTS_MONTH_FACTORS: Readonly<Record<number, number>> = {
|
||||
1: 0.5,
|
||||
2: 0.75,
|
||||
};
|
||||
|
||||
export interface GtsResult {
|
||||
/** Sum of the positive daily means, weighted by month, from January 1st. */
|
||||
gts: number;
|
||||
/** The last day with a mean at or before the requested end; null if none. */
|
||||
throughDay: string | null;
|
||||
/** Days from January 1st to the requested end. */
|
||||
expectedDays: number;
|
||||
/** Of those, the days without a mean. */
|
||||
missingDays: number;
|
||||
/** False when more than `maxGapShare` of the days are missing. */
|
||||
usable: boolean;
|
||||
}
|
||||
|
||||
const monthFactor = (day: string): number =>
|
||||
GTS_MONTH_FACTORS[Number(day.slice(5, 7))] ?? 1;
|
||||
|
||||
/**
|
||||
* The grassland temperature sum (Grünlandtemperatursumme, DWD) of `year` up
|
||||
* to and including `throughDay`: every day's positive mean counts, January
|
||||
* with 0.5, February with 0.75, from March with 1; negative means count 0.
|
||||
* Days without a mean count as missing; with more than `maxGapShare` of them
|
||||
* the sum is not usable.
|
||||
*/
|
||||
export function grasslandTemperatureSum(
|
||||
days: readonly DailyMean[],
|
||||
year: number,
|
||||
throughDay: string,
|
||||
maxGapShare: number,
|
||||
): GtsResult {
|
||||
const first = `${year}-01-01`;
|
||||
const last = throughDay > `${year}-12-31` ? `${year}-12-31` : throughDay;
|
||||
if (last < first) {
|
||||
return {
|
||||
gts: 0,
|
||||
throughDay: null,
|
||||
expectedDays: 0,
|
||||
missingDays: 0,
|
||||
usable: false,
|
||||
};
|
||||
}
|
||||
const means = new Map<string, number>();
|
||||
for (const { day, meanTempC } of days) {
|
||||
if (meanTempC !== null && Number.isFinite(meanTempC)) {
|
||||
means.set(day, meanTempC);
|
||||
}
|
||||
}
|
||||
let gts = 0;
|
||||
let expectedDays = 0;
|
||||
let missingDays = 0;
|
||||
let lastDataDay: string | null = null;
|
||||
for (let day = first; day <= last; day = addDays(day, 1)) {
|
||||
expectedDays += 1;
|
||||
const mean = means.get(day);
|
||||
if (mean === undefined) {
|
||||
missingDays += 1;
|
||||
continue;
|
||||
}
|
||||
lastDataDay = day;
|
||||
gts += Math.max(0, mean) * monthFactor(day);
|
||||
}
|
||||
return {
|
||||
gts,
|
||||
throughDay: lastDataDay,
|
||||
expectedDays,
|
||||
missingDays,
|
||||
usable: missingDays / expectedDays <= maxGapShare,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { calibrateThresholds } from './phenology-calibration';
|
||||
|
||||
const year = (
|
||||
firstReportDay: Record<string, string>,
|
||||
gtsByDay: Record<string, number>,
|
||||
) => ({
|
||||
firstReportDay,
|
||||
gtsOn: (day: string): number | null => gtsByDay[day] ?? null,
|
||||
});
|
||||
|
||||
describe('calibrateThresholds', () => {
|
||||
it('uses the median GTS on the first report day of up to three previous years', () => {
|
||||
// Most recent year first; the fourth year is ignored.
|
||||
const years = [
|
||||
year({ KIRSCHE: '2025-04-10' }, { '2025-04-10': 300 }),
|
||||
year({ KIRSCHE: '2024-04-20' }, { '2024-04-20': 340 }),
|
||||
year({ KIRSCHE: '2023-04-05' }, { '2023-04-05': 280 }),
|
||||
year({ KIRSCHE: '2022-04-05' }, { '2022-04-05': 9999 }),
|
||||
];
|
||||
|
||||
expect(calibrateThresholds(years, ['KIRSCHE', 'APFEL'])).toEqual({
|
||||
KIRSCHE: 300,
|
||||
});
|
||||
});
|
||||
|
||||
it('averages the two middle values for an even count', () => {
|
||||
const years = [
|
||||
year({ APFEL: '2025-04-25' }, { '2025-04-25': 400 }),
|
||||
year({ APFEL: '2024-04-25' }, { '2024-04-25': 440 }),
|
||||
];
|
||||
|
||||
expect(calibrateThresholds(years, ['APFEL'])).toEqual({ APFEL: 420 });
|
||||
});
|
||||
|
||||
it('skips years without a report or without GTS and never invents a threshold', () => {
|
||||
const years = [
|
||||
year({}, {}),
|
||||
year({ KIRSCHE: '2024-04-20' }, {}),
|
||||
year({ HOLUNDER: '2023-05-30' }, { '2023-05-30': 700 }),
|
||||
];
|
||||
|
||||
expect(calibrateThresholds(years, ['KIRSCHE', 'APFEL'])).toEqual({});
|
||||
expect(calibrateThresholds([], ['KIRSCHE'])).toEqual({});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { PhenologyPhaseKey } from '../season/season-types';
|
||||
|
||||
/** At most this many previous years take part in the calibration. */
|
||||
export const CALIBRATION_YEARS = 3;
|
||||
|
||||
export interface CalibrationYear {
|
||||
/** First report day (YYYY-MM-DD) per phase in that year. */
|
||||
firstReportDay: Partial<Record<PhenologyPhaseKey, string>>;
|
||||
/** GTS of that year on a day; null if the weather data does not allow it. */
|
||||
gtsOn(day: string): number | null;
|
||||
}
|
||||
|
||||
function median(values: readonly number[]): number {
|
||||
const sorted = [...values].sort((a, b) => a - b);
|
||||
const middle = Math.floor(sorted.length / 2);
|
||||
return sorted.length % 2 === 1
|
||||
? sorted[middle]
|
||||
: (sorted[middle - 1] + sorted[middle]) / 2;
|
||||
}
|
||||
|
||||
/**
|
||||
* GTS thresholds from the reports of the previous years: per phase the median
|
||||
* of the GTS on the first report day, over up to CALIBRATION_YEARS years
|
||||
* (`years` most recent first). A phase without any usable year gets no
|
||||
* threshold - nothing is invented.
|
||||
*/
|
||||
export function calibrateThresholds(
|
||||
years: readonly CalibrationYear[],
|
||||
phases: readonly PhenologyPhaseKey[],
|
||||
): Partial<Record<PhenologyPhaseKey, number>> {
|
||||
const thresholds: Partial<Record<PhenologyPhaseKey, number>> = {};
|
||||
const recent = years.slice(0, CALIBRATION_YEARS);
|
||||
for (const phase of phases) {
|
||||
const values: number[] = [];
|
||||
for (const year of recent) {
|
||||
const day = year.firstReportDay[phase];
|
||||
const gts = day === undefined ? null : year.gtsOn(day);
|
||||
if (gts !== null) {
|
||||
values.push(gts);
|
||||
}
|
||||
}
|
||||
if (values.length > 0) {
|
||||
thresholds[phase] = median(values);
|
||||
}
|
||||
}
|
||||
return thresholds;
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
import { DEFAULT_PHENOLOGY_CONFIG } from '../automation/rule-config/phenology.config';
|
||||
import type { GtsResult } from './gts';
|
||||
import {
|
||||
effectivePhaseOf,
|
||||
reportsApplyingTo,
|
||||
seasonNoteFor,
|
||||
type PhaseInputs,
|
||||
} from './phenology-phase';
|
||||
|
||||
const fresh = (gts: number, throughDay = '2026-04-19'): GtsResult => ({
|
||||
gts,
|
||||
throughDay,
|
||||
expectedDays: 109,
|
||||
missingDays: 0,
|
||||
usable: true,
|
||||
});
|
||||
|
||||
function inputs(overrides: Partial<PhaseInputs> = {}): PhaseInputs {
|
||||
return {
|
||||
today: '2026-04-20',
|
||||
reports: [],
|
||||
gts: fresh(250),
|
||||
hasCoordinates: true,
|
||||
thresholds: { SALWEIDE: 200 },
|
||||
config: DEFAULT_PHENOLOGY_CONFIG,
|
||||
previous: null,
|
||||
allowBackward: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('effectivePhaseOf', () => {
|
||||
it('takes the maximum of report, GTS and calendar', () => {
|
||||
// GTS: SALWEIDE; calendar (04-15): KIRSCHE; report: APFEL.
|
||||
const result = effectivePhaseOf(
|
||||
inputs({
|
||||
reports: [{ phase: 'APFEL', date: '2026-04-18', revoked: false }],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
reportedPhase: 'APFEL',
|
||||
reportedOn: '2026-04-18',
|
||||
estimatedPhase: 'KIRSCHE',
|
||||
effectivePhase: 'APFEL',
|
||||
phaseSource: 'REPORT',
|
||||
note: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('uses GTS when it is ahead of the calendar', () => {
|
||||
const result = effectivePhaseOf(
|
||||
inputs({ today: '2026-03-25', gts: fresh(250, '2026-03-24') }),
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
effectivePhase: 'SALWEIDE',
|
||||
phaseSource: 'GTS',
|
||||
reportedPhase: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('ignores revoked reports', () => {
|
||||
const result = effectivePhaseOf(
|
||||
inputs({
|
||||
reports: [{ phase: 'APFEL', date: '2026-04-18', revoked: true }],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.reportedPhase).toBeNull();
|
||||
expect(result.effectivePhase).toBe('KIRSCHE');
|
||||
expect(result.phaseSource).toBe('CALENDAR');
|
||||
});
|
||||
|
||||
it('ignores reports dated in the future', () => {
|
||||
const result = effectivePhaseOf(
|
||||
inputs({
|
||||
reports: [{ phase: 'LINDE', date: '2026-04-21', revoked: false }],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.reportedPhase).toBeNull();
|
||||
});
|
||||
|
||||
it('without a previous-year report there is no KIRSCHE threshold, the calendar applies', () => {
|
||||
// GTS is usable but only SALWEIDE has a threshold: KIRSCHE and later
|
||||
// phases come from the calendar.
|
||||
const result = effectivePhaseOf(
|
||||
inputs({ gts: fresh(10, '2026-04-15'), today: '2026-04-16' }),
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
effectivePhase: 'KIRSCHE',
|
||||
phaseSource: 'CALENDAR',
|
||||
});
|
||||
});
|
||||
|
||||
it('does not let the calendar decide a phase that has a GTS threshold', () => {
|
||||
const result = effectivePhaseOf(
|
||||
inputs({
|
||||
gts: fresh(10, '2026-04-15'),
|
||||
today: '2026-04-16',
|
||||
thresholds: { SALWEIDE: 200, KIRSCHE: 400 },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.effectivePhase).toBeNull();
|
||||
});
|
||||
|
||||
it('a calibrated threshold lets GTS reach KIRSCHE', () => {
|
||||
const result = effectivePhaseOf(
|
||||
inputs({
|
||||
gts: fresh(320, '2026-04-09'),
|
||||
today: '2026-04-10',
|
||||
thresholds: { SALWEIDE: 200, KIRSCHE: 300 },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
effectivePhase: 'KIRSCHE',
|
||||
phaseSource: 'GTS',
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to the calendar for every phase without usable weather', () => {
|
||||
const gappy: GtsResult = { ...fresh(250), usable: false, missingDays: 30 };
|
||||
const result = effectivePhaseOf(inputs({ gts: gappy }));
|
||||
|
||||
expect(result).toMatchObject({
|
||||
effectivePhase: 'KIRSCHE',
|
||||
phaseSource: 'CALENDAR',
|
||||
note: 'WEATHER_GAPS',
|
||||
});
|
||||
});
|
||||
|
||||
it('does not use GTS older than 3 days', () => {
|
||||
const result = effectivePhaseOf(
|
||||
inputs({ gts: fresh(250, '2026-04-16'), today: '2026-04-20' }),
|
||||
);
|
||||
|
||||
expect(result.note).toBe('WEATHER_STALE');
|
||||
expect(result.phaseSource).toBe('CALENDAR');
|
||||
});
|
||||
|
||||
it('flags missing coordinates and uses the calendar', () => {
|
||||
const result = effectivePhaseOf(
|
||||
inputs({ gts: null, hasCoordinates: false }),
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
note: 'NO_COORDINATES',
|
||||
effectivePhase: 'KIRSCHE',
|
||||
phaseSource: 'CALENDAR',
|
||||
});
|
||||
});
|
||||
|
||||
it('has no phase before spring, with the calendar as source', () => {
|
||||
const result = effectivePhaseOf(
|
||||
inputs({ gts: fresh(5, '2026-01-30'), today: '2026-01-31' }),
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
effectivePhase: null,
|
||||
phaseSource: 'CALENDAR',
|
||||
});
|
||||
});
|
||||
|
||||
it('never moves backwards unless a report was revoked', () => {
|
||||
const base = inputs({ previous: { phase: 'APFEL', source: 'REPORT' } });
|
||||
|
||||
const held = effectivePhaseOf(base);
|
||||
const released = effectivePhaseOf({ ...base, allowBackward: true });
|
||||
|
||||
expect(held).toMatchObject({
|
||||
effectivePhase: 'APFEL',
|
||||
phaseSource: 'REPORT',
|
||||
estimatedPhase: 'KIRSCHE',
|
||||
});
|
||||
expect(released.effectivePhase).toBe('KIRSCHE');
|
||||
});
|
||||
});
|
||||
|
||||
describe('effectivePhaseOf edge cases', () => {
|
||||
it('still uses GTS whose last day is exactly 3 days old', () => {
|
||||
const base = {
|
||||
today: '2026-04-20',
|
||||
reports: [],
|
||||
hasCoordinates: true,
|
||||
thresholds: { SALWEIDE: 200, KIRSCHE: 240 },
|
||||
config: DEFAULT_PHENOLOGY_CONFIG,
|
||||
previous: null,
|
||||
allowBackward: false,
|
||||
} satisfies Omit<PhaseInputs, 'gts'>;
|
||||
|
||||
const used = effectivePhaseOf({ ...base, gts: fresh(250, '2026-04-17') });
|
||||
const stale = effectivePhaseOf({ ...base, gts: fresh(250, '2026-04-16') });
|
||||
|
||||
expect(used).toMatchObject({
|
||||
effectivePhase: 'KIRSCHE',
|
||||
phaseSource: 'GTS',
|
||||
note: null,
|
||||
});
|
||||
expect(stale.note).toBe('WEATHER_STALE');
|
||||
expect(stale.phaseSource).toBe('CALENDAR');
|
||||
});
|
||||
|
||||
it('reports the earliest of two reports of the same phase', () => {
|
||||
const result = effectivePhaseOf(
|
||||
inputs({
|
||||
reports: [
|
||||
{ phase: 'APFEL', date: '2026-04-18', revoked: false },
|
||||
{ phase: 'APFEL', date: '2026-04-12', revoked: false },
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
reportedPhase: 'APFEL',
|
||||
reportedOn: '2026-04-12',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('reportsApplyingTo', () => {
|
||||
it('a report for all apiaries counts for each apiary', () => {
|
||||
const reports = [
|
||||
{
|
||||
apiaryId: null,
|
||||
phase: 'APFEL' as const,
|
||||
date: '2026-04-18',
|
||||
revoked: false,
|
||||
},
|
||||
{
|
||||
apiaryId: 'a',
|
||||
phase: 'KIRSCHE' as const,
|
||||
date: '2026-04-10',
|
||||
revoked: false,
|
||||
},
|
||||
{
|
||||
apiaryId: 'b',
|
||||
phase: 'LINDE' as const,
|
||||
date: '2026-04-11',
|
||||
revoked: false,
|
||||
},
|
||||
];
|
||||
|
||||
expect(reportsApplyingTo('a', reports).map((r) => r.phase)).toEqual([
|
||||
'APFEL',
|
||||
'KIRSCHE',
|
||||
]);
|
||||
expect(reportsApplyingTo('c', reports).map((r) => r.phase)).toEqual([
|
||||
'APFEL',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('seasonNoteFor', () => {
|
||||
const base = {
|
||||
useGts: true,
|
||||
hasCoordinates: true,
|
||||
today: '2026-04-20',
|
||||
lastDataDay: '2026-04-19' as string | null,
|
||||
gtsUsable: true,
|
||||
};
|
||||
|
||||
it.each([
|
||||
[{}, null],
|
||||
[{ useGts: false, hasCoordinates: false }, null],
|
||||
[{ hasCoordinates: false }, 'NO_COORDINATES'],
|
||||
[{ lastDataDay: null }, 'WEATHER_STALE'],
|
||||
[{ lastDataDay: '2026-04-16' }, 'WEATHER_STALE'],
|
||||
[{ lastDataDay: '2026-04-17' }, null],
|
||||
[{ gtsUsable: false }, 'WEATHER_GAPS'],
|
||||
[{ today: '2026-01-01', lastDataDay: null, gtsUsable: false }, null],
|
||||
])('%j -> %s', (override, note) => {
|
||||
expect(seasonNoteFor({ ...base, ...override })).toBe(note);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,219 @@
|
||||
import type { PhenologyConfig } from '../automation/rule-config/rule-config.types';
|
||||
import { daysBetween } from '../common/dates';
|
||||
import {
|
||||
PHENOLOGY_PHASES,
|
||||
type PhaseSourceKey,
|
||||
type PhenologyPhaseKey,
|
||||
type SeasonNote,
|
||||
} from '../season/season-types';
|
||||
import type { GtsResult } from './gts';
|
||||
|
||||
/** GTS whose last day with data is older than this many days is not used. */
|
||||
export const GTS_MAX_AGE_DAYS = 3;
|
||||
|
||||
export interface PhaseReport {
|
||||
phase: PhenologyPhaseKey;
|
||||
date: string;
|
||||
revoked: boolean;
|
||||
}
|
||||
|
||||
export interface PhaseInputs {
|
||||
today: string;
|
||||
reports: readonly PhaseReport[];
|
||||
gts: GtsResult | null;
|
||||
hasCoordinates: boolean;
|
||||
/** GTS thresholds in force: configured plus calibrated. */
|
||||
thresholds: Partial<Record<PhenologyPhaseKey, number>>;
|
||||
config: PhenologyConfig;
|
||||
previous: {
|
||||
phase: PhenologyPhaseKey | null;
|
||||
source?: PhaseSourceKey | null;
|
||||
} | null;
|
||||
/** True after a report was revoked: the phase may fall back. */
|
||||
allowBackward: boolean;
|
||||
}
|
||||
|
||||
export interface EffectivePhase {
|
||||
reportedPhase: PhenologyPhaseKey | null;
|
||||
reportedOn: string | null;
|
||||
estimatedPhase: PhenologyPhaseKey | null;
|
||||
effectivePhase: PhenologyPhaseKey | null;
|
||||
phaseSource: PhaseSourceKey | null;
|
||||
note: SeasonNote;
|
||||
}
|
||||
|
||||
interface Candidate {
|
||||
phase: PhenologyPhaseKey;
|
||||
source: PhaseSourceKey;
|
||||
}
|
||||
|
||||
const rankOf = (phase: PhenologyPhaseKey): number =>
|
||||
PHENOLOGY_PHASES.indexOf(phase);
|
||||
|
||||
/** The higher phase; the first wins a tie. */
|
||||
function higher(a: Candidate | null, b: Candidate | null): Candidate | null {
|
||||
if (a === null || b === null) {
|
||||
return a ?? b;
|
||||
}
|
||||
return rankOf(b.phase) > rankOf(a.phase) ? b : a;
|
||||
}
|
||||
|
||||
/** Reports that count for an apiary: its own and those for all apiaries. */
|
||||
export function reportsApplyingTo<T extends { apiaryId: string | null }>(
|
||||
apiaryId: string,
|
||||
reports: readonly T[],
|
||||
): T[] {
|
||||
return reports.filter(
|
||||
(report) => report.apiaryId === null || report.apiaryId === apiaryId,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Why the weather cannot decide the phase, if it cannot: no coordinates, the
|
||||
* last day with data too old, or too many gaps. Null if GTS is off or fine.
|
||||
*/
|
||||
export function seasonNoteFor(input: {
|
||||
useGts: boolean;
|
||||
hasCoordinates: boolean;
|
||||
today: string;
|
||||
lastDataDay: string | null;
|
||||
gtsUsable: boolean;
|
||||
}): SeasonNote {
|
||||
if (!input.useGts) {
|
||||
return null;
|
||||
}
|
||||
if (!input.hasCoordinates) {
|
||||
return 'NO_COORDINATES';
|
||||
}
|
||||
// January 1st: no day of the year has passed, there is nothing to judge.
|
||||
if (input.today.endsWith('-01-01')) {
|
||||
return null;
|
||||
}
|
||||
if (
|
||||
input.lastDataDay === null ||
|
||||
daysBetween(input.lastDataDay, input.today) > GTS_MAX_AGE_DAYS
|
||||
) {
|
||||
return 'WEATHER_STALE';
|
||||
}
|
||||
return input.gtsUsable ? null : 'WEATHER_GAPS';
|
||||
}
|
||||
|
||||
/** The GTS that may decide today's phase; null if it is off, stale or gappy. */
|
||||
export function usableGts(
|
||||
inputs: Pick<PhaseInputs, 'today' | 'gts' | 'hasCoordinates' | 'config'>,
|
||||
): GtsResult | null {
|
||||
const { gts } = inputs;
|
||||
if (gts === null || !gts.usable || gts.throughDay === null) {
|
||||
return null;
|
||||
}
|
||||
const note = seasonNoteFor({
|
||||
useGts: inputs.config.useGts,
|
||||
hasCoordinates: inputs.hasCoordinates,
|
||||
today: inputs.today,
|
||||
lastDataDay: gts.throughDay,
|
||||
gtsUsable: gts.usable,
|
||||
});
|
||||
return inputs.config.useGts && note === null ? gts : null;
|
||||
}
|
||||
|
||||
function reported(inputs: PhaseInputs): {
|
||||
phase: PhenologyPhaseKey | null;
|
||||
on: string | null;
|
||||
} {
|
||||
const yearStart = `${inputs.today.slice(0, 4)}-01-01`;
|
||||
const valid = inputs.reports.filter(
|
||||
(report) =>
|
||||
!report.revoked &&
|
||||
report.date >= yearStart &&
|
||||
report.date <= inputs.today,
|
||||
);
|
||||
let phase: PhenologyPhaseKey | null = null;
|
||||
for (const report of valid) {
|
||||
if (phase === null || rankOf(report.phase) > rankOf(phase)) {
|
||||
phase = report.phase;
|
||||
}
|
||||
}
|
||||
const dates = valid.filter((r) => r.phase === phase).map((r) => r.date);
|
||||
return { phase, on: dates.length > 0 ? dates.sort()[0] : null };
|
||||
}
|
||||
|
||||
function gtsCandidate(
|
||||
inputs: PhaseInputs,
|
||||
gts: GtsResult | null,
|
||||
): Candidate | null {
|
||||
if (gts === null) {
|
||||
return null;
|
||||
}
|
||||
let best: Candidate | null = null;
|
||||
for (const phase of PHENOLOGY_PHASES) {
|
||||
const threshold = inputs.thresholds[phase];
|
||||
if (threshold !== undefined && gts.gts >= threshold) {
|
||||
best = { phase, source: 'GTS' };
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/** The latest calendar phase reached; phases with a GTS threshold are skipped while GTS decides. */
|
||||
function calendarCandidate(
|
||||
inputs: PhaseInputs,
|
||||
gtsDecides: boolean,
|
||||
): Candidate | null {
|
||||
const year = inputs.today.slice(0, 4);
|
||||
let best: Candidate | null = null;
|
||||
for (const phase of PHENOLOGY_PHASES) {
|
||||
if (gtsDecides && inputs.thresholds[phase] !== undefined) {
|
||||
continue;
|
||||
}
|
||||
if (`${year}-${inputs.config.calendarFallback[phase]}` <= inputs.today) {
|
||||
best = { phase, source: 'CALENDAR' };
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/**
|
||||
* The phase of an apiary today: the maximum of the user's report, the GTS
|
||||
* estimate and the calendar (the calendar only for phases without a GTS
|
||||
* threshold, or everywhere while the weather cannot be used). Within a year it
|
||||
* only moves forward, unless `allowBackward` (a report was revoked). Before
|
||||
* the first phase of the year there is no phase, and the calendar is the source.
|
||||
*/
|
||||
export function effectivePhaseOf(inputs: PhaseInputs): EffectivePhase {
|
||||
const gts = usableGts(inputs);
|
||||
const note = seasonNoteFor({
|
||||
useGts: inputs.config.useGts,
|
||||
hasCoordinates: inputs.hasCoordinates,
|
||||
today: inputs.today,
|
||||
lastDataDay: inputs.gts?.throughDay ?? null,
|
||||
gtsUsable: inputs.gts?.usable ?? false,
|
||||
});
|
||||
const report = reported(inputs);
|
||||
const reportCandidate: Candidate | null =
|
||||
report.phase === null ? null : { phase: report.phase, source: 'REPORT' };
|
||||
const estimate = higher(
|
||||
gtsCandidate(inputs, gts),
|
||||
calendarCandidate(inputs, gts !== null),
|
||||
);
|
||||
let chosen = higher(reportCandidate, estimate);
|
||||
|
||||
const previous = inputs.previous?.phase ?? null;
|
||||
if (
|
||||
previous !== null &&
|
||||
!inputs.allowBackward &&
|
||||
(chosen === null || rankOf(previous) > rankOf(chosen.phase))
|
||||
) {
|
||||
chosen = {
|
||||
phase: previous,
|
||||
source: inputs.previous?.source ?? chosen?.source ?? 'CALENDAR',
|
||||
};
|
||||
}
|
||||
return {
|
||||
reportedPhase: report.phase,
|
||||
reportedOn: report.on,
|
||||
estimatedPhase: estimate?.phase ?? null,
|
||||
effectivePhase: chosen?.phase ?? null,
|
||||
phaseSource: chosen?.source ?? 'CALENDAR',
|
||||
note,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { ApiaryPhenology } from '../../generated/prisma';
|
||||
import type { PhenologyConfig } from '../automation/rule-config/rule-config.types';
|
||||
import { utcDay } from '../domain-events/domain-event-types';
|
||||
import type { SeasonState } from '../season/season-types';
|
||||
import { seasonNoteFor } from './phenology-phase';
|
||||
|
||||
/**
|
||||
* The season state a stored ApiaryPhenology row stands for.
|
||||
*
|
||||
* `reportedOn` is not stored and stays null. `springSince` (the day FRUEHJAHR
|
||||
* began) is not stored either: in FRUEHJAHR it is `segmentSince`; later in the
|
||||
* year it is the calendar day of SALWEIDE, but not later than `segmentSince`;
|
||||
* before the spring it is null.
|
||||
*/
|
||||
export function seasonStateFromRow(
|
||||
row: Omit<ApiaryPhenology, 'updatedAt'>,
|
||||
config: PhenologyConfig,
|
||||
hasCoordinates: boolean,
|
||||
today: string,
|
||||
): SeasonState {
|
||||
const segmentSince = utcDay(row.segmentSince);
|
||||
const calendarSpring = `${row.year}-${config.calendarFallback.SALWEIDE}`;
|
||||
let springSince: string | null;
|
||||
if (row.segment === 'FRUEHJAHR') {
|
||||
springSince = segmentSince;
|
||||
} else if (row.segment === 'WINTERRUHE' && row.effectivePhase !== 'WINTER') {
|
||||
springSince = null;
|
||||
} else {
|
||||
springSince = calendarSpring < segmentSince ? calendarSpring : segmentSince;
|
||||
}
|
||||
const gtsThroughDay =
|
||||
row.gtsThroughDay === null ? null : utcDay(row.gtsThroughDay);
|
||||
return {
|
||||
apiaryId: row.apiaryId,
|
||||
year: row.year,
|
||||
phase: row.effectivePhase,
|
||||
phaseSource: row.phaseSource,
|
||||
reportedOn: null,
|
||||
segment: row.segment,
|
||||
segmentSince,
|
||||
springSince,
|
||||
gts: row.gts,
|
||||
gtsThroughDay,
|
||||
note: seasonNoteFor({
|
||||
useGts: config.useGts,
|
||||
hasCoordinates,
|
||||
today,
|
||||
lastDataDay: gtsThroughDay,
|
||||
gtsUsable: row.gts !== null,
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,425 @@
|
||||
import type { Prisma } from '../../generated/prisma';
|
||||
import { DEFAULT_PHENOLOGY_CONFIG } from '../automation/rule-config/phenology.config';
|
||||
import { calendarSeasonState } from '../season/season-calendar';
|
||||
import { DomainEventsService } from '../domain-events/domain-events.service';
|
||||
import type { PrismaService } from '../prisma/prisma.service';
|
||||
import { PhenologyStateService } from './phenology-state.service';
|
||||
|
||||
const d = (day: string): Date => new Date(`${day}T00:00:00.000Z`);
|
||||
|
||||
interface Fixture {
|
||||
apiary: { latitude: number | null; longitude: number | null } | null;
|
||||
row: Record<string, unknown> | null;
|
||||
reports: Record<string, unknown>[];
|
||||
weather: { day: Date; meanTempC: number | null }[];
|
||||
}
|
||||
|
||||
function setup(fixture: Partial<Fixture> = {}) {
|
||||
const data: Fixture = {
|
||||
apiary: { latitude: 48.1, longitude: 9.9 },
|
||||
row: null,
|
||||
reports: [],
|
||||
weather: [],
|
||||
...fixture,
|
||||
};
|
||||
const upsert = jest.fn().mockResolvedValue(undefined);
|
||||
const record = jest.fn().mockResolvedValue(undefined);
|
||||
const queryRaw = jest.fn().mockResolvedValue([{ id: 'apiary-1' }]);
|
||||
const tx = {
|
||||
$queryRaw: queryRaw,
|
||||
$executeRaw: jest.fn(),
|
||||
$transaction: undefined,
|
||||
apiary: { findUnique: jest.fn().mockResolvedValue(data.apiary) },
|
||||
automationRule: {
|
||||
findUnique: jest.fn().mockResolvedValue({
|
||||
id: 'rule-1',
|
||||
active: true,
|
||||
actionTemplate: null,
|
||||
config: DEFAULT_PHENOLOGY_CONFIG,
|
||||
}),
|
||||
},
|
||||
apiaryPhenology: {
|
||||
findUnique: jest.fn().mockResolvedValue(data.row),
|
||||
upsert,
|
||||
},
|
||||
phenologyReport: { findMany: jest.fn().mockResolvedValue(data.reports) },
|
||||
apiaryWeatherDay: { findMany: jest.fn().mockResolvedValue(data.weather) },
|
||||
};
|
||||
const events = { record } as unknown as DomainEventsService;
|
||||
const service = new PhenologyStateService({} as PrismaService, events);
|
||||
return { service, tx, upsert, record, queryRaw };
|
||||
}
|
||||
|
||||
/** Every day of 2026 from January 1 to `through` at `mean` degrees. */
|
||||
function weather(through: string, mean: number) {
|
||||
const days: { day: Date; meanTempC: number | null }[] = [];
|
||||
for (
|
||||
let t = d('2026-01-01').getTime();
|
||||
t <= d(through).getTime();
|
||||
t += 86_400_000
|
||||
) {
|
||||
days.push({ day: new Date(t), meanTempC: mean });
|
||||
}
|
||||
return days;
|
||||
}
|
||||
|
||||
const asTx = (tx: unknown) => tx as Prisma.TransactionClient;
|
||||
|
||||
describe('PhenologyStateService.recomputeIn', () => {
|
||||
it('records SeasonPhaseChanged once per apiary and day on a change', async () => {
|
||||
// Stored: FRUEHJAHR since March 20th; today the report puts it on APFEL.
|
||||
const { service, tx, record } = setup({
|
||||
row: {
|
||||
apiaryId: 'apiary-1',
|
||||
year: 2026,
|
||||
gts: null,
|
||||
gtsThroughDay: null,
|
||||
missingDays: 0,
|
||||
estimatedPhase: 'SALWEIDE',
|
||||
reportedPhase: null,
|
||||
effectivePhase: 'SALWEIDE',
|
||||
phaseSource: 'CALENDAR',
|
||||
segment: 'FRUEHJAHR',
|
||||
segmentSince: d('2026-03-20'),
|
||||
},
|
||||
reports: [
|
||||
{
|
||||
apiaryId: null,
|
||||
phase: 'APFEL',
|
||||
date: d('2026-04-18'),
|
||||
revokedAt: null,
|
||||
},
|
||||
],
|
||||
weather: weather('2026-04-19', 10),
|
||||
});
|
||||
|
||||
const first = await service.recomputeIn(asTx(tx), 'apiary-1', '2026-04-20');
|
||||
const second = await service.recomputeIn(
|
||||
asTx(tx),
|
||||
'apiary-1',
|
||||
'2026-04-20',
|
||||
);
|
||||
|
||||
expect(first.changed).toBe(true);
|
||||
expect(first.state).toMatchObject({
|
||||
phase: 'APFEL',
|
||||
phaseSource: 'REPORT',
|
||||
segment: 'SCHWARMZEIT',
|
||||
segmentSince: '2026-04-20',
|
||||
});
|
||||
// Both runs report the same change (the mocked row stays unchanged); the
|
||||
// event carries the same dedupe key, so the outbox keeps one of them.
|
||||
expect(second.changed).toBe(true);
|
||||
const keys = record.mock.calls.map(
|
||||
([, event]: [unknown, { dedupeKey: string }]) => event.dedupeKey,
|
||||
);
|
||||
expect(keys).toEqual([
|
||||
'SeasonPhaseChanged:apiary-1:2026-04-20',
|
||||
'SeasonPhaseChanged:apiary-1:2026-04-20',
|
||||
]);
|
||||
expect(record).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
type: 'SeasonPhaseChanged',
|
||||
aggregateType: 'Apiary',
|
||||
aggregateId: 'apiary-1',
|
||||
payload: {
|
||||
apiaryId: 'apiary-1',
|
||||
phase: 'APFEL',
|
||||
segment: 'SCHWARMZEIT',
|
||||
previousPhase: 'SALWEIDE',
|
||||
previousSegment: 'FRUEHJAHR',
|
||||
source: 'REPORT',
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('records nothing when phase and segment stay', async () => {
|
||||
const { service, tx, record, upsert } = setup({
|
||||
row: {
|
||||
apiaryId: 'apiary-1',
|
||||
year: 2026,
|
||||
gts: 800,
|
||||
gtsThroughDay: d('2026-04-19'),
|
||||
missingDays: 0,
|
||||
estimatedPhase: 'KIRSCHE',
|
||||
reportedPhase: null,
|
||||
effectivePhase: 'KIRSCHE',
|
||||
phaseSource: 'CALENDAR',
|
||||
segment: 'SCHWARMZEIT',
|
||||
segmentSince: d('2026-04-15'),
|
||||
},
|
||||
weather: weather('2026-04-19', 10),
|
||||
});
|
||||
|
||||
const result = await service.recomputeIn(
|
||||
asTx(tx),
|
||||
'apiary-1',
|
||||
'2026-04-20',
|
||||
);
|
||||
|
||||
expect(result.changed).toBe(false);
|
||||
expect(result.state.segmentSince).toBe('2026-04-15');
|
||||
expect(record).not.toHaveBeenCalled();
|
||||
expect(upsert).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('locks the apiary row before it reads the stored state', async () => {
|
||||
const { service, tx, queryRaw } = setup();
|
||||
const order: string[] = [];
|
||||
queryRaw.mockImplementation(() => {
|
||||
order.push('lock');
|
||||
return Promise.resolve([{ id: 'apiary-1' }]);
|
||||
});
|
||||
tx.apiaryPhenology.findUnique.mockImplementation(() => {
|
||||
order.push('read');
|
||||
return Promise.resolve(null);
|
||||
});
|
||||
|
||||
await service.recomputeIn(asTx(tx), 'apiary-1', '2026-04-20');
|
||||
|
||||
expect(order.slice(0, 2)).toEqual(['lock', 'read']);
|
||||
});
|
||||
|
||||
it('first row of the year equals the calendar state: no event', async () => {
|
||||
const { service, tx, record, upsert } = setup({
|
||||
apiary: { latitude: null, longitude: null },
|
||||
});
|
||||
|
||||
const result = await service.recomputeIn(
|
||||
asTx(tx),
|
||||
'apiary-1',
|
||||
'2026-04-20',
|
||||
);
|
||||
|
||||
expect(result.changed).toBe(false);
|
||||
expect(record).not.toHaveBeenCalled();
|
||||
expect(result.state).toMatchObject({
|
||||
note: 'NO_COORDINATES',
|
||||
phase: 'KIRSCHE',
|
||||
segment: 'SCHWARMZEIT',
|
||||
segmentSince: '2026-04-15',
|
||||
});
|
||||
const call = upsert.mock.calls[0] as [{ create: Record<string, unknown> }];
|
||||
expect(call[0].create).toMatchObject({
|
||||
apiaryId: 'apiary-1',
|
||||
year: 2026,
|
||||
gts: null,
|
||||
effectivePhase: 'KIRSCHE',
|
||||
});
|
||||
});
|
||||
|
||||
it('allows going backwards only with allowBackward', async () => {
|
||||
const stored = {
|
||||
apiaryId: 'apiary-1',
|
||||
year: 2026,
|
||||
gts: null,
|
||||
gtsThroughDay: null,
|
||||
missingDays: 0,
|
||||
estimatedPhase: 'KIRSCHE',
|
||||
reportedPhase: 'LINDE',
|
||||
effectivePhase: 'LINDE',
|
||||
phaseSource: 'REPORT',
|
||||
segment: 'SOMMER',
|
||||
segmentSince: d('2026-04-18'),
|
||||
};
|
||||
const held = setup({
|
||||
row: stored,
|
||||
apiary: { latitude: null, longitude: null },
|
||||
});
|
||||
const released = setup({
|
||||
row: stored,
|
||||
apiary: { latitude: null, longitude: null },
|
||||
});
|
||||
|
||||
const keep = await held.service.recomputeIn(
|
||||
asTx(held.tx),
|
||||
'apiary-1',
|
||||
'2026-04-20',
|
||||
);
|
||||
const back = await released.service.recomputeIn(
|
||||
asTx(released.tx),
|
||||
'apiary-1',
|
||||
'2026-04-20',
|
||||
{ allowBackward: true },
|
||||
);
|
||||
|
||||
expect(keep.state.phase).toBe('LINDE');
|
||||
expect(keep.changed).toBe(false);
|
||||
expect(back.state.phase).toBe('KIRSCHE');
|
||||
expect(back.changed).toBe(true);
|
||||
expect(released.record).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('throws for an apiary that does not exist', async () => {
|
||||
const { service, tx } = setup({ apiary: null });
|
||||
|
||||
await expect(
|
||||
service.recomputeIn(asTx(tx), 'gone', '2026-04-20'),
|
||||
).rejects.toThrow('Apiary gone not found');
|
||||
});
|
||||
});
|
||||
|
||||
function weatherRange(from: string, through: string, mean: number) {
|
||||
const days: { day: Date; meanTempC: number | null }[] = [];
|
||||
for (let t = d(from).getTime(); t <= d(through).getTime(); t += 86_400_000) {
|
||||
days.push({ day: new Date(t), meanTempC: mean });
|
||||
}
|
||||
return days;
|
||||
}
|
||||
|
||||
describe('PhenologyStateService fix round 1', () => {
|
||||
const noCoordinates = { latitude: null, longitude: null };
|
||||
|
||||
it('filters the reports in SQL: own and those for all apiaries', async () => {
|
||||
const { service, tx } = setup({ apiary: noCoordinates });
|
||||
|
||||
await service.recomputeIn(asTx(tx), 'apiary-1', '2026-04-20');
|
||||
|
||||
const call = tx.phenologyReport.findMany.mock.calls[0] as [
|
||||
{ where: { revokedAt: null; OR: unknown } },
|
||||
];
|
||||
expect(call[0].where.revokedAt).toBeNull();
|
||||
expect(call[0].where.OR).toEqual([
|
||||
{ apiaryId: 'apiary-1' },
|
||||
{ apiaryId: null },
|
||||
]);
|
||||
});
|
||||
|
||||
it('recomputeAll reads the rule and the reports once, not per apiary', async () => {
|
||||
const { tx, record } = setup({ apiary: noCoordinates });
|
||||
const prisma = {
|
||||
apiary: {
|
||||
findMany: jest
|
||||
.fn()
|
||||
.mockResolvedValue([{ id: 'apiary-1' }, { id: 'apiary-2' }]),
|
||||
},
|
||||
automationRule: tx.automationRule,
|
||||
phenologyReport: {
|
||||
findMany: jest.fn().mockResolvedValue([
|
||||
{
|
||||
apiaryId: 'apiary-2',
|
||||
phase: 'LINDE',
|
||||
date: d('2026-04-18'),
|
||||
},
|
||||
]),
|
||||
},
|
||||
$transaction: (fn: (t: unknown) => Promise<unknown>) => fn(tx),
|
||||
};
|
||||
const all = new PhenologyStateService(
|
||||
prisma as unknown as PrismaService,
|
||||
{ record } as unknown as DomainEventsService,
|
||||
);
|
||||
|
||||
const result = await all.recomputeAll('2026-04-20');
|
||||
|
||||
expect(result).toEqual({ apiaries: 2, changes: 1, failed: 0 });
|
||||
expect(prisma.automationRule.findUnique).toHaveBeenCalledTimes(1);
|
||||
expect(prisma.phenologyReport.findMany).toHaveBeenCalledTimes(1);
|
||||
expect(tx.phenologyReport.findMany).not.toHaveBeenCalled();
|
||||
// The LINDE report of apiary-2 moved only apiary-2 (SOMMER).
|
||||
expect(record).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('recomputeAll counts a failing apiary and goes on', async () => {
|
||||
const { tx, record } = setup({ apiary: noCoordinates });
|
||||
const prisma = {
|
||||
apiary: {
|
||||
findMany: jest
|
||||
.fn()
|
||||
.mockResolvedValue([{ id: 'apiary-1' }, { id: 'apiary-2' }]),
|
||||
},
|
||||
automationRule: tx.automationRule,
|
||||
phenologyReport: { findMany: jest.fn().mockResolvedValue([]) },
|
||||
$transaction: jest
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error('boom'))
|
||||
.mockImplementation((fn: (t: unknown) => Promise<unknown>) => fn(tx)),
|
||||
};
|
||||
const all = new PhenologyStateService(
|
||||
prisma as unknown as PrismaService,
|
||||
{ record } as unknown as DomainEventsService,
|
||||
);
|
||||
jest
|
||||
.spyOn(
|
||||
(all as unknown as { logger: { error: () => void } }).logger,
|
||||
'error',
|
||||
)
|
||||
.mockImplementation(() => undefined);
|
||||
|
||||
expect(await all.recomputeAll('2026-04-20')).toMatchObject({
|
||||
apiaries: 1,
|
||||
failed: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('pins the pre-spring state: phase null here, WINTER in the calendar, WINTERRUHE in both', async () => {
|
||||
const { service, tx } = setup({ apiary: noCoordinates });
|
||||
|
||||
const { state } = await service.recomputeIn(
|
||||
asTx(tx),
|
||||
'apiary-1',
|
||||
'2026-02-10',
|
||||
);
|
||||
const calendar = calendarSeasonState(
|
||||
'apiary-1',
|
||||
'2026-02-10',
|
||||
DEFAULT_PHENOLOGY_CONFIG,
|
||||
);
|
||||
|
||||
expect(state.phase).toBeNull();
|
||||
expect(calendar.phase).toBe('WINTER');
|
||||
expect(state.segment).toBe('WINTERRUHE');
|
||||
expect(calendar.segment).toBe('WINTERRUHE');
|
||||
});
|
||||
|
||||
it('calibrates KIRSCHE from last year report and weather end to end', async () => {
|
||||
// 2025: 10 degrees all year; KIRSCHE reported 2025-04-10 -> GTS through
|
||||
// April 9th = 155 + 210 + 310 + 90 = 765. 2026: the same weather, so the
|
||||
// threshold is reached on 2026-04-10, five days before the calendar.
|
||||
const { service, tx } = setup({
|
||||
reports: [
|
||||
{
|
||||
apiaryId: null,
|
||||
phase: 'KIRSCHE',
|
||||
date: d('2025-04-10'),
|
||||
revokedAt: null,
|
||||
},
|
||||
],
|
||||
weather: [
|
||||
...weatherRange('2025-01-01', '2025-12-31', 10),
|
||||
...weatherRange('2026-01-01', '2026-04-09', 10),
|
||||
],
|
||||
});
|
||||
|
||||
const { state } = await service.recomputeIn(
|
||||
asTx(tx),
|
||||
'apiary-1',
|
||||
'2026-04-10',
|
||||
);
|
||||
|
||||
expect(state).toMatchObject({
|
||||
phase: 'KIRSCHE',
|
||||
phaseSource: 'GTS',
|
||||
gts: 765,
|
||||
segment: 'SCHWARMZEIT',
|
||||
});
|
||||
});
|
||||
|
||||
it('looks up the row of the new year at rollover, not last year', async () => {
|
||||
const { service, tx } = setup({ apiary: noCoordinates });
|
||||
|
||||
const { state } = await service.recomputeIn(
|
||||
asTx(tx),
|
||||
'apiary-1',
|
||||
'2027-01-02',
|
||||
);
|
||||
|
||||
expect(tx.apiaryPhenology.findUnique).toHaveBeenCalledWith({
|
||||
where: { apiaryId_year: { apiaryId: 'apiary-1', year: 2027 } },
|
||||
});
|
||||
expect(state).toMatchObject({ year: 2027, phase: null });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,369 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import type { Prisma } from '../../generated/prisma';
|
||||
import type { DbClient } from '../prisma/db-client';
|
||||
import { readRule } from '../automation/rule-config';
|
||||
import type { PhenologyConfig } from '../automation/rule-config/rule-config.types';
|
||||
import { addDays, dayToDate } from '../common/dates';
|
||||
import { loggableError } from '../common/loggable-error';
|
||||
import { dailyDedupeKey, utcDay } from '../domain-events/domain-event-types';
|
||||
import { DomainEventsService } from '../domain-events/domain-events.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { lockRowForUpdate } from '../prisma/row-lock';
|
||||
import { calendarSeasonState } from '../season/season-calendar';
|
||||
import { segmentOf } from '../season/season-segment';
|
||||
import type {
|
||||
DailyMean,
|
||||
PhenologyPhaseKey,
|
||||
SeasonState,
|
||||
} from '../season/season-types';
|
||||
import { grasslandTemperatureSum } from './gts';
|
||||
import {
|
||||
CALIBRATION_YEARS,
|
||||
calibrateThresholds,
|
||||
type CalibrationYear,
|
||||
} from './phenology-calibration';
|
||||
import {
|
||||
effectivePhaseOf,
|
||||
reportsApplyingTo,
|
||||
usableGts,
|
||||
} from './phenology-phase';
|
||||
import { seasonStateFromRow } from './phenology-row';
|
||||
|
||||
export interface DatedReport {
|
||||
apiaryId: string | null;
|
||||
phase: PhenologyPhaseKey;
|
||||
date: string;
|
||||
revoked: boolean;
|
||||
}
|
||||
|
||||
/** What recomputeAll reads once per run instead of once per apiary. */
|
||||
export interface PreloadedPhenology {
|
||||
config: PhenologyConfig;
|
||||
/** Reports of all apiaries (apiaryId null = all), revoked ones excluded. */
|
||||
reports: readonly DatedReport[];
|
||||
}
|
||||
|
||||
export interface RecomputeOptions {
|
||||
/** After a revoked report: the phase may fall back. */
|
||||
allowBackward?: boolean;
|
||||
preloaded?: PreloadedPhenology;
|
||||
}
|
||||
|
||||
export interface RecomputeResult {
|
||||
changed: boolean;
|
||||
state: SeasonState;
|
||||
}
|
||||
|
||||
export interface RecomputeAllResult {
|
||||
apiaries: number;
|
||||
changes: number;
|
||||
/** Apiaries whose recomputation failed; the next run tries again. */
|
||||
failed: number;
|
||||
}
|
||||
|
||||
function groupByYear(days: readonly DailyMean[]): Map<number, DailyMean[]> {
|
||||
const byYear = new Map<number, DailyMean[]>();
|
||||
for (const day of days) {
|
||||
const year = Number(day.day.slice(0, 4));
|
||||
byYear.set(year, [...(byYear.get(year) ?? []), day]);
|
||||
}
|
||||
return byYear;
|
||||
}
|
||||
|
||||
/** The calibration input of one previous year. */
|
||||
function calibrationYear(
|
||||
year: number,
|
||||
reports: readonly DatedReport[],
|
||||
days: readonly DailyMean[],
|
||||
config: PhenologyConfig,
|
||||
): CalibrationYear {
|
||||
const firstReportDay: CalibrationYear['firstReportDay'] = {};
|
||||
for (const report of reports) {
|
||||
if (
|
||||
report.date.startsWith(`${year}-`) &&
|
||||
config.calibratePhases.includes(report.phase) &&
|
||||
(firstReportDay[report.phase] ?? '9999') > report.date
|
||||
) {
|
||||
firstReportDay[report.phase] = report.date;
|
||||
}
|
||||
}
|
||||
return {
|
||||
firstReportDay,
|
||||
// The GTS the morning of that day would have shown: through the day before.
|
||||
gtsOn: (day) => {
|
||||
const result = grasslandTemperatureSum(
|
||||
days,
|
||||
year,
|
||||
addDays(day, -1),
|
||||
config.maxGapShare,
|
||||
);
|
||||
return result.usable ? result.gts : null;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes and stores the season state of apiaries (ApiaryPhenology): the
|
||||
* phase from the user's reports, the GTS and the calendar (see
|
||||
* effectivePhaseOf), and the season segment. A change of phase or segment is
|
||||
* recorded as SeasonPhaseChanged (once per apiary and day).
|
||||
*/
|
||||
@Injectable()
|
||||
export class PhenologyStateService {
|
||||
private readonly logger = new Logger(PhenologyStateService.name);
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly events: DomainEventsService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Recomputes one apiary for `today` (YYYY-MM-DD, Berlin) inside the
|
||||
* caller's transaction: locks the apiary row, then reads the stored state,
|
||||
* so two recomputations of one apiary run one after the other.
|
||||
*/
|
||||
async recomputeIn(
|
||||
tx: Prisma.TransactionClient,
|
||||
apiaryId: string,
|
||||
today: string,
|
||||
options: RecomputeOptions = {},
|
||||
): Promise<RecomputeResult> {
|
||||
const exists = await lockRowForUpdate(tx, 'Apiary', apiaryId);
|
||||
const apiary = exists
|
||||
? await tx.apiary.findUnique({
|
||||
where: { id: apiaryId },
|
||||
select: { latitude: true, longitude: true },
|
||||
})
|
||||
: null;
|
||||
if (apiary === null) {
|
||||
throw new Error(`Apiary ${apiaryId} not found`);
|
||||
}
|
||||
const year = Number(today.slice(0, 4));
|
||||
const config =
|
||||
options.preloaded?.config ?? (await readRule(tx, 'PHENOLOGY')).config;
|
||||
const stored = await tx.apiaryPhenology.findUnique({
|
||||
where: { apiaryId_year: { apiaryId, year } },
|
||||
});
|
||||
const hasCoordinates =
|
||||
apiary.latitude !== null && apiary.longitude !== null;
|
||||
const useWeather = config.useGts && hasCoordinates;
|
||||
const reports = options.preloaded
|
||||
? reportsApplyingTo(apiaryId, options.preloaded.reports)
|
||||
: await this.loadReports(tx, apiaryId, year, today);
|
||||
const days = useWeather
|
||||
? await this.loadWeather(tx, apiaryId, year, today, config, reports)
|
||||
: [];
|
||||
const byYear = groupByYear(days);
|
||||
|
||||
const gts = useWeather
|
||||
? grasslandTemperatureSum(
|
||||
byYear.get(year) ?? [],
|
||||
year,
|
||||
addDays(today, -1),
|
||||
config.maxGapShare,
|
||||
)
|
||||
: null;
|
||||
const calibrated =
|
||||
useWeather && config.calibrateFromReports
|
||||
? calibrateThresholds(
|
||||
Array.from(
|
||||
{ length: CALIBRATION_YEARS },
|
||||
(_, i) => year - 1 - i,
|
||||
).map((previousYear) =>
|
||||
calibrationYear(
|
||||
previousYear,
|
||||
reports,
|
||||
byYear.get(previousYear) ?? [],
|
||||
config,
|
||||
),
|
||||
),
|
||||
config.calibratePhases,
|
||||
)
|
||||
: {};
|
||||
const phaseInputs = {
|
||||
today,
|
||||
reports: reports.filter((report) => report.date.startsWith(`${year}-`)),
|
||||
gts,
|
||||
hasCoordinates,
|
||||
thresholds: config.useGts
|
||||
? { ...config.gtsThresholds, ...calibrated }
|
||||
: {},
|
||||
config,
|
||||
previous: stored
|
||||
? { phase: stored.effectivePhase, source: stored.phaseSource }
|
||||
: null,
|
||||
allowBackward: options.allowBackward ?? false,
|
||||
};
|
||||
const effective = effectivePhaseOf(phaseInputs);
|
||||
const segment = segmentOf(effective.effectivePhase, config.swarmSeason);
|
||||
|
||||
// Without a stored row the calendar state is what the users saw so far.
|
||||
const baseline = calendarSeasonState(apiaryId, today, config);
|
||||
const previousPhase = stored
|
||||
? stored.effectivePhase
|
||||
: baseline.springSince === null
|
||||
? null
|
||||
: baseline.phase;
|
||||
const previousSegment = stored ? stored.segment : baseline.segment;
|
||||
const segmentSince =
|
||||
previousSegment === segment
|
||||
? stored
|
||||
? utcDay(stored.segmentSince)
|
||||
: baseline.segmentSince
|
||||
: today;
|
||||
// Pre-spring rows hold phase null; calendarSeasonState says WINTER there
|
||||
// (segment WINTERRUHE in both). A stored WINTER would block every later
|
||||
// phase under the never-backwards rule.
|
||||
const changed =
|
||||
previousPhase !== effective.effectivePhase || previousSegment !== segment;
|
||||
|
||||
const used = usableGts(phaseInputs);
|
||||
const data = {
|
||||
gts: used === null ? null : used.gts,
|
||||
gtsThroughDay: gts?.throughDay ? dayToDate(gts.throughDay) : null,
|
||||
missingDays: gts?.missingDays ?? 0,
|
||||
estimatedPhase: effective.estimatedPhase,
|
||||
reportedPhase: effective.reportedPhase,
|
||||
effectivePhase: effective.effectivePhase,
|
||||
phaseSource: effective.phaseSource,
|
||||
segment,
|
||||
segmentSince: dayToDate(segmentSince),
|
||||
};
|
||||
await tx.apiaryPhenology.upsert({
|
||||
where: { apiaryId_year: { apiaryId, year } },
|
||||
create: { apiaryId, year, ...data },
|
||||
update: data,
|
||||
});
|
||||
if (changed) {
|
||||
await this.events.record(tx, {
|
||||
type: 'SeasonPhaseChanged',
|
||||
aggregateType: 'Apiary',
|
||||
aggregateId: apiaryId,
|
||||
payload: {
|
||||
apiaryId,
|
||||
phase: effective.effectivePhase,
|
||||
segment,
|
||||
previousPhase,
|
||||
previousSegment,
|
||||
source: effective.phaseSource,
|
||||
},
|
||||
// By design at most one SeasonPhaseChanged per apiary and day: a second
|
||||
// change the same day updates the row but adds no event.
|
||||
dedupeKey: dailyDedupeKey('SeasonPhaseChanged', apiaryId, today),
|
||||
});
|
||||
}
|
||||
return {
|
||||
changed,
|
||||
state: seasonStateFromRow(
|
||||
{ apiaryId, year, ...data },
|
||||
config,
|
||||
hasCoordinates,
|
||||
today,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Recomputes every active apiary, one transaction each. A failing apiary is
|
||||
* logged and does not stop the others.
|
||||
*/
|
||||
async recomputeAll(today: string): Promise<RecomputeAllResult> {
|
||||
const apiaries = await this.prisma.apiary.findMany({
|
||||
where: { status: 'ACTIVE' },
|
||||
select: { id: true },
|
||||
orderBy: { id: 'asc' },
|
||||
});
|
||||
const preloaded: PreloadedPhenology = {
|
||||
config: (await readRule(this.prisma, 'PHENOLOGY')).config,
|
||||
reports: await this.loadReports(
|
||||
this.prisma,
|
||||
null,
|
||||
Number(today.slice(0, 4)),
|
||||
today,
|
||||
),
|
||||
};
|
||||
let done = 0;
|
||||
let changes = 0;
|
||||
let failed = 0;
|
||||
for (const { id } of apiaries) {
|
||||
try {
|
||||
const result = await this.prisma.$transaction((tx) =>
|
||||
this.recomputeIn(tx, id, today, { preloaded }),
|
||||
);
|
||||
done += 1;
|
||||
changes += result.changed ? 1 : 0;
|
||||
} catch (error: unknown) {
|
||||
const { message, stack } = loggableError(error);
|
||||
this.logger.error(
|
||||
`Phenology of apiary ${id} failed: ${message}`,
|
||||
stack,
|
||||
);
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
return { apiaries: done, changes, failed };
|
||||
}
|
||||
|
||||
/**
|
||||
* Reports of the last years, revoked ones excluded: those that count for
|
||||
* the apiary (its own and those for all apiaries), or all when `apiaryId`
|
||||
* is null (the preload of recomputeAll).
|
||||
*/
|
||||
private async loadReports(
|
||||
db: DbClient,
|
||||
apiaryId: string | null,
|
||||
year: number,
|
||||
today: string,
|
||||
): Promise<DatedReport[]> {
|
||||
const rows = await db.phenologyReport.findMany({
|
||||
where: {
|
||||
revokedAt: null,
|
||||
...(apiaryId === null
|
||||
? {}
|
||||
: { OR: [{ apiaryId }, { apiaryId: null }] }),
|
||||
date: {
|
||||
gte: dayToDate(`${year - CALIBRATION_YEARS}-01-01`),
|
||||
lte: dayToDate(today),
|
||||
},
|
||||
},
|
||||
select: { apiaryId: true, phase: true, date: true },
|
||||
});
|
||||
return rows.map((row) => ({
|
||||
apiaryId: row.apiaryId,
|
||||
phase: row.phase,
|
||||
date: utcDay(row.date),
|
||||
revoked: false,
|
||||
}));
|
||||
}
|
||||
|
||||
/** The daily means of this year, and of earlier years that have reports to calibrate on. */
|
||||
private async loadWeather(
|
||||
tx: Prisma.TransactionClient,
|
||||
apiaryId: string,
|
||||
year: number,
|
||||
today: string,
|
||||
config: PhenologyConfig,
|
||||
reports: readonly DatedReport[],
|
||||
): Promise<DailyMean[]> {
|
||||
const calibrate = config.calibrateFromReports;
|
||||
const earliest = calibrate
|
||||
? Math.min(
|
||||
year,
|
||||
...reports
|
||||
.filter((report) => config.calibratePhases.includes(report.phase))
|
||||
.map((report) => Number(report.date.slice(0, 4))),
|
||||
)
|
||||
: year;
|
||||
const rows = await tx.apiaryWeatherDay.findMany({
|
||||
where: {
|
||||
apiaryId,
|
||||
day: { gte: dayToDate(`${earliest}-01-01`), lt: dayToDate(today) },
|
||||
},
|
||||
select: { day: true, meanTempC: true },
|
||||
});
|
||||
return rows.map((row) => ({
|
||||
day: utcDay(row.day),
|
||||
meanTempC: row.meanTempC,
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { DomainEventsModule } from '../domain-events/domain-events.module';
|
||||
import { PhenologyStateService } from './phenology-state.service';
|
||||
import { PhenologyStep } from './phenology.step';
|
||||
|
||||
/**
|
||||
* The season state of apiaries (ApiaryPhenology): the service that computes
|
||||
* it and the first step of the daily evaluation. Imported by AppModule and
|
||||
* WorkerModule (the reports of Task 28 recompute through the service) and by
|
||||
* JobsModule (the step).
|
||||
*/
|
||||
@Module({
|
||||
imports: [DomainEventsModule],
|
||||
providers: [PhenologyStateService, PhenologyStep],
|
||||
exports: [PhenologyStateService, PhenologyStep],
|
||||
})
|
||||
export class PhenologyModule {}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import type { DailyEvaluationStep } from '../jobs/daily-evaluation-steps';
|
||||
import type { JobResult } from '../jobs/job';
|
||||
import { JobPartialFailure } from '../system/job-run.service';
|
||||
import { PhenologyStateService } from './phenology-state.service';
|
||||
|
||||
/**
|
||||
* Step "phenology" of the daily evaluation (first, so the later steps see
|
||||
* today's season): recomputes the season state of every active apiary, one
|
||||
* transaction each. If apiaries failed, the step fails with the counters of
|
||||
* the others; the next run tries again.
|
||||
*/
|
||||
@Injectable()
|
||||
export class PhenologyStep implements DailyEvaluationStep {
|
||||
readonly name = 'phenology';
|
||||
|
||||
constructor(private readonly state: PhenologyStateService) {}
|
||||
|
||||
async run(today: string): Promise<JobResult> {
|
||||
const { apiaries, changes, failed } = await this.state.recomputeAll(today);
|
||||
const counts: JobResult = {
|
||||
phenologyApiaries: apiaries,
|
||||
seasonChanges: changes,
|
||||
};
|
||||
if (failed > 0) {
|
||||
throw new JobPartialFailure(counts, [
|
||||
`${failed} ${failed === 1 ? 'apiary' : 'apiaries'}`,
|
||||
]);
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
}
|
||||
@@ -199,6 +199,7 @@ describe('lock keys per table', () => {
|
||||
'StockItem',
|
||||
'Treatment',
|
||||
'VarroaMeasurement',
|
||||
'NotificationDelivery',
|
||||
] as const)('locks %s by "id"', async (table) => {
|
||||
const tx = { $queryRaw: jest.fn().mockResolvedValue([]) };
|
||||
|
||||
@@ -240,6 +241,7 @@ describe('lock keys per table', () => {
|
||||
'PhenologyReport',
|
||||
'Extraction',
|
||||
'HoneyBatch',
|
||||
'NotificationDelivery',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -38,6 +38,8 @@ const LOCKABLE_TABLES = {
|
||||
PhenologyReport: lockable('PhenologyReport'),
|
||||
Extraction: lockable('Extraction'),
|
||||
HoneyBatch: lockable('HoneyBatch'),
|
||||
// T4c (Task 34): the Telegram delivery outbox.
|
||||
NotificationDelivery: lockable('NotificationDelivery'),
|
||||
} as const;
|
||||
|
||||
export type LockableTable = keyof typeof LOCKABLE_TABLES;
|
||||
|
||||
@@ -321,4 +321,43 @@ describe('assertStartupEnvironment', () => {
|
||||
).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('telegram bot token', () => {
|
||||
const TOKEN = '123456789:AAEhBOweik6ad9r_QXMENQjcrGbqCr4K-RM';
|
||||
|
||||
it('starts without a token in every role (Telegram stays disabled)', () => {
|
||||
for (const role of ['api', 'worker', 'job', 'all'] as const) {
|
||||
expect(() => assertStartupEnvironment(role, PROD_ENV)).not.toThrow();
|
||||
}
|
||||
});
|
||||
|
||||
it('the worker accepts a well-formed token in production', () => {
|
||||
expect(() =>
|
||||
assertStartupEnvironment('worker', {
|
||||
...PROD_ENV,
|
||||
TELEGRAM_BOT_TOKEN: TOKEN,
|
||||
}),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it('a malformed or placeholder token never stops the worker (Telegram stays off)', () => {
|
||||
for (const bad of ['garbage', 'change-me-telegram-token']) {
|
||||
expect(() =>
|
||||
assertStartupEnvironment('worker', {
|
||||
...PROD_ENV,
|
||||
TELEGRAM_BOT_TOKEN: bad,
|
||||
}),
|
||||
).not.toThrow();
|
||||
}
|
||||
});
|
||||
|
||||
it('the API pod does not look at the token', () => {
|
||||
expect(() =>
|
||||
assertStartupEnvironment('api', {
|
||||
...PROD_ENV,
|
||||
TELEGRAM_BOT_TOKEN: 'garbage',
|
||||
}),
|
||||
).not.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
PREVIOUS_KEY_SUFFIX,
|
||||
readRingSecrets,
|
||||
} from '../common/crypto/key-ring';
|
||||
import { assertTelegramToken } from '../telegram/telegram-config';
|
||||
import type { ApicoreRole } from './role';
|
||||
|
||||
const REQUIRED_ENV_VARS = ['DATABASE_URL', 'JWT_ACCESS_SECRET'] as const;
|
||||
@@ -130,16 +131,17 @@ function assertEncryptionKeys(env: NodeJS.ProcessEnv): void {
|
||||
|
||||
/**
|
||||
* Startup checks shared by every entry point (API, worker, CLI job). All
|
||||
* roles load the same modules and secrets today, so the checks do not differ
|
||||
* per role yet; the role is part of the signature so role-specific checks can
|
||||
* be added without touching the callers.
|
||||
* roles load the same modules and secrets today, so the checks hardly differ
|
||||
* per role; the Telegram bot token is the first role-specific one (only the
|
||||
* worker holds it).
|
||||
*/
|
||||
export function assertStartupEnvironment(
|
||||
_role: ApicoreRole,
|
||||
role: ApicoreRole,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): void {
|
||||
assertRequiredEnvVars(env);
|
||||
assertProductionSettings(env);
|
||||
assertNoPlaceholderSecrets(env);
|
||||
assertEncryptionKeys(env);
|
||||
assertTelegramToken(role, env);
|
||||
}
|
||||
@@ -4,11 +4,16 @@ import { SeasonService } from './season.service';
|
||||
|
||||
describe('SeasonService', () => {
|
||||
const findUnique = jest.fn();
|
||||
const db = { automationRule: { findUnique } } as unknown as DbClient;
|
||||
const findMany = jest.fn();
|
||||
const db = {
|
||||
automationRule: { findUnique },
|
||||
apiaryPhenology: { findMany },
|
||||
} as unknown as DbClient;
|
||||
const service = new SeasonService();
|
||||
|
||||
beforeEach(() => {
|
||||
findUnique.mockReset().mockResolvedValue(null);
|
||||
findMany.mockReset().mockResolvedValue([]);
|
||||
});
|
||||
|
||||
it('gives every apiary the calendar state, reading PHENOLOGY once', async () => {
|
||||
@@ -55,4 +60,130 @@ describe('SeasonService', () => {
|
||||
springSince: '2026-03-01',
|
||||
});
|
||||
});
|
||||
|
||||
describe('with stored phenology', () => {
|
||||
const d = (day: string): Date => new Date(`${day}T00:00:00.000Z`);
|
||||
const row = (overrides: Record<string, unknown> = {}) => ({
|
||||
apiaryId: 'apiary-1',
|
||||
year: 2026,
|
||||
gts: 412.5,
|
||||
gtsThroughDay: d('2026-05-11'),
|
||||
missingDays: 0,
|
||||
estimatedPhase: 'KIRSCHE',
|
||||
reportedPhase: 'APFEL',
|
||||
effectivePhase: 'APFEL',
|
||||
phaseSource: 'REPORT',
|
||||
segment: 'SCHWARMZEIT',
|
||||
segmentSince: d('2026-05-02'),
|
||||
updatedAt: d('2026-05-12'),
|
||||
apiary: { latitude: 48.1, longitude: 9.9 },
|
||||
...overrides,
|
||||
});
|
||||
|
||||
it('prefers the row of the current year over the calendar', async () => {
|
||||
findMany.mockResolvedValue([row()]);
|
||||
|
||||
const states = await service.statesFor(
|
||||
db,
|
||||
['apiary-1', 'apiary-2'],
|
||||
'2026-05-12',
|
||||
);
|
||||
|
||||
expect(states.get('apiary-1')).toEqual({
|
||||
apiaryId: 'apiary-1',
|
||||
year: 2026,
|
||||
phase: 'APFEL',
|
||||
phaseSource: 'REPORT',
|
||||
reportedOn: null,
|
||||
segment: 'SCHWARMZEIT',
|
||||
segmentSince: '2026-05-02',
|
||||
springSince: '2026-03-20',
|
||||
gts: 412.5,
|
||||
gtsThroughDay: '2026-05-11',
|
||||
note: null,
|
||||
});
|
||||
expect(states.get('apiary-2')).toMatchObject({ phaseSource: 'CALENDAR' });
|
||||
expect(findMany).toHaveBeenCalledTimes(1);
|
||||
expect(findUnique).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('asks for the rows of the year of today', async () => {
|
||||
await service.statesFor(db, ['apiary-1'], '2026-05-12');
|
||||
|
||||
expect(findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { apiaryId: { in: ['apiary-1'] }, year: 2026 },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('takes the segment start as spring start while in FRUEHJAHR', async () => {
|
||||
findMany.mockResolvedValue([
|
||||
row({ segment: 'FRUEHJAHR', segmentSince: d('2026-03-28') }),
|
||||
]);
|
||||
|
||||
const states = await service.statesFor(db, ['apiary-1'], '2026-04-01');
|
||||
|
||||
expect(states.get('apiary-1')?.springSince).toBe('2026-03-28');
|
||||
});
|
||||
|
||||
it('has no spring start before the spring', async () => {
|
||||
findMany.mockResolvedValue([
|
||||
row({
|
||||
segment: 'WINTERRUHE',
|
||||
effectivePhase: null,
|
||||
phaseSource: 'CALENDAR',
|
||||
segmentSince: d('2026-01-01'),
|
||||
gts: 60,
|
||||
}),
|
||||
]);
|
||||
|
||||
const states = await service.statesFor(db, ['apiary-1'], '2026-02-10');
|
||||
|
||||
expect(states.get('apiary-1')?.springSince).toBeNull();
|
||||
});
|
||||
|
||||
it('explains why the weather was not used', async () => {
|
||||
findMany.mockResolvedValue([
|
||||
row({ gts: null, gtsThroughDay: d('2026-05-02') }),
|
||||
row({
|
||||
apiaryId: 'apiary-2',
|
||||
apiary: { latitude: null, longitude: null },
|
||||
gts: null,
|
||||
gtsThroughDay: null,
|
||||
}),
|
||||
row({ apiaryId: 'apiary-3', gts: null }),
|
||||
]);
|
||||
|
||||
const states = await service.statesFor(
|
||||
db,
|
||||
['apiary-1', 'apiary-2', 'apiary-3'],
|
||||
'2026-05-12',
|
||||
);
|
||||
|
||||
expect(states.get('apiary-1')?.note).toBe('WEATHER_STALE');
|
||||
expect(states.get('apiary-2')?.note).toBe('NO_COORDINATES');
|
||||
expect(states.get('apiary-3')?.note).toBe('WEATHER_GAPS');
|
||||
});
|
||||
|
||||
it('uses a row updated 2 days ago but not one updated 3 days ago', async () => {
|
||||
findMany.mockResolvedValue([
|
||||
row({ updatedAt: new Date('2026-05-10T04:31:00.000Z') }),
|
||||
row({
|
||||
apiaryId: 'apiary-2',
|
||||
updatedAt: new Date('2026-05-09T04:31:00.000Z'),
|
||||
}),
|
||||
]);
|
||||
|
||||
const states = await service.statesFor(
|
||||
db,
|
||||
['apiary-1', 'apiary-2'],
|
||||
'2026-05-12',
|
||||
);
|
||||
|
||||
expect(states.get('apiary-1')?.phaseSource).toBe('REPORT');
|
||||
// The job missed its runs: back to the calendar.
|
||||
expect(states.get('apiary-2')?.phaseSource).toBe('CALENDAR');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,13 +1,22 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { berlinDayOf, daysBetween } from '../common/dates';
|
||||
import { readRule } from '../automation/rule-config';
|
||||
import { seasonStateFromRow } from '../phenology/phenology-row';
|
||||
import type { DbClient } from '../prisma/db-client';
|
||||
import { calendarSeasonState } from './season-calendar';
|
||||
import type { SeasonState } from './season-types';
|
||||
|
||||
/** A stored row not updated for longer than this (days) means the job missed its runs. */
|
||||
const MAX_ROW_AGE_DAYS = 2;
|
||||
|
||||
/**
|
||||
* The season of apiaries. Until the phenology data exists (Task 25) every
|
||||
* apiary gets the calendar state; then ApiaryPhenology takes over here, and
|
||||
* the callers stay as they are.
|
||||
* The season of apiaries: the stored state of the current year
|
||||
* (ApiaryPhenology, written by the phenology step and the reports) where
|
||||
* there is one and the job kept it fresh (updated at most MAX_ROW_AGE_DAYS
|
||||
* days ago), else the calendar state. A stale row falls back to the calendar
|
||||
* instead of recomputing GTS on the fly: that would read a year of weather per
|
||||
* apiary on every read path, the calendar costs nothing. Callers do not see
|
||||
* the difference.
|
||||
*/
|
||||
@Injectable()
|
||||
export class SeasonService {
|
||||
@@ -21,9 +30,31 @@ export class SeasonService {
|
||||
if (apiaryIds.length === 0) {
|
||||
return states;
|
||||
}
|
||||
const ids = [...new Set(apiaryIds)];
|
||||
const { config } = await readRule(db, 'PHENOLOGY');
|
||||
for (const apiaryId of new Set(apiaryIds)) {
|
||||
states.set(apiaryId, calendarSeasonState(apiaryId, today, config));
|
||||
const rows = await db.apiaryPhenology.findMany({
|
||||
where: { apiaryId: { in: ids }, year: Number(today.slice(0, 4)) },
|
||||
include: { apiary: { select: { latitude: true, longitude: true } } },
|
||||
});
|
||||
const stored = new Map(rows.map((row) => [row.apiaryId, row]));
|
||||
for (const apiaryId of ids) {
|
||||
const found = stored.get(apiaryId);
|
||||
const row =
|
||||
found !== undefined &&
|
||||
daysBetween(berlinDayOf(found.updatedAt), today) <= MAX_ROW_AGE_DAYS
|
||||
? found
|
||||
: undefined;
|
||||
states.set(
|
||||
apiaryId,
|
||||
row === undefined
|
||||
? calendarSeasonState(apiaryId, today, config)
|
||||
: seasonStateFromRow(
|
||||
row,
|
||||
config,
|
||||
row.apiary.latitude !== null && row.apiary.longitude !== null,
|
||||
today,
|
||||
),
|
||||
);
|
||||
}
|
||||
return states;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { loggableError } from '../common/loggable-error';
|
||||
import {
|
||||
describeTelegramFailure,
|
||||
sanitizeTelegramDescription,
|
||||
TelegramApiError,
|
||||
} from './telegram-api-error';
|
||||
|
||||
describe('TelegramApiError', () => {
|
||||
it('carries method, status, description and retry_after, and a message without a URL', () => {
|
||||
const error = new TelegramApiError(
|
||||
'sendMessage',
|
||||
429,
|
||||
'Too Many Requests',
|
||||
7,
|
||||
);
|
||||
|
||||
expect(error).toBeInstanceOf(Error);
|
||||
expect(error.name).toBe('TelegramApiError');
|
||||
expect(error.method).toBe('sendMessage');
|
||||
expect(error.statusCode).toBe(429);
|
||||
expect(error.description).toBe('Too Many Requests');
|
||||
expect(error.retryAfterSec).toBe(7);
|
||||
expect(error.message).toBe(
|
||||
'Telegram sendMessage failed: Too Many Requests',
|
||||
);
|
||||
expect(loggableError(error).message).not.toMatch(/https?:/);
|
||||
});
|
||||
|
||||
it('allows a network failure without status', () => {
|
||||
const error = new TelegramApiError('getUpdates', null, 'timeout', null);
|
||||
expect(error.statusCode).toBeNull();
|
||||
expect(error.retryAfterSec).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('sanitizeTelegramDescription', () => {
|
||||
it('removes bot URLs, tokens, control characters and caps the length', () => {
|
||||
const token = '123456789:AAEhBOweik6ad9r_QXMENQjcrGbqCr4K-RM';
|
||||
const dirty = `bad https://api.telegram.org/bot${token}/sendMessage \u0007x ${token} ${'y'.repeat(400)}`;
|
||||
const clean = sanitizeTelegramDescription(dirty);
|
||||
|
||||
expect(clean).not.toContain('AAEhBOweik6ad9r');
|
||||
expect(clean).not.toContain('api.telegram.org');
|
||||
expect(clean).not.toContain('\u0007');
|
||||
expect(clean.length).toBeLessThanOrEqual(200);
|
||||
});
|
||||
|
||||
it('turns a non-string into a fixed text', () => {
|
||||
expect(sanitizeTelegramDescription(undefined)).toBe('unknown error');
|
||||
expect(sanitizeTelegramDescription(42)).toBe('unknown error');
|
||||
});
|
||||
});
|
||||
|
||||
describe('describeTelegramFailure', () => {
|
||||
it('classifies abort and timeout without using the underlying message', () => {
|
||||
const timeout = Object.assign(
|
||||
new Error('boom bot123456789:SECRETSECRETSECRETSECRETSECRETSECRET'),
|
||||
{ name: 'TimeoutError' },
|
||||
);
|
||||
const abort = Object.assign(new Error('x'), { name: 'AbortError' });
|
||||
|
||||
expect(describeTelegramFailure(timeout)).toBe('timeout');
|
||||
expect(describeTelegramFailure(abort)).toBe('aborted');
|
||||
expect(describeTelegramFailure(new TypeError('fetch failed'))).toBe(
|
||||
'network error',
|
||||
);
|
||||
});
|
||||
|
||||
it.each([['text'], [null], [undefined], [42], [{ message: 'x' }]])(
|
||||
'classifies a non-Error rejection (%p) as a network error',
|
||||
(value) => {
|
||||
expect(describeTelegramFailure(value)).toBe('network error');
|
||||
},
|
||||
);
|
||||
|
||||
it('finds a timeout or abort in the cause chain, and never returns its message', () => {
|
||||
const secret = 'bot123456789:SECRETSECRETSECRETSECRETSECRETSECRET';
|
||||
const timeout = Object.assign(new Error(secret), { name: 'TimeoutError' });
|
||||
const wrapped = new TypeError('fetch failed', {
|
||||
cause: new Error('socket', { cause: timeout }),
|
||||
});
|
||||
|
||||
expect(describeTelegramFailure(wrapped)).toBe('timeout');
|
||||
});
|
||||
|
||||
it('survives a cyclic cause chain', () => {
|
||||
const a = new Error('a');
|
||||
const b = new Error('b', { cause: a });
|
||||
(a as { cause?: unknown }).cause = b;
|
||||
expect(describeTelegramFailure(a)).toBe('network error');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
const MAX_DESCRIPTION_CHARS = 200;
|
||||
const UNKNOWN_DESCRIPTION = 'unknown error';
|
||||
const MAX_CAUSE_DEPTH = 5;
|
||||
|
||||
// A request URL carries the bot token ("…/bot<id>:<secret>/<method>"), and a
|
||||
// description could quote one. Both shapes are removed wherever text from
|
||||
// outside ends up in an error.
|
||||
const BOT_URL = /https?:\/\/\S*/gi;
|
||||
const BOT_TOKEN = /\b(?:bot)?\d{5,}:[A-Za-z0-9_-]{20,}/g;
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f-\u009f]/g;
|
||||
|
||||
/**
|
||||
* A failed Telegram call. Carries the method and what Telegram answered -
|
||||
* never the request URL, so the bot token cannot reach a log or lastError
|
||||
* through it.
|
||||
*/
|
||||
export class TelegramApiError extends Error {
|
||||
constructor(
|
||||
readonly method: string,
|
||||
readonly statusCode: number | null,
|
||||
readonly description: string,
|
||||
readonly retryAfterSec: number | null,
|
||||
) {
|
||||
super(`Telegram ${method} failed: ${description}`);
|
||||
this.name = 'TelegramApiError';
|
||||
}
|
||||
}
|
||||
|
||||
/** Text from Telegram, safe for an error: no URL, no token, no control characters, short. */
|
||||
export function sanitizeTelegramDescription(value: unknown): string {
|
||||
if (typeof value !== 'string') {
|
||||
return UNKNOWN_DESCRIPTION;
|
||||
}
|
||||
const clean = value
|
||||
.replace(BOT_URL, '')
|
||||
.replace(BOT_TOKEN, '')
|
||||
.replace(CONTROL_CHARACTERS, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
.slice(0, MAX_DESCRIPTION_CHARS);
|
||||
return clean === '' ? UNKNOWN_DESCRIPTION : clean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fixed description for a call that failed before an answer arrived. The
|
||||
* underlying message is dropped on purpose: a fetch error can quote the URL.
|
||||
*/
|
||||
export function describeTelegramFailure(error: unknown): string {
|
||||
const seen = new Set<unknown>();
|
||||
let current: unknown = error;
|
||||
// fetch wraps the real reason in `cause`; the depth limit and the visited
|
||||
// set keep a cyclic chain from looping.
|
||||
for (let depth = 0; depth < MAX_CAUSE_DEPTH; depth++) {
|
||||
if (!(current instanceof Error) || seen.has(current)) {
|
||||
break;
|
||||
}
|
||||
seen.add(current);
|
||||
if (current.name === 'TimeoutError') {
|
||||
return 'timeout';
|
||||
}
|
||||
if (current.name === 'AbortError') {
|
||||
return 'aborted';
|
||||
}
|
||||
current = current.cause;
|
||||
}
|
||||
return 'network error';
|
||||
}
|
||||
@@ -0,0 +1,345 @@
|
||||
import { loggableError } from '../common/loggable-error';
|
||||
import { TelegramApiError } from './telegram-api-error';
|
||||
import {
|
||||
sanitizeTelegramText,
|
||||
TELEGRAM_MAX_MESSAGE_CHARS,
|
||||
TelegramClient,
|
||||
} from './telegram-client';
|
||||
|
||||
const TOKEN = '123456789:AAEhBOweik6ad9r_QXMENQjcrGbqCr4K-RM';
|
||||
|
||||
function jsonResponse(body: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
describe('TelegramClient', () => {
|
||||
let fetchMock: jest.SpyInstance;
|
||||
let client: TelegramClient;
|
||||
|
||||
beforeEach(() => {
|
||||
fetchMock = jest.spyOn(globalThis, 'fetch');
|
||||
client = new TelegramClient({ token: TOKEN, botUsername: 'apicore_bot' });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fetchMock.mockRestore();
|
||||
});
|
||||
|
||||
it('is configured only with a well-formed token', () => {
|
||||
expect(client.isConfigured()).toBe(true);
|
||||
expect(
|
||||
new TelegramClient({ token: null, botUsername: 'b' }).isConfigured(),
|
||||
).toBe(false);
|
||||
expect(
|
||||
new TelegramClient({ token: 'junk', botUsername: 'b' }).isConfigured(),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('the token never appears in TelegramApiError or loggableError', async () => {
|
||||
fetchMock.mockRejectedValue(
|
||||
new TypeError(
|
||||
`connect failed https://api.telegram.org/bot${TOKEN}/sendMessage`,
|
||||
),
|
||||
);
|
||||
|
||||
const error = await client.sendMessage(1n, 'x').catch((e: unknown) => e);
|
||||
|
||||
expect(error).toBeInstanceOf(TelegramApiError);
|
||||
const apiError = error as TelegramApiError;
|
||||
expect(
|
||||
JSON.stringify({
|
||||
...apiError,
|
||||
message: apiError.message,
|
||||
log: loggableError(apiError),
|
||||
}),
|
||||
).not.toContain(TOKEN);
|
||||
expect(apiError.statusCode).toBeNull();
|
||||
});
|
||||
|
||||
it('the token does not leak through a description returned by Telegram', async () => {
|
||||
fetchMock.mockResolvedValue(
|
||||
jsonResponse({ ok: false, description: `Unauthorized ${TOKEN}` }, 401),
|
||||
);
|
||||
|
||||
const error = (await client
|
||||
.sendMessage(1n, 'x')
|
||||
.catch((e: unknown) => e)) as TelegramApiError;
|
||||
|
||||
expect(error.statusCode).toBe(401);
|
||||
expect(`${error.message}${error.description}`).not.toContain(TOKEN);
|
||||
});
|
||||
|
||||
it('sends plain text without parse_mode and with link preview disabled', async () => {
|
||||
fetchMock.mockResolvedValue(jsonResponse({ ok: true, result: {} }));
|
||||
|
||||
await client.sendMessage(4242n, 'Hallo');
|
||||
|
||||
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
||||
expect(url).toBe(`https://api.telegram.org/bot${TOKEN}/sendMessage`);
|
||||
expect(init.method).toBe('POST');
|
||||
const body = JSON.parse(init.body as string) as Record<string, unknown>;
|
||||
expect(body).toEqual({
|
||||
chat_id: 4242,
|
||||
text: 'Hallo',
|
||||
link_preview_options: { is_disabled: true },
|
||||
});
|
||||
expect(body).not.toHaveProperty('parse_mode');
|
||||
});
|
||||
|
||||
it('does not follow redirects (the URL carries the token)', async () => {
|
||||
fetchMock.mockResolvedValue(jsonResponse({ ok: true, result: {} }));
|
||||
|
||||
await client.sendMessage(1n, 'x');
|
||||
|
||||
const [, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
||||
expect(init.redirect).toBe('error');
|
||||
});
|
||||
|
||||
it('a redirect error becomes a network error without the URL', async () => {
|
||||
fetchMock.mockRejectedValue(
|
||||
new TypeError('fetch failed', {
|
||||
cause: new Error(`redirect to https://evil.example/bot${TOKEN}`),
|
||||
}),
|
||||
);
|
||||
|
||||
const error = (await client
|
||||
.sendMessage(1n, 'x')
|
||||
.catch((e: unknown) => e)) as TelegramApiError;
|
||||
|
||||
expect(error.description).toBe('network error');
|
||||
expect(JSON.stringify({ ...error, m: error.message })).not.toContain(TOKEN);
|
||||
});
|
||||
|
||||
it('maps 429 with retry_after into TelegramApiError.retryAfterSec', async () => {
|
||||
fetchMock.mockResolvedValue(
|
||||
jsonResponse(
|
||||
{
|
||||
ok: false,
|
||||
description: 'Too Many Requests: retry after 12',
|
||||
parameters: { retry_after: 12 },
|
||||
},
|
||||
429,
|
||||
),
|
||||
);
|
||||
|
||||
const error = (await client
|
||||
.sendMessage(1n, 'x')
|
||||
.catch((e: unknown) => e)) as TelegramApiError;
|
||||
|
||||
expect(error).toBeInstanceOf(TelegramApiError);
|
||||
expect(error.statusCode).toBe(429);
|
||||
expect(error.retryAfterSec).toBe(12);
|
||||
expect(error.method).toBe('sendMessage');
|
||||
});
|
||||
|
||||
it('maps 429 without parameters to retryAfterSec null', async () => {
|
||||
fetchMock.mockResolvedValue(
|
||||
jsonResponse({ ok: false, description: 'Too Many Requests' }, 429),
|
||||
);
|
||||
await expect(client.sendMessage(1n, 'x')).rejects.toMatchObject({
|
||||
statusCode: 429,
|
||||
retryAfterSec: null,
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
[12, 12],
|
||||
[0, 1],
|
||||
[-5, 1],
|
||||
[999_999, 3600],
|
||||
['12', null],
|
||||
[1.5, null],
|
||||
[null, null],
|
||||
])('clamps retry_after %p to %p', async (raw, expected) => {
|
||||
fetchMock.mockResolvedValue(
|
||||
jsonResponse(
|
||||
{ ok: false, description: 'x', parameters: { retry_after: raw } },
|
||||
429,
|
||||
),
|
||||
);
|
||||
await expect(client.sendMessage(1n, 'x')).rejects.toMatchObject({
|
||||
retryAfterSec: expected,
|
||||
});
|
||||
});
|
||||
|
||||
it('passes a 403 description through unchanged (callers decide)', async () => {
|
||||
fetchMock.mockResolvedValue(
|
||||
jsonResponse(
|
||||
{ ok: false, description: 'Forbidden: bot was blocked by the user' },
|
||||
403,
|
||||
),
|
||||
);
|
||||
|
||||
await expect(client.sendMessage(1n, 'x')).rejects.toMatchObject({
|
||||
statusCode: 403,
|
||||
description: 'Forbidden: bot was blocked by the user',
|
||||
retryAfterSec: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects an ok:false answer and a non-JSON body as TelegramApiError', async () => {
|
||||
fetchMock.mockResolvedValueOnce(jsonResponse({ ok: false }));
|
||||
await expect(client.sendMessage(1n, 'x')).rejects.toBeInstanceOf(
|
||||
TelegramApiError,
|
||||
);
|
||||
|
||||
fetchMock.mockResolvedValueOnce(new Response('<html>', { status: 502 }));
|
||||
await expect(client.sendMessage(1n, 'x')).rejects.toMatchObject({
|
||||
statusCode: 502,
|
||||
description: 'invalid response',
|
||||
});
|
||||
});
|
||||
|
||||
it('refuses an oversized response body', async () => {
|
||||
fetchMock.mockResolvedValue(
|
||||
new Response('x'.repeat(300_000), { status: 200 }),
|
||||
);
|
||||
|
||||
await expect(client.sendMessage(1n, 'x')).rejects.toMatchObject({
|
||||
description: 'invalid response',
|
||||
});
|
||||
});
|
||||
|
||||
it('stops reading a streamed body past the cap even without Content-Length', async () => {
|
||||
const chunk = new Uint8Array(100 * 1024).fill(120);
|
||||
let sent = 0;
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
pull(controller) {
|
||||
sent++;
|
||||
controller.enqueue(chunk);
|
||||
if (sent >= 50) {
|
||||
controller.close();
|
||||
}
|
||||
},
|
||||
});
|
||||
fetchMock.mockResolvedValue(new Response(stream, { status: 200 }));
|
||||
|
||||
await expect(client.sendMessage(1n, 'x')).rejects.toMatchObject({
|
||||
description: 'invalid response',
|
||||
});
|
||||
expect(sent).toBeLessThan(10);
|
||||
});
|
||||
|
||||
it('classifies a caller abort and a timeout without status', async () => {
|
||||
fetchMock.mockRejectedValueOnce(
|
||||
Object.assign(new Error('x'), { name: 'AbortError' }),
|
||||
);
|
||||
await expect(client.sendMessage(1n, 'x')).rejects.toMatchObject({
|
||||
statusCode: null,
|
||||
description: 'aborted',
|
||||
});
|
||||
|
||||
fetchMock.mockRejectedValueOnce(
|
||||
Object.assign(new Error('x'), { name: 'TimeoutError' }),
|
||||
);
|
||||
await expect(client.sendMessage(1n, 'x')).rejects.toMatchObject({
|
||||
statusCode: null,
|
||||
description: 'timeout',
|
||||
});
|
||||
});
|
||||
|
||||
it('throws a TelegramApiError when no token is configured, without calling out', async () => {
|
||||
const bare = new TelegramClient({ token: null, botUsername: null });
|
||||
|
||||
await expect(bare.sendMessage(1n, 'x')).rejects.toMatchObject({
|
||||
description: 'bot token not configured',
|
||||
});
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not call out with a malformed token either', async () => {
|
||||
const bad = new TelegramClient({ token: 'junk', botUsername: 'b' });
|
||||
|
||||
await expect(bad.sendMessage(1n, 'x')).rejects.toMatchObject({
|
||||
description: 'bot token not configured',
|
||||
});
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('refuses a chat id that does not fit a JSON number', async () => {
|
||||
await expect(client.sendMessage(2n ** 60n, 'x')).rejects.toBeInstanceOf(
|
||||
TelegramApiError,
|
||||
);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe('getUpdates', () => {
|
||||
it('asks for messages only with a 25 s long poll and parses the updates', async () => {
|
||||
fetchMock.mockResolvedValue(
|
||||
jsonResponse({
|
||||
ok: true,
|
||||
result: [
|
||||
{
|
||||
update_id: 100,
|
||||
message: {
|
||||
text: '/start abc',
|
||||
chat: { id: 555, type: 'private' },
|
||||
from: { first_name: 'Imke' },
|
||||
},
|
||||
},
|
||||
{ update_id: 101, edited_message: {} },
|
||||
{ update_id: 102, message: { chat: { id: -9, type: 'group' } } },
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
const updates = await client.getUpdates(100n);
|
||||
|
||||
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
||||
expect(url).toBe(`https://api.telegram.org/bot${TOKEN}/getUpdates`);
|
||||
expect(JSON.parse(init.body as string)).toEqual({
|
||||
offset: 100,
|
||||
timeout: 25,
|
||||
allowed_updates: ['message'],
|
||||
});
|
||||
expect(updates).toEqual([
|
||||
{
|
||||
updateId: 100n,
|
||||
chatId: 555n,
|
||||
chatType: 'private',
|
||||
text: '/start abc',
|
||||
firstName: 'Imke',
|
||||
},
|
||||
{
|
||||
updateId: 102n,
|
||||
chatId: -9n,
|
||||
chatType: 'group',
|
||||
text: null,
|
||||
firstName: null,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns an empty list when nothing arrived', async () => {
|
||||
fetchMock.mockResolvedValue(jsonResponse({ ok: true, result: [] }));
|
||||
await expect(client.getUpdates(0n)).resolves.toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('sanitizeTelegramText', () => {
|
||||
it('strips control characters but keeps line breaks', () => {
|
||||
expect(sanitizeTelegramText('a\u0000b\u001bc\r\nd\te')).toBe('abc\nd e');
|
||||
});
|
||||
|
||||
it('caps the text at the maximum length', () => {
|
||||
const out = sanitizeTelegramText('x'.repeat(2000));
|
||||
expect(out.length).toBe(TELEGRAM_MAX_MESSAGE_CHARS);
|
||||
expect(out.endsWith('…')).toBe(true);
|
||||
});
|
||||
|
||||
it('cuts by code point, never inside a surrogate pair', () => {
|
||||
const out = sanitizeTelegramText('😀'.repeat(700));
|
||||
expect([...out]).toHaveLength(TELEGRAM_MAX_MESSAGE_CHARS);
|
||||
expect(out.endsWith('…')).toBe(true);
|
||||
expect(out).not.toMatch(/[\ud800-\udbff](?![\udc00-\udfff])/);
|
||||
});
|
||||
|
||||
it('strips bidi overrides, isolates and zero-width characters', () => {
|
||||
const dirty = 'abcdefghijklmno';
|
||||
expect(sanitizeTelegramText(dirty)).toBe('abcdefghijklmno');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,260 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import {
|
||||
describeTelegramFailure,
|
||||
sanitizeTelegramDescription,
|
||||
TelegramApiError,
|
||||
} from './telegram-api-error';
|
||||
import { TELEGRAM_TOKEN_PATTERN, type TelegramConfig } from './telegram-config';
|
||||
|
||||
export const TELEGRAM_CONFIG = Symbol('TELEGRAM_CONFIG');
|
||||
|
||||
export const TELEGRAM_SEND_TIMEOUT_MS = 10_000;
|
||||
export const TELEGRAM_POLL_TIMEOUT_SEC = 25;
|
||||
export const TELEGRAM_POLL_FETCH_TIMEOUT_MS = 35_000;
|
||||
export const TELEGRAM_MAX_MESSAGE_CHARS = 600;
|
||||
const MIN_RETRY_AFTER_SEC = 1;
|
||||
const MAX_RETRY_AFTER_SEC = 3600;
|
||||
|
||||
const API_BASE = 'https://api.telegram.org';
|
||||
const MAX_RESPONSE_BYTES = 256 * 1024;
|
||||
const INVALID_RESPONSE = 'invalid response';
|
||||
|
||||
export interface TelegramUpdate {
|
||||
updateId: bigint;
|
||||
chatId: bigint;
|
||||
chatType: string;
|
||||
text: string | null;
|
||||
firstName: string | null;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const CONTROL_CHARACTERS = /[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/g;
|
||||
// Zero-width characters, bidi marks, overrides and isolates: invisible, and
|
||||
// they can make a text read differently from what it says.
|
||||
const INVISIBLE_CHARACTERS = /[\u200b-\u200f\u202a-\u202e\u2066-\u2069]/g;
|
||||
|
||||
/**
|
||||
* Plain text for a message: tabs and line breaks kept as space and newline,
|
||||
* other control characters and invisible/bidi characters removed, at most
|
||||
* 600 characters (cut by code point, never inside a surrogate pair).
|
||||
*/
|
||||
export function sanitizeTelegramText(text: string): string {
|
||||
const characters = [
|
||||
...text
|
||||
.replace(/\r\n?/g, '\n')
|
||||
.replace(/\t/g, ' ')
|
||||
.replace(CONTROL_CHARACTERS, '')
|
||||
.replace(INVISIBLE_CHARACTERS, ''),
|
||||
];
|
||||
return characters.length > TELEGRAM_MAX_MESSAGE_CHARS
|
||||
? `${characters.slice(0, TELEGRAM_MAX_MESSAGE_CHARS - 1).join('')}…`
|
||||
: characters.join('');
|
||||
}
|
||||
|
||||
type JsonObject = Record<string, unknown>;
|
||||
|
||||
function asObject(value: unknown): JsonObject | null {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
? (value as JsonObject)
|
||||
: null;
|
||||
}
|
||||
|
||||
function asString(value: unknown): string | null {
|
||||
return typeof value === 'string' ? value : null;
|
||||
}
|
||||
|
||||
function asInteger(value: unknown): bigint | null {
|
||||
return typeof value === 'number' && Number.isSafeInteger(value)
|
||||
? BigInt(value)
|
||||
: null;
|
||||
}
|
||||
|
||||
/** Reads the body up to MAX_RESPONSE_BYTES; null if it is larger or unreadable. */
|
||||
async function readLimitedText(response: Response): Promise<string | null> {
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) {
|
||||
return '';
|
||||
}
|
||||
const chunks: Uint8Array[] = [];
|
||||
let size = 0;
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) {
|
||||
break;
|
||||
}
|
||||
size += value.byteLength;
|
||||
if (size > MAX_RESPONSE_BYTES) {
|
||||
await reader.cancel();
|
||||
return null;
|
||||
}
|
||||
chunks.push(value);
|
||||
}
|
||||
return Buffer.concat(chunks).toString('utf8');
|
||||
}
|
||||
|
||||
/** parameters.retry_after as whole seconds clamped to 1..3600; null if absent or not an integer. */
|
||||
function retryAfterOf(parsed: JsonObject): number | null {
|
||||
const seconds = asInteger(asObject(parsed.parameters)?.retry_after);
|
||||
if (seconds === null) {
|
||||
return null;
|
||||
}
|
||||
return Math.min(
|
||||
MAX_RETRY_AFTER_SEC,
|
||||
Math.max(MIN_RETRY_AFTER_SEC, Number(seconds)),
|
||||
);
|
||||
}
|
||||
|
||||
function parseUpdate(raw: unknown): TelegramUpdate | null {
|
||||
const update = asObject(raw);
|
||||
const updateId = asInteger(update?.update_id);
|
||||
const message = asObject(update?.message);
|
||||
const chat = asObject(message?.chat);
|
||||
const chatId = asInteger(chat?.id);
|
||||
const chatType = asString(chat?.type);
|
||||
if (updateId === null || chatId === null || chatType === null) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
updateId,
|
||||
chatId,
|
||||
chatType,
|
||||
text: asString(message?.text),
|
||||
firstName: asString(asObject(message?.from)?.first_name),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Telegram Bot API client. Used by the worker loops only (never from a
|
||||
* request, handler or job; see no-network-outside-loops.spec). The request
|
||||
* URL contains the token and stays inside this class: every failure becomes
|
||||
* a TelegramApiError that carries no URL, and text taken from the answer is
|
||||
* sanitized.
|
||||
*/
|
||||
@Injectable()
|
||||
export class TelegramClient {
|
||||
constructor(
|
||||
@Inject(TELEGRAM_CONFIG) private readonly config: TelegramConfig,
|
||||
) {}
|
||||
|
||||
/** Worker only: a token is present and well-formed. */
|
||||
isConfigured(): boolean {
|
||||
return (
|
||||
this.config.token !== null &&
|
||||
TELEGRAM_TOKEN_PATTERN.test(this.config.token)
|
||||
);
|
||||
}
|
||||
|
||||
/** Plain text, no parse_mode, link preview off. Throws TelegramApiError. */
|
||||
async sendMessage(
|
||||
chatId: bigint,
|
||||
text: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<void> {
|
||||
await this.call(
|
||||
'sendMessage',
|
||||
{
|
||||
chat_id: this.jsonNumber('sendMessage', chatId),
|
||||
text: sanitizeTelegramText(text),
|
||||
link_preview_options: { is_disabled: true },
|
||||
},
|
||||
TELEGRAM_SEND_TIMEOUT_MS,
|
||||
signal,
|
||||
);
|
||||
}
|
||||
|
||||
/** Long poll for messages from `offset` on. Throws TelegramApiError. */
|
||||
async getUpdates(
|
||||
offset: bigint,
|
||||
signal?: AbortSignal,
|
||||
): Promise<TelegramUpdate[]> {
|
||||
const result = await this.call(
|
||||
'getUpdates',
|
||||
{
|
||||
offset: this.jsonNumber('getUpdates', offset),
|
||||
timeout: TELEGRAM_POLL_TIMEOUT_SEC,
|
||||
allowed_updates: ['message'],
|
||||
},
|
||||
TELEGRAM_POLL_FETCH_TIMEOUT_MS,
|
||||
signal,
|
||||
);
|
||||
if (!Array.isArray(result)) {
|
||||
throw new TelegramApiError('getUpdates', null, INVALID_RESPONSE, null);
|
||||
}
|
||||
return result
|
||||
.map(parseUpdate)
|
||||
.filter((update): update is TelegramUpdate => update !== null);
|
||||
}
|
||||
|
||||
private jsonNumber(method: string, value: bigint): number {
|
||||
const number = Number(value);
|
||||
if (!Number.isSafeInteger(number)) {
|
||||
throw new TelegramApiError(method, null, 'id out of range', null);
|
||||
}
|
||||
return number;
|
||||
}
|
||||
|
||||
private async call(
|
||||
method: string,
|
||||
body: JsonObject,
|
||||
timeoutMs: number,
|
||||
signal?: AbortSignal,
|
||||
): Promise<unknown> {
|
||||
const { token } = this.config;
|
||||
if (token === null || !this.isConfigured()) {
|
||||
throw new TelegramApiError(
|
||||
method,
|
||||
null,
|
||||
'bot token not configured',
|
||||
null,
|
||||
);
|
||||
}
|
||||
const timeout = AbortSignal.timeout(timeoutMs);
|
||||
let response: Response;
|
||||
let text: string | null;
|
||||
try {
|
||||
response = await fetch(`${API_BASE}/bot${token}/${method}`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
// The URL carries the token: never follow it somewhere else.
|
||||
redirect: 'error',
|
||||
signal: signal ? AbortSignal.any([signal, timeout]) : timeout,
|
||||
});
|
||||
text = await readLimitedText(response);
|
||||
} catch (error) {
|
||||
throw new TelegramApiError(
|
||||
method,
|
||||
null,
|
||||
describeTelegramFailure(error),
|
||||
null,
|
||||
);
|
||||
}
|
||||
return this.interpret(method, response.status, text);
|
||||
}
|
||||
|
||||
/** Reads only ok, description, parameters.retry_after and result. */
|
||||
private interpret(
|
||||
method: string,
|
||||
status: number,
|
||||
text: string | null,
|
||||
): unknown {
|
||||
let parsed: JsonObject | null = null;
|
||||
try {
|
||||
parsed = text === null ? null : asObject(JSON.parse(text));
|
||||
} catch {
|
||||
parsed = null;
|
||||
}
|
||||
if (parsed === null) {
|
||||
throw new TelegramApiError(method, status, INVALID_RESPONSE, null);
|
||||
}
|
||||
if (status >= 200 && status < 300 && parsed.ok === true) {
|
||||
return parsed.result;
|
||||
}
|
||||
throw new TelegramApiError(
|
||||
method,
|
||||
status,
|
||||
sanitizeTelegramDescription(parsed.description),
|
||||
retryAfterOf(parsed),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import {
|
||||
assertTelegramToken,
|
||||
telegramBotAvailable,
|
||||
telegramConfig,
|
||||
} from './telegram-config';
|
||||
|
||||
const TOKEN = '123456789:AAEhBOweik6ad9r_QXMENQjcrGbqCr4K-RM';
|
||||
|
||||
describe('telegramConfig', () => {
|
||||
it('reads token and bot username, trimmed', () => {
|
||||
expect(
|
||||
telegramConfig({
|
||||
TELEGRAM_BOT_TOKEN: ` ${TOKEN} `,
|
||||
TELEGRAM_BOT_USERNAME: ' apicore_bot ',
|
||||
}),
|
||||
).toEqual({ token: TOKEN, botUsername: 'apicore_bot' });
|
||||
});
|
||||
|
||||
it('maps empty and missing values to null', () => {
|
||||
expect(
|
||||
telegramConfig({ TELEGRAM_BOT_TOKEN: ' ', TELEGRAM_BOT_USERNAME: '' }),
|
||||
).toEqual({ token: null, botUsername: null });
|
||||
expect(telegramConfig({})).toEqual({ token: null, botUsername: null });
|
||||
});
|
||||
});
|
||||
|
||||
describe('telegramBotAvailable', () => {
|
||||
it('is true with the username set and the token unset (API pod)', () => {
|
||||
expect(telegramBotAvailable({ TELEGRAM_BOT_USERNAME: 'apicore_bot' })).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('is false with an empty or blank username, even with a token', () => {
|
||||
expect(telegramBotAvailable({ TELEGRAM_BOT_USERNAME: ' ' })).toBe(false);
|
||||
expect(telegramBotAvailable({ TELEGRAM_BOT_TOKEN: TOKEN })).toBe(false);
|
||||
expect(telegramBotAvailable({})).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('telegramConfig bot username', () => {
|
||||
it.each([
|
||||
['@apicore_bot', 'apicore_bot'],
|
||||
['ApiCore_Bot', 'ApiCore_Bot'],
|
||||
['a'.repeat(32), 'a'.repeat(32)],
|
||||
])('accepts %s', (raw, expected) => {
|
||||
expect(telegramConfig({ TELEGRAM_BOT_USERNAME: raw }).botUsername).toBe(
|
||||
expected,
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
'abcd',
|
||||
'1abcde',
|
||||
'a'.repeat(33),
|
||||
'api core bot',
|
||||
'bot/../x',
|
||||
'@@apicore_bot',
|
||||
'https://t.me/x',
|
||||
'_apicore',
|
||||
])('treats %s as unset', (raw) => {
|
||||
expect(
|
||||
telegramConfig({ TELEGRAM_BOT_USERNAME: raw }).botUsername,
|
||||
).toBeNull();
|
||||
expect(telegramBotAvailable({ TELEGRAM_BOT_USERNAME: raw })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('assertTelegramToken', () => {
|
||||
const production = { NODE_ENV: 'production' };
|
||||
let logger: { warn: jest.Mock; error: jest.Mock };
|
||||
|
||||
beforeEach(() => {
|
||||
logger = { warn: jest.fn(), error: jest.fn() };
|
||||
});
|
||||
|
||||
it('accepts a missing token in every role (Telegram is optional)', () => {
|
||||
for (const role of ['api', 'worker', 'job', 'all'] as const) {
|
||||
expect(() => assertTelegramToken(role, production, logger)).not.toThrow();
|
||||
}
|
||||
expect(logger.error).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('accepts a well-formed token in the worker without a log', () => {
|
||||
assertTelegramToken(
|
||||
'worker',
|
||||
{
|
||||
...production,
|
||||
TELEGRAM_BOT_TOKEN: TOKEN,
|
||||
TELEGRAM_BOT_USERNAME: 'apicore_bot',
|
||||
},
|
||||
logger,
|
||||
);
|
||||
expect(logger.error).not.toHaveBeenCalled();
|
||||
expect(logger.warn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['worker', 'not-a-token-SECRETPART', {}],
|
||||
['all', 'not-a-token-SECRETPART', {}],
|
||||
['worker', 'change-me-SECRETPART', production],
|
||||
] as const)(
|
||||
'the %s never crashes on a malformed token (%s): logs an error without the value',
|
||||
(role, bad, extra) => {
|
||||
expect(() =>
|
||||
assertTelegramToken(
|
||||
role,
|
||||
{ ...extra, TELEGRAM_BOT_TOKEN: bad },
|
||||
logger,
|
||||
),
|
||||
).not.toThrow();
|
||||
|
||||
expect(logger.error).toHaveBeenCalledTimes(1);
|
||||
const logged = (logger.error.mock.calls as [string][])[0][0];
|
||||
expect(logged).toMatch(/TELEGRAM_BOT_TOKEN/);
|
||||
expect(logged).toMatch(/disabled/);
|
||||
expect(logged).not.toContain('SECRETPART');
|
||||
},
|
||||
);
|
||||
|
||||
it('warns about an invalid bot username in every role', () => {
|
||||
assertTelegramToken('api', { TELEGRAM_BOT_USERNAME: 'no good' }, logger);
|
||||
expect(logger.warn).toHaveBeenCalledTimes(1);
|
||||
expect((logger.warn.mock.calls as [string][])[0][0]).toMatch(
|
||||
/TELEGRAM_BOT_USERNAME/,
|
||||
);
|
||||
});
|
||||
|
||||
it('the API pod and jobs ignore the token entirely (they never have one)', () => {
|
||||
assertTelegramToken('api', { TELEGRAM_BOT_TOKEN: 'garbage' }, logger);
|
||||
assertTelegramToken('job', { TELEGRAM_BOT_TOKEN: 'garbage' }, logger);
|
||||
expect(logger.error).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
import type { ApicoreRole } from '../runtime/role';
|
||||
|
||||
/** Bot token format issued by BotFather: "<bot id>:<secret>". */
|
||||
export const TELEGRAM_TOKEN_PATTERN = /^\d{5,}:[A-Za-z0-9_-]{30,}$/;
|
||||
|
||||
const PLACEHOLDER_PATTERN = /^change-me/i;
|
||||
/** Bot usernames: 5-32 characters, letters, digits, underscore, starting with a letter. */
|
||||
const BOT_USERNAME_PATTERN = /^[A-Za-z][A-Za-z0-9_]{4,31}$/;
|
||||
const WORKER_ROLES: readonly ApicoreRole[] = ['worker', 'all'];
|
||||
|
||||
export interface TelegramConfig {
|
||||
token: string | null;
|
||||
botUsername: string | null;
|
||||
}
|
||||
|
||||
function trimmed(value: string | undefined): string | null {
|
||||
const text = value?.trim();
|
||||
return text ? text : null;
|
||||
}
|
||||
|
||||
/** The configured bot name without a leading "@"; null if unset or not a valid username. */
|
||||
function validBotUsername(raw: string | undefined): string | null {
|
||||
const name = trimmed(raw)?.replace(/^@/, '') ?? null;
|
||||
return name !== null && BOT_USERNAME_PATTERN.test(name) ? name : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Token and bot name from the environment. Empty values count as unset, and
|
||||
* so does a bot name that is not a valid username (it goes into a link).
|
||||
*/
|
||||
export function telegramConfig(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): TelegramConfig {
|
||||
return {
|
||||
token: trimmed(env.TELEGRAM_BOT_TOKEN),
|
||||
botUsername: validBotUsername(env.TELEGRAM_BOT_USERNAME),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* "Telegram is set up" as the API sees it: the bot name is known. Never
|
||||
* reads the token - the API pod does not have it (Secret apicore-telegram
|
||||
* goes to the worker only). Whether updates really arrive shows
|
||||
* TelegramPollState.
|
||||
*/
|
||||
export function telegramBotAvailable(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): boolean {
|
||||
return telegramConfig(env).botUsername !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Startup check. Telegram is optional and a bad setting never stops a pod:
|
||||
* a bot username that is not valid is ignored with a warning (every role);
|
||||
* in the worker, a token that is malformed - or, in production, an example
|
||||
* placeholder - is logged as an error and Telegram stays off (the client
|
||||
* refuses to call out with it). Logs name the variable, never its value.
|
||||
* API and job pods never hold the token and skip its check.
|
||||
*/
|
||||
export function assertTelegramToken(
|
||||
role: ApicoreRole,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
logger: Pick<Logger, 'warn' | 'error'> = new Logger('TelegramStartup'),
|
||||
): void {
|
||||
const { token, botUsername } = telegramConfig(env);
|
||||
if (botUsername === null && trimmed(env.TELEGRAM_BOT_USERNAME) !== null) {
|
||||
logger.warn(
|
||||
'TELEGRAM_BOT_USERNAME is not a valid bot username (5-32 letters, digits or underscores): ignored, users cannot connect.',
|
||||
);
|
||||
}
|
||||
if (!WORKER_ROLES.includes(role)) {
|
||||
return;
|
||||
}
|
||||
if (token === null) {
|
||||
if (botUsername !== null) {
|
||||
logger.warn(
|
||||
'TELEGRAM_BOT_USERNAME is set but TELEGRAM_BOT_TOKEN is not: Telegram delivery and receiving stay disabled.',
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (env.NODE_ENV === 'production' && PLACEHOLDER_PATTERN.test(token)) {
|
||||
logger.error(
|
||||
'TELEGRAM_BOT_TOKEN is still set to an example placeholder: Telegram stays disabled - set the real token from BotFather or remove it.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!TELEGRAM_TOKEN_PATTERN.test(token)) {
|
||||
logger.error(
|
||||
'TELEGRAM_BOT_TOKEN is malformed (expected "<bot id>:<secret>" as issued by BotFather): Telegram stays disabled.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (botUsername === null) {
|
||||
logger.warn(
|
||||
'TELEGRAM_BOT_TOKEN is set but TELEGRAM_BOT_USERNAME is not: users cannot connect, replies still work.',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { telegramConfig } from './telegram-config';
|
||||
import { TELEGRAM_CONFIG, TelegramClient } from './telegram-client';
|
||||
|
||||
/**
|
||||
* The Bot API client for the worker's Telegram loops. Reads the token from
|
||||
* the environment once; without it the client reports isConfigured() false
|
||||
* and the loops do not run.
|
||||
*/
|
||||
@Module({
|
||||
providers: [
|
||||
{ provide: TELEGRAM_CONFIG, useFactory: () => telegramConfig() },
|
||||
TelegramClient,
|
||||
],
|
||||
exports: [TelegramClient],
|
||||
})
|
||||
export class TelegramModule {}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { abortableDelay } from './abortable-delay';
|
||||
|
||||
describe('abortableDelay', () => {
|
||||
beforeEach(() => jest.useFakeTimers());
|
||||
afterEach(() => jest.useRealTimers());
|
||||
|
||||
it('resolves after the delay', async () => {
|
||||
const done = jest.fn();
|
||||
void abortableDelay(300, new AbortController().signal).then(done);
|
||||
|
||||
await jest.advanceTimersByTimeAsync(299);
|
||||
expect(done).not.toHaveBeenCalled();
|
||||
await jest.advanceTimersByTimeAsync(1);
|
||||
expect(done).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('resolves at once when the signal aborts, and when it is already aborted', async () => {
|
||||
const controller = new AbortController();
|
||||
const done = jest.fn();
|
||||
void abortableDelay(60_000, controller.signal).then(done);
|
||||
controller.abort();
|
||||
await jest.advanceTimersByTimeAsync(0);
|
||||
expect(done).toHaveBeenCalledTimes(1);
|
||||
expect(jest.getTimerCount()).toBe(0);
|
||||
|
||||
await expect(
|
||||
abortableDelay(60_000, controller.signal),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
/** Resolves after `ms`, or early (without error) when the signal aborts. */
|
||||
export function abortableDelay(ms: number, signal: AbortSignal): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
if (signal.aborted) return resolve();
|
||||
const done = () => {
|
||||
clearTimeout(timer);
|
||||
signal.removeEventListener('abort', done);
|
||||
resolve();
|
||||
};
|
||||
const timer = setTimeout(done, ms);
|
||||
signal.addEventListener('abort', done, { once: true });
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,339 @@
|
||||
import { addDays, berlinToday } from '../../common/dates';
|
||||
import {
|
||||
RECENT_WINDOW_DAYS,
|
||||
WEATHER_FETCH_TIMEOUT_MS,
|
||||
WEATHER_MAX_RETRY_AFTER_MS,
|
||||
WEATHER_MAX_RESPONSE_BYTES,
|
||||
WeatherHistoryConfig,
|
||||
} from './weather-history.config';
|
||||
import {
|
||||
OpenMeteoHistorySource,
|
||||
WeatherSourceError,
|
||||
} from './open-meteo-history.source';
|
||||
|
||||
const RECENT = { window: 'recent' } as const;
|
||||
const ARCHIVE = { window: 'archive' } as const;
|
||||
const SECRET = 'topsecretkey';
|
||||
const POINT = { lat: 52.52, lon: 13.4 };
|
||||
|
||||
function config(apiKey: string | null = null): WeatherHistoryConfig {
|
||||
return {
|
||||
source: 'open-meteo',
|
||||
forecastUrl: new URL('https://forecast.example/v1/forecast'),
|
||||
archiveUrl: new URL('https://archive.example/v1/archive'),
|
||||
apiKey,
|
||||
userAgent: 'ApiCore/test (self-hosted beekeeping app)',
|
||||
};
|
||||
}
|
||||
|
||||
function json(body: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), { status });
|
||||
}
|
||||
|
||||
describe('OpenMeteoHistorySource', () => {
|
||||
let fetchMock: jest.SpiedFunction<typeof fetch>;
|
||||
const today = berlinToday();
|
||||
const recentFrom = addDays(today, -10);
|
||||
const recentTo = addDays(today, -8);
|
||||
const oldFrom = addDays(today, -RECENT_WINDOW_DAYS - 20);
|
||||
const oldTo = addDays(today, -RECENT_WINDOW_DAYS - 18);
|
||||
|
||||
beforeEach(() => {
|
||||
fetchMock = jest.spyOn(global, 'fetch');
|
||||
});
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
const requestedUrl = (): URL => fetchMock.mock.calls[0][0] as URL;
|
||||
|
||||
it('reads recent days from the forecast endpoint with past_days=92', async () => {
|
||||
fetchMock.mockResolvedValue(
|
||||
json({
|
||||
daily: {
|
||||
time: [addDays(today, -11), recentFrom, addDays(today, -9), recentTo],
|
||||
temperature_2m_mean: [1, 8.5, 9, 10],
|
||||
},
|
||||
}),
|
||||
);
|
||||
const source = new OpenMeteoHistorySource(config());
|
||||
|
||||
const result = await source.dailyMeans(POINT, recentFrom, recentTo, RECENT);
|
||||
|
||||
expect(source.name).toBe('open-meteo');
|
||||
const url = requestedUrl();
|
||||
expect(url.origin).toBe('https://forecast.example');
|
||||
expect(url.searchParams.get('latitude')).toBe('52.52');
|
||||
expect(url.searchParams.get('longitude')).toBe('13.40');
|
||||
expect(url.searchParams.get('daily')).toBe('temperature_2m_mean');
|
||||
expect(url.searchParams.get('timezone')).toBe('Europe/Berlin');
|
||||
expect(url.searchParams.get('past_days')).toBe('92');
|
||||
expect(url.searchParams.has('apikey')).toBe(false);
|
||||
// Only the requested range comes back.
|
||||
expect(result).toEqual([
|
||||
{ day: recentFrom, meanTempC: 8.5 },
|
||||
{ day: addDays(today, -9), meanTempC: 9 },
|
||||
{ day: recentTo, meanTempC: 10 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('reads older days from the archive endpoint with start_date and end_date', async () => {
|
||||
fetchMock.mockResolvedValue(
|
||||
json({ daily: { time: [oldFrom], temperature_2m_mean: [3] } }),
|
||||
);
|
||||
|
||||
await new OpenMeteoHistorySource(config()).dailyMeans(
|
||||
POINT,
|
||||
oldFrom,
|
||||
oldTo,
|
||||
ARCHIVE,
|
||||
);
|
||||
|
||||
const url = requestedUrl();
|
||||
expect(url.origin).toBe('https://archive.example');
|
||||
expect(url.searchParams.get('start_date')).toBe(oldFrom);
|
||||
expect(url.searchParams.get('end_date')).toBe(oldTo);
|
||||
expect(url.searchParams.has('past_days')).toBe(false);
|
||||
});
|
||||
|
||||
it('sends apikey only when one is configured', async () => {
|
||||
fetchMock.mockResolvedValue(
|
||||
json({ daily: { time: [], temperature_2m_mean: [] } }),
|
||||
);
|
||||
await new OpenMeteoHistorySource(config(SECRET)).dailyMeans(
|
||||
POINT,
|
||||
recentFrom,
|
||||
recentTo,
|
||||
RECENT,
|
||||
);
|
||||
expect(requestedUrl().searchParams.get('apikey')).toBe(SECRET);
|
||||
});
|
||||
|
||||
it('maps values outside -50..50 °C and non-numbers to null', async () => {
|
||||
const days = [0, 1, 2, 3, 4, 5].map((n) => addDays(recentFrom, n));
|
||||
fetchMock.mockResolvedValue(
|
||||
json({
|
||||
daily: {
|
||||
time: days,
|
||||
temperature_2m_mean: [-50, 50, -50.1, 50.1, null, '12'],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await new OpenMeteoHistorySource(config()).dailyMeans(
|
||||
POINT,
|
||||
recentFrom,
|
||||
days[5],
|
||||
RECENT,
|
||||
);
|
||||
|
||||
expect(result.map((entry) => entry.meanTempC)).toEqual([
|
||||
-50,
|
||||
50,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
]);
|
||||
});
|
||||
|
||||
it('never puts the API key or the URL into an error message', async () => {
|
||||
const source = new OpenMeteoHistorySource(config(SECRET));
|
||||
const failures: unknown[] = [];
|
||||
const attempt = async () =>
|
||||
source
|
||||
.dailyMeans(POINT, recentFrom, recentTo, RECENT)
|
||||
.catch((e: unknown) => e);
|
||||
fetchMock.mockRejectedValueOnce(
|
||||
new Error(`fetch failed https://forecast.example/?apikey=${SECRET}`),
|
||||
);
|
||||
failures.push(await attempt());
|
||||
fetchMock.mockResolvedValueOnce(new Response('x', { status: 500 }));
|
||||
failures.push(await attempt());
|
||||
fetchMock.mockResolvedValueOnce(new Response('not json', { status: 200 }));
|
||||
failures.push(await attempt());
|
||||
fetchMock.mockResolvedValueOnce(json({ daily: { time: 'x' } }));
|
||||
failures.push(await attempt());
|
||||
|
||||
for (const failure of failures) {
|
||||
expect(failure).toBeInstanceOf(Error);
|
||||
const { message, stack, cause } = failure as Error;
|
||||
expect(message).not.toContain(SECRET);
|
||||
expect(message).not.toContain('forecast.example');
|
||||
expect(stack ?? '').not.toContain(SECRET);
|
||||
expect(cause).toBeUndefined();
|
||||
}
|
||||
expect((failures[1] as Error).message).toContain('500');
|
||||
});
|
||||
|
||||
it('stops reading after 1 000 000 bytes', async () => {
|
||||
let pulls = 0;
|
||||
let cancelled = false;
|
||||
const chunk = new Uint8Array(100_000).fill(32);
|
||||
const body = new ReadableStream<Uint8Array>({
|
||||
pull(controller) {
|
||||
pulls += 1;
|
||||
controller.enqueue(chunk);
|
||||
},
|
||||
cancel() {
|
||||
cancelled = true;
|
||||
},
|
||||
});
|
||||
fetchMock.mockResolvedValue(new Response(body, { status: 200 }));
|
||||
|
||||
await expect(
|
||||
new OpenMeteoHistorySource(config()).dailyMeans(
|
||||
POINT,
|
||||
recentFrom,
|
||||
recentTo,
|
||||
RECENT,
|
||||
),
|
||||
).rejects.toThrow('zu groß');
|
||||
|
||||
expect(cancelled).toBe(true);
|
||||
// 10 chunks reach the limit, the 11th exceeds it; the stream is not drained.
|
||||
expect(pulls).toBeLessThanOrEqual(
|
||||
Math.ceil(WEATHER_MAX_RESPONSE_BYTES / 100_000) + 3,
|
||||
);
|
||||
});
|
||||
|
||||
it('aborts after 8 s and when the caller aborts', async () => {
|
||||
const timeout = new AbortController();
|
||||
const timeoutSpy = jest
|
||||
.spyOn(AbortSignal, 'timeout')
|
||||
.mockReturnValue(timeout.signal);
|
||||
fetchMock.mockImplementation(
|
||||
(_url, init) =>
|
||||
new Promise((_resolve, reject) => {
|
||||
init?.signal?.addEventListener('abort', () =>
|
||||
reject(new Error('aborted')),
|
||||
);
|
||||
}),
|
||||
);
|
||||
const source = new OpenMeteoHistorySource(config(SECRET));
|
||||
|
||||
const timedOut = source.dailyMeans(POINT, recentFrom, recentTo, RECENT);
|
||||
expect(timeoutSpy).toHaveBeenCalledWith(WEATHER_FETCH_TIMEOUT_MS);
|
||||
timeout.abort();
|
||||
await expect(timedOut).rejects.toThrow('Zeitüberschreitung');
|
||||
|
||||
const caller = new AbortController();
|
||||
timeoutSpy.mockReturnValue(new AbortController().signal);
|
||||
const cancelled = source.dailyMeans(POINT, recentFrom, recentTo, {
|
||||
window: 'recent',
|
||||
signal: caller.signal,
|
||||
});
|
||||
caller.abort();
|
||||
await expect(cancelled).rejects.toThrow('Zeitüberschreitung');
|
||||
});
|
||||
|
||||
it('does not follow redirects (the key travels in the query)', async () => {
|
||||
fetchMock.mockResolvedValue(
|
||||
json({ daily: { time: [], temperature_2m_mean: [] } }),
|
||||
);
|
||||
await new OpenMeteoHistorySource(config(SECRET)).dailyMeans(
|
||||
POINT,
|
||||
recentFrom,
|
||||
recentTo,
|
||||
RECENT,
|
||||
);
|
||||
expect(fetchMock.mock.calls[0][1]).toEqual(
|
||||
expect.objectContaining({ redirect: 'error' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('sends the configured User-Agent', async () => {
|
||||
fetchMock.mockResolvedValue(
|
||||
json({ daily: { time: [], temperature_2m_mean: [] } }),
|
||||
);
|
||||
await new OpenMeteoHistorySource(config()).dailyMeans(
|
||||
POINT,
|
||||
recentFrom,
|
||||
recentTo,
|
||||
RECENT,
|
||||
);
|
||||
expect(fetchMock.mock.calls[0][1]).toEqual(
|
||||
expect.objectContaining({
|
||||
headers: { 'User-Agent': 'ApiCore/test (self-hosted beekeeping app)' },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('reports status and Retry-After (seconds or date, capped) of a 429', async () => {
|
||||
const source = new OpenMeteoHistorySource(config(SECRET));
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
new Response('', { status: 429, headers: { 'Retry-After': '120' } }),
|
||||
);
|
||||
const bySeconds = (await source
|
||||
.dailyMeans(POINT, recentFrom, recentTo, RECENT)
|
||||
.catch((e: unknown) => e)) as WeatherSourceError;
|
||||
expect(bySeconds).toBeInstanceOf(WeatherSourceError);
|
||||
expect(bySeconds.status).toBe(429);
|
||||
expect(bySeconds.retryAfterMs).toBe(120_000);
|
||||
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
new Response('', {
|
||||
status: 429,
|
||||
headers: { 'Retry-After': new Date(Date.now() + 60_000).toUTCString() },
|
||||
}),
|
||||
);
|
||||
const byDate = (await source
|
||||
.dailyMeans(POINT, recentFrom, recentTo, RECENT)
|
||||
.catch((e: unknown) => e)) as WeatherSourceError;
|
||||
expect(byDate.retryAfterMs).toBeGreaterThan(50_000);
|
||||
expect(byDate.retryAfterMs).toBeLessThanOrEqual(61_000);
|
||||
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
new Response('', { status: 429, headers: { 'Retry-After': '99999999' } }),
|
||||
);
|
||||
const capped = (await source
|
||||
.dailyMeans(POINT, recentFrom, recentTo, RECENT)
|
||||
.catch((e: unknown) => e)) as WeatherSourceError;
|
||||
expect(capped.retryAfterMs).toBe(WEATHER_MAX_RETRY_AFTER_MS);
|
||||
|
||||
fetchMock.mockResolvedValueOnce(new Response('', { status: 503 }));
|
||||
const none = (await source
|
||||
.dailyMeans(POINT, recentFrom, recentTo, RECENT)
|
||||
.catch((e: unknown) => e)) as WeatherSourceError;
|
||||
expect(none.retryAfterMs).toBeUndefined();
|
||||
});
|
||||
|
||||
it('names a refused redirect "Weiterleitung"', async () => {
|
||||
fetchMock.mockRejectedValueOnce(
|
||||
new TypeError('fetch failed', {
|
||||
cause: new Error('unexpected redirect to https://x.example/?apikey=1'),
|
||||
}),
|
||||
);
|
||||
const error = (await new OpenMeteoHistorySource(config(SECRET))
|
||||
.dailyMeans(POINT, recentFrom, recentTo, RECENT)
|
||||
.catch((e: unknown) => e)) as WeatherSourceError;
|
||||
expect(error.message).toContain('Weiterleitung');
|
||||
expect(error.message).not.toContain('x.example');
|
||||
});
|
||||
|
||||
// Daily values are plain calendar days: the 23 h and 25 h days of the
|
||||
// clock changes (last Sundays of March and October) are days like any other.
|
||||
it.each([
|
||||
['2026-03-28', '2026-03-30'],
|
||||
['2026-10-24', '2026-10-26'],
|
||||
])('keeps every day around the clock change %s..%s', async (from, to) => {
|
||||
const time = [from, addDays(from, 1), to];
|
||||
fetchMock.mockResolvedValue(
|
||||
json({ daily: { time, temperature_2m_mean: [1, 2, 3] } }),
|
||||
);
|
||||
|
||||
const result = await new OpenMeteoHistorySource(config()).dailyMeans(
|
||||
POINT,
|
||||
from,
|
||||
to,
|
||||
ARCHIVE,
|
||||
);
|
||||
|
||||
expect(result).toEqual([
|
||||
{ day: from, meanTempC: 1 },
|
||||
{ day: time[1], meanTempC: 2 },
|
||||
{ day: to, meanTempC: 3 },
|
||||
]);
|
||||
expect(addDays(from, 1)).toBe(time[1]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,192 @@
|
||||
import { dayToDate } from '../../common/dates';
|
||||
import type { DailyMean } from '../../season/season-types';
|
||||
import {
|
||||
FORECAST_PAST_DAYS,
|
||||
WEATHER_MAX_RETRY_AFTER_MS,
|
||||
WEATHER_FETCH_TIMEOUT_MS,
|
||||
WEATHER_MAX_RESPONSE_BYTES,
|
||||
WeatherHistoryConfig,
|
||||
} from './weather-history.config';
|
||||
import type {
|
||||
DailyMeansOptions,
|
||||
GeoPoint,
|
||||
WeatherHistorySource,
|
||||
} from './weather-history-source';
|
||||
|
||||
const MIN_PLAUSIBLE_TEMP_C = -50;
|
||||
const MAX_PLAUSIBLE_TEMP_C = 50;
|
||||
|
||||
/**
|
||||
* Every failure of this adapter. The message is fixed text plus an HTTP
|
||||
* status: no URL (it carries the API key as a query parameter), no cause
|
||||
* (a fetch error names the host). `status` and `retryAfterMs` let the caller
|
||||
* back off.
|
||||
*/
|
||||
export class WeatherSourceError extends Error {
|
||||
constructor(
|
||||
readonly reason: string,
|
||||
readonly status?: number,
|
||||
readonly retryAfterMs?: number,
|
||||
) {
|
||||
super(`Wetterquelle open-meteo: ${reason}`);
|
||||
this.name = 'WeatherSourceError';
|
||||
}
|
||||
}
|
||||
|
||||
/** Retry-After as seconds or HTTP date; capped; undefined when absent or unreadable. */
|
||||
function retryAfterMs(
|
||||
header: string | null,
|
||||
now = Date.now(),
|
||||
): number | undefined {
|
||||
if (!header) return undefined;
|
||||
const value = header.trim();
|
||||
let ms: number;
|
||||
if (/^\d+$/.test(value)) {
|
||||
ms = Number(value) * 1000;
|
||||
} else {
|
||||
const at = Date.parse(value);
|
||||
if (Number.isNaN(at)) return undefined;
|
||||
ms = at - now;
|
||||
}
|
||||
return Math.min(Math.max(ms, 0), WEATHER_MAX_RETRY_AFTER_MS);
|
||||
}
|
||||
|
||||
/** undici reports a refused redirect as TypeError("fetch failed") with the cause. */
|
||||
function isRedirectError(error: unknown): boolean {
|
||||
if (!(error instanceof Error)) return false;
|
||||
const { cause } = error;
|
||||
const causeMessage = cause instanceof Error ? cause.message : '';
|
||||
return /redirect/i.test(`${error.message} ${causeMessage}`);
|
||||
}
|
||||
|
||||
interface DailyPayload {
|
||||
time: unknown[];
|
||||
values: unknown[];
|
||||
}
|
||||
|
||||
function temperature(value: unknown): number | null {
|
||||
return typeof value === 'number' &&
|
||||
Number.isFinite(value) &&
|
||||
value >= MIN_PLAUSIBLE_TEMP_C &&
|
||||
value <= MAX_PLAUSIBLE_TEMP_C
|
||||
? value
|
||||
: null;
|
||||
}
|
||||
|
||||
function parseDaily(text: string): DailyPayload {
|
||||
let body: unknown;
|
||||
try {
|
||||
body = JSON.parse(text);
|
||||
} catch {
|
||||
throw new WeatherSourceError('Antwort ist kein JSON');
|
||||
}
|
||||
const daily = (body as { daily?: unknown } | null)?.daily as
|
||||
{ time?: unknown; temperature_2m_mean?: unknown } | undefined;
|
||||
if (
|
||||
!daily ||
|
||||
!Array.isArray(daily.time) ||
|
||||
!Array.isArray(daily.temperature_2m_mean)
|
||||
) {
|
||||
throw new WeatherSourceError('Antwort hat ein unerwartetes Format');
|
||||
}
|
||||
return { time: daily.time, values: daily.temperature_2m_mean };
|
||||
}
|
||||
|
||||
/** Reads the body up to the byte limit; a bigger answer is cut off, not buffered. */
|
||||
async function readLimited(response: Response): Promise<string> {
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) {
|
||||
throw new WeatherSourceError('Antwort ohne Inhalt');
|
||||
}
|
||||
const chunks: Uint8Array[] = [];
|
||||
let received = 0;
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
received += value.byteLength;
|
||||
if (received > WEATHER_MAX_RESPONSE_BYTES) {
|
||||
await reader.cancel().catch(() => undefined);
|
||||
throw new WeatherSourceError('Antwort ist zu groß');
|
||||
}
|
||||
chunks.push(value);
|
||||
}
|
||||
return Buffer.concat(chunks).toString('utf8');
|
||||
}
|
||||
|
||||
/**
|
||||
* Daily mean temperatures from the free Open-Meteo API (forecast endpoint
|
||||
* for the last 90 days, archive endpoint for older ones). The owner accepted
|
||||
* its non-commercial terms; the adapter interface keeps a switch cheap.
|
||||
*/
|
||||
export class OpenMeteoHistorySource implements WeatherHistorySource {
|
||||
readonly name = 'open-meteo';
|
||||
|
||||
constructor(private readonly config: WeatherHistoryConfig) {}
|
||||
|
||||
async dailyMeans(
|
||||
point: GeoPoint,
|
||||
fromDay: string,
|
||||
toDay: string,
|
||||
{ window, signal }: DailyMeansOptions,
|
||||
): Promise<DailyMean[]> {
|
||||
dayToDate(fromDay);
|
||||
dayToDate(toDay);
|
||||
const recent = window === 'recent';
|
||||
const url = new URL(
|
||||
recent ? this.config.forecastUrl : this.config.archiveUrl,
|
||||
);
|
||||
url.searchParams.set('latitude', point.lat.toFixed(2));
|
||||
url.searchParams.set('longitude', point.lon.toFixed(2));
|
||||
url.searchParams.set('daily', 'temperature_2m_mean');
|
||||
url.searchParams.set('timezone', 'Europe/Berlin');
|
||||
if (recent) {
|
||||
url.searchParams.set('past_days', String(FORECAST_PAST_DAYS));
|
||||
url.searchParams.set('forecast_days', '1');
|
||||
} else {
|
||||
url.searchParams.set('start_date', fromDay);
|
||||
url.searchParams.set('end_date', toDay);
|
||||
}
|
||||
if (this.config.apiKey) {
|
||||
url.searchParams.set('apikey', this.config.apiKey);
|
||||
}
|
||||
|
||||
const payload = parseDaily(await this.fetchText(url, signal));
|
||||
const means: DailyMean[] = [];
|
||||
payload.time.forEach((day, index) => {
|
||||
if (typeof day === 'string' && day >= fromDay && day <= toDay) {
|
||||
means.push({ day, meanTempC: temperature(payload.values[index]) });
|
||||
}
|
||||
});
|
||||
return means;
|
||||
}
|
||||
|
||||
private async fetchText(url: URL, signal?: AbortSignal): Promise<string> {
|
||||
const timeout = AbortSignal.timeout(WEATHER_FETCH_TIMEOUT_MS);
|
||||
const combined = signal ? AbortSignal.any([signal, timeout]) : timeout;
|
||||
try {
|
||||
// No redirects: the key travels in the query string.
|
||||
const response = await fetch(url, {
|
||||
signal: combined,
|
||||
redirect: 'error',
|
||||
headers: { 'User-Agent': this.config.userAgent },
|
||||
});
|
||||
if (!response.ok) {
|
||||
await response.body?.cancel().catch(() => undefined);
|
||||
throw new WeatherSourceError(
|
||||
`HTTP ${response.status}`,
|
||||
response.status,
|
||||
retryAfterMs(response.headers.get('Retry-After')),
|
||||
);
|
||||
}
|
||||
return await readLimited(response);
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof WeatherSourceError) throw error;
|
||||
if (combined.aborted) {
|
||||
throw new WeatherSourceError('Zeitüberschreitung oder Abbruch');
|
||||
}
|
||||
throw new WeatherSourceError(
|
||||
isRedirectError(error) ? 'Weiterleitung' : 'Netzwerkfehler',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { DailyMean } from '../../season/season-types';
|
||||
|
||||
/** A place, rounded to 0.01 degrees (roundPoint) before it leaves the app. */
|
||||
export interface GeoPoint {
|
||||
lat: number;
|
||||
lon: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Where daily mean temperatures come from. The phenology feature depends on
|
||||
* this interface only, so the free Open-Meteo API can be swapped for another
|
||||
* adapter (WEATHER_HISTORY_SOURCE) without touching the callers.
|
||||
*/
|
||||
/**
|
||||
* Which endpoint family serves a range: `recent` (the last 90 days, forecast
|
||||
* endpoint with past days) or `archive`. The caller decides, so the adapter
|
||||
* needs no clock.
|
||||
*/
|
||||
export type HistoryWindow = 'recent' | 'archive';
|
||||
|
||||
export interface DailyMeansOptions {
|
||||
window: HistoryWindow;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
export interface WeatherHistorySource {
|
||||
readonly name: string;
|
||||
/**
|
||||
* Daily means from `fromDay` to `toDay` (both YYYY-MM-DD, Europe/Berlin,
|
||||
* inclusive). Throws on any failure; the message never carries a URL or
|
||||
* a key.
|
||||
*/
|
||||
dailyMeans(
|
||||
point: GeoPoint,
|
||||
fromDay: string,
|
||||
toDay: string,
|
||||
options: DailyMeansOptions,
|
||||
): Promise<DailyMean[]>;
|
||||
}
|
||||
|
||||
export const WEATHER_HISTORY_SOURCE = Symbol('WEATHER_HISTORY_SOURCE');
|
||||
@@ -0,0 +1,117 @@
|
||||
import {
|
||||
FORECAST_PAST_DAYS,
|
||||
RECENT_WINDOW_DAYS,
|
||||
roundPoint,
|
||||
WEATHER_FETCH_TIMEOUT_MS,
|
||||
WEATHER_FIRST_TICK_DELAY_MS,
|
||||
WEATHER_HISTORY_POLL_MS,
|
||||
WEATHER_MAX_RESPONSE_BYTES,
|
||||
WEATHER_RETENTION_YEARS,
|
||||
weatherHistoryConfig,
|
||||
} from './weather-history.config';
|
||||
|
||||
describe('weatherHistoryConfig', () => {
|
||||
it('keeps the documented constants', () => {
|
||||
expect(WEATHER_HISTORY_POLL_MS).toBe(3_600_000);
|
||||
expect(WEATHER_FETCH_TIMEOUT_MS).toBe(8_000);
|
||||
expect(WEATHER_MAX_RESPONSE_BYTES).toBe(1_000_000);
|
||||
expect(FORECAST_PAST_DAYS).toBe(92);
|
||||
expect(RECENT_WINDOW_DAYS).toBe(90);
|
||||
expect(WEATHER_RETENTION_YEARS).toBe(4);
|
||||
expect(WEATHER_FIRST_TICK_DELAY_MS).toBe(10_000);
|
||||
});
|
||||
|
||||
it('selects open-meteo by WEATHER_HISTORY_SOURCE and refuses http: URLs', () => {
|
||||
const config = weatherHistoryConfig({});
|
||||
expect(config.source).toBe('open-meteo');
|
||||
expect(config.forecastUrl.href).toBe(
|
||||
'https://api.open-meteo.com/v1/forecast',
|
||||
);
|
||||
expect(config.archiveUrl.href).toBe(
|
||||
'https://archive-api.open-meteo.com/v1/archive',
|
||||
);
|
||||
expect(config.apiKey).toBeNull();
|
||||
expect(
|
||||
weatherHistoryConfig({ WEATHER_HISTORY_SOURCE: 'open-meteo' }).source,
|
||||
).toBe('open-meteo');
|
||||
|
||||
expect(() =>
|
||||
weatherHistoryConfig({ WEATHER_HISTORY_SOURCE: 'dwd' }),
|
||||
).toThrow('WEATHER_HISTORY_SOURCE');
|
||||
expect(() =>
|
||||
weatherHistoryConfig({
|
||||
OPEN_METEO_FORECAST_URL: 'http://api.open-meteo.com/v1/forecast',
|
||||
}),
|
||||
).toThrow('OPEN_METEO_FORECAST_URL');
|
||||
expect(() =>
|
||||
weatherHistoryConfig({ OPEN_METEO_ARCHIVE_URL: 'ftp://example.com/x' }),
|
||||
).toThrow('OPEN_METEO_ARCHIVE_URL');
|
||||
expect(() =>
|
||||
weatherHistoryConfig({ OPEN_METEO_ARCHIVE_URL: 'not a url' }),
|
||||
).toThrow('OPEN_METEO_ARCHIVE_URL');
|
||||
});
|
||||
|
||||
it('takes URLs and key from the environment; an empty key is none', () => {
|
||||
const config = weatherHistoryConfig({
|
||||
OPEN_METEO_FORECAST_URL: 'https://weather.example/forecast',
|
||||
OPEN_METEO_API_KEY: ' secret-key ',
|
||||
});
|
||||
expect(config.forecastUrl.href).toBe('https://weather.example/forecast');
|
||||
expect(config.apiKey).toBe('secret-key');
|
||||
expect(
|
||||
weatherHistoryConfig({ OPEN_METEO_API_KEY: ' ' }).apiKey,
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('never puts the API key or the URL into an error message', () => {
|
||||
let message = '';
|
||||
try {
|
||||
weatherHistoryConfig({
|
||||
OPEN_METEO_API_KEY: 'topsecretkey',
|
||||
OPEN_METEO_FORECAST_URL: 'http://user:[email protected]/forecast?x=1',
|
||||
});
|
||||
} catch (error) {
|
||||
message = (error as Error).message;
|
||||
}
|
||||
expect(message).not.toBe('');
|
||||
expect(message).not.toContain('topsecretkey');
|
||||
expect(message).not.toContain('evil.example');
|
||||
expect(message).not.toContain('user:pw');
|
||||
});
|
||||
});
|
||||
|
||||
describe('weatherUserAgent', () => {
|
||||
it('names the app and version, with the contact when configured', () => {
|
||||
expect(weatherHistoryConfig({}).userAgent).toMatch(
|
||||
/^ApiCore\/\S+ \(self-hosted beekeeping app\)$/,
|
||||
);
|
||||
expect(
|
||||
weatherHistoryConfig({
|
||||
WEATHER_USER_AGENT_CONTACT: '[email protected]',
|
||||
}).userAgent,
|
||||
).toMatch(/^ApiCore\/\S+ \(\+imker@example\.org\)$/);
|
||||
});
|
||||
|
||||
it('strips control characters from the contact (header injection)', () => {
|
||||
const { userAgent } = weatherHistoryConfig({
|
||||
WEATHER_USER_AGENT_CONTACT: '[email protected]\r\nX-Evil: 1',
|
||||
});
|
||||
expect(userAgent).not.toMatch(/[\r\n]/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('roundPoint', () => {
|
||||
it('rounds to 0.01 degrees', () => {
|
||||
expect(roundPoint(52.5234, 13.4049)).toEqual({ lat: 52.52, lon: 13.4 });
|
||||
expect(roundPoint(-33.8688, 151.2093)).toEqual({
|
||||
lat: -33.87,
|
||||
lon: 151.21,
|
||||
});
|
||||
});
|
||||
|
||||
it('never yields negative zero', () => {
|
||||
const point = roundPoint(-0.004, -0.001);
|
||||
expect(Object.is(point.lat, 0)).toBe(true);
|
||||
expect(Object.is(point.lon, 0)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,123 @@
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { addDays } from '../../common/dates';
|
||||
import type { GeoPoint } from './weather-history-source';
|
||||
|
||||
export const WEATHER_HISTORY_POLL_MS = 3_600_000;
|
||||
export const WEATHER_FETCH_TIMEOUT_MS = 8_000;
|
||||
export const WEATHER_MAX_RESPONSE_BYTES = 1_000_000;
|
||||
/** The forecast endpoint reaches this far back (past_days). */
|
||||
export const FORECAST_PAST_DAYS = 92;
|
||||
/** Days up to this age come from the forecast endpoint, older ones from the archive. */
|
||||
export const RECENT_WINDOW_DAYS = 90;
|
||||
export const WEATHER_RETENTION_YEARS = 4;
|
||||
/** Pause between two points of one pass: no burst against the free API. */
|
||||
export const WEATHER_POINT_DELAY_MS = 300;
|
||||
/** This many failed points in a row end the pass; the next hourly tick retries. */
|
||||
export const WEATHER_MAX_CONSECUTIVE_FAILURES = 3;
|
||||
/** Upserts per transaction. */
|
||||
export const WEATHER_WRITE_CHUNK_SIZE = 200;
|
||||
/** An old forecast row is offered to the archive again after this long. */
|
||||
export const FORECAST_RECHECK_MS = 7 * 24 * 3_600_000;
|
||||
/** The first pass waits this long after the worker started. */
|
||||
export const WEATHER_FIRST_TICK_DELAY_MS = 10_000;
|
||||
/** A Retry-After longer than this is capped. */
|
||||
export const WEATHER_MAX_RETRY_AFTER_MS = 24 * 3_600_000;
|
||||
|
||||
const DEFAULT_SOURCE = 'open-meteo';
|
||||
const DEFAULT_FORECAST_URL = 'https://api.open-meteo.com/v1/forecast';
|
||||
const DEFAULT_ARCHIVE_URL = 'https://archive-api.open-meteo.com/v1/archive';
|
||||
|
||||
export interface WeatherHistoryConfig {
|
||||
source: 'open-meteo';
|
||||
forecastUrl: URL;
|
||||
archiveUrl: URL;
|
||||
apiKey: string | null;
|
||||
/** Identifies the app (and, if configured, a contact) to the provider. */
|
||||
userAgent: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Only https: URLs, only from the configuration. Errors name the variable,
|
||||
* never its value (a URL can carry credentials).
|
||||
*/
|
||||
function httpsUrl(env: NodeJS.ProcessEnv, name: string, fallback: string): URL {
|
||||
const raw = env[name]?.trim() || fallback;
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(raw);
|
||||
} catch {
|
||||
throw new Error(`${name} ist keine gültige URL.`);
|
||||
}
|
||||
if (url.protocol !== 'https:') {
|
||||
throw new Error(`${name} muss eine https:-URL sein.`);
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
function appVersion(): string {
|
||||
let dir = __dirname;
|
||||
for (let level = 0; level < 6; level += 1) {
|
||||
const file = join(dir, 'package.json');
|
||||
if (existsSync(file)) {
|
||||
try {
|
||||
const { version } = JSON.parse(readFileSync(file, 'utf8')) as {
|
||||
version?: unknown;
|
||||
};
|
||||
if (typeof version === 'string') return version;
|
||||
} catch {
|
||||
return 'unknown';
|
||||
}
|
||||
}
|
||||
dir = dirname(dir);
|
||||
}
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
/** Printable ASCII only: the value ends up in a request header. */
|
||||
function headerSafe(value: string): string {
|
||||
return value.replace(/[^ -~]/g, '').trim();
|
||||
}
|
||||
|
||||
export function weatherUserAgent(env: NodeJS.ProcessEnv): string {
|
||||
const contact = headerSafe(env.WEATHER_USER_AGENT_CONTACT ?? '');
|
||||
return `ApiCore/${appVersion()} (${contact ? `+${contact}` : 'self-hosted beekeeping app'})`;
|
||||
}
|
||||
|
||||
/** Read once at startup (module factory, WeatherService); throws on bad settings. */
|
||||
export function weatherHistoryConfig(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): WeatherHistoryConfig {
|
||||
const source = env.WEATHER_HISTORY_SOURCE?.trim() || DEFAULT_SOURCE;
|
||||
if (source !== DEFAULT_SOURCE) {
|
||||
throw new Error(
|
||||
`WEATHER_HISTORY_SOURCE: unbekannte Wetterquelle (erlaubt: ${DEFAULT_SOURCE}).`,
|
||||
);
|
||||
}
|
||||
return {
|
||||
source,
|
||||
forecastUrl: httpsUrl(env, 'OPEN_METEO_FORECAST_URL', DEFAULT_FORECAST_URL),
|
||||
archiveUrl: httpsUrl(env, 'OPEN_METEO_ARCHIVE_URL', DEFAULT_ARCHIVE_URL),
|
||||
apiKey: env.OPEN_METEO_API_KEY?.trim() || null,
|
||||
userAgent: weatherUserAgent(env),
|
||||
};
|
||||
}
|
||||
|
||||
/** Coordinates at 0.01 degrees (about 1 km): enough for a daily mean, and no exact address leaves the app. */
|
||||
export function roundPoint(lat: number, lon: number): GeoPoint {
|
||||
// "+ 0" turns -0 into 0.
|
||||
return {
|
||||
lat: Math.round(lat * 100) / 100 + 0,
|
||||
lon: Math.round(lon * 100) / 100 + 0,
|
||||
};
|
||||
}
|
||||
|
||||
/** First day of the window served by the forecast endpoint. */
|
||||
export function recentWindowStart(today: string): string {
|
||||
return addDays(today, -RECENT_WINDOW_DAYS);
|
||||
}
|
||||
|
||||
/** True when a range starting at `fromDay` is read from the forecast endpoint. */
|
||||
export function isRecentRange(fromDay: string, today: string): boolean {
|
||||
return fromDay >= recentWindowStart(today);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { OpenMeteoHistorySource } from './open-meteo-history.source';
|
||||
import { weatherHistoryConfig } from './weather-history.config';
|
||||
import { WeatherHistoryService } from './weather-history.service';
|
||||
import { WEATHER_HISTORY_SOURCE } from './weather-history-source';
|
||||
|
||||
/**
|
||||
* Daily mean temperatures for the phenology. Imported by WorkerRuntimeModule
|
||||
* only (the loop `phenology-weather`); the API never calls the source. The
|
||||
* factory reads WEATHER_HISTORY_SOURCE and the URLs once at startup: an
|
||||
* unknown source or an http: URL stops the process.
|
||||
*/
|
||||
@Module({
|
||||
providers: [
|
||||
{
|
||||
provide: WEATHER_HISTORY_SOURCE,
|
||||
useFactory: () => new OpenMeteoHistorySource(weatherHistoryConfig()),
|
||||
},
|
||||
WeatherHistoryService,
|
||||
],
|
||||
exports: [WeatherHistoryService],
|
||||
})
|
||||
export class WeatherHistoryModule {}
|
||||
@@ -0,0 +1,469 @@
|
||||
import { addDays, dayToDate } from '../../common/dates';
|
||||
import type { PrismaService } from '../../prisma/prisma.service';
|
||||
import { RECENT_WINDOW_DAYS } from './weather-history.config';
|
||||
import type { WeatherHistorySource } from './weather-history-source';
|
||||
import { abortableDelay } from './abortable-delay';
|
||||
import { WeatherSourceError } from './open-meteo-history.source';
|
||||
import { WeatherHistoryService } from './weather-history.service';
|
||||
|
||||
interface ApiaryRow {
|
||||
id: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
}
|
||||
interface DayRow {
|
||||
apiaryId: string;
|
||||
day: Date;
|
||||
meanTempC: number | null;
|
||||
source: string;
|
||||
fetchedAt?: Date;
|
||||
}
|
||||
interface Upsert {
|
||||
create: {
|
||||
apiaryId: string;
|
||||
day: Date;
|
||||
meanTempC: number | null;
|
||||
source: string;
|
||||
};
|
||||
}
|
||||
|
||||
const dayKey = (date: Date): string => date.toISOString().slice(0, 10);
|
||||
|
||||
jest.mock('./abortable-delay', () => ({
|
||||
abortableDelay: jest.fn(() => Promise.resolve()),
|
||||
}));
|
||||
|
||||
describe('WeatherHistoryService', () => {
|
||||
const today = '2026-05-20';
|
||||
const yesterday = '2026-05-19';
|
||||
const recentStart = addDays(today, -RECENT_WINDOW_DAYS);
|
||||
const signal = new AbortController().signal;
|
||||
let apiaries: ApiaryRow[];
|
||||
let existing: DayRow[];
|
||||
let dailyMeans: jest.Mock;
|
||||
let apiaryFindMany: jest.Mock;
|
||||
let upserts: Upsert[];
|
||||
let transactionSizes: number[];
|
||||
let touches: unknown[];
|
||||
let service: WeatherHistoryService;
|
||||
|
||||
beforeEach(() => {
|
||||
apiaries = [];
|
||||
existing = [];
|
||||
upserts = [];
|
||||
(abortableDelay as jest.Mock).mockClear();
|
||||
transactionSizes = [];
|
||||
touches = [];
|
||||
dailyMeans = jest
|
||||
.fn()
|
||||
.mockImplementation((_point: unknown, from: string, to: string) => {
|
||||
const out: { day: string; meanTempC: number | null }[] = [];
|
||||
for (let d = from; d <= to; d = addDays(d, 1)) {
|
||||
out.push({ day: d, meanTempC: 5 });
|
||||
}
|
||||
return Promise.resolve(out);
|
||||
});
|
||||
apiaryFindMany = jest.fn(() => Promise.resolve(apiaries));
|
||||
const prisma = {
|
||||
apiary: { findMany: apiaryFindMany },
|
||||
apiaryWeatherDay: {
|
||||
findMany: jest.fn(() =>
|
||||
Promise.resolve(
|
||||
existing.map((row) => ({ fetchedAt: new Date(0), ...row })),
|
||||
),
|
||||
),
|
||||
update: jest.fn((args: unknown) => {
|
||||
touches.push(args);
|
||||
return Promise.resolve(args);
|
||||
}),
|
||||
upsert: jest.fn((args: Upsert) => {
|
||||
upserts.push(args);
|
||||
return Promise.resolve(args);
|
||||
}),
|
||||
},
|
||||
$transaction: jest.fn((ops: Promise<unknown>[]) => {
|
||||
transactionSizes.push(ops.length);
|
||||
return Promise.all(ops);
|
||||
}),
|
||||
};
|
||||
const source: WeatherHistorySource = { name: 'fake', dailyMeans };
|
||||
service = new WeatherHistoryService(
|
||||
prisma as unknown as PrismaService,
|
||||
source,
|
||||
);
|
||||
});
|
||||
|
||||
it('apiaries on the same rounded point share one request', async () => {
|
||||
apiaries = [
|
||||
{ id: 'a', latitude: 52.5234, longitude: 13.4049 },
|
||||
{ id: 'b', latitude: 52.5199, longitude: 13.3951 },
|
||||
{ id: 'c', latitude: 48.1, longitude: 11.5 },
|
||||
];
|
||||
|
||||
const result = await service.syncOnce(today, signal);
|
||||
|
||||
// Two points; each needs an archive range and a forecast range.
|
||||
expect(result.points).toBe(2);
|
||||
expect(result.failedPoints).toBe(0);
|
||||
const forLat = (lat: number) =>
|
||||
dailyMeans.mock.calls.filter(
|
||||
([point]) => (point as { lat: number }).lat === lat,
|
||||
) as [
|
||||
{ lat: number; lon: number },
|
||||
string,
|
||||
string,
|
||||
{ window: string; signal: AbortSignal },
|
||||
][];
|
||||
expect(forLat(52.52)).toHaveLength(2);
|
||||
expect(forLat(48.1)).toHaveLength(2);
|
||||
const [point, from, to, options] = forLat(52.52)[0];
|
||||
expect(point).toEqual({ lat: 52.52, lon: 13.4 });
|
||||
expect([from, to]).toEqual(['2026-01-01', addDays(recentStart, -1)]);
|
||||
expect(options).toEqual({ window: 'archive', signal });
|
||||
expect(forLat(52.52)[1][3].window).toBe('recent');
|
||||
expect(forLat(52.52).map(([, f, t]) => [f, t])).toContainEqual([
|
||||
recentStart,
|
||||
yesterday,
|
||||
]);
|
||||
// Both apiaries of the shared point got their rows (Jan 1 .. May 19).
|
||||
const rowsOf = (id: string) =>
|
||||
upserts.filter((u) => u.create.apiaryId === id);
|
||||
expect(rowsOf('a')).toHaveLength(139);
|
||||
expect(rowsOf('b')).toHaveLength(139);
|
||||
expect(result.daysWritten).toBe(139 * 3);
|
||||
});
|
||||
|
||||
it('fetches only missing days and labels the source by window', async () => {
|
||||
apiaries = [{ id: 'a', latitude: 52.5, longitude: 13.4 }];
|
||||
// Complete, except one old day, one recent day and one recent null day.
|
||||
for (let d = '2026-01-01'; d <= yesterday; d = addDays(d, 1)) {
|
||||
if (d === '2026-01-10' || d === '2026-04-01') continue;
|
||||
existing.push({
|
||||
apiaryId: 'a',
|
||||
day: dayToDate(d),
|
||||
meanTempC: d === '2026-05-01' ? null : 4,
|
||||
source: d < recentStart ? 'archive' : 'forecast',
|
||||
});
|
||||
}
|
||||
|
||||
const result = await service.syncOnce(today, signal);
|
||||
|
||||
expect(
|
||||
(dailyMeans.mock.calls as [unknown, string, string][]).map(([, f, t]) => [
|
||||
f,
|
||||
t,
|
||||
]),
|
||||
).toEqual([
|
||||
['2026-01-10', '2026-01-10'],
|
||||
['2026-04-01', '2026-05-01'],
|
||||
]);
|
||||
const written = upserts.map((u) => [dayKey(u.create.day), u.create.source]);
|
||||
// Days inside the range that already have a value are not rewritten.
|
||||
expect(written).toEqual([
|
||||
['2026-01-10', 'archive'],
|
||||
['2026-04-01', 'forecast'],
|
||||
['2026-05-01', 'forecast'],
|
||||
]);
|
||||
expect(result.daysWritten).toBe(3);
|
||||
});
|
||||
|
||||
it('does not refetch old days whose value is null', async () => {
|
||||
apiaries = [{ id: 'a', latitude: 52.5, longitude: 13.4 }];
|
||||
for (let d = '2026-01-01'; d <= yesterday; d = addDays(d, 1)) {
|
||||
existing.push({
|
||||
apiaryId: 'a',
|
||||
day: dayToDate(d),
|
||||
meanTempC: d < recentStart ? null : 4,
|
||||
source: d < recentStart ? 'archive' : 'forecast',
|
||||
});
|
||||
}
|
||||
|
||||
const result = await service.syncOnce(today, signal);
|
||||
|
||||
expect(dailyMeans).not.toHaveBeenCalled();
|
||||
expect(result).toEqual({
|
||||
points: 0,
|
||||
daysWritten: 0,
|
||||
failedPoints: 0,
|
||||
endedEarly: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('writes nothing for a point on an error and tries the others', async () => {
|
||||
apiaries = [
|
||||
{ id: 'a', latitude: 52.5, longitude: 13.4 },
|
||||
{ id: 'c', latitude: 48.1, longitude: 11.5 },
|
||||
];
|
||||
dailyMeans.mockImplementation(
|
||||
(point: { lat: number }, from: string, to: string) =>
|
||||
point.lat === 52.5 && from !== '2026-01-01'
|
||||
? Promise.reject(new Error('boom 52.5'))
|
||||
: Promise.resolve([
|
||||
{ day: from, meanTempC: 1 },
|
||||
{ day: to, meanTempC: 1 },
|
||||
]),
|
||||
);
|
||||
const warn = jest
|
||||
.spyOn(service['logger'], 'warn')
|
||||
.mockImplementation(() => undefined);
|
||||
|
||||
const result = await service.syncOnce(today, signal);
|
||||
|
||||
expect(result.failedPoints).toBe(1);
|
||||
expect(upserts.length).toBeGreaterThan(0);
|
||||
expect(upserts.every((u) => u.create.apiaryId === 'c')).toBe(true);
|
||||
expect(warn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('reads active apiaries with coordinates and stops when aborted', async () => {
|
||||
apiaries = [{ id: 'a', latitude: 52.5, longitude: 13.4 }];
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
|
||||
const result = await service.syncOnce(today, controller.signal);
|
||||
|
||||
expect(dailyMeans).not.toHaveBeenCalled();
|
||||
expect(result.points).toBe(0);
|
||||
expect(apiaryFindMany.mock.calls[0]).toEqual([
|
||||
expect.objectContaining({
|
||||
where: expect.objectContaining({
|
||||
status: 'ACTIVE',
|
||||
latitude: { not: null },
|
||||
longitude: { not: null },
|
||||
}) as unknown,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
describe('backoff', () => {
|
||||
const manyPoints = (count: number): ApiaryRow[] =>
|
||||
Array.from({ length: count }, (_, i) => ({
|
||||
id: `p${i}`,
|
||||
latitude: 40 + i,
|
||||
longitude: 10,
|
||||
}));
|
||||
const quiet = () =>
|
||||
jest.spyOn(service['logger'], 'warn').mockImplementation(() => undefined);
|
||||
|
||||
it('pauses between points, abortable', async () => {
|
||||
apiaries = manyPoints(3);
|
||||
|
||||
await service.syncOnce(today, signal);
|
||||
|
||||
expect(abortableDelay).toHaveBeenCalledTimes(2);
|
||||
expect(abortableDelay).toHaveBeenCalledWith(300, signal);
|
||||
});
|
||||
|
||||
it('ends the pass after 3 failed points in a row', async () => {
|
||||
apiaries = manyPoints(6);
|
||||
dailyMeans.mockRejectedValue(new WeatherSourceError('HTTP 500', 500));
|
||||
quiet();
|
||||
|
||||
const result = await service.syncOnce(today, signal);
|
||||
|
||||
expect(result).toEqual({
|
||||
points: 3,
|
||||
daysWritten: 0,
|
||||
failedPoints: 3,
|
||||
endedEarly: true,
|
||||
});
|
||||
expect(dailyMeans).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('a success resets the failure streak', async () => {
|
||||
apiaries = manyPoints(5);
|
||||
dailyMeans.mockImplementation(
|
||||
(point: { lat: number }, from: string, to: string) =>
|
||||
point.lat === 42
|
||||
? Promise.resolve([
|
||||
{ day: from, meanTempC: 1 },
|
||||
{ day: to, meanTempC: 1 },
|
||||
])
|
||||
: Promise.reject(new WeatherSourceError('HTTP 500', 500)),
|
||||
);
|
||||
quiet();
|
||||
|
||||
const result = await service.syncOnce(today, signal);
|
||||
|
||||
expect(result.points).toBe(5);
|
||||
expect(result.failedPoints).toBe(4);
|
||||
expect(result.endedEarly).toBe(false);
|
||||
});
|
||||
|
||||
it('stops on a 429 and sends no request before Retry-After has passed', async () => {
|
||||
jest.useFakeTimers({ doNotFake: ['nextTick', 'setImmediate'] });
|
||||
try {
|
||||
apiaries = manyPoints(4);
|
||||
dailyMeans.mockRejectedValueOnce(
|
||||
new WeatherSourceError('HTTP 429', 429, 120_000),
|
||||
);
|
||||
quiet();
|
||||
|
||||
const first = await service.syncOnce(today, signal);
|
||||
expect(first.endedEarly).toBe(true);
|
||||
expect(first.points).toBe(1);
|
||||
expect(dailyMeans).toHaveBeenCalledTimes(1);
|
||||
|
||||
jest.advanceTimersByTime(119_000);
|
||||
const blocked = await service.syncOnce(today, signal);
|
||||
expect(blocked.endedEarly).toBe(true);
|
||||
expect(blocked.points).toBe(0);
|
||||
expect(dailyMeans).toHaveBeenCalledTimes(1);
|
||||
|
||||
jest.advanceTimersByTime(2_000);
|
||||
dailyMeans.mockClear();
|
||||
const resumed = await service.syncOnce(today, signal);
|
||||
expect(resumed.points).toBe(4);
|
||||
expect(dailyMeans).toHaveBeenCalled();
|
||||
} finally {
|
||||
jest.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('replaces forecast values older than the recent window with archive values', async () => {
|
||||
apiaries = [{ id: 'a', latitude: 52.5, longitude: 13.4 }];
|
||||
for (let d = '2026-01-01'; d <= yesterday; d = addDays(d, 1)) {
|
||||
existing.push({
|
||||
apiaryId: 'a',
|
||||
day: dayToDate(d),
|
||||
meanTempC: 4,
|
||||
source: d === '2026-01-15' || d >= recentStart ? 'forecast' : 'archive',
|
||||
});
|
||||
}
|
||||
|
||||
await service.syncOnce(today, signal);
|
||||
|
||||
const calls = dailyMeans.mock.calls as [
|
||||
unknown,
|
||||
string,
|
||||
string,
|
||||
{ window: string },
|
||||
][];
|
||||
expect(calls.map(([, f, t, o]) => [f, t, o.window])).toEqual([
|
||||
['2026-01-15', '2026-01-15', 'archive'],
|
||||
]);
|
||||
expect(upserts.map((u) => [dayKey(u.create.day), u.create.source])).toEqual(
|
||||
[['2026-01-15', 'archive']],
|
||||
);
|
||||
});
|
||||
|
||||
it('writes a large backfill in several transactions', async () => {
|
||||
apiaries = [{ id: 'a', latitude: 52.5, longitude: 13.4 }];
|
||||
|
||||
const result = await service.syncOnce('2026-12-31', signal);
|
||||
|
||||
expect(result.daysWritten).toBe(364);
|
||||
const sizes = transactionSizes;
|
||||
expect(sizes.length).toBeGreaterThan(1);
|
||||
expect(Math.max(...sizes)).toBeLessThanOrEqual(200);
|
||||
expect(sizes.reduce((a, b) => a + b, 0)).toBe(364);
|
||||
});
|
||||
|
||||
it('counts the days of the clock changes like any other day', async () => {
|
||||
apiaries = [{ id: 'a', latitude: 52.5, longitude: 13.4 }];
|
||||
|
||||
await service.syncOnce('2026-11-02', signal);
|
||||
|
||||
const written = upserts.map((u) => dayKey(u.create.day));
|
||||
expect(written).toHaveLength(305); // Jan 1 .. Nov 1
|
||||
expect(new Set(written).size).toBe(305);
|
||||
expect(written).toEqual(
|
||||
expect.arrayContaining(['2026-03-28', '2026-03-29', '2026-03-30']),
|
||||
);
|
||||
expect(written).toEqual(
|
||||
expect.arrayContaining(['2026-10-24', '2026-10-25', '2026-10-26']),
|
||||
);
|
||||
});
|
||||
|
||||
it('logs the pass with points, daysWritten, failedPoints and endedEarly', async () => {
|
||||
apiaries = [{ id: 'a', latitude: 52.5, longitude: 13.4 }];
|
||||
const log = jest
|
||||
.spyOn(service['logger'], 'log')
|
||||
.mockImplementation(() => undefined);
|
||||
|
||||
await service.syncOnce(today, signal);
|
||||
|
||||
expect(log).toHaveBeenCalledWith(
|
||||
expect.stringMatching(
|
||||
/points=1, daysWritten=139, failedPoints=0, endedEarly=false/,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
describe('old forecast rows', () => {
|
||||
const oldForecastRows = (fetchedAt?: Date): void => {
|
||||
for (let d = '2026-01-01'; d <= yesterday; d = addDays(d, 1)) {
|
||||
existing.push({
|
||||
apiaryId: 'a',
|
||||
day: dayToDate(d),
|
||||
meanTempC: 4,
|
||||
source: d === '2026-01-15' ? 'forecast' : 'archive',
|
||||
...(d === '2026-01-15' && fetchedAt ? { fetchedAt } : {}),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
it('keeps the value when the archive has none and only notes the check', async () => {
|
||||
apiaries = [{ id: 'a', latitude: 52.5, longitude: 13.4 }];
|
||||
oldForecastRows();
|
||||
dailyMeans.mockResolvedValue([{ day: '2026-01-15', meanTempC: null }]);
|
||||
|
||||
const result = await service.syncOnce(today, signal);
|
||||
|
||||
expect(upserts).toHaveLength(0);
|
||||
expect(touches).toEqual([
|
||||
expect.objectContaining({
|
||||
where: {
|
||||
apiaryId_day: { apiaryId: 'a', day: dayToDate('2026-01-15') },
|
||||
},
|
||||
data: { fetchedAt: expect.any(Date) as unknown },
|
||||
}),
|
||||
]);
|
||||
expect(result.daysWritten).toBe(0);
|
||||
});
|
||||
|
||||
it('does not ask again within 7 days of the last check', async () => {
|
||||
apiaries = [{ id: 'a', latitude: 52.5, longitude: 13.4 }];
|
||||
oldForecastRows(new Date(Date.now() - 6 * 24 * 3_600_000));
|
||||
|
||||
await service.syncOnce(today, signal);
|
||||
expect(dailyMeans).not.toHaveBeenCalled();
|
||||
|
||||
existing = [];
|
||||
oldForecastRows(new Date(Date.now() - 8 * 24 * 3_600_000));
|
||||
await service.syncOnce(today, signal);
|
||||
expect(dailyMeans).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
it('honours Retry-After of a 503 like a 429, but not a 503 without it', async () => {
|
||||
jest.useFakeTimers({ doNotFake: ['nextTick', 'setImmediate'] });
|
||||
try {
|
||||
apiaries = [
|
||||
{ id: 'a', latitude: 40, longitude: 10 },
|
||||
{ id: 'b', latitude: 41, longitude: 10 },
|
||||
];
|
||||
jest.spyOn(service['logger'], 'warn').mockImplementation(() => undefined);
|
||||
dailyMeans.mockRejectedValueOnce(
|
||||
new WeatherSourceError('HTTP 503', 503, 60_000),
|
||||
);
|
||||
|
||||
const first = await service.syncOnce(today, signal);
|
||||
expect(first).toEqual(
|
||||
expect.objectContaining({ points: 1, endedEarly: true }),
|
||||
);
|
||||
jest.advanceTimersByTime(30_000);
|
||||
dailyMeans.mockClear();
|
||||
expect((await service.syncOnce(today, signal)).points).toBe(0);
|
||||
expect(dailyMeans).not.toHaveBeenCalled();
|
||||
|
||||
jest.advanceTimersByTime(31_000);
|
||||
dailyMeans.mockRejectedValueOnce(new WeatherSourceError('HTTP 503', 503));
|
||||
const plain = await service.syncOnce(today, signal);
|
||||
expect(plain.points).toBe(2);
|
||||
} finally {
|
||||
jest.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,314 @@
|
||||
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||
import { addDays, dayToDate } from '../../common/dates';
|
||||
import { loggableError } from '../../common/loggable-error';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import {
|
||||
type GeoPoint,
|
||||
WEATHER_HISTORY_SOURCE,
|
||||
type WeatherHistorySource,
|
||||
} from './weather-history-source';
|
||||
import { WeatherSourceError } from './open-meteo-history.source';
|
||||
import { abortableDelay } from './abortable-delay';
|
||||
import {
|
||||
isRecentRange,
|
||||
recentWindowStart,
|
||||
roundPoint,
|
||||
FORECAST_RECHECK_MS,
|
||||
WEATHER_MAX_CONSECUTIVE_FAILURES,
|
||||
WEATHER_POINT_DELAY_MS,
|
||||
WEATHER_WRITE_CHUNK_SIZE,
|
||||
} from './weather-history.config';
|
||||
|
||||
export interface WeatherSyncResult {
|
||||
points: number;
|
||||
daysWritten: number;
|
||||
failedPoints: number;
|
||||
/** The pass stopped before the last point (rate limit, repeated failures, shutdown). */
|
||||
endedEarly: boolean;
|
||||
}
|
||||
|
||||
interface PlannedApiary {
|
||||
id: string;
|
||||
/** Days this apiary lacks (no row, or no value inside the recent window). */
|
||||
missing: Set<string>;
|
||||
/** Days that already have a row (with no value): not rewritten by another null. */
|
||||
stored: Set<string>;
|
||||
/** Forecast values past the window that the archive is asked to replace. */
|
||||
replaceable: Set<string>;
|
||||
}
|
||||
|
||||
interface PlannedPoint {
|
||||
point: GeoPoint;
|
||||
apiaries: PlannedApiary[];
|
||||
}
|
||||
|
||||
interface Range {
|
||||
from: string;
|
||||
to: string;
|
||||
}
|
||||
|
||||
type WeatherLabel = 'forecast' | 'archive';
|
||||
|
||||
const dayKey = (date: Date): string => date.toISOString().slice(0, 10);
|
||||
|
||||
/** The smallest range covering the given days, or null for none. */
|
||||
function spanOf(days: Iterable<string>): Range | null {
|
||||
let from: string | null = null;
|
||||
let to: string | null = null;
|
||||
for (const day of days) {
|
||||
if (from === null || day < from) from = day;
|
||||
if (to === null || day > to) to = day;
|
||||
}
|
||||
return from === null || to === null ? null : { from, to };
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps the daily mean temperatures of the active apiaries (ApiaryWeatherDay)
|
||||
* complete from January 1st to yesterday, for the growing-degree-day sum of
|
||||
* the phenology. Runs in the worker loop `phenology-weather`; the only code
|
||||
* that talks to the weather source for history.
|
||||
*/
|
||||
@Injectable()
|
||||
export class WeatherHistoryService {
|
||||
private readonly logger = new Logger(WeatherHistoryService.name);
|
||||
/** Wall-clock ms before which no request goes out (Retry-After of a 429). */
|
||||
private blockedUntil = 0;
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
@Inject(WEATHER_HISTORY_SOURCE)
|
||||
private readonly source: WeatherHistorySource,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* One pass. A point whose request fails writes nothing and is tried again
|
||||
* by the next pass; no event, no notification.
|
||||
*/
|
||||
async syncOnce(
|
||||
today: string,
|
||||
signal: AbortSignal,
|
||||
): Promise<WeatherSyncResult> {
|
||||
const result: WeatherSyncResult = {
|
||||
points: 0,
|
||||
daysWritten: 0,
|
||||
failedPoints: 0,
|
||||
endedEarly: false,
|
||||
};
|
||||
if (Date.now() < this.blockedUntil) {
|
||||
this.logger.log('Wetterabgleich pausiert (Retry-After der Quelle).');
|
||||
return { ...result, endedEarly: true };
|
||||
}
|
||||
const plan = await this.plan(today);
|
||||
let consecutiveFailures = 0;
|
||||
for (const planned of plan) {
|
||||
if (signal.aborted) {
|
||||
result.endedEarly = true;
|
||||
break;
|
||||
}
|
||||
if (result.points > 0) {
|
||||
await abortableDelay(WEATHER_POINT_DELAY_MS, signal);
|
||||
if (signal.aborted) {
|
||||
result.endedEarly = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
result.points += 1;
|
||||
try {
|
||||
result.daysWritten += await this.syncPoint(planned, today, signal);
|
||||
consecutiveFailures = 0;
|
||||
} catch (error: unknown) {
|
||||
result.failedPoints += 1;
|
||||
consecutiveFailures += 1;
|
||||
// Message only (no stack): nothing of the request goes to the log.
|
||||
this.logger.warn(
|
||||
`Wetterabruf für einen Standortpunkt fehlgeschlagen: ${loggableError(error).message}`,
|
||||
);
|
||||
if (
|
||||
error instanceof WeatherSourceError &&
|
||||
(error.status === 429 ||
|
||||
(error.status === 503 && error.retryAfterMs !== undefined))
|
||||
) {
|
||||
// The source asks us to slow down: stop, and stay quiet until
|
||||
// Retry-After has passed (at least until the next tick).
|
||||
this.blockedUntil = Date.now() + (error.retryAfterMs ?? 0);
|
||||
result.endedEarly = true;
|
||||
break;
|
||||
}
|
||||
if (consecutiveFailures >= WEATHER_MAX_CONSECUTIVE_FAILURES) {
|
||||
result.endedEarly = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (result.points > 0 || result.endedEarly) {
|
||||
this.logger.log(
|
||||
`Wetterabgleich: points=${result.points}, daysWritten=${result.daysWritten}, failedPoints=${result.failedPoints}, endedEarly=${result.endedEarly}`,
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Active apiaries with coordinates, grouped by rounded point, minus what is complete. */
|
||||
private async plan(today: string): Promise<PlannedPoint[]> {
|
||||
const yesterday = addDays(today, -1);
|
||||
const januaryFirst = `${today.slice(0, 4)}-01-01`;
|
||||
if (yesterday < januaryFirst) return [];
|
||||
const apiaries = await this.prisma.apiary.findMany({
|
||||
where: {
|
||||
status: 'ACTIVE',
|
||||
latitude: { not: null },
|
||||
longitude: { not: null },
|
||||
},
|
||||
select: { id: true, latitude: true, longitude: true },
|
||||
orderBy: { id: 'asc' },
|
||||
});
|
||||
if (apiaries.length === 0) return [];
|
||||
const stored = await this.prisma.apiaryWeatherDay.findMany({
|
||||
where: {
|
||||
apiaryId: { in: apiaries.map(({ id }) => id) },
|
||||
day: { gte: dayToDate(januaryFirst), lte: dayToDate(yesterday) },
|
||||
},
|
||||
select: {
|
||||
apiaryId: true,
|
||||
day: true,
|
||||
meanTempC: true,
|
||||
source: true,
|
||||
fetchedAt: true,
|
||||
},
|
||||
});
|
||||
const complete = new Map<string, Set<string>>();
|
||||
const storedDays = new Map<string, Set<string>>();
|
||||
const replaceable = new Map<string, Set<string>>();
|
||||
const now = Date.now();
|
||||
const recentStart = recentWindowStart(today);
|
||||
for (const row of stored) {
|
||||
const day = dayKey(row.day);
|
||||
const rows = storedDays.get(row.apiaryId) ?? new Set<string>();
|
||||
rows.add(day);
|
||||
storedDays.set(row.apiaryId, rows);
|
||||
// A null inside the recent window is asked for again; an older one
|
||||
// stays (the archive does not know it either).
|
||||
if (row.meanTempC === null && day >= recentStart) continue;
|
||||
// A forecast value older than the window is replaced by the archive's
|
||||
// (the forecast endpoint's model values are not the reanalysis).
|
||||
// Asked at most once per FORECAST_RECHECK_DAYS: when the archive has
|
||||
// no value either, the row is touched (fetchedAt) and left alone.
|
||||
if (
|
||||
row.source === 'forecast' &&
|
||||
day < recentStart &&
|
||||
now - row.fetchedAt.getTime() >= FORECAST_RECHECK_MS
|
||||
) {
|
||||
const days = replaceable.get(row.apiaryId) ?? new Set<string>();
|
||||
days.add(day);
|
||||
replaceable.set(row.apiaryId, days);
|
||||
continue;
|
||||
}
|
||||
const days = complete.get(row.apiaryId) ?? new Set<string>();
|
||||
days.add(day);
|
||||
complete.set(row.apiaryId, days);
|
||||
}
|
||||
const allDays: string[] = [];
|
||||
for (let day = januaryFirst; day <= yesterday; day = addDays(day, 1)) {
|
||||
allDays.push(day);
|
||||
}
|
||||
const byPoint = new Map<string, PlannedPoint>();
|
||||
for (const apiary of apiaries) {
|
||||
if (apiary.latitude === null || apiary.longitude === null) continue;
|
||||
const point = roundPoint(apiary.latitude, apiary.longitude);
|
||||
const have = complete.get(apiary.id);
|
||||
const missing = new Set(allDays.filter((day) => !have?.has(day)));
|
||||
if (missing.size === 0) continue;
|
||||
const key = `${point.lat}/${point.lon}`;
|
||||
const planned = byPoint.get(key) ?? { point, apiaries: [] };
|
||||
planned.apiaries.push({
|
||||
id: apiary.id,
|
||||
missing,
|
||||
stored: storedDays.get(apiary.id) ?? new Set<string>(),
|
||||
replaceable: replaceable.get(apiary.id) ?? new Set<string>(),
|
||||
});
|
||||
byPoint.set(key, planned);
|
||||
}
|
||||
return [...byPoint.values()];
|
||||
}
|
||||
|
||||
/** Fetches both ranges of a point first, then writes: all or nothing. */
|
||||
private async syncPoint(
|
||||
planned: PlannedPoint,
|
||||
today: string,
|
||||
signal: AbortSignal,
|
||||
): Promise<number> {
|
||||
const union = new Set(
|
||||
planned.apiaries.flatMap(({ missing }) => [...missing]),
|
||||
);
|
||||
const ranges: Range[] = [];
|
||||
const older = spanOf([...union].filter((d) => !isRecentRange(d, today)));
|
||||
const recent = spanOf([...union].filter((d) => isRecentRange(d, today)));
|
||||
for (const range of [older, recent]) {
|
||||
if (range) ranges.push(range);
|
||||
}
|
||||
|
||||
const fetched = new Map<
|
||||
string,
|
||||
{ value: number | null; label: WeatherLabel }
|
||||
>();
|
||||
for (const range of ranges) {
|
||||
const recent = isRecentRange(range.from, today);
|
||||
const label: WeatherLabel = recent ? 'forecast' : 'archive';
|
||||
const means = await this.source.dailyMeans(
|
||||
planned.point,
|
||||
range.from,
|
||||
range.to,
|
||||
{ window: recent ? 'recent' : 'archive', signal },
|
||||
);
|
||||
for (const { day, meanTempC } of means) {
|
||||
fetched.set(day, { value: meanTempC, label });
|
||||
}
|
||||
}
|
||||
|
||||
let written = 0;
|
||||
for (const { id, missing, stored, replaceable } of planned.apiaries) {
|
||||
let touched = 0;
|
||||
const writes = [...missing].flatMap((day) => {
|
||||
const entry = fetched.get(day);
|
||||
if (!entry) return [];
|
||||
if (entry.value === null && stored.has(day)) {
|
||||
// The archive has no value for an old forecast row: keep the
|
||||
// value, only note the check so it is not repeated every hour.
|
||||
if (!replaceable.has(day)) return [];
|
||||
touched += 1;
|
||||
return [this.touchDay(id, day)];
|
||||
}
|
||||
return [this.upsertDay(id, day, entry.value, entry.label)];
|
||||
});
|
||||
// Chunked: a year of backfill is no single transaction.
|
||||
for (let i = 0; i < writes.length; i += WEATHER_WRITE_CHUNK_SIZE) {
|
||||
await this.prisma.$transaction(
|
||||
writes.slice(i, i + WEATHER_WRITE_CHUNK_SIZE),
|
||||
);
|
||||
}
|
||||
written += writes.length - touched;
|
||||
}
|
||||
return written;
|
||||
}
|
||||
|
||||
private touchDay(apiaryId: string, day: string) {
|
||||
return this.prisma.apiaryWeatherDay.update({
|
||||
where: { apiaryId_day: { apiaryId, day: dayToDate(day) } },
|
||||
data: { fetchedAt: new Date() },
|
||||
});
|
||||
}
|
||||
|
||||
private upsertDay(
|
||||
apiaryId: string,
|
||||
day: string,
|
||||
meanTempC: number | null,
|
||||
source: WeatherLabel,
|
||||
) {
|
||||
const date = dayToDate(day);
|
||||
return this.prisma.apiaryWeatherDay.upsert({
|
||||
where: { apiaryId_day: { apiaryId, day: date } },
|
||||
create: { apiaryId, day: date, meanTempC, source },
|
||||
update: { meanTempC, source, fetchedAt: new Date() },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -105,6 +105,77 @@ describe('WeatherService', () => {
|
||||
expect(url.toString()).toContain('longitude=13.4');
|
||||
});
|
||||
|
||||
it('takes the forecast URL and API key from weatherHistoryConfig', async () => {
|
||||
const saved = {
|
||||
url: process.env.OPEN_METEO_FORECAST_URL,
|
||||
key: process.env.OPEN_METEO_API_KEY,
|
||||
};
|
||||
process.env.OPEN_METEO_FORECAST_URL = 'https://weather.example/forecast';
|
||||
process.env.OPEN_METEO_API_KEY = 'configured-key';
|
||||
try {
|
||||
const configured = new WeatherService(
|
||||
prisma as unknown as PrismaService,
|
||||
);
|
||||
prisma.apiary.findUnique.mockResolvedValue({
|
||||
latitude: 52.5,
|
||||
longitude: 13.4,
|
||||
});
|
||||
fetchMock.mockResolvedValue(
|
||||
new Response(JSON.stringify(openMeteoResponse([])), { status: 200 }),
|
||||
);
|
||||
|
||||
await configured.getWeather('apiary-1');
|
||||
|
||||
const [url] = fetchMock.mock.calls[0] as [URL];
|
||||
expect(url.origin).toBe('https://weather.example');
|
||||
expect(url.searchParams.get('apikey')).toBe('configured-key');
|
||||
} finally {
|
||||
for (const [name, value] of [
|
||||
['OPEN_METEO_FORECAST_URL', saved.url],
|
||||
['OPEN_METEO_API_KEY', saved.key],
|
||||
] as const) {
|
||||
if (value === undefined) delete process.env[name];
|
||||
else process.env[name] = value;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('rounds the coordinates to 0.01 degrees and sends a User-Agent', async () => {
|
||||
prisma.apiary.findUnique.mockResolvedValue({
|
||||
latitude: 52.5234,
|
||||
longitude: 13.4049,
|
||||
});
|
||||
fetchMock.mockResolvedValue(
|
||||
new Response(JSON.stringify(openMeteoResponse([])), { status: 200 }),
|
||||
);
|
||||
|
||||
await weatherService.getWeather('apiary-1');
|
||||
|
||||
const [url, init] = fetchMock.mock.calls[0] as [
|
||||
URL,
|
||||
{ headers: Record<string, string> },
|
||||
];
|
||||
expect(url.searchParams.get('latitude')).toBe('52.52');
|
||||
expect(url.searchParams.get('longitude')).toBe('13.4');
|
||||
expect(init.headers['User-Agent']).toMatch(/^ApiCore\/\S+ \(/);
|
||||
});
|
||||
|
||||
it('sends no apikey parameter when none is configured', async () => {
|
||||
prisma.apiary.findUnique.mockResolvedValue({
|
||||
latitude: 52.5,
|
||||
longitude: 13.4,
|
||||
});
|
||||
fetchMock.mockResolvedValue(
|
||||
new Response(JSON.stringify(openMeteoResponse([])), { status: 200 }),
|
||||
);
|
||||
|
||||
await weatherService.getWeather('apiary-1');
|
||||
|
||||
const [url] = fetchMock.mock.calls[0] as [URL];
|
||||
expect(url.origin).toBe('https://api.open-meteo.com');
|
||||
expect(url.searchParams.has('apikey')).toBe(false);
|
||||
});
|
||||
|
||||
it('maps daily weather_code to weatherCode', async () => {
|
||||
prisma.apiary.findUnique.mockResolvedValue({
|
||||
latitude: 52.5,
|
||||
|
||||
@@ -4,8 +4,12 @@ import {
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import {
|
||||
WeatherHistoryConfig,
|
||||
weatherHistoryConfig,
|
||||
roundPoint,
|
||||
} from './history/weather-history.config';
|
||||
|
||||
const OPEN_METEO_FORECAST_URL = 'https://api.open-meteo.com/v1/forecast';
|
||||
const PAST_DAYS = 14;
|
||||
const FORECAST_DAYS = 7;
|
||||
const RECENT_WINDOW_DAYS = 7;
|
||||
@@ -66,6 +70,10 @@ interface OpenMeteoResponse {
|
||||
|
||||
@Injectable()
|
||||
export class WeatherService {
|
||||
// Same variables as the history source (one place for URL and key); a bad
|
||||
// setting stops the process at startup.
|
||||
private readonly config: WeatherHistoryConfig = weatherHistoryConfig();
|
||||
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async getWeather(apiaryId: string): Promise<ApiaryWeather> {
|
||||
@@ -107,9 +115,11 @@ export class WeatherService {
|
||||
latitude: number,
|
||||
longitude: number,
|
||||
): Promise<DailyWeather[]> {
|
||||
const url = new URL(OPEN_METEO_FORECAST_URL);
|
||||
url.searchParams.set('latitude', String(latitude));
|
||||
url.searchParams.set('longitude', String(longitude));
|
||||
const url = new URL(this.config.forecastUrl);
|
||||
// 0.01 degrees: no exact stand location leaves the app.
|
||||
const point = roundPoint(latitude, longitude);
|
||||
url.searchParams.set('latitude', String(point.lat));
|
||||
url.searchParams.set('longitude', String(point.lon));
|
||||
url.searchParams.set(
|
||||
'daily',
|
||||
'temperature_2m_max,temperature_2m_min,precipitation_sum,wind_speed_10m_max,weather_code',
|
||||
@@ -118,11 +128,15 @@ export class WeatherService {
|
||||
url.searchParams.set('past_days', String(PAST_DAYS));
|
||||
url.searchParams.set('forecast_days', String(FORECAST_DAYS));
|
||||
url.searchParams.set('timezone', 'auto');
|
||||
if (this.config.apiKey) {
|
||||
url.searchParams.set('apikey', this.config.apiKey);
|
||||
}
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(url, {
|
||||
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
||||
headers: { 'User-Agent': this.config.userAgent },
|
||||
});
|
||||
} catch {
|
||||
throw new BadRequestException('Wetterdaten konnten nicht geladen werden');
|
||||
|
||||
@@ -4,6 +4,7 @@ import type {
|
||||
DomainEventEnvelope,
|
||||
DomainEventType,
|
||||
} from '../domain-events/domain-event-types';
|
||||
import { honeyBatchInclude } from '../honey-batches/honey-batch-include';
|
||||
import { domainEventHandlerMetadata } from '../domain-events/on-domain-event.decorator';
|
||||
import {
|
||||
DOMAIN_EVENT_WEBHOOKS,
|
||||
@@ -78,6 +79,7 @@ describe('WebhookEventHandlers', () => {
|
||||
HarvestRecorded: 'harvest.created',
|
||||
BatchBottled: 'batch.updated',
|
||||
BottlingCorrected: 'batch.updated',
|
||||
HoneyBatchRecorded: 'batch.created',
|
||||
StockChanged: 'stock.changed',
|
||||
TaskCreated: 'task.created',
|
||||
TaskCompleted: 'task.completed',
|
||||
@@ -232,6 +234,43 @@ describe('WebhookEventHandlers', () => {
|
||||
},
|
||||
);
|
||||
|
||||
it('enqueues batch.created with the honey batch and its relations for HoneyBatchRecorded', async () => {
|
||||
const honeyBatch = { id: 'hb1', batchNumber: 'H-1', extraction: {} };
|
||||
tx.honeyBatch.findUnique.mockResolvedValueOnce(honeyBatch);
|
||||
|
||||
await forward(
|
||||
envelope('HoneyBatchRecorded', 'hb1', { extractionId: 'e1' }),
|
||||
);
|
||||
|
||||
expect(tx.honeyBatch.findUnique).toHaveBeenCalledWith({
|
||||
where: { id: 'hb1' },
|
||||
include: honeyBatchInclude,
|
||||
});
|
||||
expect(webhooks.enqueueDeliveries).toHaveBeenCalledWith(
|
||||
tx,
|
||||
'batch.created',
|
||||
honeyBatch,
|
||||
);
|
||||
});
|
||||
|
||||
it('sends nothing for HoneyBatchRecorded when the batch is gone', async () => {
|
||||
tx.honeyBatch.findUnique.mockResolvedValueOnce(null);
|
||||
|
||||
await forward(
|
||||
envelope('HoneyBatchRecorded', 'hb1', { extractionId: 'e1' }),
|
||||
);
|
||||
|
||||
expect(webhooks.enqueueDeliveries).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('sends no webhook for a corrected honey batch (as before)', async () => {
|
||||
await forward(
|
||||
envelope('HoneyBatchCorrected', 'hb1', { extractionId: 'e1' }),
|
||||
);
|
||||
|
||||
expect(webhooks.enqueueDeliveries).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('enqueues stock.changed with the bottling batch and product ids, without raw material kinds', async () => {
|
||||
await forward(
|
||||
envelope('StockChanged', 'm1', {
|
||||
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
} from '../domain-events/domain-event-types';
|
||||
import { OnDomainEvent } from '../domain-events/on-domain-event.decorator';
|
||||
import { Prisma } from '../../generated/prisma';
|
||||
import { honeyBatchInclude } from '../honey-batches/honey-batch-include';
|
||||
import { WebhookEvent } from './webhook-events';
|
||||
import { WebhooksService } from './webhooks.service';
|
||||
|
||||
@@ -20,6 +21,7 @@ export const DOMAIN_EVENT_WEBHOOKS: Readonly<
|
||||
HarvestRecorded: 'harvest.created',
|
||||
BatchBottled: 'batch.updated',
|
||||
BottlingCorrected: 'batch.updated',
|
||||
HoneyBatchRecorded: 'batch.created',
|
||||
StockChanged: 'stock.changed',
|
||||
TaskCreated: 'task.created',
|
||||
TaskCompleted: 'task.completed',
|
||||
@@ -88,6 +90,13 @@ async function webhookData(
|
||||
return tx.honeyBatch.findUnique({
|
||||
where: { id: event.payload.honeyBatchId },
|
||||
});
|
||||
// The new honey batch with the relations the API shows, as the legacy
|
||||
// path sent it after the create.
|
||||
case 'HoneyBatchRecorded':
|
||||
return tx.honeyBatch.findUnique({
|
||||
...byAggregate,
|
||||
include: honeyBatchInclude,
|
||||
});
|
||||
case 'StockChanged':
|
||||
return stockChange(event.payload);
|
||||
case 'TaskCreated':
|
||||
|
||||
@@ -53,6 +53,7 @@ describe('WithdrawalCheckService', () => {
|
||||
treatment: { findMany: jest.Mock; findUnique: jest.Mock };
|
||||
colony: { findMany: jest.Mock };
|
||||
honeyBatch: { findUnique: jest.Mock };
|
||||
extractionHarvest: { findMany: jest.Mock };
|
||||
bottlingBatch: { findMany: jest.Mock };
|
||||
};
|
||||
let notifications: { recordForPermission: jest.Mock };
|
||||
@@ -69,6 +70,7 @@ describe('WithdrawalCheckService', () => {
|
||||
findMany: jest.fn().mockResolvedValue([{ id: 'c1', code: 'B3' }]),
|
||||
},
|
||||
honeyBatch: { findUnique: jest.fn() },
|
||||
extractionHarvest: { findMany: jest.fn() },
|
||||
bottlingBatch: { findMany: jest.fn().mockResolvedValue([]) },
|
||||
};
|
||||
notifications = { recordForPermission: jest.fn().mockResolvedValue([]) };
|
||||
@@ -269,6 +271,35 @@ describe('WithdrawalCheckService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('conflictsForExtraction', () => {
|
||||
it('checks the harvests of the extraction', async () => {
|
||||
db.extractionHarvest.findMany.mockResolvedValue([
|
||||
{ harvestId: 'h1' },
|
||||
{ harvestId: 'h2' },
|
||||
]);
|
||||
db.harvest.findMany.mockResolvedValue([
|
||||
harvestOn('h1', '2026-08-10', ['c1']),
|
||||
]);
|
||||
db.treatment.findMany.mockResolvedValue([treatment({})]);
|
||||
|
||||
const conflicts = await service.conflictsForExtraction(asDb(), 'e1');
|
||||
|
||||
expect(db.extractionHarvest.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ where: { extractionId: 'e1' } }),
|
||||
);
|
||||
expect(db.harvest.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ where: { id: { in: ['h1', 'h2'] } } }),
|
||||
);
|
||||
expect(conflicts).toEqual([conflict()]);
|
||||
});
|
||||
|
||||
it('answers nothing for an extraction without harvests', async () => {
|
||||
db.extractionHarvest.findMany.mockResolvedValue([]);
|
||||
|
||||
expect(await service.conflictsForExtraction(asDb(), 'e1')).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('warningsFor', () => {
|
||||
it('words the warning with colony code, product and the end day', async () => {
|
||||
const warnings = await service.warningsFor(asDb(), [conflict()]);
|
||||
|
||||
@@ -199,6 +199,22 @@ export class WithdrawalCheckService {
|
||||
);
|
||||
}
|
||||
|
||||
/** Extraction -> harvests. */
|
||||
async conflictsForExtraction(
|
||||
db: DbClient,
|
||||
extractionId: string,
|
||||
): Promise<WithdrawalConflict[]> {
|
||||
const links = await db.extractionHarvest.findMany({
|
||||
where: { extractionId },
|
||||
select: { harvestId: true },
|
||||
orderBy: { harvestId: 'asc' },
|
||||
});
|
||||
return this.conflictsForHarvests(
|
||||
db,
|
||||
links.map((link) => link.harvestId),
|
||||
);
|
||||
}
|
||||
|
||||
async warningsFor(
|
||||
db: DbClient,
|
||||
conflicts: readonly WithdrawalConflict[],
|
||||
|
||||
@@ -50,6 +50,7 @@ describe('WithdrawalEventHandlers', () => {
|
||||
let check: {
|
||||
conflictsForHarvests: jest.Mock;
|
||||
conflictsForHoneyBatch: jest.Mock;
|
||||
conflictsForExtraction: jest.Mock;
|
||||
conflictsForTreatment: jest.Mock;
|
||||
notifyConflictsIn: jest.Mock;
|
||||
};
|
||||
@@ -64,6 +65,7 @@ describe('WithdrawalEventHandlers', () => {
|
||||
check = {
|
||||
conflictsForHarvests: jest.fn().mockResolvedValue([conflict]),
|
||||
conflictsForHoneyBatch: jest.fn().mockResolvedValue([conflict]),
|
||||
conflictsForExtraction: jest.fn().mockResolvedValue([conflict]),
|
||||
conflictsForTreatment: jest.fn().mockResolvedValue([conflict]),
|
||||
notifyConflictsIn: jest.fn().mockResolvedValue(1),
|
||||
};
|
||||
@@ -133,6 +135,37 @@ describe('WithdrawalEventHandlers', () => {
|
||||
);
|
||||
});
|
||||
|
||||
describe('extraction check', () => {
|
||||
it('registers under its id', () => {
|
||||
expect(
|
||||
domainEventHandlerMetadata(
|
||||
Object.getOwnPropertyDescriptor(
|
||||
WithdrawalEventHandlers.prototype,
|
||||
'extractionCheck',
|
||||
)?.value as object,
|
||||
),
|
||||
).toEqual([
|
||||
{
|
||||
types: ['ExtractionRecorded', 'ExtractionCorrected'],
|
||||
handlerId: 'withdrawal.extraction-check',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it.each(['ExtractionRecorded', 'ExtractionCorrected'] as const)(
|
||||
'notifies the conflicts of the extraction on %s',
|
||||
async (type) => {
|
||||
await handlers.extractionCheck(
|
||||
envelope(type, 'e1', { harvestIds: ['h1'] }),
|
||||
asTx(),
|
||||
);
|
||||
|
||||
expect(check.conflictsForExtraction).toHaveBeenCalledWith(tx, 'e1');
|
||||
expect(check.notifyConflictsIn).toHaveBeenCalledWith(tx, [conflict]);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('treatment check', () => {
|
||||
it.each(['TreatmentRecorded', 'TreatmentCorrected'] as const)(
|
||||
'notifies harvests that already lie in the window of the treatment on %s',
|
||||
|
||||
@@ -62,8 +62,24 @@ export class WithdrawalEventHandlers {
|
||||
await this.check.notifyConflictsIn(tx, conflicts);
|
||||
}
|
||||
|
||||
// The extraction (Schleuderung) joins the check with T4b; until then the
|
||||
// bottling is the second stage. A removed bottling has nothing to check.
|
||||
// Second stage: the extraction (Schleuderung). Same key as the harvest, so
|
||||
// a conflict found at the harvest is not notified again here.
|
||||
@OnDomainEvent(
|
||||
['ExtractionRecorded', 'ExtractionCorrected'],
|
||||
'withdrawal.extraction-check',
|
||||
)
|
||||
async extractionCheck(
|
||||
event: AnyDomainEventEnvelope<'ExtractionRecorded' | 'ExtractionCorrected'>,
|
||||
tx: Prisma.TransactionClient,
|
||||
): Promise<void> {
|
||||
const conflicts = await this.check.conflictsForExtraction(
|
||||
tx,
|
||||
event.aggregateId,
|
||||
);
|
||||
await this.check.notifyConflictsIn(tx, conflicts);
|
||||
}
|
||||
|
||||
// Third stage: the bottling. A removed bottling has nothing to check.
|
||||
@OnDomainEvent(
|
||||
['BatchBottled', 'BottlingCorrected'],
|
||||
'withdrawal.bottling-check',
|
||||
|
||||
@@ -6,6 +6,8 @@ export interface PollingLoopOptions {
|
||||
intervalMs: number;
|
||||
tick: () => Promise<unknown>;
|
||||
logger: Logger;
|
||||
/** Wait before the first tick after start() (default: none). */
|
||||
initialDelayMs?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -29,7 +31,7 @@ export class PollingLoop {
|
||||
// schedules the next one, so ticks never overlap across stop()/start().
|
||||
// The first tick after start() runs immediately, also in that case.
|
||||
if (this.running) this.rerunRequested = true;
|
||||
else this.schedule(0);
|
||||
else this.schedule(this.options.initialDelayMs ?? 0);
|
||||
}
|
||||
|
||||
wake(): void {
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { WeatherHistoryModule } from '../weather/history/weather-history.module';
|
||||
import { WorkerRuntime } from './worker-runtime.service';
|
||||
|
||||
/**
|
||||
* The worker loops. Imported by WorkerModule (dist/worker.js) and
|
||||
* AllInOneModule ("all"), never by AppModule: API pods run no background
|
||||
* loops. Its dependencies come from the global Prisma, DomainEvents,
|
||||
* CalendarSync and Webhooks modules of the importing root module.
|
||||
* CalendarSync and Webhooks modules of the importing root module; the weather
|
||||
* source comes with WeatherHistoryModule.
|
||||
*/
|
||||
@Module({
|
||||
imports: [WeatherHistoryModule],
|
||||
providers: [WorkerRuntime],
|
||||
exports: [WorkerRuntime],
|
||||
})
|
||||
|
||||
@@ -7,6 +7,11 @@ import {
|
||||
DRAIN_MAX_ROUNDS,
|
||||
} from '../domain-events/domain-event-dispatcher';
|
||||
import type { PrismaService } from '../prisma/prisma.service';
|
||||
import type { WeatherHistoryService } from '../weather/history/weather-history.service';
|
||||
import {
|
||||
WEATHER_FIRST_TICK_DELAY_MS,
|
||||
WEATHER_HISTORY_POLL_MS,
|
||||
} from '../weather/history/weather-history.config';
|
||||
import type { WebhookDeliveryWorker } from '../webhooks/webhook-delivery.worker';
|
||||
import { PgListener, PgListenerOptions } from './pg-listener';
|
||||
import { HEALTH_STALE_MS } from './worker-health';
|
||||
@@ -64,6 +69,7 @@ describe('WorkerRuntime', () => {
|
||||
processOutbox: jest.Mock<Promise<void>, [AbortSignal?]>;
|
||||
};
|
||||
let webhookDeliveries: FakeWebhookDeliveries;
|
||||
let weatherHistory: { syncOnce: jest.Mock };
|
||||
let runtime: WorkerRuntime;
|
||||
let logError: jest.SpyInstance;
|
||||
|
||||
@@ -93,6 +99,11 @@ describe('WorkerRuntime', () => {
|
||||
.mockResolvedValue(undefined),
|
||||
};
|
||||
webhookDeliveries = new FakeWebhookDeliveries();
|
||||
weatherHistory = {
|
||||
syncOnce: jest
|
||||
.fn()
|
||||
.mockResolvedValue({ points: 0, daysWritten: 0, failedPoints: 0 }),
|
||||
};
|
||||
logError = jest
|
||||
.spyOn(Logger.prototype, 'error')
|
||||
.mockImplementation(() => undefined);
|
||||
@@ -102,6 +113,7 @@ describe('WorkerRuntime', () => {
|
||||
dispatcher as unknown as DomainEventDispatcher,
|
||||
calendarSync as unknown as CalendarSyncService,
|
||||
webhookDeliveries as unknown as WebhookDeliveryWorker,
|
||||
weatherHistory as unknown as WeatherHistoryService,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -122,6 +134,7 @@ describe('WorkerRuntime', () => {
|
||||
expect(CALENDAR_OUTBOX_POLL_MS).toBe(60_000);
|
||||
expect(WEBHOOK_DELIVERY_POLL_MS).toBe(5_000);
|
||||
expect(HEALTH_QUERY_TIMEOUT_MS).toBe(5_000);
|
||||
expect(WEATHER_HISTORY_POLL_MS).toBe(3_600_000);
|
||||
});
|
||||
|
||||
it('starts the listener and every loop at bootstrap', async () => {
|
||||
@@ -381,4 +394,37 @@ describe('WorkerRuntime', () => {
|
||||
await shutdown;
|
||||
expect(stopped).toBe(true);
|
||||
});
|
||||
|
||||
it('runs the loop without a wake channel and stops on shutdown', async () => {
|
||||
await bootstrap();
|
||||
|
||||
// The phenology-weather loop has no channel: nothing to LISTEN to.
|
||||
expect(listenerOptions().channels).toEqual([
|
||||
'domain_event',
|
||||
'calendar_sync',
|
||||
'webhook_delivery',
|
||||
]);
|
||||
// The first pass waits 10 s: not in the startup burst of the other loops.
|
||||
expect(weatherHistory.syncOnce).not.toHaveBeenCalled();
|
||||
await jest.advanceTimersByTimeAsync(WEATHER_FIRST_TICK_DELAY_MS - 1);
|
||||
expect(weatherHistory.syncOnce).not.toHaveBeenCalled();
|
||||
await jest.advanceTimersByTimeAsync(1);
|
||||
expect(weatherHistory.syncOnce).toHaveBeenCalledTimes(1);
|
||||
const [today, signal] = weatherHistory.syncOnce.mock.calls[0] as [
|
||||
string,
|
||||
AbortSignal,
|
||||
];
|
||||
expect(today).toMatch(/^\d{4}-\d{2}-\d{2}$/);
|
||||
expect(signal.aborted).toBe(false);
|
||||
|
||||
await jest.advanceTimersByTimeAsync(WEATHER_HISTORY_POLL_MS - 1);
|
||||
expect(weatherHistory.syncOnce).toHaveBeenCalledTimes(1);
|
||||
await jest.advanceTimersByTimeAsync(1);
|
||||
expect(weatherHistory.syncOnce).toHaveBeenCalledTimes(2);
|
||||
|
||||
await runtime.onModuleDestroy();
|
||||
expect(signal.aborted).toBe(true);
|
||||
await jest.advanceTimersByTimeAsync(WEATHER_HISTORY_POLL_MS * 2);
|
||||
expect(weatherHistory.syncOnce).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
OnModuleDestroy,
|
||||
} from '@nestjs/common';
|
||||
import { CalendarSyncService } from '../calendar-sync/calendar-sync.service';
|
||||
import { berlinToday } from '../common/dates';
|
||||
import { loggableError } from '../common/loggable-error';
|
||||
import { withTimeout } from '../common/with-timeout';
|
||||
import { WAKE_CHANNELS, WakeChannel } from '../common/worker-wakeup';
|
||||
@@ -13,6 +14,11 @@ import {
|
||||
DRAIN_MAX_ROUNDS,
|
||||
} from '../domain-events/domain-event-dispatcher';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import {
|
||||
WEATHER_FIRST_TICK_DELAY_MS,
|
||||
WEATHER_HISTORY_POLL_MS,
|
||||
} from '../weather/history/weather-history.config';
|
||||
import { WeatherHistoryService } from '../weather/history/weather-history.service';
|
||||
import { WebhookDeliveryWorker } from '../webhooks/webhook-delivery.worker';
|
||||
import { PgListener } from './pg-listener';
|
||||
import { PollingLoop } from './polling-loop';
|
||||
@@ -24,11 +30,16 @@ export const WEBHOOK_DELIVERY_POLL_MS = 5_000;
|
||||
/** The health check's SELECT 1 must answer within this. */
|
||||
export const HEALTH_QUERY_TIMEOUT_MS = 5_000;
|
||||
|
||||
/** One background loop: its poll interval and the channel that wakes it. */
|
||||
/**
|
||||
* One background loop: its poll interval and, if an outbox feeds it, the
|
||||
* channel that wakes it early. Loops without a channel only poll.
|
||||
*/
|
||||
interface WorkerLoop {
|
||||
name: string;
|
||||
intervalMs: number;
|
||||
channel: WakeChannel;
|
||||
channel?: WakeChannel;
|
||||
/** Wait before the first tick after the worker started. */
|
||||
initialDelayMs?: number;
|
||||
tick: () => Promise<unknown>;
|
||||
}
|
||||
|
||||
@@ -50,7 +61,8 @@ function databaseUrl(): string {
|
||||
@Injectable()
|
||||
export class WorkerRuntime implements OnApplicationBootstrap, OnModuleDestroy {
|
||||
private readonly logger = new Logger(WorkerRuntime.name);
|
||||
private readonly loops: ReadonlyMap<WakeChannel, PollingLoop>;
|
||||
private readonly loops: ReadonlyMap<string, PollingLoop>;
|
||||
private readonly loopsByChannel: ReadonlyMap<WakeChannel, PollingLoop>;
|
||||
private listener: PgListener | undefined;
|
||||
/** Aborted at shutdown: running ticks claim no further work. */
|
||||
private readonly stopping = new AbortController();
|
||||
@@ -64,12 +76,25 @@ export class WorkerRuntime implements OnApplicationBootstrap, OnModuleDestroy {
|
||||
private readonly dispatcher: DomainEventDispatcher,
|
||||
private readonly calendarSync: CalendarSyncService,
|
||||
private readonly webhookDeliveries: WebhookDeliveryWorker,
|
||||
private readonly weatherHistory: WeatherHistoryService,
|
||||
) {
|
||||
const loops = this.workerLoops().map((definition) => ({
|
||||
definition,
|
||||
loop: new PollingLoop({
|
||||
name: definition.name,
|
||||
intervalMs: definition.intervalMs,
|
||||
tick: definition.tick,
|
||||
initialDelayMs: definition.initialDelayMs,
|
||||
logger: this.logger,
|
||||
}),
|
||||
}));
|
||||
this.loops = new Map(
|
||||
this.workerLoops().map(({ channel, name, intervalMs, tick }) => [
|
||||
channel,
|
||||
new PollingLoop({ name, intervalMs, tick, logger: this.logger }),
|
||||
]),
|
||||
loops.map(({ definition, loop }) => [definition.name, loop]),
|
||||
);
|
||||
this.loopsByChannel = new Map(
|
||||
loops.flatMap(({ definition, loop }) =>
|
||||
definition.channel ? [[definition.channel, loop] as const] : [],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -94,6 +119,15 @@ export class WorkerRuntime implements OnApplicationBootstrap, OnModuleDestroy {
|
||||
channel: WAKE_CHANNELS.webhookDeliveries,
|
||||
tick: () => this.deliverWebhooks(),
|
||||
},
|
||||
{
|
||||
// Daily mean temperatures for the phenology; hourly, no outbox.
|
||||
name: 'phenology-weather',
|
||||
intervalMs: WEATHER_HISTORY_POLL_MS,
|
||||
// Not in the startup burst of the other loops.
|
||||
initialDelayMs: WEATHER_FIRST_TICK_DELAY_MS,
|
||||
tick: () =>
|
||||
this.weatherHistory.syncOnce(berlinToday(), this.stopping.signal),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -101,8 +135,8 @@ export class WorkerRuntime implements OnApplicationBootstrap, OnModuleDestroy {
|
||||
this.progressAt = performance.now();
|
||||
this.listener = new PgListener({
|
||||
connectionString: databaseUrl(),
|
||||
channels: [...this.loops.keys()],
|
||||
onNotify: (channel) => this.loops.get(channel)?.wake(),
|
||||
channels: [...this.loopsByChannel.keys()],
|
||||
onNotify: (channel) => this.loopsByChannel.get(channel)?.wake(),
|
||||
logger: this.logger,
|
||||
});
|
||||
// LISTEN first: work committed from now on wakes a loop, and work
|
||||
|
||||
@@ -8,6 +8,7 @@ import { CalendarSyncModule } from '../calendar-sync/calendar-sync.module';
|
||||
import { DomainEventsModule } from '../domain-events/domain-events.module';
|
||||
import { NotificationsModule } from '../notifications/notifications.module';
|
||||
import { PrismaModule } from '../prisma/prisma.module';
|
||||
import { PhenologyModule } from '../phenology/phenology.module';
|
||||
import { RemindersModule } from '../reminders/reminders.module';
|
||||
import { SuggestionsModule } from '../suggestions/suggestions.module';
|
||||
import { TasksModule } from '../tasks/tasks.module';
|
||||
@@ -37,6 +38,7 @@ import { WorkerRuntimeModule } from './worker-runtime.module';
|
||||
AutomationModule,
|
||||
SuggestionsModule,
|
||||
AutopilotModule,
|
||||
PhenologyModule,
|
||||
WithdrawalModule,
|
||||
RemindersModule,
|
||||
WorkerRuntimeModule,
|
||||
|
||||
@@ -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