feat(backend): Bienenstaende-Modul (Modul 2)

ApiariesModule: CRUD + Deactivate. Lesen fuer alle authentifizierten
Rollen offen, Schreiben (Anlegen/Aendern/Deaktivieren) auf ADMIN und
BEEKEEPER beschraenkt (Lastenheft §3.2: Imker verwaltet Bienenstaende).
Audit-Log-Eintraege bei Create/Update/Deactivate ueber AuditLogService.

4 Unit-Tests + 4 e2e-Tests (inkl. Rollenpruefung READONLY vs. BEEKEEPER),
Lint, Typecheck und Build gruen.
This commit is contained in:
Nick Szaunig committed 2026-08-21 08:50:50 +02:00
1 parent 029484f9d5
commit 5c92c810e1
9 files changed
+379 -1

No files matched your search

@@ -0,0 +1,51 @@
import { Body, Controller, Get, Param, Patch, Post } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { Role } from '../../generated/prisma';
import { Roles } from '../auth/decorators/roles.decorator';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import type { AuthenticatedUser } from '../auth/types/authenticated-user';
import { CreateApiaryDto } from './dto/create-apiary.dto';
import { UpdateApiaryDto } from './dto/update-apiary.dto';
import { ApiariesService } from './apiaries.service';
@ApiTags('apiaries')
@ApiBearerAuth()
@Controller('apiaries')
export class ApiariesController {
constructor(private readonly apiariesService: ApiariesService) {}
@Get()
findAll() {
return this.apiariesService.findAll();
}
@Get(':id')
findOne(@Param('id') id: string) {
return this.apiariesService.findById(id);
}
@Roles(Role.ADMIN, Role.BEEKEEPER)
@Post()
create(
@Body() dto: CreateApiaryDto,
@CurrentUser() actor: AuthenticatedUser,
) {
return this.apiariesService.create(dto, actor.id);
}
@Roles(Role.ADMIN, Role.BEEKEEPER)
@Patch(':id')
update(
@Param('id') id: string,
@Body() dto: UpdateApiaryDto,
@CurrentUser() actor: AuthenticatedUser,
) {
return this.apiariesService.update(id, dto, actor.id);
}
@Roles(Role.ADMIN, Role.BEEKEEPER)
@Post(':id/deactivate')
deactivate(@Param('id') id: string, @CurrentUser() actor: AuthenticatedUser) {
return this.apiariesService.deactivate(id, actor.id);
}
}
@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { ApiariesController } from './apiaries.controller';
import { ApiariesService } from './apiaries.service';
@Module({
controllers: [ApiariesController],
providers: [ApiariesService],
exports: [ApiariesService],
})
export class ApiariesModule {}
@@ -0,0 +1,78 @@
import { NotFoundException } from '@nestjs/common';
import { ApiariesService } from './apiaries.service';
import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service';
describe('ApiariesService', () => {
let service: ApiariesService;
let prisma: {
apiary: {
findMany: jest.Mock;
findUnique: jest.Mock;
create: jest.Mock;
update: jest.Mock;
};
};
let auditLog: { record: jest.Mock };
const existingApiary = {
id: 'apiary-1',
name: 'Heimstand',
status: 'ACTIVE',
deactivatedAt: null,
};
beforeEach(() => {
prisma = {
apiary: {
findMany: jest.fn(),
findUnique: jest.fn(),
create: jest.fn(),
update: jest.fn(),
},
};
auditLog = { record: jest.fn().mockResolvedValue(undefined) };
service = new ApiariesService(
prisma as unknown as PrismaService,
auditLog as unknown as AuditLogService,
);
});
it('findById throws NotFoundException when missing', async () => {
prisma.apiary.findUnique.mockResolvedValue(null);
await expect(service.findById('missing')).rejects.toBeInstanceOf(
NotFoundException,
);
});
it('create persists the apiary and records an audit entry', async () => {
prisma.apiary.create.mockResolvedValue(existingApiary);
const result = await service.create({ name: 'Heimstand' }, 'user-1');
expect(result).toEqual(existingApiary);
expect(auditLog.record).toHaveBeenCalledWith(
expect.objectContaining({
entityType: 'Apiary',
action: 'CREATE',
userId: 'user-1',
}),
);
});
it('deactivate sets status to INACTIVE and records an audit entry', async () => {
prisma.apiary.findUnique.mockResolvedValue(existingApiary);
prisma.apiary.update.mockResolvedValue({
...existingApiary,
status: 'INACTIVE',
deactivatedAt: new Date(),
});
const result = await service.deactivate('apiary-1', 'user-1');
expect(result.status).toBe('INACTIVE');
expect(auditLog.record).toHaveBeenCalledWith(
expect.objectContaining({ entityType: 'Apiary', action: 'DEACTIVATE' }),
);
});
});
@@ -0,0 +1,76 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service';
import { Apiary } from '../../generated/prisma';
import { CreateApiaryDto } from './dto/create-apiary.dto';
import { UpdateApiaryDto } from './dto/update-apiary.dto';
@Injectable()
export class ApiariesService {
constructor(
private readonly prisma: PrismaService,
private readonly auditLog: AuditLogService,
) {}
async findAll(): Promise<Apiary[]> {
return this.prisma.apiary.findMany({ orderBy: { name: 'asc' } });
}
async findById(id: string): Promise<Apiary> {
const apiary = await this.prisma.apiary.findUnique({ where: { id } });
if (!apiary) {
throw new NotFoundException('Apiary not found');
}
return apiary;
}
async create(dto: CreateApiaryDto, actingUserId: string): Promise<Apiary> {
const apiary = await this.prisma.apiary.create({ data: { ...dto } });
await this.auditLog.record({
entityType: 'Apiary',
entityId: apiary.id,
action: 'CREATE',
userId: actingUserId,
afterData: apiary,
});
return apiary;
}
async update(
id: string,
dto: UpdateApiaryDto,
actingUserId: string,
): Promise<Apiary> {
const before = await this.findById(id);
const apiary = await this.prisma.apiary.update({
where: { id },
data: { ...dto },
});
await this.auditLog.record({
entityType: 'Apiary',
entityId: id,
action: 'UPDATE',
userId: actingUserId,
beforeData: before,
afterData: apiary,
});
return apiary;
}
async deactivate(id: string, actingUserId: string): Promise<Apiary> {
const before = await this.findById(id);
const apiary = await this.prisma.apiary.update({
where: { id },
data: { status: 'INACTIVE', deactivatedAt: new Date() },
});
await this.auditLog.record({
entityType: 'Apiary',
entityId: id,
action: 'DEACTIVATE',
userId: actingUserId,
beforeData: before,
afterData: apiary,
});
return apiary;
}
}
@@ -0,0 +1,38 @@
import { ApiPropertyOptional, ApiProperty } from '@nestjs/swagger';
import { IsLatitude, IsLongitude, IsOptional, IsString } from 'class-validator';
export class CreateApiaryDto {
@ApiProperty()
@IsString()
name!: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
description?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
location?: string;
@ApiPropertyOptional()
@IsOptional()
@IsLatitude()
latitude?: number;
@ApiPropertyOptional()
@IsOptional()
@IsLongitude()
longitude?: number;
@ApiPropertyOptional()
@IsOptional()
@IsString()
address?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
notes?: string;
}
@@ -0,0 +1,4 @@
import { PartialType } from '@nestjs/swagger';
import { CreateApiaryDto } from './create-apiary.dto';
export class UpdateApiaryDto extends PartialType(CreateApiaryDto) {}
+2
View File
@@ -6,6 +6,7 @@ import { HealthModule } from './health/health.module';
import { AuditModule } from './audit/audit.module';
import { UsersModule } from './users/users.module';
import { AuthModule } from './auth/auth.module';
import { ApiariesModule } from './apiaries/apiaries.module';
import { JwtAuthGuard } from './auth/guards/jwt-auth.guard';
import { RolesGuard } from './auth/guards/roles.guard';
@@ -17,6 +18,7 @@ import { RolesGuard } from './auth/guards/roles.guard';
HealthModule,
UsersModule,
AuthModule,
ApiariesModule,
],
controllers: [],
providers: [
+119
View File
@@ -0,0 +1,119 @@
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;
}
describe('Apiaries (e2e)', () => {
let app: INestApplication<App>;
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;
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: 'BEEKEEPER',
},
});
beekeeperId = beekeeper.id;
const readonly = await prisma.user.create({
data: {
email: readonlyEmail,
passwordHash,
name: 'Readonly',
role: 'READONLY',
},
});
readonlyId = readonly.id;
beekeeperToken = (await login(app, beekeeperEmail, password)).accessToken;
readonlyToken = (await login(app, readonlyEmail, password)).accessToken;
});
afterAll(async () => {
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');
});
});