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