fix: block deactivating an apiary while it still has active colonies
Colony.apiaryId is a plain FK, never checked against the apiary's own status. Without a guard, deactivating an apiary silently orphaned any colonies still living there: they kept showing as ACTIVE, but their apiary had disappeared from every "active apiaries" list, dashboard count, and colony-creation picker - a confusing half-state with no warning at all. ApiariesService.deactivate() now throws ConflictException when active colonies remain, so the beekeeper has to move or dissolve them first - matching the same assertActive-style guard pattern already used elsewhere (colonies, e.g. move()/merge()/split()). Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
1 parent
20981ec1ce
commit
961a237335
2 files changed
+25
No files matched your search
@@ -12,6 +12,7 @@ describe('ApiariesService', () => {
|
|||||||
create: jest.Mock;
|
create: jest.Mock;
|
||||||
update: jest.Mock;
|
update: jest.Mock;
|
||||||
};
|
};
|
||||||
|
colony: { count: jest.Mock };
|
||||||
};
|
};
|
||||||
let auditLog: { record: jest.Mock };
|
let auditLog: { record: jest.Mock };
|
||||||
|
|
||||||
@@ -30,6 +31,7 @@ describe('ApiariesService', () => {
|
|||||||
create: jest.fn(),
|
create: jest.fn(),
|
||||||
update: jest.fn(),
|
update: jest.fn(),
|
||||||
},
|
},
|
||||||
|
colony: { count: jest.fn().mockResolvedValue(0) },
|
||||||
};
|
};
|
||||||
auditLog = { record: jest.fn().mockResolvedValue(undefined) };
|
auditLog = { record: jest.fn().mockResolvedValue(undefined) };
|
||||||
service = new ApiariesService(
|
service = new ApiariesService(
|
||||||
@@ -76,6 +78,16 @@ describe('ApiariesService', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('deactivate throws ConflictException when active colonies remain', async () => {
|
||||||
|
prisma.apiary.findUnique.mockResolvedValue(existingApiary);
|
||||||
|
prisma.colony.count.mockResolvedValue(2);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.deactivate('apiary-1', 'user-1'),
|
||||||
|
).rejects.toBeInstanceOf(ConflictException);
|
||||||
|
expect(prisma.apiary.update).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it('reactivate throws ConflictException when already active', async () => {
|
it('reactivate throws ConflictException when already active', async () => {
|
||||||
prisma.apiary.findUnique.mockResolvedValue(existingApiary);
|
prisma.apiary.findUnique.mockResolvedValue(existingApiary);
|
||||||
await expect(
|
await expect(
|
||||||
|
|||||||
@@ -76,6 +76,19 @@ export class ApiariesService {
|
|||||||
|
|
||||||
async deactivate(id: string, actingUserId: string): Promise<Apiary> {
|
async deactivate(id: string, actingUserId: string): Promise<Apiary> {
|
||||||
const before = await this.findById(id);
|
const before = await this.findById(id);
|
||||||
|
// Colony.apiaryId is a plain FK, not enforced against apiary status -
|
||||||
|
// without this guard a colony can silently keep pointing at an
|
||||||
|
// inactive apiary (still shows ACTIVE, still countable, but its
|
||||||
|
// "location" has vanished from the active apiary list). Move the
|
||||||
|
// colonies elsewhere or dissolve them first instead.
|
||||||
|
const activeColonyCount = await this.prisma.colony.count({
|
||||||
|
where: { apiaryId: id, status: 'ACTIVE' },
|
||||||
|
});
|
||||||
|
if (activeColonyCount > 0) {
|
||||||
|
throw new ConflictException(
|
||||||
|
`Apiary ${id} still has ${activeColonyCount} active colonies - move or dissolve them first`,
|
||||||
|
);
|
||||||
|
}
|
||||||
const apiary = await this.prisma.apiary.update({
|
const apiary = await this.prisma.apiary.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: { status: 'INACTIVE', deactivatedAt: new Date() },
|
data: { status: 'INACTIVE', deactivatedAt: new Date() },
|
||||||
|
|||||||
Reference in new issue
Block a user