diff --git a/ApiCore-Backend/src/app.module.ts b/ApiCore-Backend/src/app.module.ts index b456e91..5dfda78 100644 --- a/ApiCore-Backend/src/app.module.ts +++ b/ApiCore-Backend/src/app.module.ts @@ -7,6 +7,7 @@ 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 { ColoniesModule } from './colonies/colonies.module'; import { JwtAuthGuard } from './auth/guards/jwt-auth.guard'; import { RolesGuard } from './auth/guards/roles.guard'; @@ -19,6 +20,7 @@ import { RolesGuard } from './auth/guards/roles.guard'; UsersModule, AuthModule, ApiariesModule, + ColoniesModule, ], controllers: [], providers: [ diff --git a/ApiCore-Backend/src/colonies/colonies.controller.ts b/ApiCore-Backend/src/colonies/colonies.controller.ts new file mode 100644 index 0000000..f9f0915 --- /dev/null +++ b/ApiCore-Backend/src/colonies/colonies.controller.ts @@ -0,0 +1,89 @@ +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 { CreateColonyDto } from './dto/create-colony.dto'; +import { UpdateColonyDto } from './dto/update-colony.dto'; +import { MoveColonyDto } from './dto/move-colony.dto'; +import { SplitColonyDto } from './dto/split-colony.dto'; +import { MergeColonyDto } from './dto/merge-colony.dto'; +import { ColoniesService } from './colonies.service'; + +@ApiTags('colonies') +@ApiBearerAuth() +@Controller('colonies') +export class ColoniesController { + constructor(private readonly coloniesService: ColoniesService) {} + + @Get() + findAll() { + return this.coloniesService.findAll(); + } + + @Get(':id') + findOne(@Param('id') id: string) { + return this.coloniesService.findById(id); + } + + @Get(':id/history') + getHistory(@Param('id') id: string) { + return this.coloniesService.getHistory(id); + } + + @Roles(Role.ADMIN, Role.BEEKEEPER) + @Post() + create( + @Body() dto: CreateColonyDto, + @CurrentUser() actor: AuthenticatedUser, + ) { + return this.coloniesService.create(dto, actor.id); + } + + @Roles(Role.ADMIN, Role.BEEKEEPER) + @Patch(':id') + update( + @Param('id') id: string, + @Body() dto: UpdateColonyDto, + @CurrentUser() actor: AuthenticatedUser, + ) { + return this.coloniesService.update(id, dto, actor.id); + } + + @Roles(Role.ADMIN, Role.BEEKEEPER) + @Post(':id/move') + move( + @Param('id') id: string, + @Body() dto: MoveColonyDto, + @CurrentUser() actor: AuthenticatedUser, + ) { + return this.coloniesService.move(id, dto, actor.id); + } + + @Roles(Role.ADMIN, Role.BEEKEEPER) + @Post(':id/split') + split( + @Param('id') id: string, + @Body() dto: SplitColonyDto, + @CurrentUser() actor: AuthenticatedUser, + ) { + return this.coloniesService.split(id, dto, actor.id); + } + + @Roles(Role.ADMIN, Role.BEEKEEPER) + @Post(':id/merge') + merge( + @Param('id') id: string, + @Body() dto: MergeColonyDto, + @CurrentUser() actor: AuthenticatedUser, + ) { + return this.coloniesService.merge(id, dto, actor.id); + } + + @Roles(Role.ADMIN, Role.BEEKEEPER) + @Post(':id/dissolve') + dissolve(@Param('id') id: string, @CurrentUser() actor: AuthenticatedUser) { + return this.coloniesService.dissolve(id, actor.id); + } +} diff --git a/ApiCore-Backend/src/colonies/colonies.module.ts b/ApiCore-Backend/src/colonies/colonies.module.ts new file mode 100644 index 0000000..18cbd42 --- /dev/null +++ b/ApiCore-Backend/src/colonies/colonies.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { ColoniesController } from './colonies.controller'; +import { ColoniesService } from './colonies.service'; + +@Module({ + controllers: [ColoniesController], + providers: [ColoniesService], + exports: [ColoniesService], +}) +export class ColoniesModule {} diff --git a/ApiCore-Backend/src/colonies/colonies.service.spec.ts b/ApiCore-Backend/src/colonies/colonies.service.spec.ts new file mode 100644 index 0000000..14eeeb4 --- /dev/null +++ b/ApiCore-Backend/src/colonies/colonies.service.spec.ts @@ -0,0 +1,160 @@ +import { NotFoundException } from '@nestjs/common'; +import { ColoniesService } from './colonies.service'; +import { PrismaService } from '../prisma/prisma.service'; +import { AuditLogService } from '../audit/audit-log.service'; + +describe('ColoniesService', () => { + let service: ColoniesService; + let tx: { + colony: { create: jest.Mock; update: jest.Mock; findUnique: jest.Mock }; + colonyApiaryHistory: { create: jest.Mock }; + colonyLineage: { create: jest.Mock }; + }; + let prisma: { + colony: { + findMany: jest.Mock; + findUnique: jest.Mock; + create: jest.Mock; + update: jest.Mock; + }; + colonyApiaryHistory: { findMany: jest.Mock }; + queenColonyHistory: { findMany: jest.Mock }; + colonyLineage: { findMany: jest.Mock }; + $transaction: jest.Mock; + }; + let auditLog: { record: jest.Mock }; + + const parentColony = { + id: 'colony-1', + code: 'V-001', + apiaryId: 'apiary-1', + status: 'ACTIVE', + deactivatedAt: null, + }; + + beforeEach(() => { + tx = { + colony: { create: jest.fn(), update: jest.fn(), findUnique: jest.fn() }, + colonyApiaryHistory: { create: jest.fn() }, + colonyLineage: { create: jest.fn() }, + }; + prisma = { + colony: { + findMany: jest.fn(), + findUnique: jest.fn(), + create: jest.fn(), + update: jest.fn(), + }, + colonyApiaryHistory: { findMany: jest.fn() }, + queenColonyHistory: { findMany: jest.fn() }, + colonyLineage: { findMany: jest.fn() }, + $transaction: jest.fn((callback: (txArg: typeof tx) => unknown) => + callback(tx), + ), + }; + auditLog = { record: jest.fn().mockResolvedValue(undefined) }; + service = new ColoniesService( + prisma as unknown as PrismaService, + auditLog as unknown as AuditLogService, + ); + }); + + it('findById throws NotFoundException when missing', async () => { + prisma.colony.findUnique.mockResolvedValue(null); + await expect(service.findById('missing')).rejects.toBeInstanceOf( + NotFoundException, + ); + }); + + it('move updates the apiary and records apiary history', async () => { + prisma.colony.findUnique.mockResolvedValue(parentColony); + tx.colony.update.mockResolvedValue({ + ...parentColony, + apiaryId: 'apiary-2', + }); + tx.colonyApiaryHistory.create.mockResolvedValue({}); + + const result = await service.move( + 'colony-1', + { apiaryId: 'apiary-2' }, + 'user-1', + ); + + expect(result.apiaryId).toBe('apiary-2'); + const [[historyArg]] = tx.colonyApiaryHistory.create.mock.calls as [ + [{ data: { colonyId: string; apiaryId: string } }], + ]; + expect(historyArg.data.colonyId).toBe('colony-1'); + expect(historyArg.data.apiaryId).toBe('apiary-2'); + }); + + it('split creates a new colony and a SPLIT lineage entry', async () => { + prisma.colony.findUnique.mockResolvedValue(parentColony); + const child = { id: 'colony-2', code: 'V-001-A', apiaryId: 'apiary-1' }; + tx.colony.create.mockResolvedValue(child); + tx.colonyLineage.create.mockResolvedValue({}); + + const result = await service.split( + 'colony-1', + { code: 'V-001-A', colonyType: 'NUCLEUS_COLONY' }, + 'user-1', + ); + + expect(result).toEqual(child); + const [[lineageArg]] = tx.colonyLineage.create.mock.calls as [ + [{ data: { type: string; fromColonyId: string; toColonyId: string } }], + ]; + expect(lineageArg.data.type).toBe('SPLIT'); + expect(lineageArg.data.fromColonyId).toBe('colony-1'); + expect(lineageArg.data.toColonyId).toBe('colony-2'); + }); + + it('merge marks the merged colony as MERGED and records a MERGE lineage entry', async () => { + prisma.colony.findUnique + .mockResolvedValueOnce(parentColony) + .mockResolvedValueOnce({ + ...parentColony, + id: 'colony-1', + }); + tx.colony.findUnique.mockResolvedValue({ + id: 'colony-3', + status: 'ACTIVE', + }); + tx.colony.update.mockResolvedValue({}); + tx.colonyLineage.create.mockResolvedValue({}); + + await service.merge( + 'colony-1', + { mergedColonyIds: ['colony-3'] }, + 'user-1', + ); + + const [[updateArg]] = tx.colony.update.mock.calls as [ + [{ where: { id: string }; data: { status: string } }], + ]; + expect(updateArg.where.id).toBe('colony-3'); + expect(updateArg.data.status).toBe('MERGED'); + + const [[lineageArg]] = tx.colonyLineage.create.mock.calls as [ + [{ data: { type: string; fromColonyId: string; toColonyId: string } }], + ]; + expect(lineageArg.data.type).toBe('MERGE'); + expect(lineageArg.data.fromColonyId).toBe('colony-3'); + expect(lineageArg.data.toColonyId).toBe('colony-1'); + }); + + it('dissolve sets status DISSOLVED and records DEACTIVATE', async () => { + prisma.colony.findUnique.mockResolvedValue(parentColony); + prisma.colony.update.mockResolvedValue({ + ...parentColony, + status: 'DISSOLVED', + }); + + const result = await service.dissolve('colony-1', 'user-1'); + + expect(result.status).toBe('DISSOLVED'); + expect(auditLog.record).toHaveBeenCalledWith( + expect.objectContaining({ action: 'DEACTIVATE' }), + ); + }); +}); diff --git a/ApiCore-Backend/src/colonies/colonies.service.ts b/ApiCore-Backend/src/colonies/colonies.service.ts new file mode 100644 index 0000000..272b044 --- /dev/null +++ b/ApiCore-Backend/src/colonies/colonies.service.ts @@ -0,0 +1,205 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; +import { AuditLogService } from '../audit/audit-log.service'; +import { Colony } from '../../generated/prisma'; +import { CreateColonyDto } from './dto/create-colony.dto'; +import { UpdateColonyDto } from './dto/update-colony.dto'; +import { MoveColonyDto } from './dto/move-colony.dto'; +import { SplitColonyDto } from './dto/split-colony.dto'; +import { MergeColonyDto } from './dto/merge-colony.dto'; + +@Injectable() +export class ColoniesService { + constructor( + private readonly prisma: PrismaService, + private readonly auditLog: AuditLogService, + ) {} + + async findAll(): Promise { + return this.prisma.colony.findMany({ orderBy: { code: 'asc' } }); + } + + async findById(id: string): Promise { + const colony = await this.prisma.colony.findUnique({ where: { id } }); + if (!colony) { + throw new NotFoundException('Colony not found'); + } + return colony; + } + + async create(dto: CreateColonyDto, actingUserId: string): Promise { + const colony = await this.prisma.colony.create({ data: { ...dto } }); + await this.auditLog.record({ + entityType: 'Colony', + entityId: colony.id, + action: 'CREATE', + userId: actingUserId, + afterData: colony, + }); + return colony; + } + + async update( + id: string, + dto: UpdateColonyDto, + actingUserId: string, + ): Promise { + const before = await this.findById(id); + const colony = await this.prisma.colony.update({ + where: { id }, + data: { ...dto }, + }); + await this.auditLog.record({ + entityType: 'Colony', + entityId: id, + action: 'UPDATE', + userId: actingUserId, + beforeData: before, + afterData: colony, + }); + return colony; + } + + async move( + id: string, + dto: MoveColonyDto, + actingUserId: string, + ): Promise { + const before = await this.findById(id); + const colony = await this.prisma.$transaction(async (tx) => { + const updated = await tx.colony.update({ + where: { id }, + data: { apiaryId: dto.apiaryId }, + }); + await tx.colonyApiaryHistory.create({ + data: { + colonyId: id, + apiaryId: dto.apiaryId, + movedByUserId: actingUserId, + notes: dto.notes, + }, + }); + return updated; + }); + await this.auditLog.record({ + entityType: 'Colony', + entityId: id, + action: 'UPDATE', + userId: actingUserId, + beforeData: before, + afterData: colony, + }); + return colony; + } + + async split( + id: string, + dto: SplitColonyDto, + actingUserId: string, + ): Promise { + const parent = await this.findById(id); + const child = await this.prisma.$transaction(async (tx) => { + const created = await tx.colony.create({ + data: { + code: dto.code, + apiaryId: parent.apiaryId, + colonyType: dto.colonyType, + notes: dto.notes, + }, + }); + await tx.colonyLineage.create({ + data: { + type: 'SPLIT', + fromColonyId: parent.id, + toColonyId: created.id, + date: new Date(), + notes: dto.notes, + }, + }); + return created; + }); + await this.auditLog.record({ + entityType: 'Colony', + entityId: child.id, + action: 'CREATE', + userId: actingUserId, + afterData: { ...child, splitFromColonyId: parent.id }, + }); + return child; + } + + async merge( + id: string, + dto: MergeColonyDto, + actingUserId: string, + ): Promise { + const survivor = await this.findById(id); + await this.prisma.$transaction(async (tx) => { + for (const mergedColonyId of dto.mergedColonyIds) { + const merged = await tx.colony.findUnique({ + where: { id: mergedColonyId }, + }); + if (!merged) { + throw new NotFoundException(`Colony ${mergedColonyId} not found`); + } + await tx.colony.update({ + where: { id: mergedColonyId }, + data: { status: 'MERGED', deactivatedAt: new Date() }, + }); + await tx.colonyLineage.create({ + data: { + type: 'MERGE', + fromColonyId: mergedColonyId, + toColonyId: survivor.id, + date: new Date(), + notes: dto.notes, + }, + }); + } + }); + await this.auditLog.record({ + entityType: 'Colony', + entityId: survivor.id, + action: 'UPDATE', + userId: actingUserId, + beforeData: survivor, + afterData: { ...survivor, mergedColonyIds: dto.mergedColonyIds }, + }); + return this.findById(survivor.id); + } + + async dissolve(id: string, actingUserId: string): Promise { + const before = await this.findById(id); + const colony = await this.prisma.colony.update({ + where: { id }, + data: { status: 'DISSOLVED', deactivatedAt: new Date() }, + }); + await this.auditLog.record({ + entityType: 'Colony', + entityId: id, + action: 'DEACTIVATE', + userId: actingUserId, + beforeData: before, + afterData: colony, + }); + return colony; + } + + async getHistory(id: string) { + await this.findById(id); + const [apiaryHistory, queenHistory, lineageAsSource, lineageAsResult] = + await Promise.all([ + this.prisma.colonyApiaryHistory.findMany({ + where: { colonyId: id }, + orderBy: { movedAt: 'desc' }, + }), + this.prisma.queenColonyHistory.findMany({ + where: { colonyId: id }, + orderBy: { startDate: 'desc' }, + }), + this.prisma.colonyLineage.findMany({ where: { fromColonyId: id } }), + this.prisma.colonyLineage.findMany({ where: { toColonyId: id } }), + ]); + return { apiaryHistory, queenHistory, lineageAsSource, lineageAsResult }; + } +} diff --git a/ApiCore-Backend/src/colonies/dto/create-colony.dto.ts b/ApiCore-Backend/src/colonies/dto/create-colony.dto.ts new file mode 100644 index 0000000..97b7e2a --- /dev/null +++ b/ApiCore-Backend/src/colonies/dto/create-colony.dto.ts @@ -0,0 +1,52 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsEnum, IsInt, IsOptional, IsString } from 'class-validator'; +import { ColonyType } from '../../../generated/prisma'; + +export class CreateColonyDto { + @ApiProperty() + @IsString() + code!: string; + + @ApiProperty() + @IsString() + apiaryId!: string; + + @ApiProperty({ enum: ColonyType }) + @IsEnum(ColonyType) + colonyType!: ColonyType; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + hiveType?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + originText?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsInt() + strength?: number; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + broodStatus?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + feedStatus?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + healthStatus?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + notes?: string; +} diff --git a/ApiCore-Backend/src/colonies/dto/merge-colony.dto.ts b/ApiCore-Backend/src/colonies/dto/merge-colony.dto.ts new file mode 100644 index 0000000..4b1a484 --- /dev/null +++ b/ApiCore-Backend/src/colonies/dto/merge-colony.dto.ts @@ -0,0 +1,15 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { ArrayMinSize, IsArray, IsOptional, IsString } from 'class-validator'; + +export class MergeColonyDto { + @ApiProperty({ type: [String] }) + @IsArray() + @ArrayMinSize(1) + @IsString({ each: true }) + mergedColonyIds!: string[]; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + notes?: string; +} diff --git a/ApiCore-Backend/src/colonies/dto/move-colony.dto.ts b/ApiCore-Backend/src/colonies/dto/move-colony.dto.ts new file mode 100644 index 0000000..7ea714a --- /dev/null +++ b/ApiCore-Backend/src/colonies/dto/move-colony.dto.ts @@ -0,0 +1,13 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsOptional, IsString } from 'class-validator'; + +export class MoveColonyDto { + @ApiProperty() + @IsString() + apiaryId!: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + notes?: string; +} diff --git a/ApiCore-Backend/src/colonies/dto/split-colony.dto.ts b/ApiCore-Backend/src/colonies/dto/split-colony.dto.ts new file mode 100644 index 0000000..ee5100b --- /dev/null +++ b/ApiCore-Backend/src/colonies/dto/split-colony.dto.ts @@ -0,0 +1,18 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsEnum, IsOptional, IsString } from 'class-validator'; +import { ColonyType } from '../../../generated/prisma'; + +export class SplitColonyDto { + @ApiProperty() + @IsString() + code!: string; + + @ApiProperty({ enum: ColonyType }) + @IsEnum(ColonyType) + colonyType!: ColonyType; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + notes?: string; +} diff --git a/ApiCore-Backend/src/colonies/dto/update-colony.dto.ts b/ApiCore-Backend/src/colonies/dto/update-colony.dto.ts new file mode 100644 index 0000000..a4ccbcf --- /dev/null +++ b/ApiCore-Backend/src/colonies/dto/update-colony.dto.ts @@ -0,0 +1,24 @@ +import { ApiPropertyOptional, PartialType, PickType } from '@nestjs/swagger'; +import { IsEnum, IsOptional } from 'class-validator'; +import { ColonyStatus } from '../../../generated/prisma'; +import { CreateColonyDto } from './create-colony.dto'; + +class UpdatableColonyFieldsDto extends PartialType( + PickType(CreateColonyDto, [ + 'hiveType', + 'originText', + 'colonyType', + 'strength', + 'broodStatus', + 'feedStatus', + 'healthStatus', + 'notes', + ] as const), +) {} + +export class UpdateColonyDto extends UpdatableColonyFieldsDto { + @ApiPropertyOptional({ enum: ColonyStatus }) + @IsOptional() + @IsEnum(ColonyStatus) + status?: ColonyStatus; +} diff --git a/ApiCore-Backend/test/colonies.e2e-spec.ts b/ApiCore-Backend/test/colonies.e2e-spec.ts new file mode 100644 index 0000000..acad51b --- /dev/null +++ b/ApiCore-Backend/test/colonies.e2e-spec.ts @@ -0,0 +1,192 @@ +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 ColonyBody { + id: string; + code: string; + apiaryId: string; + status: string; +} + +interface ColonyHistoryBody { + apiaryHistory: unknown[]; + queenHistory: unknown[]; + lineageAsSource: unknown[]; + lineageAsResult: unknown[]; +} + +describe('Colonies (e2e)', () => { + let app: INestApplication; + let prisma: PrismaService; + const beekeeperEmail = `colonies-e2e-beekeeper-${Date.now()}@example.com`; + const staffEmail = `colonies-e2e-staff-${Date.now()}@example.com`; + const password = 'SuperSecret123!'; + let beekeeperId: string; + let staffId: string; + let beekeeperToken: string; + let staffToken: string; + let apiaryAId: string; + let apiaryBId: string; + let colonyId: string; + let childColonyId: string; + let colonyToMergeId: 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 staff = await prisma.user.create({ + data: { email: staffEmail, passwordHash, name: 'Staff', role: 'STAFF' }, + }); + staffId = staff.id; + + beekeeperToken = (await login(app, beekeeperEmail, password)).accessToken; + staffToken = (await login(app, staffEmail, password)).accessToken; + + const apiaryA = await prisma.apiary.create({ + data: { name: `Stand A ${Date.now()}` }, + }); + apiaryAId = apiaryA.id; + const apiaryB = await prisma.apiary.create({ + data: { name: `Stand B ${Date.now()}` }, + }); + apiaryBId = apiaryB.id; + }); + + afterAll(async () => { + await prisma.colonyLineage.deleteMany({ + where: { OR: [{ fromColonyId: colonyId }, { toColonyId: colonyId }] }, + }); + await prisma.colonyApiaryHistory.deleteMany({ where: { colonyId } }); + await prisma.colony.deleteMany({ + where: { + id: { in: [colonyId, childColonyId, colonyToMergeId].filter(Boolean) }, + }, + }); + await prisma.apiary.deleteMany({ + where: { id: { in: [apiaryAId, apiaryBId] } }, + }); + await prisma.refreshToken.deleteMany({ + where: { userId: { in: [beekeeperId, staffId] } }, + }); + await prisma.auditLog.deleteMany({ + where: { userId: { in: [beekeeperId, staffId] } }, + }); + await prisma.user.deleteMany({ + where: { id: { in: [beekeeperId, staffId] } }, + }); + await app.close(); + }); + + it('rejects colony creation for STAFF', () => { + return request(app.getHttpServer()) + .post('/api/v1/colonies') + .set('Authorization', `Bearer ${staffToken}`) + .send({ + code: `V-${Date.now()}`, + apiaryId: apiaryAId, + colonyType: 'PRODUCTION_COLONY', + }) + .expect(403); + }); + + it('lets a BEEKEEPER create a colony', async () => { + const response = await request(app.getHttpServer()) + .post('/api/v1/colonies') + .set('Authorization', `Bearer ${beekeeperToken}`) + .send({ + code: `V-${Date.now()}`, + apiaryId: apiaryAId, + colonyType: 'PRODUCTION_COLONY', + }) + .expect(201); + const created = response.body as ColonyBody; + expect(created.apiaryId).toBe(apiaryAId); + colonyId = created.id; + }); + + it('moves the colony to another apiary and records history', async () => { + const response = await request(app.getHttpServer()) + .post(`/api/v1/colonies/${colonyId}/move`) + .set('Authorization', `Bearer ${beekeeperToken}`) + .send({ apiaryId: apiaryBId, notes: 'Umzug' }) + .expect(201); + expect((response.body as ColonyBody).apiaryId).toBe(apiaryBId); + + const history = await request(app.getHttpServer()) + .get(`/api/v1/colonies/${colonyId}/history`) + .set('Authorization', `Bearer ${beekeeperToken}`) + .expect(200); + expect((history.body as ColonyHistoryBody).apiaryHistory).toHaveLength(1); + }); + + it('splits the colony into a new nucleus colony', async () => { + const response = await request(app.getHttpServer()) + .post(`/api/v1/colonies/${colonyId}/split`) + .set('Authorization', `Bearer ${beekeeperToken}`) + .send({ code: `V-${Date.now()}-A`, colonyType: 'NUCLEUS_COLONY' }) + .expect(201); + const child = response.body as ColonyBody; + expect(child.apiaryId).toBe(apiaryBId); + childColonyId = child.id; + + const history = await request(app.getHttpServer()) + .get(`/api/v1/colonies/${colonyId}/history`) + .set('Authorization', `Bearer ${beekeeperToken}`) + .expect(200); + expect((history.body as ColonyHistoryBody).lineageAsSource).toHaveLength(1); + }); + + it('merges another colony into the surviving one', async () => { + const otherColony = await request(app.getHttpServer()) + .post('/api/v1/colonies') + .set('Authorization', `Bearer ${beekeeperToken}`) + .send({ + code: `V-${Date.now()}-M`, + apiaryId: apiaryBId, + colonyType: 'PRODUCTION_COLONY', + }) + .expect(201); + colonyToMergeId = (otherColony.body as ColonyBody).id; + + await request(app.getHttpServer()) + .post(`/api/v1/colonies/${colonyId}/merge`) + .set('Authorization', `Bearer ${beekeeperToken}`) + .send({ mergedColonyIds: [colonyToMergeId] }) + .expect(201); + + const merged = await request(app.getHttpServer()) + .get(`/api/v1/colonies/${colonyToMergeId}`) + .set('Authorization', `Bearer ${beekeeperToken}`) + .expect(200); + expect((merged.body as ColonyBody).status).toBe('MERGED'); + }); + + it('dissolves the colony without deleting it', async () => { + const response = await request(app.getHttpServer()) + .post(`/api/v1/colonies/${colonyId}/dissolve`) + .set('Authorization', `Bearer ${beekeeperToken}`) + .expect(201); + expect((response.body as ColonyBody).status).toBe('DISSOLVED'); + + await request(app.getHttpServer()) + .get(`/api/v1/colonies/${colonyId}`) + .set('Authorization', `Bearer ${staffToken}`) + .expect(200); + }); +}); diff --git a/TASKS.md b/TASKS.md index dc24655..056c87a 100644 --- a/TASKS.md +++ b/TASKS.md @@ -34,7 +34,7 @@ Abgeleitet aus [`docs/lastenheft.md`](docs/lastenheft.md) Abschnitt 26. Reihenfo - Backend: `ColoniesModule` (inkl. `ColonyApiaryHistory`, `ColonyLineage` für Ableger/Vereinigung) - Endpunkte: `GET/POST /api/v1/colonies`, `GET/PATCH /api/v1/colonies/:id`, `POST /api/v1/colonies/:id/move`, `POST /api/v1/colonies/:id/split`, `POST /api/v1/colonies/:id/merge`, `POST /api/v1/colonies/:id/dissolve`, `GET /api/v1/colonies/:id/history` - Frontend: `/colonies`, `/colonies/[id]`, `/colonies/new` -- [ ] Backend-Modul fertig, getestet, grün, committet +- [x] Backend-Modul fertig, getestet, grün, committet - [ ] Frontend-Views fertig, getestet, grün, committet ### 4. Königinnen