Die Königinnen-Liste trennt Aktiv und Historie über deactivatedAt, das Backend setzte das Feld aber nie. Jetzt stempelt ein Statuswechsel auf LOST, DECEASED, REPLACED oder SOLD das Datum (update und updateStatusIn), ein korrigierter Status nimmt es wieder weg. Die Migration 20261021100000_queen_deactivated_backfill trägt das Datum für bestehende Königinnen nach (Ende der letzten Volkszuordnung, sonst updatedAt). Auch die Lebensdauer in der Statistik rechnet damit wieder. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
362 lines
12 KiB
TypeScript
362 lines
12 KiB
TypeScript
import { INestApplication } from '@nestjs/common';
|
|
import * as bcrypt from 'bcryptjs';
|
|
import request from 'supertest';
|
|
import { App } from 'supertest/types';
|
|
import { PrismaService } from '../src/prisma/prisma.service';
|
|
import { createTestApp } from './utils/create-test-app';
|
|
import { login } from './utils/api';
|
|
import { drainDomainEvents } from './utils/domain-events';
|
|
import { CalendarSyncSettingsService } from '../src/calendar-sync/calendar-sync-settings.service';
|
|
|
|
interface QueenBody {
|
|
id: string;
|
|
code: string;
|
|
}
|
|
|
|
describe('Queens (e2e)', () => {
|
|
let app: INestApplication<App>;
|
|
let prisma: PrismaService;
|
|
const beekeeperEmail = `queens-e2e-beekeeper-${Date.now()}@example.com`;
|
|
const password = 'SuperSecret123!';
|
|
let beekeeperId: string;
|
|
let beekeeperToken: string;
|
|
let apiaryId: string;
|
|
let colonyId: string;
|
|
let queenId: string;
|
|
let daughterQueenId: string;
|
|
let secondColonyId: string;
|
|
let subscriptionId: string;
|
|
let retiredQueenId: string;
|
|
|
|
beforeAll(async () => {
|
|
app = await createTestApp();
|
|
prisma = app.get(PrismaService);
|
|
|
|
const passwordHash = await bcrypt.hash(password, 12);
|
|
const beekeeper = await prisma.user.create({
|
|
data: {
|
|
email: beekeeperEmail,
|
|
passwordHash,
|
|
name: 'Beekeeper',
|
|
role: { connect: { key: 'BEEKEEPER' } },
|
|
},
|
|
});
|
|
beekeeperId = beekeeper.id;
|
|
beekeeperToken = (await login(app, beekeeperEmail, password)).accessToken;
|
|
|
|
const apiary = await prisma.apiary.create({
|
|
data: { name: `Stand ${Date.now()}` },
|
|
});
|
|
apiaryId = apiary.id;
|
|
const colony = await prisma.colony.create({
|
|
data: {
|
|
code: `V-${Date.now()}`,
|
|
apiaryId,
|
|
colonyType: 'PRODUCTION_COLONY',
|
|
},
|
|
});
|
|
colonyId = colony.id;
|
|
});
|
|
|
|
afterEach(() => {
|
|
jest.restoreAllMocks();
|
|
});
|
|
|
|
afterAll(async () => {
|
|
if (subscriptionId) {
|
|
// Deliveries go with their subscription (ON DELETE CASCADE).
|
|
await prisma.webhookSubscription.deleteMany({
|
|
where: { id: subscriptionId },
|
|
});
|
|
}
|
|
if (queenId) {
|
|
const historyIds = (
|
|
await prisma.queenColonyHistory.findMany({
|
|
where: { queenId },
|
|
select: { id: true },
|
|
})
|
|
).map((history) => history.id);
|
|
await prisma.calendarSyncOutboxEntry.deleteMany({
|
|
where: { sourceType: 'QUEEN_ASSIGNMENT', sourceId: { in: historyIds } },
|
|
});
|
|
}
|
|
if (secondColonyId) {
|
|
await prisma.queenColonyHistory.deleteMany({
|
|
where: { colonyId: secondColonyId },
|
|
});
|
|
await prisma.colony.update({
|
|
where: { id: secondColonyId },
|
|
data: { currentQueenId: null },
|
|
});
|
|
}
|
|
if (queenId) {
|
|
// Events go with their deliveries (ON DELETE CASCADE).
|
|
await prisma.domainEvent.deleteMany({
|
|
where: { aggregateType: 'Queen', aggregateId: queenId },
|
|
});
|
|
}
|
|
if (queenId) {
|
|
await prisma.queenEvaluation.deleteMany({ where: { queenId } });
|
|
}
|
|
await prisma.queenColonyHistory.deleteMany({ where: { colonyId } });
|
|
if (colonyId) {
|
|
await prisma.colony.update({
|
|
where: { id: colonyId },
|
|
data: { currentQueenId: null },
|
|
});
|
|
}
|
|
if (retiredQueenId) {
|
|
await prisma.queen.delete({ where: { id: retiredQueenId } });
|
|
}
|
|
if (daughterQueenId) {
|
|
await prisma.queen.delete({ where: { id: daughterQueenId } });
|
|
}
|
|
if (queenId) {
|
|
await prisma.queen.delete({ where: { id: queenId } });
|
|
}
|
|
await prisma.colony.deleteMany({
|
|
where: { id: { in: [colonyId, secondColonyId].filter(Boolean) } },
|
|
});
|
|
await prisma.apiary.deleteMany({ where: { id: apiaryId } });
|
|
await prisma.refreshToken.deleteMany({ where: { userId: beekeeperId } });
|
|
await prisma.auditLog.deleteMany({ where: { userId: beekeeperId } });
|
|
await prisma.user.deleteMany({ where: { id: beekeeperId } });
|
|
await app.close();
|
|
});
|
|
|
|
it('lets a BEEKEEPER create a queen', async () => {
|
|
const response = await request(app.getHttpServer())
|
|
.post('/api/v1/queens')
|
|
.set('Authorization', `Bearer ${beekeeperToken}`)
|
|
.send({ code: `Q-${Date.now()}`, breed: 'Carnica' })
|
|
.expect(201);
|
|
queenId = (response.body as QueenBody).id;
|
|
expect(queenId).toEqual(expect.any(String));
|
|
});
|
|
|
|
it('assigns the queen to a colony and records history', async () => {
|
|
await request(app.getHttpServer())
|
|
.post(`/api/v1/queens/${queenId}/assign-colony`)
|
|
.set('Authorization', `Bearer ${beekeeperToken}`)
|
|
.send({ colonyId })
|
|
.expect(201);
|
|
// Settle the first assignment's event, so later tests see only their own.
|
|
await drainDomainEvents(app, [queenId]);
|
|
|
|
const colony = await request(app.getHttpServer())
|
|
.get(`/api/v1/colonies/${colonyId}`)
|
|
.set('Authorization', `Bearer ${beekeeperToken}`)
|
|
.expect(200);
|
|
expect((colony.body as { currentQueenId: string }).currentQueenId).toBe(
|
|
queenId,
|
|
);
|
|
|
|
const history = await request(app.getHttpServer())
|
|
.get(`/api/v1/queens/${queenId}/history`)
|
|
.set('Authorization', `Bearer ${beekeeperToken}`)
|
|
.expect(200);
|
|
expect(history.body).toHaveLength(1);
|
|
});
|
|
|
|
it('assigning a queen leaves a calendar entry and a queen.replaced delivery after draining', async () => {
|
|
jest.spyOn(app.get(CalendarSyncSettingsService), 'get').mockResolvedValue({
|
|
id: 'singleton',
|
|
enabled: true,
|
|
groupId: 'group-e2e',
|
|
syncTask: true,
|
|
syncInspection: true,
|
|
syncTreatment: true,
|
|
syncFeeding: true,
|
|
syncHarvest: true,
|
|
syncQueenAssignment: true,
|
|
syncWithdrawal: true,
|
|
updatedAt: new Date(),
|
|
});
|
|
subscriptionId = (
|
|
await prisma.webhookSubscription.create({
|
|
data: {
|
|
url: 'https://example.com/hook',
|
|
events: ['queen.replaced'],
|
|
secret: 'a'.repeat(64),
|
|
createdByUserId: beekeeperId,
|
|
},
|
|
})
|
|
).id;
|
|
secondColonyId = (
|
|
await prisma.colony.create({
|
|
data: {
|
|
code: `V2-${Date.now()}`,
|
|
apiaryId,
|
|
colonyType: 'PRODUCTION_COLONY',
|
|
},
|
|
})
|
|
).id;
|
|
|
|
await request(app.getHttpServer())
|
|
.post(`/api/v1/queens/${queenId}/assign-colony`)
|
|
.set('Authorization', `Bearer ${beekeeperToken}`)
|
|
.send({ colonyId: secondColonyId })
|
|
.expect(201);
|
|
|
|
// Nothing but the event is written in the request.
|
|
const history = await prisma.queenColonyHistory.findFirstOrThrow({
|
|
where: { queenId, colonyId: secondColonyId },
|
|
});
|
|
expect(
|
|
await prisma.calendarSyncOutboxEntry.count({
|
|
where: { sourceType: 'QUEEN_ASSIGNMENT', sourceId: history.id },
|
|
}),
|
|
).toBe(0);
|
|
expect(
|
|
await prisma.webhookDelivery.count({
|
|
where: { subscriptionId },
|
|
}),
|
|
).toBe(0);
|
|
const event = await prisma.domainEvent.findFirstOrThrow({
|
|
where: { type: 'QueenAssigned', aggregateId: queenId },
|
|
orderBy: { occurredAt: 'desc' },
|
|
});
|
|
expect(event.payload).toEqual(
|
|
expect.objectContaining({
|
|
colonyId: secondColonyId,
|
|
historyId: history.id,
|
|
previousColonyId: colonyId,
|
|
}),
|
|
);
|
|
|
|
await drainDomainEvents(app, [queenId]);
|
|
|
|
expect(
|
|
await prisma.calendarSyncOutboxEntry.count({
|
|
where: { sourceType: 'QUEEN_ASSIGNMENT', sourceId: history.id },
|
|
}),
|
|
).toBe(1);
|
|
const deliveries = await prisma.webhookDelivery.findMany({
|
|
where: { subscriptionId, event: 'queen.replaced' },
|
|
});
|
|
expect(deliveries).toHaveLength(1);
|
|
expect(deliveries[0]?.body).toContain(queenId);
|
|
});
|
|
|
|
it('adds a queen evaluation and lists it back', async () => {
|
|
await request(app.getHttpServer())
|
|
.post(`/api/v1/queens/${queenId}/evaluations`)
|
|
.set('Authorization', `Bearer ${beekeeperToken}`)
|
|
.send({
|
|
colonyId,
|
|
date: new Date().toISOString(),
|
|
colonyStrength: 4,
|
|
gentleness: 5,
|
|
combBuild: 3,
|
|
swarmingTendency: 2,
|
|
broodPattern: 4,
|
|
honeyYield: 5,
|
|
})
|
|
.expect(201);
|
|
|
|
const response = await request(app.getHttpServer())
|
|
.get(`/api/v1/queens/${queenId}/evaluations`)
|
|
.set('Authorization', `Bearer ${beekeeperToken}`)
|
|
.expect(200);
|
|
const evaluations = response.body as { colonyStrength: number }[];
|
|
expect(evaluations).toHaveLength(1);
|
|
expect(evaluations[0]?.colonyStrength).toBe(4);
|
|
});
|
|
|
|
it('rejects creating a queen with a motherQueenId that does not exist', () => {
|
|
return request(app.getHttpServer())
|
|
.post('/api/v1/queens')
|
|
.set('Authorization', `Bearer ${beekeeperToken}`)
|
|
.send({ code: `Q-orphan-${Date.now()}`, motherQueenId: 'does-not-exist' })
|
|
.expect(404);
|
|
});
|
|
|
|
it('rejects a queen set as her own mother', () => {
|
|
return request(app.getHttpServer())
|
|
.patch(`/api/v1/queens/${queenId}`)
|
|
.set('Authorization', `Bearer ${beekeeperToken}`)
|
|
.send({ motherQueenId: queenId })
|
|
.expect(400);
|
|
});
|
|
|
|
it('creates a daughter queen and returns the ancestor/descendant lineage', async () => {
|
|
const daughterResponse = await request(app.getHttpServer())
|
|
.post('/api/v1/queens')
|
|
.set('Authorization', `Bearer ${beekeeperToken}`)
|
|
.send({ code: `Q-daughter-${Date.now()}`, motherQueenId: queenId })
|
|
.expect(201);
|
|
daughterQueenId = (daughterResponse.body as QueenBody).id;
|
|
|
|
const motherLineage = await request(app.getHttpServer())
|
|
.get(`/api/v1/queens/${queenId}/lineage`)
|
|
.set('Authorization', `Bearer ${beekeeperToken}`)
|
|
.expect(200);
|
|
const motherBody = motherLineage.body as {
|
|
queen: { id: string; mother: unknown };
|
|
descendants: { id: string }[];
|
|
};
|
|
expect(motherBody.queen.id).toBe(queenId);
|
|
expect(motherBody.queen.mother).toBeNull();
|
|
expect(motherBody.descendants).toHaveLength(1);
|
|
expect(motherBody.descendants[0]?.id).toBe(daughterQueenId);
|
|
|
|
const daughterLineage = await request(app.getHttpServer())
|
|
.get(`/api/v1/queens/${daughterQueenId}/lineage`)
|
|
.set('Authorization', `Bearer ${beekeeperToken}`)
|
|
.expect(200);
|
|
const daughterBody = daughterLineage.body as {
|
|
queen: { id: string; mother: { id: string } | null };
|
|
descendants: unknown[];
|
|
};
|
|
expect(daughterBody.queen.mother?.id).toBe(queenId);
|
|
expect(daughterBody.descendants).toHaveLength(0);
|
|
});
|
|
|
|
it('moves a queen into the history when she dies and back when the status is corrected', async () => {
|
|
const created = await request(app.getHttpServer())
|
|
.post('/api/v1/queens')
|
|
.set('Authorization', `Bearer ${beekeeperToken}`)
|
|
.send({ code: `Q-retired-${Date.now()}` })
|
|
.expect(201);
|
|
retiredQueenId = (created.body as QueenBody).id;
|
|
expect(
|
|
(created.body as { deactivatedAt: string | null }).deactivatedAt,
|
|
).toBeNull();
|
|
|
|
const deceased = await request(app.getHttpServer())
|
|
.patch(`/api/v1/queens/${retiredQueenId}`)
|
|
.set('Authorization', `Bearer ${beekeeperToken}`)
|
|
.send({ status: 'DECEASED' })
|
|
.expect(200);
|
|
expect(
|
|
(deceased.body as { deactivatedAt: string | null }).deactivatedAt,
|
|
).toEqual(expect.any(String));
|
|
|
|
const list = await request(app.getHttpServer())
|
|
.get('/api/v1/queens')
|
|
.set('Authorization', `Bearer ${beekeeperToken}`)
|
|
.expect(200);
|
|
const listed = (
|
|
list.body as { id: string; deactivatedAt: string | null }[]
|
|
).find((queen) => queen.id === retiredQueenId);
|
|
expect(listed?.deactivatedAt).toEqual(expect.any(String));
|
|
|
|
const corrected = await request(app.getHttpServer())
|
|
.patch(`/api/v1/queens/${retiredQueenId}`)
|
|
.set('Authorization', `Bearer ${beekeeperToken}`)
|
|
.send({ status: 'ACTIVE' })
|
|
.expect(200);
|
|
expect(
|
|
(corrected.body as { deactivatedAt: string | null }).deactivatedAt,
|
|
).toBeNull();
|
|
});
|
|
|
|
it('does not let a client set deactivatedAt directly', () => {
|
|
return request(app.getHttpServer())
|
|
.patch(`/api/v1/queens/${queenId}`)
|
|
.set('Authorization', `Bearer ${beekeeperToken}`)
|
|
.send({ deactivatedAt: new Date().toISOString() })
|
|
.expect(400);
|
|
});
|
|
});
|