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; 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 { 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): Promise { 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 { await drainDomainEvents(app, [reportId]); } const correlationsOf = async (aggregateIds: string[]): Promise => ( 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 { 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 { 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); }); });