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

+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);
});
});