feat: T4b/T4c wave 9 - phenology reports, autopilot A4-A10, honey ledger, Telegram delivery
- 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:
1 parent
1971b2d9c4
commit
16cc557462
74 files changed
+9634
-340
No files matched your search
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user