feat(backend): Koeniginnen-Modul (Modul 4)
QueensModule: CRUD + assign-colony + history, entsprechend Lastenheft §4.5 (Koeniginnenverwaltung). assign-colony schliesst die offene QueenColonyHistory der bisherigen Koenigin am Zielvolk (falls vorhanden) sowie eine evtl. noch offene History dieser Koenigin an einem anderen Volk, bevor Colony.currentQueenId gesetzt und ein neuer History-Eintrag angelegt wird - damit bleibt jeder Koeniginnenwechsel nachvollziehbar, wie gefordert. Rollen wie bei Apiaries/Colonies: Lesen offen, Schreiben auf ADMIN/BEEKEEPER beschraenkt. 5 Unit-Tests + 2 e2e-Tests, Lint, Typecheck und Build gruen.
This commit is contained in:
1 parent
6e328cdacb
commit
b18d61640d
10 files changed
+486
-1
No files matched your search
@@ -8,6 +8,7 @@ import { UsersModule } from './users/users.module';
|
||||
import { AuthModule } from './auth/auth.module';
|
||||
import { ApiariesModule } from './apiaries/apiaries.module';
|
||||
import { ColoniesModule } from './colonies/colonies.module';
|
||||
import { QueensModule } from './queens/queens.module';
|
||||
import { JwtAuthGuard } from './auth/guards/jwt-auth.guard';
|
||||
import { RolesGuard } from './auth/guards/roles.guard';
|
||||
|
||||
@@ -21,6 +22,7 @@ import { RolesGuard } from './auth/guards/roles.guard';
|
||||
AuthModule,
|
||||
ApiariesModule,
|
||||
ColoniesModule,
|
||||
QueensModule,
|
||||
],
|
||||
controllers: [],
|
||||
providers: [
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsOptional, IsString } from 'class-validator';
|
||||
|
||||
export class AssignColonyDto {
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
colonyId!: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
notes?: string;
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsInt, IsOptional, IsString } from 'class-validator';
|
||||
|
||||
export class CreateQueenDto {
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
code!: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
hatchYear?: number;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
breed?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
origin?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
breeder?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
markingColor?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
markingNumber?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
matingType?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
matingStatus?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
inseminationData?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
motherLine?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
fatherLine?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
notes?: string;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { ApiPropertyOptional, PartialType } from '@nestjs/swagger';
|
||||
import { IsEnum, IsOptional } from 'class-validator';
|
||||
import { QueenStatus } from '../../../generated/prisma';
|
||||
import { CreateQueenDto } from './create-queen.dto';
|
||||
|
||||
export class UpdateQueenDto extends PartialType(CreateQueenDto) {
|
||||
@ApiPropertyOptional({ enum: QueenStatus })
|
||||
@IsOptional()
|
||||
@IsEnum(QueenStatus)
|
||||
status?: QueenStatus;
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
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 { CreateQueenDto } from './dto/create-queen.dto';
|
||||
import { UpdateQueenDto } from './dto/update-queen.dto';
|
||||
import { AssignColonyDto } from './dto/assign-colony.dto';
|
||||
import { QueensService } from './queens.service';
|
||||
|
||||
@ApiTags('queens')
|
||||
@ApiBearerAuth()
|
||||
@Controller('queens')
|
||||
export class QueensController {
|
||||
constructor(private readonly queensService: QueensService) {}
|
||||
|
||||
@Get()
|
||||
findAll() {
|
||||
return this.queensService.findAll();
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
findOne(@Param('id') id: string) {
|
||||
return this.queensService.findById(id);
|
||||
}
|
||||
|
||||
@Get(':id/history')
|
||||
getHistory(@Param('id') id: string) {
|
||||
return this.queensService.getHistory(id);
|
||||
}
|
||||
|
||||
@Roles(Role.ADMIN, Role.BEEKEEPER)
|
||||
@Post()
|
||||
create(@Body() dto: CreateQueenDto, @CurrentUser() actor: AuthenticatedUser) {
|
||||
return this.queensService.create(dto, actor.id);
|
||||
}
|
||||
|
||||
@Roles(Role.ADMIN, Role.BEEKEEPER)
|
||||
@Patch(':id')
|
||||
update(
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpdateQueenDto,
|
||||
@CurrentUser() actor: AuthenticatedUser,
|
||||
) {
|
||||
return this.queensService.update(id, dto, actor.id);
|
||||
}
|
||||
|
||||
@Roles(Role.ADMIN, Role.BEEKEEPER)
|
||||
@Post(':id/assign-colony')
|
||||
assignColony(
|
||||
@Param('id') id: string,
|
||||
@Body() dto: AssignColonyDto,
|
||||
@CurrentUser() actor: AuthenticatedUser,
|
||||
) {
|
||||
return this.queensService.assignColony(id, dto, actor.id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { QueensController } from './queens.controller';
|
||||
import { QueensService } from './queens.service';
|
||||
|
||||
@Module({
|
||||
controllers: [QueensController],
|
||||
providers: [QueensService],
|
||||
exports: [QueensService],
|
||||
})
|
||||
export class QueensModule {}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { NotFoundException } from '@nestjs/common';
|
||||
import { QueensService } from './queens.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { AuditLogService } from '../audit/audit-log.service';
|
||||
|
||||
describe('QueensService', () => {
|
||||
let service: QueensService;
|
||||
let tx: {
|
||||
queenColonyHistory: { updateMany: jest.Mock; create: jest.Mock };
|
||||
colony: { update: jest.Mock };
|
||||
};
|
||||
let prisma: {
|
||||
queen: {
|
||||
findMany: jest.Mock;
|
||||
findUnique: jest.Mock;
|
||||
create: jest.Mock;
|
||||
update: jest.Mock;
|
||||
};
|
||||
colony: { findUnique: jest.Mock };
|
||||
queenColonyHistory: { findMany: jest.Mock };
|
||||
$transaction: jest.Mock;
|
||||
};
|
||||
let auditLog: { record: jest.Mock };
|
||||
|
||||
const queen = { id: 'queen-1', code: 'Q-001', status: 'ACTIVE' };
|
||||
const colony = { id: 'colony-1', currentQueenId: null };
|
||||
|
||||
beforeEach(() => {
|
||||
tx = {
|
||||
queenColonyHistory: { updateMany: jest.fn(), create: jest.fn() },
|
||||
colony: { update: jest.fn() },
|
||||
};
|
||||
prisma = {
|
||||
queen: {
|
||||
findMany: jest.fn(),
|
||||
findUnique: jest.fn(),
|
||||
create: jest.fn(),
|
||||
update: jest.fn(),
|
||||
},
|
||||
colony: { findUnique: jest.fn() },
|
||||
queenColonyHistory: { findMany: jest.fn() },
|
||||
$transaction: jest.fn((callback: (txArg: typeof tx) => unknown) =>
|
||||
callback(tx),
|
||||
),
|
||||
};
|
||||
auditLog = { record: jest.fn().mockResolvedValue(undefined) };
|
||||
service = new QueensService(
|
||||
prisma as unknown as PrismaService,
|
||||
auditLog as unknown as AuditLogService,
|
||||
);
|
||||
});
|
||||
|
||||
it('findById throws NotFoundException when missing', async () => {
|
||||
prisma.queen.findUnique.mockResolvedValue(null);
|
||||
await expect(service.findById('missing')).rejects.toBeInstanceOf(
|
||||
NotFoundException,
|
||||
);
|
||||
});
|
||||
|
||||
it('assignColony throws NotFoundException when the colony is missing', async () => {
|
||||
prisma.queen.findUnique.mockResolvedValue(queen);
|
||||
prisma.colony.findUnique.mockResolvedValue(null);
|
||||
await expect(
|
||||
service.assignColony('queen-1', { colonyId: 'missing' }, 'user-1'),
|
||||
).rejects.toBeInstanceOf(NotFoundException);
|
||||
});
|
||||
|
||||
it('assignColony closes the previous queen history, sets currentQueenId, and opens a new history row', async () => {
|
||||
prisma.queen.findUnique.mockResolvedValue(queen);
|
||||
prisma.colony.findUnique.mockResolvedValue({
|
||||
...colony,
|
||||
currentQueenId: 'queen-old',
|
||||
});
|
||||
tx.colony.update.mockResolvedValue({});
|
||||
tx.queenColonyHistory.create.mockResolvedValue({});
|
||||
|
||||
await service.assignColony('queen-1', { colonyId: 'colony-1' }, 'user-1');
|
||||
|
||||
const [[closeArg]] = tx.queenColonyHistory.updateMany.mock.calls as [
|
||||
[{ where: { queenId: string; colonyId: string } }],
|
||||
];
|
||||
expect(closeArg.where.queenId).toBe('queen-old');
|
||||
expect(closeArg.where.colonyId).toBe('colony-1');
|
||||
expect(tx.colony.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { id: 'colony-1' },
|
||||
data: { currentQueenId: 'queen-1' },
|
||||
}),
|
||||
);
|
||||
const [[historyArg]] = tx.queenColonyHistory.create.mock.calls as [
|
||||
[{ data: { queenId: string; colonyId: string } }],
|
||||
];
|
||||
expect(historyArg.data.queenId).toBe('queen-1');
|
||||
expect(historyArg.data.colonyId).toBe('colony-1');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { AuditLogService } from '../audit/audit-log.service';
|
||||
import { Queen } from '../../generated/prisma';
|
||||
import { CreateQueenDto } from './dto/create-queen.dto';
|
||||
import { UpdateQueenDto } from './dto/update-queen.dto';
|
||||
import { AssignColonyDto } from './dto/assign-colony.dto';
|
||||
|
||||
@Injectable()
|
||||
export class QueensService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly auditLog: AuditLogService,
|
||||
) {}
|
||||
|
||||
async findAll(): Promise<Queen[]> {
|
||||
return this.prisma.queen.findMany({ orderBy: { code: 'asc' } });
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<Queen> {
|
||||
const queen = await this.prisma.queen.findUnique({ where: { id } });
|
||||
if (!queen) {
|
||||
throw new NotFoundException('Queen not found');
|
||||
}
|
||||
return queen;
|
||||
}
|
||||
|
||||
async create(dto: CreateQueenDto, actingUserId: string): Promise<Queen> {
|
||||
const queen = await this.prisma.queen.create({ data: { ...dto } });
|
||||
await this.auditLog.record({
|
||||
entityType: 'Queen',
|
||||
entityId: queen.id,
|
||||
action: 'CREATE',
|
||||
userId: actingUserId,
|
||||
afterData: queen,
|
||||
});
|
||||
return queen;
|
||||
}
|
||||
|
||||
async update(
|
||||
id: string,
|
||||
dto: UpdateQueenDto,
|
||||
actingUserId: string,
|
||||
): Promise<Queen> {
|
||||
const before = await this.findById(id);
|
||||
const queen = await this.prisma.queen.update({
|
||||
where: { id },
|
||||
data: { ...dto },
|
||||
});
|
||||
await this.auditLog.record({
|
||||
entityType: 'Queen',
|
||||
entityId: id,
|
||||
action: 'UPDATE',
|
||||
userId: actingUserId,
|
||||
beforeData: before,
|
||||
afterData: queen,
|
||||
});
|
||||
return queen;
|
||||
}
|
||||
|
||||
async assignColony(
|
||||
id: string,
|
||||
dto: AssignColonyDto,
|
||||
actingUserId: string,
|
||||
): Promise<Queen> {
|
||||
const queen = await this.findById(id);
|
||||
const colony = await this.prisma.colony.findUnique({
|
||||
where: { id: dto.colonyId },
|
||||
});
|
||||
if (!colony) {
|
||||
throw new NotFoundException('Colony not found');
|
||||
}
|
||||
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
if (colony.currentQueenId && colony.currentQueenId !== queen.id) {
|
||||
await tx.queenColonyHistory.updateMany({
|
||||
where: {
|
||||
queenId: colony.currentQueenId,
|
||||
colonyId: colony.id,
|
||||
endDate: null,
|
||||
},
|
||||
data: { endDate: new Date() },
|
||||
});
|
||||
}
|
||||
await tx.queenColonyHistory.updateMany({
|
||||
where: { queenId: queen.id, endDate: null },
|
||||
data: { endDate: new Date() },
|
||||
});
|
||||
await tx.colony.update({
|
||||
where: { id: colony.id },
|
||||
data: { currentQueenId: queen.id },
|
||||
});
|
||||
await tx.queenColonyHistory.create({
|
||||
data: {
|
||||
queenId: queen.id,
|
||||
colonyId: colony.id,
|
||||
startDate: new Date(),
|
||||
notes: dto.notes,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
await this.auditLog.record({
|
||||
entityType: 'Queen',
|
||||
entityId: queen.id,
|
||||
action: 'UPDATE',
|
||||
userId: actingUserId,
|
||||
beforeData: queen,
|
||||
afterData: { ...queen, assignedColonyId: colony.id },
|
||||
});
|
||||
|
||||
return this.findById(id);
|
||||
}
|
||||
|
||||
async getHistory(id: string) {
|
||||
await this.findById(id);
|
||||
return this.prisma.queenColonyHistory.findMany({
|
||||
where: { queenId: id },
|
||||
orderBy: { startDate: 'desc' },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
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 QueenBody {
|
||||
id: string;
|
||||
code: string;
|
||||
}
|
||||
|
||||
describe('Queens (e2e)', () => {
|
||||
let app: INestApplication<App>;
|
||||
let prisma: PrismaService;
|
||||
const beekeeperEmail = `queens-e2e-beekeeper-${Date.now()}@example.com`;
|
||||
const password = 'SuperSecret123!';
|
||||
let beekeeperId: string;
|
||||
let beekeeperToken: string;
|
||||
let apiaryId: string;
|
||||
let colonyId: string;
|
||||
let queenId: 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;
|
||||
beekeeperToken = (await login(app, beekeeperEmail, password)).accessToken;
|
||||
|
||||
const apiary = await prisma.apiary.create({
|
||||
data: { name: `Stand ${Date.now()}` },
|
||||
});
|
||||
apiaryId = apiary.id;
|
||||
const colony = await prisma.colony.create({
|
||||
data: {
|
||||
code: `V-${Date.now()}`,
|
||||
apiaryId,
|
||||
colonyType: 'PRODUCTION_COLONY',
|
||||
},
|
||||
});
|
||||
colonyId = colony.id;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await prisma.queenColonyHistory.deleteMany({ where: { colonyId } });
|
||||
if (colonyId) {
|
||||
await prisma.colony.update({
|
||||
where: { id: colonyId },
|
||||
data: { currentQueenId: null },
|
||||
});
|
||||
}
|
||||
if (queenId) {
|
||||
await prisma.queen.delete({ where: { id: queenId } });
|
||||
}
|
||||
await prisma.colony.deleteMany({ where: { id: colonyId } });
|
||||
await prisma.apiary.deleteMany({ where: { id: apiaryId } });
|
||||
await prisma.refreshToken.deleteMany({ where: { userId: beekeeperId } });
|
||||
await prisma.auditLog.deleteMany({ where: { userId: beekeeperId } });
|
||||
await prisma.user.deleteMany({ where: { id: beekeeperId } });
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('lets a BEEKEEPER create a queen', async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.post('/api/v1/queens')
|
||||
.set('Authorization', `Bearer ${beekeeperToken}`)
|
||||
.send({ code: `Q-${Date.now()}`, breed: 'Carnica' })
|
||||
.expect(201);
|
||||
queenId = (response.body as QueenBody).id;
|
||||
expect(queenId).toEqual(expect.any(String));
|
||||
});
|
||||
|
||||
it('assigns the queen to a colony and records history', async () => {
|
||||
await request(app.getHttpServer())
|
||||
.post(`/api/v1/queens/${queenId}/assign-colony`)
|
||||
.set('Authorization', `Bearer ${beekeeperToken}`)
|
||||
.send({ colonyId })
|
||||
.expect(201);
|
||||
|
||||
const colony = await request(app.getHttpServer())
|
||||
.get(`/api/v1/colonies/${colonyId}`)
|
||||
.set('Authorization', `Bearer ${beekeeperToken}`)
|
||||
.expect(200);
|
||||
expect((colony.body as { currentQueenId: string }).currentQueenId).toBe(
|
||||
queenId,
|
||||
);
|
||||
|
||||
const history = await request(app.getHttpServer())
|
||||
.get(`/api/v1/queens/${queenId}/history`)
|
||||
.set('Authorization', `Bearer ${beekeeperToken}`)
|
||||
.expect(200);
|
||||
expect(history.body).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -42,7 +42,7 @@ Abgeleitet aus [`docs/lastenheft.md`](docs/lastenheft.md) Abschnitt 26. Reihenfo
|
||||
- Backend: `QueensModule` (inkl. `QueenColonyHistory`)
|
||||
- Endpunkte: `GET/POST /api/v1/queens`, `GET/PATCH /api/v1/queens/:id`, `POST /api/v1/queens/:id/assign-colony`, `GET /api/v1/queens/:id/history`
|
||||
- Frontend: `/queens`, `/queens/[id]`, `/queens/new`
|
||||
- [ ] Backend-Modul fertig, getestet, grün, committet
|
||||
- [x] Backend-Modul fertig, getestet, grün, committet
|
||||
- [ ] Frontend-Views fertig, getestet, grün, committet
|
||||
|
||||
### 5. Durchsichten
|
||||
|
||||
Reference in new issue
Block a user