diff --git a/ApiCore-Backend/src/colonies/colonies.service.spec.ts b/ApiCore-Backend/src/colonies/colonies.service.spec.ts index 09c74a3..3dc8b44 100644 --- a/ApiCore-Backend/src/colonies/colonies.service.spec.ts +++ b/ApiCore-Backend/src/colonies/colonies.service.spec.ts @@ -20,6 +20,7 @@ describe('ColoniesService', () => { create: jest.Mock; update: jest.Mock; }; + apiary: { findUnique: jest.Mock }; colonyApiaryHistory: { findMany: jest.Mock }; queenColonyHistory: { findMany: jest.Mock }; colonyLineage: { findMany: jest.Mock }; @@ -49,6 +50,11 @@ describe('ColoniesService', () => { create: jest.fn(), update: jest.fn(), }, + apiary: { + findUnique: jest + .fn() + .mockResolvedValue({ id: 'apiary-2', status: 'ACTIVE' }), + }, colonyApiaryHistory: { findMany: jest.fn() }, queenColonyHistory: { findMany: jest.fn() }, colonyLineage: { findMany: jest.fn() }, @@ -185,6 +191,45 @@ describe('ColoniesService', () => { ).rejects.toBeInstanceOf(ConflictException); }); + it('move rejects a nonexistent destination apiary', async () => { + prisma.colony.findUnique.mockResolvedValue(parentColony); + prisma.apiary.findUnique.mockResolvedValue(null); + await expect( + service.move('colony-1', { apiaryId: 'missing' }, 'user-1'), + ).rejects.toBeInstanceOf(NotFoundException); + expect(tx.colony.update).not.toHaveBeenCalled(); + }); + + it('move rejects an inactive destination apiary', async () => { + prisma.colony.findUnique.mockResolvedValue(parentColony); + prisma.apiary.findUnique.mockResolvedValue({ + id: 'apiary-2', + status: 'INACTIVE', + }); + await expect( + service.move('colony-1', { apiaryId: 'apiary-2' }, 'user-1'), + ).rejects.toBeInstanceOf(ConflictException); + expect(tx.colony.update).not.toHaveBeenCalled(); + }); + + it('create rejects an inactive apiary', async () => { + prisma.apiary.findUnique.mockResolvedValue({ + id: 'apiary-1', + status: 'INACTIVE', + }); + await expect( + service.create( + { + code: 'V-002', + apiaryId: 'apiary-1', + colonyType: 'PRODUCTION_COLONY', + }, + 'user-1', + ), + ).rejects.toBeInstanceOf(ConflictException); + expect(prisma.colony.create).not.toHaveBeenCalled(); + }); + it('dissolve sets status DISSOLVED and records DEACTIVATE', async () => { prisma.colony.findUnique.mockResolvedValue(parentColony); tx.colony.update.mockResolvedValue({ diff --git a/ApiCore-Backend/src/colonies/colonies.service.ts b/ApiCore-Backend/src/colonies/colonies.service.ts index 2d8fe8c..a70c579 100644 --- a/ApiCore-Backend/src/colonies/colonies.service.ts +++ b/ApiCore-Backend/src/colonies/colonies.service.ts @@ -51,7 +51,27 @@ export class ColoniesService { } } + // Colony.apiaryId is a plain FK with no built-in status check - without + // this, a colony could be created or moved into an apiary that either + // doesn't exist (a raw Prisma FK-constraint error instead of a clean + // 404) or is deactivated (silently orphaning the colony the same way an + // apiary deactivated out from under an existing colony would). + private async assertApiaryActive(apiaryId: string): Promise { + const apiary = await this.prisma.apiary.findUnique({ + where: { id: apiaryId }, + }); + if (!apiary) { + throw new NotFoundException(`Apiary ${apiaryId} not found`); + } + if (apiary.status !== 'ACTIVE') { + throw new ConflictException( + `Apiary ${apiaryId} is not active (status: ${apiary.status})`, + ); + } + } + async create(dto: CreateColonyDto, actingUserId: string): Promise { + await this.assertApiaryActive(dto.apiaryId); const colony = await this.prisma.colony.create({ data: { ...dto } }); await this.auditLog.record({ entityType: 'Colony', @@ -93,6 +113,7 @@ export class ColoniesService { ): Promise { const before = await this.findById(id); this.assertActive(before); + await this.assertApiaryActive(dto.apiaryId); const colony = await this.prisma.$transaction(async (tx) => { const updated = await tx.colony.update({ where: { id },