Files
Nick SzaunigandClaude Opus 5.5 f468dc5f75 feat: T4 wave 3 - notifications per user, suggestions API, varroa bands, split/queen events
- Schema III: per-user notifications (broadcast fan-out migration,
  category, link, requiredPermissions filter), notification preferences
  and settings endpoints, calendar withdrawal switch, occupiedFrames
- Suggestions API: list/summary with permission visibility and paging,
  accept/dismiss/reopen incl. bulk, SuggestionView on all responses
- Varroa traffic light by season and method, young colonies, treatment
  effect window, sample-based percentage; shared withdrawal-window helper
  on Berlin calendar days; GET /varroa-measurements/thresholds
- ColonySplit and QueenAssigned domain events with calendar and webhook
  handlers
- Outbox claims compare against now() rounded to timestamp(3); fixes the
  flaky e2e claims of rows written with SQL now()

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-14 14:25:24 +02:00

311 lines
10 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;
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 (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);
});
});