fix(backend): Status-Guard fuer Move/Split/Merge auf inaktiven Voelkern

Beim Live-Test im Browser gefunden: move/split/merge liessen sich auch
auf einem bereits DISSOLVED/MERGED-Volk aufrufen und wuerden dort
klaglos durchlaufen - ein Datenintegritaetsproblem, kein reines
UI-Detail. assertActive() wirft jetzt ConflictException (409), wenn
das betroffene Volk (bzw. bei merge: Ziel- und alle zu vereinigenden
Voelker) nicht ACTIVE ist.

Neuer Unit-Test + neuer e2e-Test (dissolve, danach move -> 409).
This commit is contained in:
Nick Szaunig committed 2026-08-21 18:20:48 +02:00
1 parent d3f321f1b5
commit a71b31df38
3 files changed
+34 -2

No files matched your search

@@ -1,4 +1,4 @@
import { NotFoundException } from '@nestjs/common'; import { ConflictException, NotFoundException } from '@nestjs/common';
import { ColoniesService } from './colonies.service'; import { ColoniesService } from './colonies.service';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service'; import { AuditLogService } from '../audit/audit-log.service';
@@ -143,6 +143,16 @@ describe('ColoniesService', () => {
expect(lineageArg.data.toColonyId).toBe('colony-1'); expect(lineageArg.data.toColonyId).toBe('colony-1');
}); });
it('move rejects a non-active colony', async () => {
prisma.colony.findUnique.mockResolvedValue({
...parentColony,
status: 'DISSOLVED',
});
await expect(
service.move('colony-1', { apiaryId: 'apiary-2' }, 'user-1'),
).rejects.toBeInstanceOf(ConflictException);
});
it('dissolve sets status DISSOLVED and records DEACTIVATE', async () => { it('dissolve sets status DISSOLVED and records DEACTIVATE', async () => {
prisma.colony.findUnique.mockResolvedValue(parentColony); prisma.colony.findUnique.mockResolvedValue(parentColony);
prisma.colony.update.mockResolvedValue({ prisma.colony.update.mockResolvedValue({
@@ -1,4 +1,8 @@
import { Injectable, NotFoundException } from '@nestjs/common'; import {
ConflictException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service'; import { AuditLogService } from '../audit/audit-log.service';
import { Colony } from '../../generated/prisma'; import { Colony } from '../../generated/prisma';
@@ -27,6 +31,14 @@ export class ColoniesService {
return colony; return colony;
} }
private assertActive(colony: Colony): void {
if (colony.status !== 'ACTIVE') {
throw new ConflictException(
`Colony ${colony.id} is not active (status: ${colony.status})`,
);
}
}
async create(dto: CreateColonyDto, actingUserId: string): Promise<Colony> { async create(dto: CreateColonyDto, actingUserId: string): Promise<Colony> {
const colony = await this.prisma.colony.create({ data: { ...dto } }); const colony = await this.prisma.colony.create({ data: { ...dto } });
await this.auditLog.record({ await this.auditLog.record({
@@ -66,6 +78,7 @@ export class ColoniesService {
actingUserId: string, actingUserId: string,
): Promise<Colony> { ): Promise<Colony> {
const before = await this.findById(id); const before = await this.findById(id);
this.assertActive(before);
const colony = await this.prisma.$transaction(async (tx) => { const colony = await this.prisma.$transaction(async (tx) => {
const updated = await tx.colony.update({ const updated = await tx.colony.update({
where: { id }, where: { id },
@@ -98,6 +111,7 @@ export class ColoniesService {
actingUserId: string, actingUserId: string,
): Promise<Colony> { ): Promise<Colony> {
const parent = await this.findById(id); const parent = await this.findById(id);
this.assertActive(parent);
const child = await this.prisma.$transaction(async (tx) => { const child = await this.prisma.$transaction(async (tx) => {
const created = await tx.colony.create({ const created = await tx.colony.create({
data: { data: {
@@ -134,6 +148,7 @@ export class ColoniesService {
actingUserId: string, actingUserId: string,
): Promise<Colony> { ): Promise<Colony> {
const survivor = await this.findById(id); const survivor = await this.findById(id);
this.assertActive(survivor);
await this.prisma.$transaction(async (tx) => { await this.prisma.$transaction(async (tx) => {
for (const mergedColonyId of dto.mergedColonyIds) { for (const mergedColonyId of dto.mergedColonyIds) {
const merged = await tx.colony.findUnique({ const merged = await tx.colony.findUnique({
@@ -142,6 +157,7 @@ export class ColoniesService {
if (!merged) { if (!merged) {
throw new NotFoundException(`Colony ${mergedColonyId} not found`); throw new NotFoundException(`Colony ${mergedColonyId} not found`);
} }
this.assertActive(merged);
await tx.colony.update({ await tx.colony.update({
where: { id: mergedColonyId }, where: { id: mergedColonyId },
data: { status: 'MERGED', deactivatedAt: new Date() }, data: { status: 'MERGED', deactivatedAt: new Date() },
@@ -188,5 +188,11 @@ describe('Colonies (e2e)', () => {
.get(`/api/v1/colonies/${colonyId}`) .get(`/api/v1/colonies/${colonyId}`)
.set('Authorization', `Bearer ${staffToken}`) .set('Authorization', `Bearer ${staffToken}`)
.expect(200); .expect(200);
await request(app.getHttpServer())
.post(`/api/v1/colonies/${colonyId}/move`)
.set('Authorization', `Bearer ${beekeeperToken}`)
.send({ apiaryId: apiaryAId })
.expect(409);
}); });
}); });