feat: T4b/T4c wave 9 - phenology reports, autopilot A4-A10, honey ledger, Telegram delivery
Backend Image / build-and-push (push) Successful in 4m26s
Frontend Image / build-and-push (push) Successful in 7m19s
Manifests / validate (push) Successful in 17s

- Phenology reports API with corrections and revokes; recompute per
  apiary via PhenologyRecomputeRequested, allowBackward for corrections;
  season changes are announced in the morning digest (changes since the
  previous digest, each once)
- Season autopilot A4-A10 with per-apiary sync (Apiary before Colony
  locks) and WinterCheckRecorded
- Honey batch ledger at runtime: initial amount, BOTTLING bookings,
  manual movements, one gram rounding, untracked bottlings never book
- Telegram delivery: planning, Berlin delivery times incl. DST, quiet
  hours, permission re-check and stale skip at send time, rate limits,
  cleanup of old deliveries
- ARCHITECTURE.md: assumptions 47-50 and updated sections 11.x

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 21:13:23 +02:00
1 parent 1971b2d9c4
commit 16cc557462
74 files changed
+9634 -340

No files matched your search

+492
View File
@@ -0,0 +1,492 @@
import { INestApplication } from '@nestjs/common';
import * as bcrypt from 'bcryptjs';
import { App } from 'supertest/types';
import type { NotificationCategory } from '../generated/prisma';
import { berlinDateTime, berlinToday, dayToDate } from '../src/common/dates';
import { DomainEventsService } from '../src/domain-events/domain-events.service';
import { PermissionResolverService } from '../src/auth/permissions/permission-resolver.service';
import {
DeliveryPreferences,
isInQuietHours,
} from '../src/notifications/delivery-time';
import { NotificationsService } from '../src/notifications/notifications.service';
import { PrismaService } from '../src/prisma/prisma.service';
import { TelegramSender } from '../src/telegram/telegram-sender';
import { drainDomainEvents } from './utils/domain-events';
import { createTelegramTestApp, FakeTelegramClient } from './utils/telegram';
const HOUR_MS = 3_600_000;
const CLOCK_TOLERANCE_MS = 60_000;
const ALL_CATEGORIES: NotificationCategory[] = [
'TASK_DUE',
'DIGEST',
'SUGGESTION',
'SYSTEM',
];
const NEVER_ABORTED = new AbortController().signal;
describe('Telegram delivery (e2e)', () => {
let app: INestApplication<App>;
let prisma: PrismaService;
let telegram: FakeTelegramClient;
let sender: TelegramSender;
const suffix = `${Date.now()}-${Math.round(Math.random() * 1e6)}`;
const chatBase = 8_000_000_000n + BigInt(Math.round(Math.random() * 1e6));
let chatCounter = 0n;
const roleIds: string[] = [];
const userIds: string[] = [];
const taskIds: string[] = [];
interface TestUser {
id: string;
chatId: bigint;
}
async function createUser(
label: string,
permissions: string[],
preference: {
telegramCategories?: NotificationCategory[];
quietFrom?: string | null;
quietUntil?: string | null;
digestTime?: string;
telegramDetail?: boolean;
} = {},
linked = true,
): Promise<TestUser & { roleId: string }> {
const role = await prisma.role.create({
data: {
key: `TELEGRAM_${label.toUpperCase()}_${suffix}`,
name: `Telegram ${label}`,
permissions: {
create: permissions.map((key) => ({
permission: { connect: { key } },
})),
},
},
});
roleIds.push(role.id);
const user = await prisma.user.create({
data: {
email: `telegram-e2e-${label}-${suffix}@example.com`,
passwordHash: await bcrypt.hash('SuperSecret123!', 4),
name: `Telegram ${label}`,
role: { connect: { id: role.id } },
},
});
userIds.push(user.id);
const chatId = chatBase + ++chatCounter;
await prisma.notificationPreference.create({
data: {
userId: user.id,
telegramEnabled: true,
telegramCategories: ALL_CATEGORIES,
...preference,
},
});
if (linked) {
await prisma.telegramLink.create({
data: { userId: user.id, chatId, telegramFirstName: label },
});
}
return { id: user.id, chatId, roleId: role.id };
}
/** A notification with its pending delivery, as the planner leaves them. */
async function queue(
userId: string,
options: {
category?: NotificationCategory;
message?: string;
requiredPermissions?: string[];
createdAgoMs?: number;
relatedType?: string;
relatedId?: string;
link?: string;
} = {},
): Promise<{ notificationId: string; deliveryId: string }> {
const notification = await prisma.notification.create({
data: {
userId,
priority: 'INFO',
category: options.category ?? 'SYSTEM',
message: options.message ?? 'Hallo vom Test',
requiredPermissions: options.requiredPermissions ?? [],
relatedType: options.relatedType ?? null,
relatedId: options.relatedId ?? null,
link: options.link ?? '/dashboard',
createdAt: new Date(Date.now() - (options.createdAgoMs ?? 0)),
},
});
const delivery = await prisma.notificationDelivery.create({
data: {
channel: 'TELEGRAM',
userId,
notificationId: notification.id,
availableAt: new Date(Date.now() - 1_000),
},
});
return { notificationId: notification.id, deliveryId: delivery.id };
}
const drainFor = (...ids: string[]) =>
sender.drain(NEVER_ABORTED, 5, { userIds: ids });
const deliveryOf = (id: string) =>
prisma.notificationDelivery.findUniqueOrThrow({ where: { id } });
beforeAll(async () => {
({ app, telegram } = await createTelegramTestApp());
prisma = app.get(PrismaService);
sender = app.get(TelegramSender);
});
afterAll(async () => {
await prisma.notificationDelivery.deleteMany({
where: { userId: { in: userIds } },
});
await prisma.user.deleteMany({ where: { id: { in: userIds } } });
await prisma.task.deleteMany({ where: { id: { in: taskIds } } });
await prisma.role.deleteMany({ where: { id: { in: roleIds } } });
await app.close();
});
describe('planning', () => {
it('a due task yields one delivery after the morning time and none inside quiet hours', async () => {
const today = berlinToday();
const morning: DeliveryPreferences = {
digestTime: '12:00',
quietFrom: null,
quietUntil: null,
};
const night: DeliveryPreferences = {
digestTime: '06:30',
quietFrom: '00:00',
quietUntil: '23:59',
};
const early = await createUser('plan-early', ['tasks:read'], morning);
const quiet = await createUser('plan-quiet', ['tasks:read'], night);
const off = await createUser('plan-off', ['tasks:read'], {
telegramCategories: ['DIGEST'],
});
const task = await prisma.task.create({
data: {
title: `Telegram e2e ${suffix}`,
dueDate: dayToDate(today),
},
});
taskIds.push(task.id);
// Without an assignee every active user with tasks:complete gets it;
// assign it to each user in turn to keep the test to its own users.
const before = new Date();
for (const user of [early, quiet, off]) {
await prisma.task.update({
where: { id: task.id },
data: { assignedUserId: user.id },
});
await prisma.$transaction((tx) =>
app.get(DomainEventsService).record(tx, {
type: 'TaskDue',
aggregateType: 'Task',
aggregateId: task.id,
payload: { dueOn: today },
dedupeKey: `TaskDue:${task.id}:${today}:${user.id}`,
}),
);
await drainDomainEvents(app, [task.id]);
}
const forUser = (userId: string) =>
prisma.notificationDelivery.findMany({
where: { userId },
include: { notification: true },
});
const [earlyRow, ...earlyRest] = await forUser(early.id);
const [quietRow, ...quietRest] = await forUser(quiet.id);
expect(earlyRow.notification?.category).toBe('TASK_DUE');
expect(earlyRest).toHaveLength(0);
expect(earlyRow.channel).toBe('TELEGRAM');
expect(earlyRow.status).toBe('PENDING');
// Not before one minute after the morning time of that day; when that
// time has passed already, right away.
const morningAt = berlinDateTime(today, '12:01');
if (before < morningAt) {
expect(earlyRow.availableAt).toEqual(morningAt);
} else {
expect(earlyRow.availableAt.getTime()).toBeGreaterThanOrEqual(
before.getTime() - CLOCK_TOLERANCE_MS,
);
}
expect(isInQuietHours(earlyRow.availableAt, morning)).toBe(false);
// Quiet hours all day: the delivery waits for their end.
expect(quietRest).toHaveLength(0);
expect(isInQuietHours(quietRow.availableAt, night)).toBe(false);
expect(quietRow.availableAt.getTime()).toBeGreaterThanOrEqual(
before.getTime() - CLOCK_TOLERANCE_MS,
);
// The user who did not choose TASK_DUE has the notification, no delivery.
expect(await forUser(off.id)).toHaveLength(0);
expect(
await prisma.notification.count({
where: { userId: off.id, category: 'TASK_DUE' },
}),
).toBe(1);
});
it('a user without a link gets no delivery', async () => {
const user = await createUser('plan-unlinked', [], {}, false);
await app.get(NotificationsService).record({
userId: user.id,
priority: 'INFO',
category: 'SYSTEM',
message: 'm',
});
expect(
await prisma.notificationDelivery.count({ where: { userId: user.id } }),
).toBe(0);
});
it('queues the SYSTEM message of a user who chose that category, once, even if the key repeats', async () => {
const user = await createUser('plan-system', [], {
telegramCategories: ['SYSTEM'],
quietFrom: null,
quietUntil: null,
});
const input = {
userId: user.id,
priority: 'INFO' as const,
category: 'SYSTEM' as const,
message: 'm',
dedupeKey: `telegram-e2e-system:${suffix}`,
};
await app.get(NotificationsService).record(input);
await app.get(NotificationsService).record(input);
expect(
await prisma.notificationDelivery.count({ where: { userId: user.id } }),
).toBe(1);
});
});
describe('sending', () => {
it('two send loops in parallel deliver each message exactly once', async () => {
const users = await Promise.all(
['par-a', 'par-b', 'par-c', 'par-d', 'par-e'].map((label) =>
createUser(label, []),
),
);
const queued = await Promise.all(
users.map((user) =>
queue(user.id, { message: `Nachricht ${user.id}` }),
),
);
const second = new TelegramSender(
prisma,
telegram as never,
app.get(PermissionResolverService),
app.get(NotificationsService),
);
const ids = users.map((user) => user.id);
const claimed = await Promise.all([
sender.drain(NEVER_ABORTED, 5, { userIds: ids }),
second.drain(NEVER_ABORTED, 5, { userIds: ids }),
]);
expect(claimed[0] + claimed[1]).toBe(users.length);
for (const user of users) {
expect(telegram.sentTo(user.chatId)).toHaveLength(1);
}
for (const { deliveryId } of queued) {
const row = await deliveryOf(deliveryId);
expect(row.status).toBe('DELIVERED');
expect(row.deliveredAt).not.toBeNull();
}
});
it('sends plain text with the app link', async () => {
const user = await createUser('text', [], {});
const { deliveryId } = await queue(user.id, {
message: 'Heute fällig: Futter',
link: '/tasks',
});
await drainFor(user.id);
const [message] = telegram.sentTo(user.chatId);
expect(message.text).toMatch(
/^Heute fällig: Futter\n\nhttps?:\/\/.+\/tasks$/,
);
expect((await deliveryOf(deliveryId)).status).toBe('DELIVERED');
});
it('a revoked permission skips the delivery at send time', async () => {
const user = await createUser('revoked', ['tasks:read']);
const { deliveryId } = await queue(user.id, {
requiredPermissions: ['tasks:read'],
});
// The resolver in this process has the right cached ...
await app.get(PermissionResolverService).getPermissions(user.id);
// ... and loses it without any invalidateUser call.
await prisma.rolePermission.deleteMany({
where: { roleId: user.roleId },
});
await drainFor(user.id);
expect(telegram.sentTo(user.chatId)).toHaveLength(0);
const row = await deliveryOf(deliveryId);
expect(row.status).toBe('SKIPPED');
expect(row.lastError).toBe('permission revoked');
});
it('skips what went stale: a DIGEST of yesterday, a SUGGESTION older than 24 h', async () => {
const user = await createUser('stale', [], {
telegramCategories: ['DIGEST', 'SUGGESTION', 'SYSTEM'],
});
const digest = await queue(user.id, {
category: 'DIGEST',
createdAgoMs: 36 * HOUR_MS,
});
const old = await queue(user.id, {
category: 'SUGGESTION',
createdAgoMs: 25 * HOUR_MS,
});
await drainFor(user.id);
const fresh = await queue(user.id, {
category: 'SUGGESTION',
createdAgoMs: 23 * HOUR_MS,
});
await drainFor(user.id);
expect((await deliveryOf(digest.deliveryId)).lastError).toBe('stale');
expect((await deliveryOf(old.deliveryId)).status).toBe('SKIPPED');
expect((await deliveryOf(fresh.deliveryId)).status).toBe('DELIVERED');
expect(telegram.sentTo(user.chatId)).toHaveLength(1);
});
it('skips a due-task reminder once the task is done', async () => {
const user = await createUser('taskdone', ['tasks:read'], {
telegramCategories: ['TASK_DUE'],
});
const task = await prisma.task.create({
data: { title: `Done ${suffix}`, status: 'DONE' },
});
taskIds.push(task.id);
const { deliveryId } = await queue(user.id, {
category: 'TASK_DUE',
relatedType: 'Task',
relatedId: task.id,
requiredPermissions: ['tasks:read'],
});
await drainFor(user.id);
expect(telegram.sentTo(user.chatId)).toHaveLength(0);
expect((await deliveryOf(deliveryId)).lastError).toBe(
'task already done',
);
});
it('skips when Telegram was switched off, the link is gone or the category is off', async () => {
const off = await createUser('sw-off', [], {});
await prisma.notificationPreference.update({
where: { userId: off.id },
data: { telegramEnabled: false },
});
const unlinked = await createUser('sw-unlinked', [], {}, false);
const category = await createUser('sw-category', [], {
telegramCategories: ['DIGEST'],
});
const rows = await Promise.all([
queue(off.id),
queue(unlinked.id),
queue(category.id),
]);
await drainFor(off.id, unlinked.id, category.id);
const reasons = await Promise.all(
rows.map(async (row) => (await deliveryOf(row.deliveryId)).lastError),
);
expect(reasons).toEqual([
'telegram disabled',
'not linked',
'category off',
]);
});
it('403 removes the link, switches Telegram off and notifies the user in the app', async () => {
const user = await createUser('blocked', []);
const { deliveryId } = await queue(user.id);
telegram.failWith(
403,
'Forbidden: bot was blocked by the user',
user.chatId,
);
await drainFor(user.id);
expect(
await prisma.telegramLink.count({ where: { userId: user.id } }),
).toBe(0);
const preference = await prisma.notificationPreference.findUniqueOrThrow({
where: { userId: user.id },
});
expect(preference.telegramEnabled).toBe(false);
const row = await deliveryOf(deliveryId);
expect(row.status).toBe('SKIPPED');
expect(row.lastError).toBe('chat unavailable');
const notices = await prisma.notification.findMany({
where: {
userId: user.id,
category: 'SYSTEM',
message: { contains: 'Zustellung beendet' },
},
});
expect(notices).toHaveLength(1);
});
it('5xx keeps the delivery for a retry with the attempt counted', async () => {
const user = await createUser('server-error', []);
const { deliveryId } = await queue(user.id);
telegram.failWith(502, 'Bad Gateway', user.chatId);
await drainFor(user.id);
const row = await deliveryOf(deliveryId);
expect(row.status).toBe('PENDING');
expect(row.attempts).toBe(1);
expect(row.availableAt.getTime()).toBeGreaterThan(Date.now());
expect(telegram.sentTo(user.chatId)).toHaveLength(0);
});
it('keeps 1.1 s between two messages to the same chat, in order', async () => {
const user = await createUser('spacing', []);
const first = await queue(user.id, { message: 'erste' });
// Another createdAt, so the order is not a tie.
await new Promise((resolve) => setTimeout(resolve, 20));
const second = await queue(user.id, { message: 'zweite' });
await drainFor(user.id);
const sent = telegram
.sentTo(user.chatId)
.map((m) => m.text.split('\n')[0]);
expect(sent).toEqual(['erste', 'zweite']);
const [a, b] = await Promise.all([
deliveryOf(first.deliveryId),
deliveryOf(second.deliveryId),
]);
expect(a.status).toBe('DELIVERED');
expect(b.status).toBe('DELIVERED');
expect(
(b.deliveredAt as Date).getTime() - (a.deliveredAt as Date).getTime(),
).toBeGreaterThanOrEqual(1_000);
});
});
});