fix: block creating or moving a colony into an inactive/missing apiary
Colony.apiaryId was never checked against the target apiary at all - create() would hit a raw Prisma FK-constraint error for a nonexistent apiary instead of a clean 404, and both create() and move() would silently succeed against a deactivated apiary, orphaning the colony the same way (just from the opposite direction) as the apiary-side gap fixed in the previous commit. Adds a shared assertApiaryActive() check, following the same findUnique-then-status pattern already used for cross-entity checks elsewhere (e.g. QueensService.assertMotherQueenExists), used by both create() and move(). Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
1 parent
961a237335
commit
5c5c7b756f
2 files changed
+66
No files matched your search
@@ -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({
|
||||
|
||||
@@ -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<void> {
|
||||
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<Colony> {
|
||||
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<Colony> {
|
||||
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 },
|
||||
|
||||
Reference in new issue
Block a user