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

+6 -3
View File
@@ -329,10 +329,13 @@ describe('Automation rules (e2e)', () => {
it('proposes feeding when feed status is kritisch, without a notification', async () => {
await record('inspections', { feedStatus: 'CRITICAL' });
const suggestions = await suggestionsOf('FEED_STATUS_CRITICAL');
// One open thing per intent: the season autopilot (A6) may have proposed
// the feeding first, then the rule's proposal is dropped by design.
const suggestions = await prisma.suggestion.findMany({
where: { colonyId, intent: 'FEED', status: 'OPEN' },
});
expect(suggestions).toHaveLength(1);
expect(suggestions[0]).toMatchObject({ intent: 'FEED', status: 'OPEN' });
// The rule is off for notifications by default (notify: false).
// Neither source notifies by default (rule notify: false, autopilot never).
expect(
await prisma.notification.count({
where: { relatedType: 'Suggestion', relatedId: suggestions[0].id },
+347 -2
View File
@@ -3,6 +3,8 @@ import * as bcrypt from 'bcryptjs';
import request from 'supertest';
import { App } from 'supertest/types';
import { AutopilotService } from '../src/autopilot/autopilot.service';
import { NextStepsService } from '../src/autopilot/next-steps.service';
import { berlinToday } from '../src/common/dates';
import { DomainEventsService } from '../src/domain-events/domain-events.service';
import { PrismaService } from '../src/prisma/prisma.service';
import { login } from './utils/api';
@@ -28,6 +30,8 @@ describe('Autopilot (e2e)', () => {
const colonyIds: string[] = [];
const roleIds: string[] = [];
const userIds: string[] = [];
const seasonApiaryIds: string[] = [];
const winterCheckIds: string[] = [];
/** A living colony inspected 2001-05-03 (9 days before TODAY) and measured 2001-05-10. */
async function colonyWithLateInspection(name: string): Promise<string> {
@@ -88,11 +92,25 @@ describe('Autopilot (e2e)', () => {
select: { id: true },
});
await prisma.domainEvent.deleteMany({
where: { aggregateId: { in: inspections.map((i) => i.id) } },
where: {
aggregateId: {
in: [
...inspections.map((i) => i.id),
...seasonApiaryIds,
...winterCheckIds,
],
},
},
});
await prisma.suggestion.deleteMany({
where: { colonyId: { in: colonyIds } },
});
await prisma.winterCheck.deleteMany({
where: { colonyId: { in: colonyIds } },
});
await prisma.feeding.deleteMany({
where: { colonyId: { in: colonyIds } },
});
await prisma.inspection.deleteMany({
where: { colonyId: { in: colonyIds } },
});
@@ -103,7 +121,9 @@ describe('Autopilot (e2e)', () => {
where: { colonyId: { in: colonyIds } },
});
await prisma.colony.deleteMany({ where: { id: { in: colonyIds } } });
await prisma.apiary.delete({ where: { id: apiaryId } });
await prisma.apiary.deleteMany({
where: { id: { in: [apiaryId, ...seasonApiaryIds] } },
});
await prisma.auditLog.deleteMany({ where: { userId } });
await prisma.user.delete({ where: { id: userId } });
await prisma.refreshToken.deleteMany({
@@ -406,4 +426,329 @@ describe('Autopilot (e2e)', () => {
expect((seen.body as unknown[]).length).toBeGreaterThanOrEqual(1);
expect(hidden.body).toEqual([]);
});
/** The stored season of the e2e apiary in 2001: KIRSCHE by report. */
async function reportKirsche(forApiaryId = apiaryId): Promise<void> {
await prisma.apiaryPhenology.create({
data: {
apiaryId: forApiaryId,
year: 2001,
effectivePhase: 'KIRSCHE',
reportedPhase: 'KIRSCHE',
phaseSource: 'REPORT',
segment: 'SCHWARMZEIT',
segmentSince: d('2001-04-20'),
},
});
}
const dropSeason = () =>
prisma.apiaryPhenology.deleteMany({ where: { apiaryId, year: 2001 } });
it('swarm season by report, last inspection 9 days ago -> step 1 INSPECT overdue', async () => {
await reportKirsche();
try {
const colonyId = await colonyWithLateInspection('report');
await sync(colonyId);
const view = await app
.get(NextStepsService)
.forColony(colonyId, userId, TODAY);
expect(view).toMatchObject({
segment: 'SCHWARMZEIT',
phase: 'KIRSCHE',
phaseSource: 'REPORT',
});
expect(view.steps[0]).toMatchObject({
intent: 'INSPECT',
urgency: 'OVERDUE',
dueOn: '2001-05-10',
});
} finally {
await dropSeason();
}
});
it('A7: KIRSCHE and 9 occupied frames -> a HONEY_SUPER suggestion; 7 frames -> none', async () => {
await reportKirsche();
try {
const strong = await colonyWithLateInspection('strong');
const weak = await colonyWithLateInspection('weakframes');
await prisma.inspection.updateMany({
where: { colonyId: strong },
data: { colonyStrength: 9 },
});
await prisma.inspection.updateMany({
where: { colonyId: weak },
data: { colonyStrength: 7 },
});
await sync(strong);
await sync(weak);
const [superSuggestion] = await suggestionsOf(strong, 'HONEY_SUPER');
expect(superSuggestion).toMatchObject({
status: 'OPEN',
mode: 'DO',
reasonCode: 'HONEY_SUPER_CHECK',
requiredPermission: 'inspections:read',
dedupeKey: `SEASON_AUTOPILOT:${strong}:2001`,
});
expect(await suggestionsOf(weak, 'HONEY_SUPER')).toHaveLength(0);
} finally {
await dropSeason();
}
});
it('never more than 3 steps per colony', async () => {
await reportKirsche();
try {
const colonyId = await colonyWithLateInspection('fourway');
await prisma.inspection.updateMany({
where: { colonyId },
data: { colonyStrength: 9, feedStatus: 'WEAK' },
});
await prisma.varroaMeasurement.updateMany({
where: { colonyId },
data: { date: d('2001-04-01') },
});
await sync(colonyId);
const open = await prisma.suggestion.findMany({
where: { colonyId, source: 'AUTOPILOT', status: 'OPEN' },
select: { intent: true },
});
expect(open.map((s) => s.intent).sort()).toEqual([
'FEED',
'HONEY_SUPER',
'INSPECT',
'VARROA_MEASURE',
]);
const view = await app
.get(NextStepsService)
.forColony(colonyId, userId, TODAY);
expect(view.steps).toHaveLength(3);
} finally {
await dropSeason();
}
});
it('a SeasonPhaseChanged syncs every living colony of the apiary', async () => {
const year = Number(berlinToday().slice(0, 4));
const own = await prisma.apiary.create({
data: { name: `Autopilot e2e season ${suffix}` },
});
const other = await prisma.apiary.create({
data: { name: `Autopilot e2e other ${suffix}` },
});
seasonApiaryIds.push(own.id, other.id);
await prisma.apiaryPhenology.create({
data: {
apiaryId: own.id,
year,
effectivePhase: 'HERBST',
phaseSource: 'REPORT',
segment: 'HERBST',
segmentSince: d(`${year}-01-01`),
},
});
const colonyIn = async (name: string, apiary: string, status = 'ACTIVE') =>
(
await prisma.colony.create({
data: {
code: `AP-E2E-${suffix}-${name}`,
apiaryId: apiary,
colonyType: 'PRODUCTION_COLONY',
status: status as 'ACTIVE',
},
})
).id;
const first = await colonyIn('season-1', own.id);
const second = await colonyIn('season-2', own.id);
const ended = await colonyIn('season-ended', own.id, 'DECEASED');
const elsewhere = await colonyIn('season-other', other.id);
colonyIds.push(first, second, ended, elsewhere);
await prisma.$transaction((tx) =>
app.get(DomainEventsService).record(tx, {
type: 'SeasonPhaseChanged',
aggregateType: 'Apiary',
aggregateId: own.id,
payload: {
apiaryId: own.id,
phase: 'HERBST',
segment: 'HERBST',
previousPhase: 'TRACHTENDE',
previousSegment: 'SPAETSOMMER',
source: 'REPORT',
},
}),
);
await drainDomainEvents(app, [own.id]);
for (const colonyId of [first, second]) {
const [winter] = await suggestionsOf(colonyId, 'WINTER_CHECK');
expect(winter).toMatchObject({
status: 'OPEN',
reasonCode: 'WINTER_CHECK_AUTUMN_MISSING',
dedupeKey: `SEASON_AUTOPILOT:${colonyId}:${year}-AUTUMN`,
});
}
expect(await suggestionsOf(ended, 'WINTER_CHECK')).toHaveLength(0);
expect(await suggestionsOf(elsewhere, 'WINTER_CHECK')).toHaveLength(0);
for (const colonyId of [first, second]) {
const [feed] = await suggestionsOf(colonyId, 'FEED');
expect(feed).toMatchObject({
status: 'OPEN',
ruleKey: 'SEASON_AUTOPILOT',
reasonCode: 'AUTUMN_FEED_MISSING',
dedupeKey: `SEASON_AUTOPILOT:${colonyId}:${year}`,
});
}
// The wintering-in entry resolves A9 right after it is recorded.
const winterCheck = await prisma.winterCheck.create({
data: {
colonyId: first,
userId,
winterYear: year,
season: 'AUTUMN',
date: new Date(),
},
});
winterCheckIds.push(winterCheck.id);
await prisma.$transaction((tx) =>
app.get(DomainEventsService).record(tx, {
type: 'WinterCheckRecorded',
aggregateType: 'WinterCheck',
aggregateId: winterCheck.id,
payload: { colonyId: first },
}),
);
await drainDomainEvents(app, [winterCheck.id]);
const [resolved] = await suggestionsOf(first, 'WINTER_CHECK');
expect(resolved.status).toBe('RESOLVED');
const [stillOpen] = await suggestionsOf(second, 'WINTER_CHECK');
expect(stillOpen.status).toBe('OPEN');
});
it('an apiary-wide sync and single-colony syncs running together finish without a deadlock', async () => {
const year = Number(berlinToday().slice(0, 4));
const crowded = await prisma.apiary.create({
data: { name: `Autopilot e2e crowd ${suffix}` },
});
seasonApiaryIds.push(crowded.id);
await prisma.apiaryPhenology.create({
data: {
apiaryId: crowded.id,
year,
effectivePhase: 'HERBST',
phaseSource: 'REPORT',
segment: 'HERBST',
segmentSince: d(`${year}-01-01`),
},
});
const ids: string[] = [];
for (let n = 0; n < 5; n += 1) {
const colony = await prisma.colony.create({
data: {
code: `AP-E2E-${suffix}-crowd-${n}`,
apiaryId: crowded.id,
colonyType: 'PRODUCTION_COLONY',
},
});
ids.push(colony.id);
colonyIds.push(colony.id);
}
const today = berlinToday();
await Promise.all([
prisma.$transaction((tx) =>
autopilot.syncApiaryIn(tx, crowded.id, today),
),
...[...ids]
.reverse()
.map((id) =>
prisma.$transaction((tx) => autopilot.syncColonyIn(tx, id, today)),
),
prisma.$transaction((tx) =>
autopilot.syncApiaryIn(tx, crowded.id, today),
),
]);
for (const id of ids) {
expect(await suggestionsOf(id, 'WINTER_CHECK')).toHaveLength(1);
expect(await suggestionsOf(id, 'FEED')).toHaveLength(1);
}
});
it('HERBST approximation: feedings count from the calendar day of TRACHTENDE (08-01), not from the real start of SPAETSOMMER', async () => {
await prisma.apiaryPhenology.create({
data: {
apiaryId,
year: 2001,
effectivePhase: 'HERBST',
phaseSource: 'REPORT',
segment: 'HERBST',
segmentSince: d('2001-09-15'),
},
});
try {
const feedAt = async (name: string, day: string) => {
const colonyId = await colonyWithLateInspection(name);
await prisma.feeding.create({
data: {
colonyId,
userId,
date: d(day),
feedType: 'SUGAR_WATER',
amount: 5,
unit: 'L',
},
});
await sync(colonyId, '2001-09-20');
return colonyId;
};
const before = await feedAt('fed-before', '2001-07-30');
const after = await feedAt('fed-after', '2001-08-05');
expect(await suggestionsOf(before, 'FEED')).toHaveLength(1);
expect(await suggestionsOf(after, 'FEED')).toHaveLength(0);
} finally {
await dropSeason();
}
});
it('A5 open and an A6 candidate present -> still one FEED suggestion', async () => {
await prisma.apiaryPhenology.create({
data: {
apiaryId,
year: 2001,
effectivePhase: 'HERBST',
phaseSource: 'REPORT',
segment: 'HERBST',
segmentSince: d('2001-09-15'),
},
});
try {
const colonyId = await colonyWithLateInspection('feedboth');
await prisma.inspection.updateMany({
where: { colonyId },
data: { feedStatus: 'WEAK' },
});
await sync(colonyId, '2001-09-20');
await sync(colonyId, '2001-09-21');
const open = await prisma.suggestion.findMany({
where: { colonyId, intent: 'FEED', status: 'OPEN' },
});
expect(open).toHaveLength(1);
} finally {
await dropSeason();
}
});
});
+20 -1
View File
@@ -126,7 +126,13 @@ describe('Bottling (e2e)', () => {
data: { extractionId, harvestId },
});
const honeyBatch = await prisma.honeyBatch.create({
data: { batchNumber: `HC-${Date.now()}`, extractionId, amount: 25 },
data: {
batchNumber: `HC-${Date.now()}`,
extractionId,
amount: 25,
// The movement book keeps this batch: bookings need a start amount.
initialAmount: 25,
},
});
honeyBatchId = honeyBatch.id;
});
@@ -141,6 +147,7 @@ describe('Bottling (e2e)', () => {
});
await prisma.bottlingBatch.deleteMany({ where: { id: bottlingBatchId } });
}
await prisma.batchMovement.deleteMany({ where: { honeyBatchId } });
await prisma.honeyBatch.deleteMany({ where: { id: honeyBatchId } });
await prisma.extractionHarvest.deleteMany({ where: { extractionId } });
await prisma.extraction.deleteMany({ where: { id: extractionId } });
@@ -198,6 +205,18 @@ describe('Bottling (e2e)', () => {
const honeyBatch = honeyBatchResponse.body as HoneyBatchBody;
expect(honeyBatch.amount).toBe(0);
expect(honeyBatch.status).toBe('BOTTLED');
// The booking in the movement book, in grams, with its bottling.
const movements = await prisma.batchMovement.findMany({
where: { honeyBatchId },
});
expect(movements).toEqual([
expect.objectContaining({
type: 'BOTTLING',
quantity: -25000,
sourceBottlingBatchId: bottlingBatchId,
userId: beekeeperId,
}),
]);
});
it('bottling create records BatchBottled and StockChanged together', async () => {
@@ -48,6 +48,7 @@ describe('Data integrity (e2e)', () => {
batchNumber: `HB-${suffix}-${Math.round(Math.random() * 1e9)}`,
extractionId: extraction.id,
amount,
initialAmount: amount,
},
});
}
@@ -219,6 +220,10 @@ describe('Data integrity (e2e)', () => {
where: { id: honeyBatch.id },
});
expect(after.amount).toBeCloseTo(4, 3);
const bookings = await prisma.batchMovement.findMany({
where: { honeyBatchId: honeyBatch.id },
});
expect(bookings.map((booking) => booking.quantity)).toEqual([-6000]);
});
it('gives the amount back when a bottling is removed', async () => {
@@ -147,8 +147,11 @@ describe('Follow-up controls (e2e)', () => {
const colony = await newColony('OFF');
await treat(colony.id);
// Only the follow-up rules are meant; the autopilot proposes on its own.
expect(
await prisma.suggestion.count({ where: { colonyId: colony.id } }),
await prisma.suggestion.count({
where: { colonyId: colony.id, source: 'RULE' },
}),
).toBe(0);
});
@@ -0,0 +1,465 @@
import { INestApplication } from '@nestjs/common';
import * as bcrypt from 'bcryptjs';
import request from 'supertest';
import { App } from 'supertest/types';
import { PrismaService } from '../src/prisma/prisma.service';
import { createTestApp } from './utils/create-test-app';
import { login } from './utils/api';
import { INVARIANT_SQL } from './utils/honey-ledger';
interface HoneyBatchBody {
id: string;
amount: number | null;
initialAmount: number | null;
status: string;
warnings: { code: string }[];
}
interface BottlingBody {
id: string;
}
interface MovementBody {
id: string;
type: string;
quantityGrams: number;
reason: string | null;
notes: string | null;
sourceBottlingBatchId: string | null;
userId: string | null;
}
interface ErrorBody {
message: string;
}
/**
* The movement book of a honey batch (spec 4.6, 7.7): every change of the
* rest is a booking, and amount = initialAmount + sum(quantity) / 1000.
* Asserts only on this file's own batches - other files share the database.
*/
describe('Honey batch movement book (e2e)', () => {
let app: INestApplication<App>;
let prisma: PrismaService;
const suffix = `${Date.now()}-${Math.round(Math.random() * 1e6)}`;
const password = 'SuperSecret123!';
const adminEmail = `honey-ledger-admin-${suffix}@example.com`;
const staffEmail = `honey-ledger-staff-${suffix}@example.com`;
let adminId: string;
let staffId: string;
let adminToken: string;
let staffToken: string;
let extractionId: string;
const honeyBatchIds: string[] = [];
const bottlingIds: string[] = [];
const server = () => app.getHttpServer();
const admin = () => ({ Authorization: `Bearer ${adminToken}` });
async function createBatch(amount?: number): Promise<HoneyBatchBody> {
const response = await request(server())
.post('/api/v1/honey-batches')
.set(admin())
.send({
batchNumber: `HL-${suffix}-${honeyBatchIds.length}`,
extractionId,
...(amount === undefined ? {} : { amount }),
})
.expect(201);
const body = response.body as HoneyBatchBody;
honeyBatchIds.push(body.id);
return body;
}
function bottle(honeyBatchId: string, jarCount: number) {
return request(server())
.post('/api/v1/bottling-batches')
.set(admin())
.send({
honeyBatchId,
date: '2026-08-01T00:00:00.000Z',
bestBeforeDate: '2028-08-01T00:00:00.000Z',
jarSize: 500,
jarCount,
lotNumber: `HL-L-${suffix}-${Math.round(Math.random() * 1e9)}`,
});
}
async function bottleOk(honeyBatchId: string, jarCount: number) {
const response = await bottle(honeyBatchId, jarCount).expect(201);
const body = response.body as BottlingBody;
bottlingIds.push(body.id);
return body;
}
async function stored(id: string) {
return prisma.honeyBatch.findUniqueOrThrow({ where: { id } });
}
async function brokenBatchIds(): Promise<string[]> {
const rows = await prisma.$queryRawUnsafe<{ id: string }[]>(INVARIANT_SQL);
return rows.map((row) => row.id);
}
async function movementsOf(id: string): Promise<MovementBody[]> {
const response = await request(server())
.get(`/api/v1/honey-batches/${id}/movements`)
.set(admin())
.expect(200);
return (response.body as { items: MovementBody[]; hasMore: boolean }).items;
}
beforeAll(async () => {
app = await createTestApp();
prisma = app.get(PrismaService);
const passwordHash = await bcrypt.hash(password, 12);
adminId = (
await prisma.user.create({
data: {
email: adminEmail,
passwordHash,
name: 'Ledger Admin',
role: { connect: { key: 'ADMIN' } },
},
})
).id;
staffId = (
await prisma.user.create({
data: {
email: staffEmail,
passwordHash,
name: 'Ledger Staff',
role: { connect: { key: 'STAFF' } },
},
})
).id;
adminToken = (await login(app, adminEmail, password)).accessToken;
staffToken = (await login(app, staffEmail, password)).accessToken;
extractionId = (
await prisma.extraction.create({
data: { date: new Date(), userId: adminId },
})
).id;
});
afterAll(async () => {
await prisma.batchMovement.deleteMany({
where: { honeyBatchId: { in: honeyBatchIds } },
});
await prisma.domainEvent.deleteMany({
where: {
aggregateId: { in: [...honeyBatchIds, ...bottlingIds] },
},
});
await prisma.bottlingBatch.deleteMany({
where: { id: { in: bottlingIds } },
});
await prisma.honeyBatch.deleteMany({
where: { id: { in: honeyBatchIds } },
});
await prisma.extraction.deleteMany({ where: { id: extractionId } });
const userIds = [adminId, staffId].filter(Boolean);
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 app.close();
});
it('keeps the invariant after create, bottle, correct and remove', async () => {
const batch = await createBatch(10);
expect(batch.amount).toBe(10);
const row = await stored(batch.id);
expect(row.initialAmount).toBe(10);
// Creating with an amount books nothing.
expect(await movementsOf(batch.id)).toEqual([]);
const bottling = await bottleOk(batch.id, 8);
expect((await stored(batch.id)).amount).toBe(6);
await request(server())
.patch(`/api/v1/honey-batches/${batch.id}`)
.set(admin())
.send({ amount: 5 })
.expect(200);
expect((await stored(batch.id)).amount).toBe(5);
await request(server())
.patch(`/api/v1/bottling-batches/${bottling.id}`)
.set(admin())
.send({ jarCount: 4 })
.expect(200);
expect((await stored(batch.id)).amount).toBe(7);
await request(server())
.delete(`/api/v1/bottling-batches/${bottling.id}`)
.set(admin())
.expect(200);
const after = await stored(batch.id);
expect(after.amount).toBe(9);
expect(after.initialAmount).toBe(10);
expect(await brokenBatchIds()).not.toContain(batch.id);
const book = await movementsOf(batch.id);
expect(
book.map((movement) => [movement.type, movement.quantityGrams]).reverse(),
).toEqual([
['BOTTLING', -4000],
['CORRECTION', -1000],
['BOTTLING', 2000],
['BOTTLING', 2000],
]);
expect(book[0]).toMatchObject({ notes: 'Abfüllung entfernt' });
});
it('two parallel bottlings over the rest: exactly one 400', async () => {
const batch = await createBatch(10);
// 12 jars of 500 g = 6 kg each, 10 kg available.
const results = await Promise.all([
bottle(batch.id, 12),
bottle(batch.id, 12),
]);
const statuses = results.map((result) => result.status).sort();
expect(statuses).toEqual([201, 400]);
for (const result of results) {
if (result.status === 201) {
bottlingIds.push((result.body as BottlingBody).id);
}
}
expect((await stored(batch.id)).amount).toBe(4);
expect(await movementsOf(batch.id)).toHaveLength(1);
expect(await brokenBatchIds()).not.toContain(batch.id);
});
it('PATCH amount null with movements answers 400', async () => {
const batch = await createBatch(10);
await bottleOk(batch.id, 2);
const response = await request(server())
.patch(`/api/v1/honey-batches/${batch.id}`)
.set(admin())
.send({ amount: null })
.expect(400);
expect((response.body as ErrorBody).message).toBe(
'Die Menge wird über das Bewegungsbuch geführt. Bitte eine Korrektur buchen.',
);
expect((await stored(batch.id)).amount).toBe(9);
});
it('PATCH amount null without movements takes the amount away again', async () => {
const batch = await createBatch(10);
await request(server())
.patch(`/api/v1/honey-batches/${batch.id}`)
.set(admin())
.send({ amount: null })
.expect(200);
const row = await stored(batch.id);
expect(row.amount).toBeNull();
expect(row.initialAmount).toBeNull();
});
it('a batch without an amount gets one by PATCH without a booking', async () => {
const batch = await createBatch();
expect(batch.amount).toBeNull();
await request(server())
.patch(`/api/v1/honey-batches/${batch.id}`)
.set(admin())
.send({ amount: 7 })
.expect(200);
const row = await stored(batch.id);
expect(row.amount).toBe(7);
expect(row.initialAmount).toBe(7);
expect(await movementsOf(batch.id)).toEqual([]);
});
it('a batch without an amount is bottled without a booking', async () => {
const batch = await createBatch();
await bottleOk(batch.id, 2);
const row = await stored(batch.id);
expect(row.amount).toBeNull();
expect(row.status).toBe('BOTTLED');
expect(await movementsOf(batch.id)).toEqual([]);
});
it('a bottling made before the amount was recorded never changes the ledger', async () => {
const batch = await createBatch();
const untracked = await bottleOk(batch.id, 4);
await request(server())
.patch(`/api/v1/honey-batches/${batch.id}`)
.set(admin())
.send({ amount: 10 })
.expect(200);
await request(server())
.patch(`/api/v1/bottling-batches/${untracked.id}`)
.set(admin())
.send({ jarCount: 2 })
.expect(200);
expect((await stored(batch.id)).amount).toBe(10);
await request(server())
.delete(`/api/v1/bottling-batches/${untracked.id}`)
.set(admin())
.expect(200);
expect((await stored(batch.id)).amount).toBe(10);
expect(await movementsOf(batch.id)).toEqual([]);
expect(await brokenBatchIds()).not.toContain(batch.id);
});
it('rounds a PATCH amount to the gram and a correction may carry a reason', async () => {
const batch = await createBatch(10);
await request(server())
.patch(`/api/v1/honey-batches/${batch.id}`)
.set(admin())
.send({ amount: 12.3456 })
.expect(200);
expect((await stored(batch.id)).amount).toBe(12.346);
const response = await request(server())
.post(`/api/v1/honey-batches/${batch.id}/movements`)
.set(admin())
.send({ type: 'CORRECTION', deltaKg: -0.346, reason: 'SONSTIGES' })
.expect(201);
expect(response.body).toMatchObject({ reason: 'SONSTIGES' });
expect((await stored(batch.id)).amount).toBe(12);
expect(await brokenBatchIds()).not.toContain(batch.id);
});
it('GET movements lists BOTTLING with its source bottling', async () => {
const batch = await createBatch(10);
const bottling = await bottleOk(batch.id, 2);
const book = await movementsOf(batch.id);
expect(book).toEqual([
expect.objectContaining({
type: 'BOTTLING',
quantityGrams: -1000,
sourceBottlingBatchId: bottling.id,
userId: adminId,
}),
]);
});
it('GET movements needs the read permission and a known batch', async () => {
await request(server())
.get('/api/v1/honey-batches/unknown/movements')
.set(admin())
.expect(404);
await request(server())
.get('/api/v1/honey-batches/unknown/movements')
.expect(401);
});
describe('POST movements', () => {
it('books a withdrawal with its reason and a correction', async () => {
const batch = await createBatch(10);
const withdrawal = await request(server())
.post(`/api/v1/honey-batches/${batch.id}/movements`)
.set(admin())
.send({
type: 'WITHDRAWAL',
deltaKg: -1.5,
reason: 'EIGENBEDARF',
notes: 'Für die Familie',
})
.expect(201);
expect(withdrawal.body).toMatchObject({
type: 'WITHDRAWAL',
quantityGrams: -1500,
reason: 'EIGENBEDARF',
notes: 'Für die Familie',
userId: adminId,
});
expect((await stored(batch.id)).amount).toBe(8.5);
await request(server())
.post(`/api/v1/honey-batches/${batch.id}/movements`)
.set(admin())
.send({ type: 'CORRECTION', deltaKg: 0.25, notes: 'Nachgewogen' })
.expect(201);
expect((await stored(batch.id)).amount).toBe(8.75);
expect(await brokenBatchIds()).not.toContain(batch.id);
const audit = await prisma.auditLog.findMany({
where: {
entityType: 'HoneyBatch',
entityId: batch.id,
userId: adminId,
},
});
expect(audit.length).toBeGreaterThanOrEqual(3);
});
it('refuses what would take the rest below 0, with the German message', async () => {
const batch = await createBatch(1);
const response = await request(server())
.post(`/api/v1/honey-batches/${batch.id}/movements`)
.set(admin())
.send({ type: 'WITHDRAWAL', deltaKg: -2, reason: 'BRUCH' })
.expect(400);
expect((response.body as ErrorBody).message).toBe(
'Die Menge kann nicht unter 0 sinken.',
);
expect((await stored(batch.id)).amount).toBe(1);
expect(await movementsOf(batch.id)).toEqual([]);
});
it('validates the booking', async () => {
const batch = await createBatch(5);
const post = (body: object) =>
request(server())
.post(`/api/v1/honey-batches/${batch.id}/movements`)
.set(admin())
.send(body);
await post({ type: 'WITHDRAWAL', deltaKg: -1 }).expect(400);
await post({ type: 'WITHDRAWAL', deltaKg: 1, reason: 'BRUCH' }).expect(
400,
);
await post({ type: 'CORRECTION', deltaKg: 0 }).expect(400);
await post({ type: 'CORRECTION', deltaKg: 100001 }).expect(400);
await post({ type: 'BOTTLING', deltaKg: -1 }).expect(400);
await post({
type: 'CORRECTION',
deltaKg: 1,
notes: 'x'.repeat(501),
}).expect(400);
expect(await movementsOf(batch.id)).toEqual([]);
});
it('refuses a batch whose amount is not recorded', async () => {
const batch = await createBatch();
await request(server())
.post(`/api/v1/honey-batches/${batch.id}/movements`)
.set(admin())
.send({ type: 'CORRECTION', deltaKg: 1 })
.expect(400);
});
it('needs the update permission', async () => {
const batch = await createBatch(5);
await request(server())
.post(`/api/v1/honey-batches/${batch.id}/movements`)
.set({ Authorization: `Bearer ${staffToken}` })
.send({ type: 'CORRECTION', deltaKg: 1 })
.expect(403);
});
});
});
+469
View File
@@ -0,0 +1,469 @@
import { INestApplication } from '@nestjs/common';
import * as bcrypt from 'bcryptjs';
import request from 'supertest';
import { App } from 'supertest/types';
import { PhenologyStateService } from '../src/phenology/phenology-state.service';
import { PrismaService } from '../src/prisma/prisma.service';
import { login } from './utils/api';
import { createTestApp } from './utils/create-test-app';
import { DomainEventDispatcher } from '../src/domain-events/domain-event-dispatcher';
import { drainDomainEvents } from './utils/domain-events';
interface ReportBody {
id: string;
apiaryId: string | null;
phase: string;
revokedAt: string | null;
}
interface StateBody {
apiaryId: string;
apiaryName: string;
phase: string | null;
segment: string;
phaseSource: string | null;
reportedOn: string | null;
}
/**
* The clock stands on a fixed day of 2002 (only Date is faked): before the
* calendar SALWEIDE of that year, so a report decides the phase and the
* segment changes. No other file works in 2002.
*/
const NOW = new Date('2002-03-10T10:00:00.000Z');
const TODAY = '2002-03-10';
describe('Phenology reports (e2e)', () => {
let app: INestApplication<App>;
let prisma: PrismaService;
const suffix = `${Date.now()}-${Math.round(Math.random() * 1e6)}`;
const password = 'SuperSecret123!';
const apiaryIds: string[] = [];
const userIds: string[] = [];
const roleIds: string[] = [];
const reportIds: string[] = [];
let beekeeperToken: string;
let noRightsToken: string;
let keyCounter = 0;
const server = () => app.getHttpServer();
const auth = (token = beekeeperToken) => ({
Authorization: `Bearer ${token}`,
});
const nextKey = () => `phenology-${suffix}-${(keyCounter += 1)}-key-0001`;
async function createUser(
label: string,
role: { key: string } | { id: string },
): Promise<{ id: string; email: string }> {
const email = `phenology-e2e-${label}-${suffix}@example.com`;
const user = await prisma.user.create({
data: {
email,
passwordHash: await bcrypt.hash(password, 4),
name: `Phenology ${label}`,
role: { connect: role },
},
});
userIds.push(user.id);
return { id: user.id, email };
}
async function createApiary(label: string): Promise<string> {
const apiary = await prisma.apiary.create({
data: {
name: `Phenology ${label} ${suffix}`,
latitude: 48.1,
longitude: 9.2,
},
});
apiaryIds.push(apiary.id);
return apiary.id;
}
async function report(body: Record<string, unknown>): Promise<ReportBody> {
const response = await request(server())
.post('/api/v1/phenology/reports')
.set(auth())
.set('Idempotency-Key', nextKey())
.send(body)
.expect(201);
const created = response.body as ReportBody;
reportIds.push(created.id);
return created;
}
async function drain(reportId: string): Promise<void> {
await drainDomainEvents(app, [reportId]);
}
const correlationsOf = async (aggregateIds: string[]): Promise<string[]> =>
(
await prisma.domainEvent.findMany({
where: { aggregateId: { in: aggregateIds } },
select: { correlationId: true },
distinct: ['correlationId'],
})
).map((event) => event.correlationId);
/**
* A report for all apiaries fans out to every active apiary of the shared
* test database. Delivers the report event (which records the follow-ups),
* removes the follow-ups of foreign apiaries (they belong to this
* correlation, i.e. to this test) and delivers the rest.
*/
async function deliverKeepingOnly(
reportId: string,
keep: string[],
): Promise<number> {
const correlationIds = await correlationsOf([reportId]);
await app.get(DomainEventDispatcher).runOnce({ correlationIds });
const requests = await prisma.domainEvent.count({
where: {
type: 'PhenologyRecomputeRequested',
correlationId: { in: correlationIds },
},
});
await prisma.domainEvent.deleteMany({
where: {
type: 'PhenologyRecomputeRequested',
correlationId: { in: correlationIds },
aggregateId: { notIn: keep },
},
});
await drain(reportId);
return requests;
}
const rowOf = (apiaryId: string) =>
prisma.apiaryPhenology.findUniqueOrThrow({
where: { apiaryId_year: { apiaryId, year: 2002 } },
});
const changesOf = (apiaryId: string) =>
prisma.domainEvent.findMany({
where: { type: 'SeasonPhaseChanged', aggregateId: apiaryId },
});
beforeAll(async () => {
app = await createTestApp();
prisma = app.get(PrismaService);
const beekeeper = await createUser('beekeeper', { key: 'BEEKEEPER' });
const role = await prisma.role.create({
data: {
key: `PHENOLOGY_NONE_${suffix}`,
name: 'Ohne Pflanzenentwicklung',
permissions: {
create: [{ permission: { connect: { key: 'apiaries:read' } } }],
},
},
});
roleIds.push(role.id);
const noRights = await createUser('norights', { id: role.id });
beekeeperToken = (await login(app, beekeeper.email, password)).accessToken;
noRightsToken = (await login(app, noRights.email, password)).accessToken;
jest.useFakeTimers({
now: NOW,
doNotFake: [
'hrtime',
'nextTick',
'performance',
'queueMicrotask',
'requestAnimationFrame',
'cancelAnimationFrame',
'requestIdleCallback',
'cancelIdleCallback',
'setImmediate',
'clearImmediate',
'setInterval',
'clearInterval',
'setTimeout',
'clearTimeout',
],
});
});
afterAll(async () => {
jest.useRealTimers();
// Only the events of this file's own reports and apiaries (and their
// follow-ups, same correlation).
const correlationIds = await correlationsOf([...reportIds, ...apiaryIds]);
await prisma.domainEvent.deleteMany({
where: {
OR: [
{ correlationId: { in: correlationIds } },
{ aggregateId: { in: [...reportIds, ...apiaryIds] } },
],
},
});
await prisma.notification.deleteMany({
where: { relatedType: 'Apiary', relatedId: { in: apiaryIds } },
});
await prisma.phenologyReport.deleteMany({
where: { id: { in: reportIds } },
});
// The state rows of our apiaries go with them (ON DELETE CASCADE).
await prisma.apiary.deleteMany({ where: { id: { in: apiaryIds } } });
await prisma.idempotencyRecord.deleteMany({
where: { key: { startsWith: `phenology-${suffix}` } },
});
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('a report for all apiaries sets SALWEIDE on every apiary and records SeasonPhaseChanged once per apiary', async () => {
const first = await createApiary('all-1');
const second = await createApiary('all-2');
const created = await report({ phase: 'SALWEIDE', date: TODAY });
try {
const requests = await deliverKeepingOnly(created.id, [first, second]);
// One follow-up per active apiary of the database, at least ours.
expect(requests).toBeGreaterThanOrEqual(2);
await assertAllApiaries([first, second]);
} finally {
// The report would otherwise count for the apiaries of the other tests.
await prisma.phenologyReport.delete({ where: { id: created.id } });
}
});
async function assertAllApiaries(apiaryIds: string[]): Promise<void> {
const [first] = apiaryIds;
for (const apiaryId of apiaryIds) {
const row = await rowOf(apiaryId);
expect(row).toMatchObject({
reportedPhase: 'SALWEIDE',
effectivePhase: 'SALWEIDE',
phaseSource: 'REPORT',
segment: 'FRUEHJAHR',
});
expect(await changesOf(apiaryId)).toHaveLength(1);
// The same state again changes nothing and records nothing.
await prisma.$transaction((tx) =>
app.get(PhenologyStateService).recomputeIn(tx, apiaryId, TODAY),
);
expect(await changesOf(apiaryId)).toHaveLength(1);
}
const states = (
await request(server())
.get('/api/v1/phenology/state')
.set(auth())
.expect(200)
).body as StateBody[];
expect(states.find((state) => state.apiaryId === first)).toMatchObject({
phase: 'SALWEIDE',
segment: 'FRUEHJAHR',
phaseSource: 'REPORT',
reportedOn: TODAY,
});
}
it('revoking recomputes and the phase can go back', async () => {
const apiaryId = await createApiary('revoke');
const created = await report({
phase: 'SALWEIDE',
date: TODAY,
apiaryId,
});
await drain(created.id);
expect((await rowOf(apiaryId)).effectivePhase).toBe('SALWEIDE');
const revoked = await request(server())
.post(`/api/v1/phenology/reports/${created.id}/revoke`)
.set(auth())
.set('Idempotency-Key', nextKey())
.expect(201);
expect((revoked.body as ReportBody).revokedAt).not.toBeNull();
await drain(created.id);
const row = await rowOf(apiaryId);
expect(row.effectivePhase).toBeNull();
expect(row.reportedPhase).toBeNull();
expect(row.segment).toBe('WINTERRUHE');
await request(server())
.post(`/api/v1/phenology/reports/${created.id}/revoke`)
.set(auth())
.set('Idempotency-Key', nextKey())
.expect(409);
const listed = (
await request(server())
.get('/api/v1/phenology/reports?year=2002')
.set(auth())
.expect(200)
).body as ReportBody[];
expect(
listed.find((entry) => entry.id === created.id)?.revokedAt,
).not.toBeNull();
});
it('a correction recomputes: the phase of the report changes', async () => {
const apiaryId = await createApiary('correct');
const created = await report({ phase: 'SALWEIDE', date: TODAY, apiaryId });
await drain(created.id);
await request(server())
.patch(`/api/v1/phenology/reports/${created.id}`)
.set(auth())
.send({ phase: 'KIRSCHE', notes: 'früh' })
.expect(200);
await drain(created.id);
expect(await rowOf(apiaryId)).toMatchObject({
reportedPhase: 'KIRSCHE',
effectivePhase: 'KIRSCHE',
segment: 'SCHWARMZEIT',
});
const audit = await prisma.auditLog.findMany({
where: { entityType: 'PhenologyReport', entityId: created.id },
orderBy: { timestamp: 'asc' },
});
// Both entries carry the faked clock, so their order is not defined.
expect(audit.map((entry) => entry.action).sort()).toEqual([
'CREATE',
'UPDATE',
]);
});
it('a retry with the same key yields one report', async () => {
const apiaryId = await createApiary('retry');
const key = nextKey();
const body = { phase: 'SALWEIDE', date: TODAY, apiaryId };
const firstResponse = await request(server())
.post('/api/v1/phenology/reports')
.set(auth())
.set('Idempotency-Key', key)
.send(body)
.expect(201);
const secondResponse = await request(server())
.post('/api/v1/phenology/reports')
.set(auth())
.set('Idempotency-Key', key)
.send(body)
.expect(201);
const id = (firstResponse.body as ReportBody).id;
reportIds.push(id);
expect((secondResponse.body as ReportBody).id).toBe(id);
expect(await prisma.phenologyReport.count({ where: { apiaryId } })).toBe(1);
expect(
await prisma.domainEvent.count({
where: { type: 'PhenologyReported', aggregateId: id },
}),
).toBe(1);
});
it('a date in the future answers 400 "Das Datum liegt in der Zukunft."', async () => {
const response = await request(server())
.post('/api/v1/phenology/reports')
.set(auth())
.set('Idempotency-Key', nextKey())
.send({ phase: 'SALWEIDE', date: '2002-03-11' })
.expect(400);
expect((response.body as { message: string }).message).toBe(
'Das Datum liegt in der Zukunft.',
);
});
it('403 without phenology:create; /phenology/state never contains coordinates', async () => {
await request(server())
.post('/api/v1/phenology/reports')
.set(auth(noRightsToken))
.set('Idempotency-Key', nextKey())
.send({ phase: 'SALWEIDE', date: TODAY })
.expect(403);
await request(server())
.get('/api/v1/phenology/reports')
.set(auth(noRightsToken))
.expect(403);
await createApiary('state');
const response = await request(server())
.get('/api/v1/phenology/state')
.set(auth(noRightsToken))
.expect(200);
expect(JSON.stringify(response.body)).not.toMatch(/latitude|longitude/i);
expect((response.body as StateBody[]).length).toBeGreaterThan(0);
});
it('a segment change sends no single notification (the morning digest carries it)', async () => {
const apiaryId = await createApiary('quiet');
const created = await report({ phase: 'KIRSCHE', date: TODAY, apiaryId });
await drain(created.id);
expect((await rowOf(apiaryId)).segment).toBe('SCHWARMZEIT');
expect(await changesOf(apiaryId)).toHaveLength(1);
expect(
await prisma.notification.count({
where: { relatedType: 'Apiary', relatedId: apiaryId },
}),
).toBe(0);
});
it('a report never moves the phase back', async () => {
const apiaryId = await createApiary('forward');
const high = await report({ phase: 'KIRSCHE', date: TODAY, apiaryId });
await drain(high.id);
const low = await report({ phase: 'SALWEIDE', date: TODAY, apiaryId });
await drain(low.id);
expect(await rowOf(apiaryId)).toMatchObject({
effectivePhase: 'KIRSCHE',
segment: 'SCHWARMZEIT',
});
});
it('moving a report to another apiary recomputes the old one and the new one', async () => {
const oldApiary = await createApiary('move-old');
const newApiary = await createApiary('move-new');
const created = await report({
phase: 'SALWEIDE',
date: TODAY,
apiaryId: oldApiary,
});
await drain(created.id);
expect((await rowOf(oldApiary)).effectivePhase).toBe('SALWEIDE');
await request(server())
.patch(`/api/v1/phenology/reports/${created.id}`)
.set(auth())
.send({ apiaryId: newApiary })
.expect(200);
await drain(created.id);
expect((await rowOf(oldApiary)).effectivePhase).toBeNull();
expect((await rowOf(newApiary)).effectivePhase).toBe('SALWEIDE');
});
it('a patch without a change records neither an audit entry nor an event', async () => {
const apiaryId = await createApiary('nochange');
const created = await report({ phase: 'SALWEIDE', date: TODAY, apiaryId });
const response = await request(server())
.patch(`/api/v1/phenology/reports/${created.id}`)
.set(auth())
.send({ phase: 'SALWEIDE' })
.expect(200);
expect((response.body as ReportBody).id).toBe(created.id);
expect(
await prisma.domainEvent.count({
where: { type: 'PhenologyCorrected', aggregateId: created.id },
}),
).toBe(0);
expect(
await prisma.auditLog.count({
where: { entityType: 'PhenologyReport', entityId: created.id },
}),
).toBe(1);
});
});
+53 -7
View File
@@ -1,6 +1,7 @@
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';
@@ -18,6 +19,13 @@ const d = (day: string): Date => new Date(`${day}T00:00:00.000Z`);
* 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>;
@@ -138,6 +146,27 @@ describe('Reminders (e2e)', () => {
},
});
}
// 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 () => {
@@ -147,7 +176,7 @@ describe('Reminders (e2e)', () => {
},
});
await prisma.domainEvent.deleteMany({
where: { aggregateId: { in: taskIds } },
where: { aggregateId: { in: [...taskIds, apiaryId] } },
});
await prisma.inspection.deleteMany({
where: { colonyId: { in: colonyIds } },
@@ -243,8 +272,8 @@ describe('Reminders (e2e)', () => {
users.nothing,
];
const first = await digests.runFor(TODAY, own);
const second = await digests.runFor(TODAY, own);
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);
@@ -264,8 +293,10 @@ describe('Reminders (e2e)', () => {
`Durchsicht fällig: 2 Völker am Stand ${apiaryName}.`,
);
expect(lines).toContain(
`Stand ${apiaryName}: Schwarmzeit beginnt. Durchsicht alle 7 Tage.`,
`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',
@@ -276,18 +307,33 @@ describe('Reminders (e2e)', () => {
);
});
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]);
await digests.runFor(TODAY, [users.noInspection], windowOf(TODAY));
const [digest] = await digestOf(users.noInspection);
expect(digest.message).toContain(`Stand ${apiaryName}: Schwarmzeit`);
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]);
await digests.runFor(TODAY, [users.nothing, users.off], windowOf(TODAY));
expect(await digestOf(users.nothing)).toHaveLength(0);
expect(await digestOf(users.off)).toHaveLength(0);
+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);
});
});
});
+79
View File
@@ -0,0 +1,79 @@
import { randomUUID } from 'node:crypto';
import { INestApplication } from '@nestjs/common';
import { NestExpressApplication } from '@nestjs/platform-express';
import { Test } from '@nestjs/testing';
import { App } from 'supertest/types';
import { AppModule } from '../../src/app.module';
import { configureApp } from '../../src/app.config';
import { THROTTLER_NAMESPACE } from '../../src/common/throttler/prisma-throttler.storage';
import { TelegramApiError } from '../../src/telegram/telegram-api-error';
import { TelegramClient } from '../../src/telegram/telegram-client';
import { TelegramModule } from '../../src/telegram/telegram.module';
export interface SentMessage {
chatId: bigint;
text: string;
}
/**
* Stands in for the Bot API: no network. Every sendMessage is recorded;
* `failWith` scripts the next answers (an error is thrown once per entry,
* in order, for the given chat or any chat).
*/
export class FakeTelegramClient {
readonly sent: SentMessage[] = [];
private readonly failures: { chatId: bigint | null; error: Error }[] = [];
isConfigured(): boolean {
return true;
}
failWith(statusCode: number, description: string, chatId?: bigint): void {
this.failures.push({
chatId: chatId ?? null,
error: new TelegramApiError('sendMessage', statusCode, description, null),
});
}
sendMessage(chatId: bigint, text: string): Promise<void> {
const index = this.failures.findIndex(
(failure) => failure.chatId === null || failure.chatId === chatId,
);
if (index >= 0) {
const [failure] = this.failures.splice(index, 1);
return Promise.reject(failure.error);
}
this.sent.push({ chatId, text });
return Promise.resolve();
}
sentTo(chatId: bigint): SentMessage[] {
return this.sent.filter((message) => message.chatId === chatId);
}
}
export interface TelegramTestApp {
app: INestApplication<App>;
telegram: FakeTelegramClient;
}
/**
* The whole API plus TelegramModule, with the Bot API client replaced by a
* FakeTelegramClient. The TelegramSender is taken from the app and driven
* by the test (no worker loop runs).
*/
export async function createTelegramTestApp(): Promise<TelegramTestApp> {
const telegram = new FakeTelegramClient();
const moduleFixture = await Test.createTestingModule({
imports: [AppModule, TelegramModule],
})
.overrideProvider(THROTTLER_NAMESPACE)
.useValue(randomUUID())
.overrideProvider(TelegramClient)
.useValue(telegram)
.compile();
const app = moduleFixture.createNestApplication<NestExpressApplication>();
configureApp(app);
await app.init();
return { app, telegram };
}