- 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]>
342 lines
12 KiB
TypeScript
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);
|
|
});
|
|
});
|