diff --git a/ApiCore-Backend/src/colonies/colonies.module.ts b/ApiCore-Backend/src/colonies/colonies.module.ts index 517fa56..8947cb7 100644 --- a/ApiCore-Backend/src/colonies/colonies.module.ts +++ b/ApiCore-Backend/src/colonies/colonies.module.ts @@ -1,4 +1,5 @@ import { Module } from '@nestjs/common'; +import { QueensModule } from '../queens/queens.module'; import { ApiaryRestrictionsModule } from '../apiary-restrictions/apiary-restrictions.module'; import { ColoniesController } from './colonies.controller'; import { ColoniesService } from './colonies.service'; @@ -6,7 +7,7 @@ import { ColoniesService } from './colonies.service'; // ApiaryRestrictionsModule: the restricted-zone endpoints ride along with the // colony paths that enforce them (AppModule imports ColoniesModule). @Module({ - imports: [ApiaryRestrictionsModule], + imports: [ApiaryRestrictionsModule, QueensModule], controllers: [ColoniesController], providers: [ColoniesService], exports: [ColoniesService], diff --git a/ApiCore-Backend/src/colonies/colonies.service.spec.ts b/ApiCore-Backend/src/colonies/colonies.service.spec.ts index 87a7afd..f51b6e7 100644 --- a/ApiCore-Backend/src/colonies/colonies.service.spec.ts +++ b/ApiCore-Backend/src/colonies/colonies.service.spec.ts @@ -5,11 +5,13 @@ import { AuditLogService } from '../audit/audit-log.service'; import { WebhooksService } from '../webhooks/webhooks.service'; import { DomainEventsService } from '../domain-events/domain-events.service'; import { PermissionResolverService } from '../auth/permissions/permission-resolver.service'; +import { QueensService } from '../queens/queens.service'; import { berlinToday } from '../common/dates'; import { expectOnlyIdPayloads } from '../domain-events/herd-payload.spec-helper'; describe('ColoniesService', () => { let service: ColoniesService; + let queens: { retireIn: jest.Mock }; let tx: { $queryRaw: jest.Mock; colony: { create: jest.Mock; update: jest.Mock; findUnique: jest.Mock }; @@ -94,12 +96,14 @@ describe('ColoniesService', () => { .fn() .mockResolvedValue(new Set(['compliance:manage'])), }; + queens = { retireIn: jest.fn().mockResolvedValue(undefined) }; service = new ColoniesService( prisma as unknown as PrismaService, auditLog as unknown as AuditLogService, webhooks as unknown as WebhooksService, events as unknown as DomainEventsService, resolver as unknown as PermissionResolverService, + queens as unknown as QueensService, ); }); @@ -307,6 +311,13 @@ describe('ColoniesService', () => { endDate: null, }); 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 [ [{ data: { type: string; fromColonyId: string; toColonyId: string } }], @@ -419,6 +430,13 @@ describe('ColoniesService', () => { endDate: null, }); 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 () => { @@ -434,6 +452,7 @@ describe('ColoniesService', () => { await service.dissolve('colony-1', 'user-1'); expect(tx.queenColonyHistory.updateMany).not.toHaveBeenCalled(); + expect(queens.retireIn).not.toHaveBeenCalled(); }); it('reactivate throws ConflictException when already active', async () => { diff --git a/ApiCore-Backend/src/colonies/colonies.service.ts b/ApiCore-Backend/src/colonies/colonies.service.ts index 327a8b3..b7aa769 100644 --- a/ApiCore-Backend/src/colonies/colonies.service.ts +++ b/ApiCore-Backend/src/colonies/colonies.service.ts @@ -26,6 +26,7 @@ import { lockRowsForShare, lockRowsForUpdate } from '../prisma/row-lock'; import { colonyTransitionEvents, lockColony } from './colony-events'; import { berlinToday, dayToDate } from '../common/dates'; import { PermissionResolverService } from '../auth/permissions/permission-resolver.service'; +import { QueensService } from '../queens/queens.service'; import { assertNoActiveRestriction, RestrictionCheck, @@ -81,6 +82,7 @@ export class ColoniesService { private readonly webhooks: WebhooksService, private readonly events: DomainEventsService, private readonly permissions: PermissionResolverService, + private readonly queens: QueensService, ) {} async findAll(filter: ColonyFilter = {}): Promise { @@ -526,6 +528,14 @@ export class ColoniesService { merged.id, dayToDate(today), ); + if (releasedQueenId) { + await this.queens.retireIn( + tx, + releasedQueenId, + 'REPLACED', + actingUserId, + ); + } const mergedAway = await tx.colony.update({ where: { id: merged.id }, data: { status: 'MERGED', deactivatedAt: new Date() }, @@ -576,6 +586,14 @@ export class ColoniesService { this.assertActive(before); const today = berlinToday(); const releasedQueenId = await releaseQueenIn(tx, id, dayToDate(today)); + if (releasedQueenId) { + await this.queens.retireIn( + tx, + releasedQueenId, + 'REPLACED', + actingUserId, + ); + } const updated = await tx.colony.update({ where: { id }, data: { status: 'DISSOLVED', deactivatedAt: new Date() }, diff --git a/ApiCore-Backend/src/queens/queens.service.spec.ts b/ApiCore-Backend/src/queens/queens.service.spec.ts index 84824e0..b702d98 100644 --- a/ApiCore-Backend/src/queens/queens.service.spec.ts +++ b/ApiCore-Backend/src/queens/queens.service.spec.ts @@ -143,6 +143,11 @@ describe('QueensService', () => { ]; expect(historyArg.data.queenId).toBe('queen-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 () => { @@ -434,6 +439,29 @@ describe('QueensService', () => { 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', () => { it('locks the queen, reads it afterwards, sets the status and audits in the same transaction', async () => { tx.queen.findUnique.mockResolvedValue(queen); diff --git a/ApiCore-Backend/src/queens/queens.service.ts b/ApiCore-Backend/src/queens/queens.service.ts index 94fc7da..b98c1a1 100644 --- a/ApiCore-Backend/src/queens/queens.service.ts +++ b/ApiCore-Backend/src/queens/queens.service.ts @@ -232,6 +232,28 @@ export class QueensService { 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 { + 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( id: string, dto: AssignColonyDto, @@ -321,6 +343,14 @@ export class QueensService { status: colony.status === 'QUEENLESS' ? 'ACTIVE' : undefined, }, }); + if (colony.currentQueenId) { + await this.retireIn( + tx, + colony.currentQueenId, + 'REPLACED', + actingUserId, + ); + } const history = await tx.queenColonyHistory.create({ data: { queenId: queen.id, diff --git a/ApiCore-Backend/src/winter-checks/winter-checks.module.ts b/ApiCore-Backend/src/winter-checks/winter-checks.module.ts index f83a31b..46cf00f 100644 --- a/ApiCore-Backend/src/winter-checks/winter-checks.module.ts +++ b/ApiCore-Backend/src/winter-checks/winter-checks.module.ts @@ -1,9 +1,11 @@ import { Module } from '@nestjs/common'; +import { QueensModule } from '../queens/queens.module'; import { ColonyWinterChecksController } from './colony-winter-checks.controller'; import { WinterChecksController } from './winter-checks.controller'; import { WinterChecksService } from './winter-checks.service'; @Module({ + imports: [QueensModule], controllers: [WinterChecksController, ColonyWinterChecksController], providers: [WinterChecksService], exports: [WinterChecksService], diff --git a/ApiCore-Backend/src/winter-checks/winter-checks.service.spec.ts b/ApiCore-Backend/src/winter-checks/winter-checks.service.spec.ts index 60d3be0..eb1a35b 100644 --- a/ApiCore-Backend/src/winter-checks/winter-checks.service.spec.ts +++ b/ApiCore-Backend/src/winter-checks/winter-checks.service.spec.ts @@ -3,6 +3,7 @@ import { PrismaService } from '../prisma/prisma.service'; import { AuditLogService } from '../audit/audit-log.service'; import { DomainEventsService } from '../domain-events/domain-events.service'; import { CreateWinterCheckDto } from './dto/create-winter-check.dto'; +import { QueensService } from '../queens/queens.service'; describe('WinterChecksService', () => { const livingColony = { @@ -34,6 +35,7 @@ describe('WinterChecksService', () => { }; let auditLog: { record: jest.Mock }; let events: { recordAll: jest.Mock; record: jest.Mock }; + let queens: { retireIn: jest.Mock }; beforeEach(() => { tx = { @@ -67,13 +69,37 @@ describe('WinterChecksService', () => { recordAll: jest.fn().mockResolvedValue(0), record: jest.fn().mockResolvedValue(undefined), }; + queens = { retireIn: jest.fn().mockResolvedValue(undefined) }; service = new WinterChecksService( prisma as unknown as PrismaService, auditLog as unknown as AuditLogService, 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 () => { await service.create({ ...lostCheck, markColonyDeceased: false }, 'u1'); diff --git a/ApiCore-Backend/src/winter-checks/winter-checks.service.ts b/ApiCore-Backend/src/winter-checks/winter-checks.service.ts index dd1875e..0e7703a 100644 --- a/ApiCore-Backend/src/winter-checks/winter-checks.service.ts +++ b/ApiCore-Backend/src/winter-checks/winter-checks.service.ts @@ -11,6 +11,7 @@ import { Colony, Prisma, WinterCheck } from '../../generated/prisma'; import { isLivingColonyStatus } from '../colonies/colony-status'; import { colonyTransitionEvents, lockColony } from '../colonies/colony-events'; import { releaseQueenIn } from '../colonies/colony-lifecycle'; +import { QueensService } from '../queens/queens.service'; import { utcDay } from '../domain-events/domain-event-types'; import { CreateWinterCheckDto } from './dto/create-winter-check.dto'; import { UpdateWinterCheckDto } from './dto/update-winter-check.dto'; @@ -48,6 +49,7 @@ export class WinterChecksService { private readonly prisma: PrismaService, private readonly auditLog: AuditLogService, private readonly events: DomainEventsService, + private readonly queens: QueensService, ) {} findAll(query: WinterCheckQueryDto): Promise { @@ -262,6 +264,10 @@ export class WinterChecksService { if (!isLivingColonyStatus(colony.status)) return; // The queen leaves with the colony, on the day of the Auswinterung. 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({ where: { id: colony.id }, data: { status: 'DECEASED', deactivatedAt: check.date }, diff --git a/ApiCore-Backend/test/colony-ended.e2e-spec.ts b/ApiCore-Backend/test/colony-ended.e2e-spec.ts index d736474..2fdd1b1 100644 --- a/ApiCore-Backend/test/colony-ended.e2e-spec.ts +++ b/ApiCore-Backend/test/colony-ended.e2e-spec.ts @@ -42,6 +42,7 @@ describe('Colony ended (e2e)', () => { 'colonies:read', 'colonies:update', 'colonies:deactivate', + 'colonies:manage-lifecycle', 'winter-checks:read', 'winter-checks:create', ]; @@ -187,6 +188,9 @@ describe('Colony ended (e2e)', () => { await prisma.queenColonyHistory.deleteMany({ where: { colonyId: { in: colonyIds } }, }); + await prisma.colonyLineage.deleteMany({ + where: { fromColonyId: { in: colonyIds } }, + }); await prisma.colony.deleteMany({ where: { id: { in: colonyIds } } }); await prisma.queen.deleteMany({ where: { id: { in: queenIds } } }); await prisma.apiary.delete({ where: { id: apiaryId } }); @@ -227,10 +231,13 @@ describe('Colony ended (e2e)', () => { }); expect(history).toHaveLength(1); 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( 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({ 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 () => { const { colonyId, taskIds, queenId } = await newColony('obsolete', 2); const open = await proposeForTest(app, { diff --git a/ApiCore-Backend/test/queens.e2e-spec.ts b/ApiCore-Backend/test/queens.e2e-spec.ts index 983461e..868c018 100644 --- a/ApiCore-Backend/test/queens.e2e-spec.ts +++ b/ApiCore-Backend/test/queens.e2e-spec.ts @@ -92,7 +92,10 @@ describe('Queens (e2e)', () => { if (queenId) { // Events go with their deliveries (ON DELETE CASCADE). await prisma.domainEvent.deleteMany({ - where: { aggregateType: 'Queen', aggregateId: queenId }, + where: { + aggregateType: 'Queen', + aggregateId: { in: [queenId, daughterQueenId].filter(Boolean) }, + }, }); } if (queenId) { @@ -358,4 +361,22 @@ describe('Queens (e2e)', () => { .send({ deactivatedAt: new Date().toISOString() }) .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, + }); + }); });