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; 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 () => { if (fieldApiaryId) { await prisma.task.deleteMany({ where: { title: { startsWith: 'field-e2e-' } }, }); 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'); 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'); await request(app.getHttpServer()) .post(`/api/v1/apiaries/${apiaryId}/reactivate`) .set('Authorization', `Bearer ${beekeeperToken}`) .expect(409); }); 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 }[]; } 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('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('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); } }); }); });