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'; interface ApiaryBody { id: string; status: string; notes: string | null; publicLocationLabel: string | null; } describe('Apiaries (e2e)', () => { let app: INestApplication; let prisma: PrismaService; const beekeeperEmail = `apiaries-e2e-beekeeper-${Date.now()}@example.com`; const readonlyEmail = `apiaries-e2e-readonly-${Date.now()}@example.com`; const password = 'SuperSecret123!'; let beekeeperId: string; let readonlyId: string; let beekeeperToken: string; let readonlyToken: string; let apiaryId: string; const noColonyEmail = `apiaries-e2e-nocolony-${Date.now()}@example.com`; let noColonyRoleId: string; let noColonyUserId: string; let noColonyToken: string; let fieldApiaryId: string; // The ApiaryChanged codes of one apiary, oldest first. async function changesOf(id: string): Promise { const events = await prisma.domainEvent.findMany({ where: { type: 'ApiaryChanged', aggregateId: id }, orderBy: { occurredAt: 'asc' }, }); return events.map((event) => (event.payload as { change: string }).change); } beforeAll(async () => { app = await createTestApp(); prisma = app.get(PrismaService); const passwordHash = await bcrypt.hash(password, 12); const beekeeper = await prisma.user.create({ data: { email: beekeeperEmail, passwordHash, name: 'Beekeeper', role: { connect: { key: 'BEEKEEPER' } }, }, }); beekeeperId = beekeeper.id; const readonly = await prisma.user.create({ data: { email: readonlyEmail, passwordHash, name: 'Readonly', role: { connect: { key: 'READONLY' } }, }, }); readonlyId = readonly.id; beekeeperToken = (await login(app, beekeeperEmail, password)).accessToken; readonlyToken = (await login(app, readonlyEmail, password)).accessToken; const apiariesRead = await prisma.permission.findMany({ where: { key: 'apiaries:read' }, }); const noColonyRole = await prisma.role.create({ data: { key: `APIARIES_ONLY_${Date.now()}`, name: 'Nur Stände', permissions: { create: apiariesRead.map((permission) => ({ permissionId: permission.id, })), }, }, }); noColonyRoleId = noColonyRole.id; const noColonyUser = await prisma.user.create({ data: { email: noColonyEmail, passwordHash, name: 'No colonies', roleId: noColonyRoleId, }, }); noColonyUserId = noColonyUser.id; noColonyToken = (await login(app, noColonyEmail, password)).accessToken; }); afterAll(async () => { await prisma.domainEvent.deleteMany({ where: { aggregateType: 'Apiary', aggregateId: { in: [apiaryId, fieldApiaryId].filter(Boolean) }, }, }); if (fieldApiaryId) { await prisma.task.deleteMany({ where: { title: { startsWith: 'field-e2e-' } }, }); await prisma.suggestion.deleteMany({ where: { colony: { apiaryId: fieldApiaryId } }, }); await prisma.hiveSetup.deleteMany({ where: { colony: { apiaryId: fieldApiaryId } }, }); await prisma.colony.deleteMany({ where: { apiaryId: fieldApiaryId } }); await prisma.apiary.delete({ where: { id: fieldApiaryId } }); } await prisma.refreshToken.deleteMany({ where: { userId: noColonyUserId } }); await prisma.user.delete({ where: { id: noColonyUserId } }); await prisma.role.delete({ where: { id: noColonyRoleId } }); if (apiaryId) { await prisma.apiary.delete({ where: { id: apiaryId } }); } await prisma.refreshToken.deleteMany({ where: { userId: { in: [beekeeperId, readonlyId] } }, }); await prisma.auditLog.deleteMany({ where: { userId: { in: [beekeeperId, readonlyId] } }, }); await prisma.user.deleteMany({ where: { id: { in: [beekeeperId, readonlyId] } }, }); await app.close(); }); it('rejects create for a READONLY user', () => { return request(app.getHttpServer()) .post('/api/v1/apiaries') .set('Authorization', `Bearer ${readonlyToken}`) .send({ name: 'Sollte nicht klappen' }) .expect(403); }); it('lets a BEEKEEPER create an apiary', async () => { const response = await request(app.getHttpServer()) .post('/api/v1/apiaries') .set('Authorization', `Bearer ${beekeeperToken}`) .send({ name: 'Heimstand', location: 'Garten' }) .expect(201); const created = response.body as ApiaryBody; expect(created.id).toEqual(expect.any(String)); apiaryId = created.id; }); it('lets a READONLY user list and read apiaries', async () => { const list = await request(app.getHttpServer()) .get('/api/v1/apiaries') .set('Authorization', `Bearer ${readonlyToken}`) .expect(200); const apiaries = list.body as ApiaryBody[]; expect(apiaries.some((apiary) => apiary.id === apiaryId)).toBe(true); await request(app.getHttpServer()) .get(`/api/v1/apiaries/${apiaryId}`) .set('Authorization', `Bearer ${readonlyToken}`) .expect(200); }); it('lets a BEEKEEPER update and deactivate an apiary', async () => { const updated = await request(app.getHttpServer()) .patch(`/api/v1/apiaries/${apiaryId}`) .set('Authorization', `Bearer ${beekeeperToken}`) .send({ notes: 'aktualisiert' }) .expect(200); expect((updated.body as ApiaryBody).notes).toBe('aktualisiert'); expect(await changesOf(apiaryId)).toEqual(['CREATED']); const deactivated = await request(app.getHttpServer()) .post(`/api/v1/apiaries/${apiaryId}/deactivate`) .set('Authorization', `Bearer ${beekeeperToken}`) .expect(201); expect((deactivated.body as ApiaryBody).status).toBe('INACTIVE'); const reactivated = await request(app.getHttpServer()) .post(`/api/v1/apiaries/${apiaryId}/reactivate`) .set('Authorization', `Bearer ${beekeeperToken}`) .expect(201); expect((reactivated.body as ApiaryBody).status).toBe('ACTIVE'); expect(await changesOf(apiaryId)).toEqual([ 'CREATED', 'DEACTIVATED', 'REACTIVATED', ]); await request(app.getHttpServer()) .post(`/api/v1/apiaries/${apiaryId}/reactivate`) .set('Authorization', `Bearer ${beekeeperToken}`) .expect(409); }); it('updating only the name of an apiary leaves no ApiaryChanged', async () => { const before = await changesOf(apiaryId); await request(app.getHttpServer()) .patch(`/api/v1/apiaries/${apiaryId}`) .set('Authorization', `Bearer ${beekeeperToken}`) .send({ name: 'Heimstand umbenannt' }) .expect(200); expect(await changesOf(apiaryId)).toEqual(before); await request(app.getHttpServer()) .patch(`/api/v1/apiaries/${apiaryId}`) .set('Authorization', `Bearer ${beekeeperToken}`) .send({ address: 'Neuer Weg 3' }) .expect(200); expect(await changesOf(apiaryId)).toEqual([...before, 'LOCATION_CHANGED']); }); it('lets publicLocationLabel be set, changed and cleared with null', async () => { const patch = (body: object) => request(app.getHttpServer()) .patch(`/api/v1/apiaries/${apiaryId}`) .set('Authorization', `Bearer ${beekeeperToken}`) .send(body); const set = await patch({ publicLocationLabel: 'Nordhessen' }).expect(200); expect((set.body as ApiaryBody).publicLocationLabel).toBe('Nordhessen'); const changed = await patch({ publicLocationLabel: 'Südhessen' }).expect( 200, ); expect((changed.body as ApiaryBody).publicLocationLabel).toBe('Südhessen'); const cleared = await patch({ publicLocationLabel: null }).expect(200); expect((cleared.body as ApiaryBody).publicLocationLabel).toBeNull(); await patch({ publicLocationLabel: 'x'.repeat(101) }).expect(400); }); it('refuses to deactivate an apiary that still holds a weak colony', async () => { const weakColony = await prisma.colony.create({ data: { code: `W-${Date.now()}`, apiaryId, colonyType: 'PRODUCTION_COLONY', status: 'WEAK', }, }); try { await request(app.getHttpServer()) .post(`/api/v1/apiaries/${apiaryId}/deactivate`) .set('Authorization', `Bearer ${beekeeperToken}`) .expect(409); } finally { await prisma.colony.delete({ where: { id: weakColony.id } }); } }); describe('apiary field endpoints', () => { interface OverviewBody { apiaryId: string; rows: { code: string; inspectionDueOn: string | null; segment: string; nextStepTitle: string | null; openWarnings: number; openCritical: number; }[]; } interface PackBody { version: number; apiary: Record; tasks: { title: string }[] | null; } const codeSuffix = /-\d+$/; beforeAll(async () => { const apiary = await prisma.apiary.create({ data: { name: 'Feldstand', location: 'Wiese', latitude: 48.1, longitude: 11.5, address: 'Geheimweg 1', notes: 'geheim', }, }); fieldApiaryId = apiary.id; const suffix = Date.now(); const colonies = await Promise.all( ( [ ['V-10', 'ACTIVE'], ['V-2', 'WEAK'], ['V-3', 'DISSOLVED'], ] as const ).map(([code, status]) => prisma.colony.create({ data: { code: `${code}-${suffix}`, apiaryId: apiary.id, colonyType: 'PRODUCTION_COLONY', status, }, }), ), ); await prisma.task.createMany({ data: [ { title: 'field-e2e-apiary', relatedType: 'APIARY', relatedId: apiary.id, }, { title: 'field-e2e-colony', relatedType: 'COLONY', relatedId: colonies[0].id, }, { title: 'field-e2e-done', relatedType: 'COLONY', relatedId: colonies[0].id, status: 'DONE', }, ], }); }); it('colony-overview lists living colonies only, sorted by code', async () => { const response = await request(app.getHttpServer()) .get(`/api/v1/apiaries/${fieldApiaryId}/colony-overview`) .set('Authorization', `Bearer ${beekeeperToken}`) .expect(200); const body = response.body as OverviewBody; expect(body.apiaryId).toBe(fieldApiaryId); expect(body.rows.map((row) => row.code.replace(codeSuffix, ''))).toEqual([ 'V-2', 'V-10', ]); }); it('colony-overview carries inspectionDueOn, segment and nextStepTitle; warnings come from suggestions', async () => { const permissions = [ 'colonies:read', 'tasks:read', 'inspections:read', 'varroa:read', ]; const role = await prisma.role.create({ data: { key: `FIELD_OVERVIEW_${Date.now()}`, name: 'Feld-Übersicht', permissions: { create: permissions.map((key) => ({ permission: { connect: { key } }, })), }, }, }); const email = `apiaries-e2e-overview-${Date.now()}@example.com`; const user = await prisma.user.create({ data: { email, passwordHash: await bcrypt.hash(password, 12), name: 'Overview', roleId: role.id, }, }); const colony = await prisma.colony.findFirstOrThrow({ where: { apiaryId: fieldApiaryId, code: { startsWith: 'V-10-' } }, }); const base = { ruleKey: 'VARROA_THRESHOLD' as const, source: 'RULE' as const, mode: 'PLAN' as const, intent: 'VARROA_TREAT' as const, relatedType: 'COLONY' as const, relatedId: colony.id, colonyId: colony.id, reasonCode: 'FIELD_E2E', evidence: { colonyId: colony.id }, }; try { await prisma.suggestion.createMany({ data: [ { ...base, priority: 'HIGH', requiredPermission: 'varroa:read' }, { ...base, intent: 'HONEY_SUPER', priority: 'URGENT', requiredPermission: 'inspections:read', }, { ...base, intent: 'FEED', priority: 'URGENT', requiredPermission: 'feedings:read', }, { ...base, intent: 'INSPECT', priority: 'NORMAL' }, ], }); const token = (await login(app, email, password)).accessToken; const response = await request(app.getHttpServer()) .get(`/api/v1/apiaries/${fieldApiaryId}/colony-overview`) .set('Authorization', `Bearer ${token}`) .expect(200); const rows = (response.body as OverviewBody).rows; const row = rows.find((item) => item.code.startsWith('V-10-')); // HIGH (varroa:read) and URGENT (inspections:read) are visible, the // URGENT one that needs feedings:read is not. expect(row).toMatchObject({ openWarnings: 1, openCritical: 1 }); expect(row?.nextStepTitle).toEqual(expect.any(String)); expect(row?.segment).toEqual(expect.any(String)); expect(row).toHaveProperty('inspectionDueOn'); if (row?.segment === 'WINTERRUHE') { expect(row.inspectionDueOn).toBeNull(); } else { expect(row?.inspectionDueOn).toMatch(/^\d{4}-\d{2}-\d{2}$/); } const quiet = rows.find((item) => item.code.startsWith('V-2-')); expect(quiet).toMatchObject({ openWarnings: 0, openCritical: 0, nextStepTitle: null, }); const pack = await request(app.getHttpServer()) .get(`/api/v1/apiaries/${fieldApiaryId}/offline-pack`) .set('Authorization', `Bearer ${token}`) .expect(200); const steps = (pack.body as { nextSteps?: Record }) .nextSteps; expect(steps?.[colony.id]?.length).toBeGreaterThan(0); expect(steps?.[colony.id]?.length).toBeLessThanOrEqual(3); } finally { await prisma.suggestion.deleteMany({ where: { colonyId: colony.id } }); await prisma.refreshToken.deleteMany({ where: { userId: user.id } }); await prisma.user.delete({ where: { id: user.id } }); await prisma.role.delete({ where: { id: role.id } }); } }); it('offline-pack contains the open tasks of the apiary and its colonies', async () => { const response = await request(app.getHttpServer()) .get(`/api/v1/apiaries/${fieldApiaryId}/offline-pack`) .set('Authorization', `Bearer ${beekeeperToken}`) .expect(200); const body = response.body as PackBody; expect(body.version).toBe(1); expect(body.tasks?.map((task) => task.title).sort()).toEqual([ 'field-e2e-apiary', 'field-e2e-colony', ]); expect(Object.keys(body.apiary).sort()).toEqual([ 'id', 'location', 'name', 'status', ]); }); it('offline-pack carries the hives of the apiary colonies and the hive catalog', async () => { const [withHive, withoutHive] = await Promise.all([ prisma.colony.findFirstOrThrow({ where: { apiaryId: fieldApiaryId, code: { startsWith: 'V-10-' } }, }), prisma.colony.findFirstOrThrow({ where: { apiaryId: fieldApiaryId, code: { startsWith: 'V-2-' } }, }), ]); await prisma.hiveSetup.create({ data: { colonyId: withHive.id, systemId: 'hive-system-zander-liebig-10', state: { schemaVersion: 1, stack: [] }, }, }); const response = await request(app.getHttpServer()) .get(`/api/v1/apiaries/${fieldApiaryId}/offline-pack`) .set('Authorization', `Bearer ${beekeeperToken}`) .expect(200); const body = response.body as { hives?: Record; hiveCatalog?: { id: string; partTypes: unknown[] }[]; }; expect(Object.keys(body.hives ?? {})).toEqual([withHive.id]); expect(body.hives?.[withHive.id]).toMatchObject({ systemId: 'hive-system-zander-liebig-10', version: 1, }); expect(body.hives).not.toHaveProperty(withoutHive.id); const system = body.hiveCatalog?.find( (candidate) => candidate.id === 'hive-system-zander-liebig-10', ); expect(system?.partTypes.length).toBeGreaterThan(0); }); it('rejects both endpoints without colonies:read', async () => { for (const path of ['colony-overview', 'offline-pack']) { await request(app.getHttpServer()) .get(`/api/v1/apiaries/${fieldApiaryId}/${path}`) .set('Authorization', `Bearer ${noColonyToken}`) .expect(403); } }); it('answers 404 for an unknown apiary', async () => { for (const path of ['colony-overview', 'offline-pack']) { await request(app.getHttpServer()) .get(`/api/v1/apiaries/does-not-exist/${path}`) .set('Authorization', `Bearer ${beekeeperToken}`) .expect(404); } }); }); });