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]>
383 lines
13 KiB
TypeScript
383 lines
13 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: { in: [queenId, daughterQueenId].filter(Boolean) },
|
|
},
|
|
});
|
|
}
|
|
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);
|
|
});
|
|
|
|
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,
|
|
});
|
|
});
|
|
});
|