- 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]>
470 lines
15 KiB
TypeScript
470 lines
15 KiB
TypeScript
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);
|
|
});
|
|
});
|