Files
ApiCore/ApiCore-Backend/test/reminders.e2e-spec.ts
T
Nick SzaunigandClaude Opus 5.5 16cc557462
Backend Image / build-and-push (push) Successful in 4m26s
Frontend Image / build-and-push (push) Successful in 7m19s
Manifests / validate (push) Successful in 17s
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]>
2026-10-14 21:13:23 +02:00

342 lines
12 KiB
TypeScript

import { INestApplication } from '@nestjs/common';
import * as bcrypt from 'bcryptjs';
import { App } from 'supertest/types';
import { addDays } from '../src/common/dates';
import { DomainEventsService } from '../src/domain-events/domain-events.service';
import type { AnyDomainEventEnvelope } from '../src/domain-events/domain-event-types';
import { PrismaService } from '../src/prisma/prisma.service';
import { DigestService } from '../src/reminders/digest.service';
import { TaskDueHandlers } from '../src/reminders/task-due.handlers';
import { createTestApp } from './utils/create-test-app';
import { drainDomainEvents } from './utils/domain-events';
const d = (day: string): Date => new Date(`${day}T00:00:00.000Z`);
/**
* Fixed day in 2001 on which the swarm season begins by calendar
* (KIRSCHE 15 April), so an apiary's segment starts "today". Other files
* share the database: assertions only look at the rows and lines of this
* file's own users, apiary and tasks.
*/
const TODAY = '2001-04-15';
const YESTERDAY = '2001-04-14';
/** The digest window of a run at 04:30 (UTC, for the test) on `day`. */
const windowOf = (day: string) => ({
from: new Date(`${addDays(day, -1)}T02:30:00.000Z`),
until: new Date(`${day}T02:30:00.000Z`),
});
describe('Reminders (e2e)', () => {
let app: INestApplication<App>;
let prisma: PrismaService;
let digests: DigestService;
const suffix = `${Date.now()}-${Math.round(Math.random() * 1e6)}`;
const roleIds: string[] = [];
const userIds: string[] = [];
const taskIds: string[] = [];
const colonyIds: string[] = [];
let apiaryId: string;
let apiaryName: string;
const users = {} as Record<
'full' | 'noInspection' | 'completer' | 'off' | 'nothing',
string
>;
async function createUser(
label: string,
permissions: string[],
): Promise<string> {
const role = await prisma.role.create({
data: {
key: `REMINDERS_${label.toUpperCase()}_${suffix}`,
name: `Reminders ${label}`,
permissions: {
create: permissions.map((key) => ({
permission: { connect: { key } },
})),
},
},
});
roleIds.push(role.id);
const user = await prisma.user.create({
data: {
email: `reminders-e2e-${label}-${suffix}@example.com`,
passwordHash: await bcrypt.hash('SuperSecret123!', 4),
name: `Reminders ${label}`,
role: { connect: { id: role.id } },
},
});
userIds.push(user.id);
return user.id;
}
async function createTask(
title: string,
assignedUserId: string | null,
dueDate: string,
): Promise<string> {
const task = await prisma.task.create({
data: { title, assignedUserId, dueDate: d(dueDate) },
});
taskIds.push(task.id);
return task.id;
}
async function recordTaskDue(taskId: string, dueOn: string): Promise<void> {
await prisma.$transaction((tx) =>
app.get(DomainEventsService).record(tx, {
type: 'TaskDue',
aggregateType: 'Task',
aggregateId: taskId,
payload: { dueOn },
dedupeKey: `TaskDue:${taskId}:${dueOn}`,
}),
);
}
const notificationsOf = (userId: string, relatedId: string) =>
prisma.notification.findMany({ where: { userId, relatedId } });
const digestOf = (userId: string) =>
prisma.notification.findMany({
where: { userId, dedupeKey: `digest:${TODAY}:${userId}` },
});
beforeAll(async () => {
app = await createTestApp();
prisma = app.get(PrismaService);
digests = app.get(DigestService);
users.full = await createUser('full', [
'tasks:read',
'colonies:read',
'inspections:read',
'apiaries:read',
]);
users.noInspection = await createUser('noinspection', [
'tasks:read',
'apiaries:read',
]);
users.completer = await createUser('completer', [
'tasks:read',
'tasks:complete',
]);
users.off = await createUser('off', ['tasks:read', 'apiaries:read']);
users.nothing = await createUser('nothing', ['colonies:read']);
await prisma.notificationPreference.create({
data: { userId: users.off, taskDueEnabled: false, digestEnabled: false },
});
apiaryName = `Reminders e2e ${suffix}`;
apiaryId = (await prisma.apiary.create({ data: { name: apiaryName } })).id;
for (const code of ['A', 'B']) {
const colony = await prisma.colony.create({
data: {
code: `REM-E2E-${suffix}-${code}`,
apiaryId,
colonyType: 'PRODUCTION_COLONY',
createdAt: d('2001-03-01'),
},
});
colonyIds.push(colony.id);
await prisma.inspection.create({
data: {
colonyId: colony.id,
userId: users.full,
date: d('2001-03-10'),
},
});
}
// Two segment changes of the apiary: one before yesterday's digest
// (reported then), one after it (reported today). Status DONE, so no
// dispatcher touches them.
const seasonEvent = (
previousSegment: string,
segment: string,
occurredAt: string,
) =>
prisma.domainEvent.create({
data: {
type: 'SeasonPhaseChanged',
aggregateType: 'Apiary',
aggregateId: apiaryId,
payload: { apiaryId, previousSegment, segment },
correlationId: `reminders-e2e-${suffix}`,
occurredAt: new Date(occurredAt),
status: 'DONE',
},
});
await seasonEvent('FRUEHJAHR', 'SCHWARMZEIT', '2001-04-13T12:00:00.000Z');
await seasonEvent('SCHWARMZEIT', 'SOMMER', '2001-04-14T12:00:00.000Z');
});
afterAll(async () => {
await prisma.notification.deleteMany({
where: {
OR: [{ userId: { in: userIds } }, { relatedId: { in: taskIds } }],
},
});
await prisma.domainEvent.deleteMany({
where: { aggregateId: { in: [...taskIds, apiaryId] } },
});
await prisma.inspection.deleteMany({
where: { colonyId: { in: colonyIds } },
});
await prisma.colony.deleteMany({ where: { id: { in: colonyIds } } });
await prisma.apiary.delete({ where: { id: apiaryId } });
await prisma.task.deleteMany({ where: { id: { in: taskIds } } });
await prisma.refreshToken.deleteMany({
where: { userId: { in: userIds } },
});
await prisma.auditLog.deleteMany({ where: { userId: { in: userIds } } });
await prisma.user.deleteMany({ where: { id: { in: userIds } } });
await prisma.role.deleteMany({ where: { id: { in: roleIds } } });
await app.close();
});
it('TaskDue after draining yields exactly one notification per recipient, also after redelivery', async () => {
const taskId = await createTask('Reminders e2e Waben', users.full, TODAY);
await recordTaskDue(taskId, TODAY);
await drainDomainEvents(app, [taskId]);
const rows = await notificationsOf(users.full, taskId);
expect(rows).toHaveLength(1);
expect(rows[0]).toMatchObject({
priority: 'HINWEIS',
category: 'TASK_DUE',
message: 'Fällig seit 15.04.: Reminders e2e Waben',
link: '/tasks',
relatedType: 'Task',
dedupeKey: `task-due:${taskId}:${TODAY}:${users.full}`,
requiredPermissions: ['tasks:read'],
});
// A redelivery runs the handler a second time.
const envelope: AnyDomainEventEnvelope<'TaskDue'> = {
id: 'redelivery',
type: 'TaskDue',
aggregateType: 'Task',
aggregateId: taskId,
payload: { dueOn: TODAY },
actorUserId: null,
correlationId: 'redelivery',
occurredAt: new Date(),
attempts: 1,
};
await prisma.$transaction((tx) =>
app.get(TaskDueHandlers).taskDue(envelope, tx),
);
expect(await notificationsOf(users.full, taskId)).toHaveLength(1);
for (const other of [users.noInspection, users.completer, users.nothing]) {
expect(await notificationsOf(other, taskId)).toHaveLength(0);
}
});
it('a task without an assignee reaches holders of tasks:complete and not users who switched it off', async () => {
const taskId = await createTask('Reminders e2e offen', null, TODAY);
await recordTaskDue(taskId, TODAY);
await drainDomainEvents(app, [taskId]);
expect(await notificationsOf(users.completer, taskId)).toHaveLength(1);
for (const other of [users.full, users.noInspection, users.off]) {
expect(await notificationsOf(other, taskId)).toHaveLength(0);
}
});
it('writes nothing for a task that was completed before delivery', async () => {
const taskId = await createTask(
'Reminders e2e erledigt',
users.full,
TODAY,
);
await prisma.task.update({
where: { id: taskId },
data: { status: 'DONE', completedAt: new Date() },
});
await recordTaskDue(taskId, TODAY);
await drainDomainEvents(app, [taskId]);
expect(await notificationsOf(users.full, taskId)).toHaveLength(0);
});
it('running the digest step twice yields one digest per user', async () => {
// Plus the open task of the first test: three due, one overdue.
await createTask('Reminders e2e heute', users.full, TODAY);
await createTask('Reminders e2e ueberfaellig', users.full, '2001-04-10');
const own = [
users.full,
users.noInspection,
users.completer,
users.off,
users.nothing,
];
const first = await digests.runFor(TODAY, own, windowOf(TODAY));
const second = await digests.runFor(TODAY, own, windowOf(TODAY));
expect(first).toBeGreaterThanOrEqual(2);
expect(second).toBe(0);
for (const userId of own) {
expect((await digestOf(userId)).length).toBeLessThanOrEqual(1);
}
const [full] = await digestOf(users.full);
expect(full).toMatchObject({
priority: 'INFO',
category: 'DIGEST',
link: '/dashboard',
});
const lines = full.message.split('\n');
expect(lines[0]).toBe('Guten Morgen. Heute für dich:');
expect(lines).toContain('3 Aufgaben fällig, 1 davon überfällig.');
expect(lines).toContain(
`Durchsicht fällig: 2 Völker am Stand ${apiaryName}.`,
);
expect(lines).toContain(
`Stand ${apiaryName}: Sommer beginnt. Durchsicht alle 14 Tage.`,
);
// The change before yesterday's digest is not repeated.
expect(full.message).not.toContain('Schwarmzeit beginnt');
expect(full.requiredPermissions).toEqual(
expect.arrayContaining([
'tasks:read',
'colonies:read',
'inspections:read',
'apiaries:read',
]),
);
});
it('the digest of the day before reports the change of its own window', async () => {
await digests.runFor(YESTERDAY, [users.full], windowOf(YESTERDAY));
const [yesterday] = await prisma.notification.findMany({
where: {
userId: users.full,
dedupeKey: `digest:${YESTERDAY}:${users.full}`,
},
});
expect(yesterday.message).toContain(
`Stand ${apiaryName}: Schwarmzeit beginnt. Durchsicht alle 7 Tage.`,
);
expect(yesterday.message).not.toContain('Sommer beginnt');
});
it('the digest of a user without varroa or inspection rights has no such line', async () => {
await digests.runFor(TODAY, [users.noInspection], windowOf(TODAY));
const [digest] = await digestOf(users.noInspection);
expect(digest.message).toContain(`Stand ${apiaryName}: Sommer`);
expect(digest.message).not.toContain('Durchsicht fällig');
expect(digest.requiredPermissions).not.toContain('inspections:read');
expect(digest.requiredPermissions).not.toContain('colonies:read');
});
it('builds no digest for a user who can see nothing in it, and none for a user who switched it off', async () => {
await digests.runFor(TODAY, [users.nothing, users.off], windowOf(TODAY));
expect(await digestOf(users.nothing)).toHaveLength(0);
expect(await digestOf(users.off)).toHaveLength(0);
});
});