feat(queens): verdrängte Königin bekommt ihren Endstatus automatisch
Backend E2E / e2e (push) Successful in 5m24s
Backend Image / build-and-push (push) Successful in 5m3s
Frontend Image / build-and-push (push) Failing after 14m46s

Entscheidung Inhaber: Zieht eine neue Königin ein, wird die bisherige
„Ersetzt“; Vereinigen und Auflösen eines Volks setzen dessen Königin auf
„Ersetzt“, ein Winterverlust auf „Eingegangen“. Nur aus einem verfügbaren
Status (ACTIVE, RESERVE, MATED, UNMATED), über updateStatusIn in derselben
Transaktion mit Archivdatum und Audit (QueensService.retireIn). Ein von
Hand gesetzter Endstatus eines Volks fragt weiter über den Aufräum-
Vorschlag nach.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick.RosenloecherandClaude Opus 5.5 committed 2026-10-19 17:03:22 +02:00
1 parent f9e03a1695
commit 81927d629a
10 files changed
+205 -4

No files matched your search

@@ -1,4 +1,5 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { QueensModule } from '../queens/queens.module';
import { ApiaryRestrictionsModule } from '../apiary-restrictions/apiary-restrictions.module'; import { ApiaryRestrictionsModule } from '../apiary-restrictions/apiary-restrictions.module';
import { ColoniesController } from './colonies.controller'; import { ColoniesController } from './colonies.controller';
import { ColoniesService } from './colonies.service'; import { ColoniesService } from './colonies.service';
@@ -6,7 +7,7 @@ import { ColoniesService } from './colonies.service';
// ApiaryRestrictionsModule: the restricted-zone endpoints ride along with the // ApiaryRestrictionsModule: the restricted-zone endpoints ride along with the
// colony paths that enforce them (AppModule imports ColoniesModule). // colony paths that enforce them (AppModule imports ColoniesModule).
@Module({ @Module({
imports: [ApiaryRestrictionsModule], imports: [ApiaryRestrictionsModule, QueensModule],
controllers: [ColoniesController], controllers: [ColoniesController],
providers: [ColoniesService], providers: [ColoniesService],
exports: [ColoniesService], exports: [ColoniesService],
@@ -5,11 +5,13 @@ import { AuditLogService } from '../audit/audit-log.service';
import { WebhooksService } from '../webhooks/webhooks.service'; import { WebhooksService } from '../webhooks/webhooks.service';
import { DomainEventsService } from '../domain-events/domain-events.service'; import { DomainEventsService } from '../domain-events/domain-events.service';
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service'; import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
import { QueensService } from '../queens/queens.service';
import { berlinToday } from '../common/dates'; import { berlinToday } from '../common/dates';
import { expectOnlyIdPayloads } from '../domain-events/herd-payload.spec-helper'; import { expectOnlyIdPayloads } from '../domain-events/herd-payload.spec-helper';
describe('ColoniesService', () => { describe('ColoniesService', () => {
let service: ColoniesService; let service: ColoniesService;
let queens: { retireIn: jest.Mock };
let tx: { let tx: {
$queryRaw: jest.Mock; $queryRaw: jest.Mock;
colony: { create: jest.Mock; update: jest.Mock; findUnique: jest.Mock }; colony: { create: jest.Mock; update: jest.Mock; findUnique: jest.Mock };
@@ -94,12 +96,14 @@ describe('ColoniesService', () => {
.fn() .fn()
.mockResolvedValue(new Set(['compliance:manage'])), .mockResolvedValue(new Set(['compliance:manage'])),
}; };
queens = { retireIn: jest.fn().mockResolvedValue(undefined) };
service = new ColoniesService( service = new ColoniesService(
prisma as unknown as PrismaService, prisma as unknown as PrismaService,
auditLog as unknown as AuditLogService, auditLog as unknown as AuditLogService,
webhooks as unknown as WebhooksService, webhooks as unknown as WebhooksService,
events as unknown as DomainEventsService, events as unknown as DomainEventsService,
resolver as unknown as PermissionResolverService, resolver as unknown as PermissionResolverService,
queens as unknown as QueensService,
); );
}); });
@@ -307,6 +311,13 @@ describe('ColoniesService', () => {
endDate: null, endDate: null,
}); });
expect(historyCloseArg.data.endDate).toBeInstanceOf(Date); expect(historyCloseArg.data.endDate).toBeInstanceOf(Date);
// The merged-away queen leaves for the archive as REPLACED.
expect(queens.retireIn).toHaveBeenCalledWith(
tx,
'queen-2',
'REPLACED',
'user-1',
);
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 } }],
@@ -419,6 +430,13 @@ describe('ColoniesService', () => {
endDate: null, endDate: null,
}); });
expect(historyCloseArg.data.endDate).toBeInstanceOf(Date); expect(historyCloseArg.data.endDate).toBeInstanceOf(Date);
// The released queen leaves for the archive as REPLACED.
expect(queens.retireIn).toHaveBeenCalledWith(
tx,
'queen-1',
'REPLACED',
'user-1',
);
}); });
it('dissolve does not touch queen history for a colony with no queen', async () => { it('dissolve does not touch queen history for a colony with no queen', async () => {
@@ -434,6 +452,7 @@ describe('ColoniesService', () => {
await service.dissolve('colony-1', 'user-1'); await service.dissolve('colony-1', 'user-1');
expect(tx.queenColonyHistory.updateMany).not.toHaveBeenCalled(); expect(tx.queenColonyHistory.updateMany).not.toHaveBeenCalled();
expect(queens.retireIn).not.toHaveBeenCalled();
}); });
it('reactivate throws ConflictException when already active', async () => { it('reactivate throws ConflictException when already active', async () => {
@@ -26,6 +26,7 @@ import { lockRowsForShare, lockRowsForUpdate } from '../prisma/row-lock';
import { colonyTransitionEvents, lockColony } from './colony-events'; import { colonyTransitionEvents, lockColony } from './colony-events';
import { berlinToday, dayToDate } from '../common/dates'; import { berlinToday, dayToDate } from '../common/dates';
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service'; import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
import { QueensService } from '../queens/queens.service';
import { import {
assertNoActiveRestriction, assertNoActiveRestriction,
RestrictionCheck, RestrictionCheck,
@@ -81,6 +82,7 @@ export class ColoniesService {
private readonly webhooks: WebhooksService, private readonly webhooks: WebhooksService,
private readonly events: DomainEventsService, private readonly events: DomainEventsService,
private readonly permissions: PermissionResolverService, private readonly permissions: PermissionResolverService,
private readonly queens: QueensService,
) {} ) {}
async findAll(filter: ColonyFilter = {}): Promise<Colony[]> { async findAll(filter: ColonyFilter = {}): Promise<Colony[]> {
@@ -526,6 +528,14 @@ export class ColoniesService {
merged.id, merged.id,
dayToDate(today), dayToDate(today),
); );
if (releasedQueenId) {
await this.queens.retireIn(
tx,
releasedQueenId,
'REPLACED',
actingUserId,
);
}
const mergedAway = await tx.colony.update({ const mergedAway = await tx.colony.update({
where: { id: merged.id }, where: { id: merged.id },
data: { status: 'MERGED', deactivatedAt: new Date() }, data: { status: 'MERGED', deactivatedAt: new Date() },
@@ -576,6 +586,14 @@ export class ColoniesService {
this.assertActive(before); this.assertActive(before);
const today = berlinToday(); const today = berlinToday();
const releasedQueenId = await releaseQueenIn(tx, id, dayToDate(today)); const releasedQueenId = await releaseQueenIn(tx, id, dayToDate(today));
if (releasedQueenId) {
await this.queens.retireIn(
tx,
releasedQueenId,
'REPLACED',
actingUserId,
);
}
const updated = await tx.colony.update({ const updated = await tx.colony.update({
where: { id }, where: { id },
data: { status: 'DISSOLVED', deactivatedAt: new Date() }, data: { status: 'DISSOLVED', deactivatedAt: new Date() },
@@ -143,6 +143,11 @@ describe('QueensService', () => {
]; ];
expect(historyArg.data.queenId).toBe('queen-1'); expect(historyArg.data.queenId).toBe('queen-1');
expect(historyArg.data.colonyId).toBe('colony-1'); expect(historyArg.data.colonyId).toBe('colony-1');
// The displaced queen leaves for the archive as REPLACED.
expect(tx.queen.update).toHaveBeenCalledWith({
where: { id: 'queen-old' },
data: { status: 'REPLACED', deactivatedAt: expect.any(Date) as Date },
});
}); });
it('assignColony records QueenAssigned with previousColonyId and calls neither webhooks.dispatch nor calendarSync', async () => { it('assignColony records QueenAssigned with previousColonyId and calls neither webhooks.dispatch nor calendarSync', async () => {
@@ -434,6 +439,29 @@ describe('QueensService', () => {
expect(lineage.descendants[0].daughters).toEqual([]); expect(lineage.descendants[0].daughters).toEqual([]);
}); });
describe('retireIn', () => {
it('sets the status of a queen still recorded as available, stamps deactivatedAt and audits', async () => {
tx.queen.findUnique.mockResolvedValue({ ...queen, deactivatedAt: null });
await service.retireIn(tx as never, 'queen-1', 'REPLACED', 'user-1');
expect(tx.queen.update).toHaveBeenCalledWith({
where: { id: 'queen-1' },
data: { status: 'REPLACED', deactivatedAt: expect.any(Date) as Date },
});
expect(auditLog.record).toHaveBeenCalledTimes(1);
});
it('never overwrites a status that already takes her out of use', async () => {
tx.queen.findUnique.mockResolvedValue({ ...queen, status: 'SOLD' });
await service.retireIn(tx as never, 'queen-1', 'REPLACED', 'user-1');
expect(tx.queen.update).not.toHaveBeenCalled();
expect(auditLog.record).not.toHaveBeenCalled();
});
});
describe('updateStatusIn', () => { describe('updateStatusIn', () => {
it('locks the queen, reads it afterwards, sets the status and audits in the same transaction', async () => { it('locks the queen, reads it afterwards, sets the status and audits in the same transaction', async () => {
tx.queen.findUnique.mockResolvedValue(queen); tx.queen.findUnique.mockResolvedValue(queen);
@@ -232,6 +232,28 @@ export class QueensService {
return updated; return updated;
} }
/**
* Retires a queen who just left her colony with it (displaced by a new
* queen, colony merged, dissolved or died): sets `status` through
* updateStatusIn while she is still recorded as available, so she moves to
* the archive. A status that already takes her out of use stays. The
* caller has released her from the colony first.
*/
async retireIn(
tx: Prisma.TransactionClient,
id: string,
status: QueenStatus,
actingUserId: string,
): Promise<void> {
assertTransactionClient(tx, 'QueensService.retireIn');
await lockRowForUpdate(tx, 'Queen', id);
const queen = await tx.queen.findUnique({ where: { id } });
if (!queen || QUEEN_NO_LONGER_AVAILABLE_STATUSES.includes(queen.status)) {
return;
}
await this.updateStatusIn(tx, id, status, actingUserId);
}
async assignColony( async assignColony(
id: string, id: string,
dto: AssignColonyDto, dto: AssignColonyDto,
@@ -321,6 +343,14 @@ export class QueensService {
status: colony.status === 'QUEENLESS' ? 'ACTIVE' : undefined, status: colony.status === 'QUEENLESS' ? 'ACTIVE' : undefined,
}, },
}); });
if (colony.currentQueenId) {
await this.retireIn(
tx,
colony.currentQueenId,
'REPLACED',
actingUserId,
);
}
const history = await tx.queenColonyHistory.create({ const history = await tx.queenColonyHistory.create({
data: { data: {
queenId: queen.id, queenId: queen.id,
@@ -1,9 +1,11 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { QueensModule } from '../queens/queens.module';
import { ColonyWinterChecksController } from './colony-winter-checks.controller'; import { ColonyWinterChecksController } from './colony-winter-checks.controller';
import { WinterChecksController } from './winter-checks.controller'; import { WinterChecksController } from './winter-checks.controller';
import { WinterChecksService } from './winter-checks.service'; import { WinterChecksService } from './winter-checks.service';
@Module({ @Module({
imports: [QueensModule],
controllers: [WinterChecksController, ColonyWinterChecksController], controllers: [WinterChecksController, ColonyWinterChecksController],
providers: [WinterChecksService], providers: [WinterChecksService],
exports: [WinterChecksService], exports: [WinterChecksService],
@@ -3,6 +3,7 @@ import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service'; import { AuditLogService } from '../audit/audit-log.service';
import { DomainEventsService } from '../domain-events/domain-events.service'; import { DomainEventsService } from '../domain-events/domain-events.service';
import { CreateWinterCheckDto } from './dto/create-winter-check.dto'; import { CreateWinterCheckDto } from './dto/create-winter-check.dto';
import { QueensService } from '../queens/queens.service';
describe('WinterChecksService', () => { describe('WinterChecksService', () => {
const livingColony = { const livingColony = {
@@ -34,6 +35,7 @@ describe('WinterChecksService', () => {
}; };
let auditLog: { record: jest.Mock }; let auditLog: { record: jest.Mock };
let events: { recordAll: jest.Mock; record: jest.Mock }; let events: { recordAll: jest.Mock; record: jest.Mock };
let queens: { retireIn: jest.Mock };
beforeEach(() => { beforeEach(() => {
tx = { tx = {
@@ -67,13 +69,37 @@ describe('WinterChecksService', () => {
recordAll: jest.fn().mockResolvedValue(0), recordAll: jest.fn().mockResolvedValue(0),
record: jest.fn().mockResolvedValue(undefined), record: jest.fn().mockResolvedValue(undefined),
}; };
queens = { retireIn: jest.fn().mockResolvedValue(undefined) };
service = new WinterChecksService( service = new WinterChecksService(
prisma as unknown as PrismaService, prisma as unknown as PrismaService,
auditLog as unknown as AuditLogService, auditLog as unknown as AuditLogService,
events as unknown as DomainEventsService, events as unknown as DomainEventsService,
queens as unknown as QueensService,
); );
}); });
it('records the queen of a colony lost over winter as DECEASED', async () => {
tx.colony.findUnique.mockResolvedValue({
...livingColony,
currentQueenId: 'q1',
});
await service.create(lostCheck, 'u1');
expect(queens.retireIn).toHaveBeenCalledWith(tx, 'q1', 'DECEASED', 'u1');
});
it('retires no queen when the lost colony had none or is not marked deceased', async () => {
await service.create(lostCheck, 'u1');
tx.colony.findUnique.mockResolvedValue({
...livingColony,
currentQueenId: 'q1',
});
await service.create({ ...lostCheck, markColonyDeceased: false }, 'u1');
expect(queens.retireIn).not.toHaveBeenCalled();
});
it('records WinterCheckRecorded with ids only in the same transaction', async () => { it('records WinterCheckRecorded with ids only in the same transaction', async () => {
await service.create({ ...lostCheck, markColonyDeceased: false }, 'u1'); await service.create({ ...lostCheck, markColonyDeceased: false }, 'u1');
@@ -11,6 +11,7 @@ import { Colony, Prisma, WinterCheck } from '../../generated/prisma';
import { isLivingColonyStatus } from '../colonies/colony-status'; import { isLivingColonyStatus } from '../colonies/colony-status';
import { colonyTransitionEvents, lockColony } from '../colonies/colony-events'; import { colonyTransitionEvents, lockColony } from '../colonies/colony-events';
import { releaseQueenIn } from '../colonies/colony-lifecycle'; import { releaseQueenIn } from '../colonies/colony-lifecycle';
import { QueensService } from '../queens/queens.service';
import { utcDay } from '../domain-events/domain-event-types'; import { utcDay } from '../domain-events/domain-event-types';
import { CreateWinterCheckDto } from './dto/create-winter-check.dto'; import { CreateWinterCheckDto } from './dto/create-winter-check.dto';
import { UpdateWinterCheckDto } from './dto/update-winter-check.dto'; import { UpdateWinterCheckDto } from './dto/update-winter-check.dto';
@@ -48,6 +49,7 @@ export class WinterChecksService {
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly auditLog: AuditLogService, private readonly auditLog: AuditLogService,
private readonly events: DomainEventsService, private readonly events: DomainEventsService,
private readonly queens: QueensService,
) {} ) {}
findAll(query: WinterCheckQueryDto): Promise<WinterCheckWithColony[]> { findAll(query: WinterCheckQueryDto): Promise<WinterCheckWithColony[]> {
@@ -262,6 +264,10 @@ export class WinterChecksService {
if (!isLivingColonyStatus(colony.status)) return; if (!isLivingColonyStatus(colony.status)) return;
// The queen leaves with the colony, on the day of the Auswinterung. // The queen leaves with the colony, on the day of the Auswinterung.
const releasedQueenId = await releaseQueenIn(tx, colony.id, check.date); const releasedQueenId = await releaseQueenIn(tx, colony.id, check.date);
if (releasedQueenId) {
// She died with the colony.
await this.queens.retireIn(tx, releasedQueenId, 'DECEASED', actingUserId);
}
const after = await tx.colony.update({ const after = await tx.colony.update({
where: { id: colony.id }, where: { id: colony.id },
data: { status: 'DECEASED', deactivatedAt: check.date }, data: { status: 'DECEASED', deactivatedAt: check.date },
+52 -2
View File
@@ -42,6 +42,7 @@ describe('Colony ended (e2e)', () => {
'colonies:read', 'colonies:read',
'colonies:update', 'colonies:update',
'colonies:deactivate', 'colonies:deactivate',
'colonies:manage-lifecycle',
'winter-checks:read', 'winter-checks:read',
'winter-checks:create', 'winter-checks:create',
]; ];
@@ -187,6 +188,9 @@ describe('Colony ended (e2e)', () => {
await prisma.queenColonyHistory.deleteMany({ await prisma.queenColonyHistory.deleteMany({
where: { colonyId: { in: colonyIds } }, where: { colonyId: { in: colonyIds } },
}); });
await prisma.colonyLineage.deleteMany({
where: { fromColonyId: { in: colonyIds } },
});
await prisma.colony.deleteMany({ where: { id: { in: colonyIds } } }); await prisma.colony.deleteMany({ where: { id: { in: colonyIds } } });
await prisma.queen.deleteMany({ where: { id: { in: queenIds } } }); await prisma.queen.deleteMany({ where: { id: { in: queenIds } } });
await prisma.apiary.delete({ where: { id: apiaryId } }); await prisma.apiary.delete({ where: { id: apiaryId } });
@@ -227,10 +231,13 @@ describe('Colony ended (e2e)', () => {
}); });
expect(history).toHaveLength(1); expect(history).toHaveLength(1);
expect(history[0].endDate?.toISOString()).toBe('2002-03-20T00:00:00.000Z'); expect(history[0].endDate?.toISOString()).toBe('2002-03-20T00:00:00.000Z');
// The queen herself keeps her status: the clean-up decides. // She died with the colony and moves to the archive.
await expect( await expect(
prisma.queen.findUniqueOrThrow({ where: { id: queenId } }), prisma.queen.findUniqueOrThrow({ where: { id: queenId } }),
).resolves.toMatchObject({ status: 'ACTIVE' }); ).resolves.toMatchObject({
status: 'DECEASED',
deactivatedAt: expect.any(Date) as Date,
});
const ended = await prisma.domainEvent.findMany({ const ended = await prisma.domainEvent.findMany({
where: { aggregateId: colonyId, type: 'ColonyEnded' }, where: { aggregateId: colonyId, type: 'ColonyEnded' },
}); });
@@ -244,6 +251,49 @@ describe('Colony ended (e2e)', () => {
]); ]);
}); });
it('dissolving a colony moves its queen to the archive as REPLACED', async () => {
const { colonyId, queenId } = await newColony('dissolve', 0);
await api()
.post(`/api/v1/colonies/${colonyId}/dissolve`)
.set(auth(tokens.full))
.expect(201);
await expect(
prisma.queen.findUniqueOrThrow({ where: { id: queenId } }),
).resolves.toMatchObject({
status: 'REPLACED',
deactivatedAt: expect.any(Date) as Date,
});
});
it('merging moves the queen of the merged-away colony to the archive and keeps a sold one sold', async () => {
const survivor = await newColony('survivor', 0);
const absorbed = await newColony('absorbed', 0);
const sold = await newColony('sold', 0);
await prisma.queen.update({
where: { id: sold.queenId },
data: { status: 'SOLD' },
});
await api()
.post(`/api/v1/colonies/${survivor.colonyId}/merge`)
.set(auth(tokens.full))
.send({ mergedColonyIds: [absorbed.colonyId, sold.colonyId] })
.expect(201);
const queens = await prisma.queen.findMany({
where: {
id: { in: [survivor.queenId, absorbed.queenId, sold.queenId] },
},
});
const statusOf = (id: string) =>
queens.find((queen) => queen.id === id)?.status;
expect(statusOf(survivor.queenId)).toBe('ACTIVE');
expect(statusOf(absorbed.queenId)).toBe('REPLACED');
expect(statusOf(sold.queenId)).toBe('SOLD');
});
it('open suggestions become OBSOLETE after draining and one CLEANUP suggestion is left', async () => { it('open suggestions become OBSOLETE after draining and one CLEANUP suggestion is left', async () => {
const { colonyId, taskIds, queenId } = await newColony('obsolete', 2); const { colonyId, taskIds, queenId } = await newColony('obsolete', 2);
const open = await proposeForTest(app, { const open = await proposeForTest(app, {
+22 -1
View File
@@ -92,7 +92,10 @@ describe('Queens (e2e)', () => {
if (queenId) { if (queenId) {
// Events go with their deliveries (ON DELETE CASCADE). // Events go with their deliveries (ON DELETE CASCADE).
await prisma.domainEvent.deleteMany({ await prisma.domainEvent.deleteMany({
where: { aggregateType: 'Queen', aggregateId: queenId }, where: {
aggregateType: 'Queen',
aggregateId: { in: [queenId, daughterQueenId].filter(Boolean) },
},
}); });
} }
if (queenId) { if (queenId) {
@@ -358,4 +361,22 @@ describe('Queens (e2e)', () => {
.send({ deactivatedAt: new Date().toISOString() }) .send({ deactivatedAt: new Date().toISOString() })
.expect(400); .expect(400);
}); });
it('moves the queen displaced by a new one into the archive as REPLACED', async () => {
// queenId sits in secondColonyId since the calendar test.
await request(app.getHttpServer())
.post(`/api/v1/queens/${daughterQueenId}/assign-colony`)
.set('Authorization', `Bearer ${beekeeperToken}`)
.send({ colonyId: secondColonyId })
.expect(201);
const displaced = await request(app.getHttpServer())
.get(`/api/v1/queens/${queenId}`)
.set('Authorization', `Bearer ${beekeeperToken}`)
.expect(200);
expect(displaced.body).toMatchObject({
status: 'REPLACED',
deactivatedAt: expect.any(String) as string,
});
});
}); });