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:
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.
|
||||
|
||||
Reference in new issue
Block a user