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
@@ -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 },
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in new issue
Block a user