feat: T4 wave 10 – bottling quality, phenology and honey chain UI, Telegram link/receive and ops

- Bottling quality: water content above the legal limit (strict >) answers
  409 CONFIRMATION_REQUIRED bound to the measured value; prefill for jar
  size, best-before (shelfLifeMonths, month-end clamping) and water content;
  impossible dates give 400; water-content checks in src/common/quality.
- Frontend phenology: "Entwicklung melden" sheet, IndexedDB v4 queue,
  season header on the apiary page, confirmed revoke for all apiaries.
- Frontend honey chain: "Vorschlag aus …" hints with explicit take-over,
  prefill status note, movement ledger with withdraw/correct dialogs.
- Telegram: link tokens (hashed, single use), private-chat receiver with
  lease and offset CAS, bounded poison-update skip, Retry-After, pending
  deliveries skipped on disconnect; system API for deliveries and retry;
  worker-only secret wiring and manifest policy tests.
- ARCHITECTURE.md §7, §9, §10, §11.11–11.13, Annahmen 51–55.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-14 22:47:17 +02:00
1 parent 16cc557462
commit fab7f7e12f
91 files changed
+8463 -104

No files matched your search

@@ -12,6 +12,10 @@ import {
TELEGRAM_SEND_POLL_MS,
type TelegramSender,
} from '../telegram/telegram-sender';
import {
NO_LEASE_WAIT_MS,
type TelegramUpdatesService,
} from '../telegram/telegram-updates.service';
import type { WeatherHistoryService } from '../weather/history/weather-history.service';
import {
WEATHER_FIRST_TICK_DELAY_MS,
@@ -69,6 +73,12 @@ class FakeTelegramSender {
drain = jest.fn<Promise<number>, unknown[]>().mockResolvedValue(0);
}
class FakeTelegramUpdates {
pollOnce = jest
.fn<Promise<string>, [AbortSignal]>()
.mockResolvedValue('no-lease');
}
describe('WorkerRuntime', () => {
const ListenerMock = PgListener as unknown as jest.Mock;
const originalDatabaseUrl = process.env.DATABASE_URL;
@@ -82,6 +92,7 @@ describe('WorkerRuntime', () => {
let weatherHistory: { syncOnce: jest.Mock };
let telegramConfigured: boolean;
let telegramSender: FakeTelegramSender;
let telegramUpdates: FakeTelegramUpdates;
let runtime: WorkerRuntime;
let logError: jest.SpyInstance;
@@ -122,6 +133,7 @@ describe('WorkerRuntime', () => {
jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
telegramConfigured = false;
telegramSender = new FakeTelegramSender();
telegramUpdates = new FakeTelegramUpdates();
runtime = createRuntime();
});
@@ -134,6 +146,7 @@ describe('WorkerRuntime', () => {
weatherHistory as unknown as WeatherHistoryService,
{ isConfigured: () => telegramConfigured } as unknown as TelegramClient,
telegramSender as unknown as TelegramSender,
telegramUpdates as unknown as TelegramUpdatesService,
);
}
@@ -269,6 +282,85 @@ describe('WorkerRuntime', () => {
});
});
describe('telegram-updates loop', () => {
it('is not registered without a bot token', async () => {
await bootstrap();
await jest.advanceTimersByTimeAsync(NO_LEASE_WAIT_MS * 2);
expect(telegramUpdates.pollOnce).not.toHaveBeenCalled();
});
describe('with a bot token', () => {
beforeEach(async () => {
await runtime.onModuleDestroy();
telegramConfigured = true;
runtime = createRuntime();
});
it('polls with the stop signal and has no wake channel', async () => {
await bootstrap();
expect(telegramUpdates.pollOnce).toHaveBeenCalledTimes(1);
expect(telegramUpdates.pollOnce).toHaveBeenCalledWith(
expect.any(AbortSignal),
);
expect(listenerOptions().channels).toEqual([
'domain_event',
'calendar_sync',
'webhook_delivery',
'notification_delivery',
]);
});
it('waits 30 s after no-lease, conflict and error', async () => {
for (const outcome of ['no-lease', 'conflict', 'error']) {
telegramUpdates.pollOnce.mockClear().mockResolvedValue(outcome);
await runtime.onModuleDestroy();
runtime = createRuntime();
await bootstrap();
expect(telegramUpdates.pollOnce).toHaveBeenCalledTimes(1);
await jest.advanceTimersByTimeAsync(NO_LEASE_WAIT_MS - 1);
expect(telegramUpdates.pollOnce).toHaveBeenCalledTimes(1);
await jest.advanceTimersByTimeAsync(1);
expect(telegramUpdates.pollOnce).toHaveBeenCalledTimes(2);
}
});
it('polls again at once after a successful poll', async () => {
telegramUpdates.pollOnce
.mockResolvedValueOnce('polled')
.mockResolvedValueOnce('polled')
.mockResolvedValue('no-lease');
await bootstrap();
await jest.advanceTimersByTimeAsync(1);
await jest.advanceTimersByTimeAsync(1);
expect(telegramUpdates.pollOnce).toHaveBeenCalledTimes(3);
await jest.advanceTimersByTimeAsync(NO_LEASE_WAIT_MS - 1);
expect(telegramUpdates.pollOnce).toHaveBeenCalledTimes(3);
});
it('a failing poll does not make the worker unhealthy', async () => {
telegramUpdates.pollOnce.mockRejectedValue(new Error('boom'));
await bootstrap();
await expect(runtime.health()).resolves.toEqual({ ok: true });
expect(logError).toHaveBeenCalled();
});
it('aborts the running poll at shutdown', async () => {
await bootstrap();
const [signal] = telegramUpdates.pollOnce.mock.calls[0];
await runtime.onModuleDestroy();
expect(signal.aborted).toBe(true);
});
});
});
it('polls webhook deliveries every 5 s', async () => {
await bootstrap();
expect(webhookDeliveries.drain).toHaveBeenCalledTimes(1);
@@ -19,6 +19,10 @@ import {
TELEGRAM_SEND_POLL_MS,
TelegramSender,
} from '../telegram/telegram-sender';
import {
NO_LEASE_WAIT_MS,
TelegramUpdatesService,
} from '../telegram/telegram-updates.service';
import {
WEATHER_FIRST_TICK_DELAY_MS,
WEATHER_HISTORY_POLL_MS,
@@ -32,6 +36,7 @@ import { HEALTH_STALE_MS, WorkerHealth } from './worker-health';
export const DOMAIN_EVENT_POLL_MS = 5_000;
export const CALENDAR_OUTBOX_POLL_MS = 60_000;
export const WEBHOOK_DELIVERY_POLL_MS = 5_000;
const TELEGRAM_UPDATES_LOOP = 'telegram-updates';
/** The health check's SELECT 1 must answer within this. */
export const HEALTH_QUERY_TIMEOUT_MS = 5_000;
@@ -84,6 +89,7 @@ export class WorkerRuntime implements OnApplicationBootstrap, OnModuleDestroy {
private readonly weatherHistory: WeatherHistoryService,
private readonly telegramClient: TelegramClient,
private readonly telegramSender: TelegramSender,
private readonly telegramUpdates: TelegramUpdatesService,
) {
const loops = this.workerLoops().map((definition) => ({
definition,
@@ -135,6 +141,14 @@ export class WorkerRuntime implements OnApplicationBootstrap, OnModuleDestroy {
channel: WAKE_CHANNELS.notificationDeliveries,
tick: () => this.sendTelegram(),
},
{
// Long polling getUpdates; no wake channel. After a poll the
// next one starts at once, otherwise the interval is the wait
// (no lease, 409, error).
name: TELEGRAM_UPDATES_LOOP,
intervalMs: NO_LEASE_WAIT_MS,
tick: () => this.receiveTelegram(),
},
]
: []),
{
@@ -239,6 +253,13 @@ export class WorkerRuntime implements OnApplicationBootstrap, OnModuleDestroy {
await this.telegramSender.drain(this.stopping.signal);
}
private async receiveTelegram(): Promise<void> {
const outcome = await this.telegramUpdates.pollOnce(this.stopping.signal);
if (outcome === 'polled') {
this.loops.get(TELEGRAM_UPDATES_LOOP)?.wake();
}
}
private async deliverWebhooks(): Promise<void> {
await this.webhookDeliveries.releaseStaleClaims();
// No filter: every due delivery of the database.