fix: release a colony's queen when it is dissolved or merged away

Colony.currentQueenId is unique across colonies. dissolve() and merge()
set status to DISSOLVED/MERGED but never cleared currentQueenId, so a
colony that still had a queen when it was dissolved or merged kept
permanently "owning" her - any later attempt to assign that queen to a
different colony (e.g. requeening another colony with her) would fail
with a unique-constraint conflict, since the dead colony's stale
reference was still occupying that slot.

Both operations now clear currentQueenId and close the open
QueenColonyHistory row for that queen, mirroring the bookkeeping
assignColony() and the queen-status-transition fix already do
elsewhere. The merge survivor keeps its own queen; a merged-away
colony's queen is released, not auto-transferred, since which queen
"wins" a merge is a beekeeper decision.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Sonnet 5 committed 2026-09-08 20:58:53 +02:00
1 parent cc46fa4d9e
commit 20981ec1ce
2 files changed
+128 -6

No files matched your search

@@ -11,6 +11,7 @@ describe('ColoniesService', () => {
colony: { create: jest.Mock; update: jest.Mock; findUnique: jest.Mock }; colony: { create: jest.Mock; update: jest.Mock; findUnique: jest.Mock };
colonyApiaryHistory: { create: jest.Mock }; colonyApiaryHistory: { create: jest.Mock };
colonyLineage: { create: jest.Mock }; colonyLineage: { create: jest.Mock };
queenColonyHistory: { updateMany: jest.Mock };
}; };
let prisma: { let prisma: {
colony: { colony: {
@@ -39,6 +40,7 @@ describe('ColoniesService', () => {
colony: { create: jest.fn(), update: jest.fn(), findUnique: jest.fn() }, colony: { create: jest.fn(), update: jest.fn(), findUnique: jest.fn() },
colonyApiaryHistory: { create: jest.fn() }, colonyApiaryHistory: { create: jest.fn() },
colonyLineage: { create: jest.fn() }, colonyLineage: { create: jest.fn() },
queenColonyHistory: { updateMany: jest.fn() },
}; };
prisma = { prisma = {
colony: { colony: {
@@ -117,7 +119,7 @@ describe('ColoniesService', () => {
expect(lineageArg.data.toColonyId).toBe('colony-2'); expect(lineageArg.data.toColonyId).toBe('colony-2');
}); });
it('merge marks the merged colony as MERGED and records a MERGE lineage entry', async () => { it('merge marks the merged colony as MERGED, releases its queen, and records a MERGE lineage entry', async () => {
prisma.colony.findUnique prisma.colony.findUnique
.mockResolvedValueOnce(parentColony) .mockResolvedValueOnce(parentColony)
.mockResolvedValueOnce({ .mockResolvedValueOnce({
@@ -127,6 +129,7 @@ describe('ColoniesService', () => {
tx.colony.findUnique.mockResolvedValue({ tx.colony.findUnique.mockResolvedValue({
id: 'colony-3', id: 'colony-3',
status: 'ACTIVE', status: 'ACTIVE',
currentQueenId: 'queen-2',
}); });
tx.colony.update.mockResolvedValue({}); tx.colony.update.mockResolvedValue({});
tx.colonyLineage.create.mockResolvedValue({}); tx.colonyLineage.create.mockResolvedValue({});
@@ -138,10 +141,31 @@ describe('ColoniesService', () => {
); );
const [[updateArg]] = tx.colony.update.mock.calls as [ const [[updateArg]] = tx.colony.update.mock.calls as [
[{ where: { id: string }; data: { status: string } }], [
{
where: { id: string };
data: { status: string; currentQueenId: string | null };
},
],
]; ];
expect(updateArg.where.id).toBe('colony-3'); expect(updateArg.where.id).toBe('colony-3');
expect(updateArg.data.status).toBe('MERGED'); expect(updateArg.data.status).toBe('MERGED');
expect(updateArg.data.currentQueenId).toBeNull();
const [[historyCloseArg]] = tx.queenColonyHistory.updateMany.mock.calls as [
[
{
where: { queenId: string; colonyId: string; endDate: null };
data: { endDate: Date };
},
],
];
expect(historyCloseArg.where).toEqual({
queenId: 'queen-2',
colonyId: 'colony-3',
endDate: null,
});
expect(historyCloseArg.data.endDate).toBeInstanceOf(Date);
const [[lineageArg]] = tx.colonyLineage.create.mock.calls as [ const [[lineageArg]] = tx.colonyLineage.create.mock.calls as [
[{ data: { type: string; fromColonyId: string; toColonyId: string } }], [{ data: { type: string; fromColonyId: string; toColonyId: string } }],
@@ -163,7 +187,7 @@ describe('ColoniesService', () => {
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({ tx.colony.update.mockResolvedValue({
...parentColony, ...parentColony,
status: 'DISSOLVED', status: 'DISSOLVED',
}); });
@@ -176,6 +200,67 @@ describe('ColoniesService', () => {
); );
}); });
it("dissolve releases the colony's queen so she can be reassigned later", async () => {
prisma.colony.findUnique.mockResolvedValue({
...parentColony,
currentQueenId: 'queen-1',
});
tx.colony.update.mockResolvedValue({
...parentColony,
status: 'DISSOLVED',
currentQueenId: null,
});
await service.dissolve('colony-1', 'user-1');
const [[dissolveUpdateArg]] = tx.colony.update.mock.calls as [
[
{
where: { id: string };
data: {
status: string;
deactivatedAt: Date;
currentQueenId: string | null;
};
},
],
];
expect(dissolveUpdateArg.where).toEqual({ id: 'colony-1' });
expect(dissolveUpdateArg.data.status).toBe('DISSOLVED');
expect(dissolveUpdateArg.data.currentQueenId).toBeNull();
expect(dissolveUpdateArg.data.deactivatedAt).toBeInstanceOf(Date);
const [[historyCloseArg]] = tx.queenColonyHistory.updateMany.mock.calls as [
[
{
where: { queenId: string; colonyId: string; endDate: null };
data: { endDate: Date };
},
],
];
expect(historyCloseArg.where).toEqual({
queenId: 'queen-1',
colonyId: 'colony-1',
endDate: null,
});
expect(historyCloseArg.data.endDate).toBeInstanceOf(Date);
});
it('dissolve does not touch queen history for a colony with no queen', async () => {
prisma.colony.findUnique.mockResolvedValue({
...parentColony,
currentQueenId: null,
});
tx.colony.update.mockResolvedValue({
...parentColony,
status: 'DISSOLVED',
});
await service.dissolve('colony-1', 'user-1');
expect(tx.queenColonyHistory.updateMany).not.toHaveBeenCalled();
});
it('reactivate throws ConflictException when already active', async () => { it('reactivate throws ConflictException when already active', async () => {
prisma.colony.findUnique.mockResolvedValue(parentColony); prisma.colony.findUnique.mockResolvedValue(parentColony);
await expect( await expect(
@@ -172,10 +172,30 @@ export class ColoniesService {
throw new NotFoundException(`Colony ${mergedColonyId} not found`); throw new NotFoundException(`Colony ${mergedColonyId} not found`);
} }
this.assertActive(merged); this.assertActive(merged);
// currentQueenId is unique across colonies - if it stayed set on
// this now-dead colony, any later attempt to assign her queen to a
// different colony would fail with a unique-constraint conflict.
// The survivor keeps its own queen; merged-away queens are simply
// released, not auto-transferred (which colony's queen "wins" a
// merge is a beekeeper decision, not one to guess here).
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(),
currentQueenId: null,
},
}); });
if (merged.currentQueenId) {
await tx.queenColonyHistory.updateMany({
where: {
queenId: merged.currentQueenId,
colonyId: mergedColonyId,
endDate: null,
},
data: { endDate: new Date() },
});
}
await tx.colonyLineage.create({ await tx.colonyLineage.create({
data: { data: {
type: 'MERGE', type: 'MERGE',
@@ -200,9 +220,26 @@ export class ColoniesService {
async dissolve(id: string, actingUserId: string): Promise<Colony> { async dissolve(id: string, actingUserId: string): Promise<Colony> {
const before = await this.findById(id); const before = await this.findById(id);
const colony = await this.prisma.colony.update({ const colony = await this.prisma.$transaction(async (tx) => {
const updated = await tx.colony.update({
where: { id }, where: { id },
data: { status: 'DISSOLVED', deactivatedAt: new Date() }, data: {
status: 'DISSOLVED',
deactivatedAt: new Date(),
currentQueenId: null,
},
});
if (before.currentQueenId) {
await tx.queenColonyHistory.updateMany({
where: {
queenId: before.currentQueenId,
colonyId: id,
endDate: null,
},
data: { endDate: new Date() },
});
}
return updated;
}); });
await this.auditLog.record({ await this.auditLog.record({
entityType: 'Colony', entityType: 'Colony',