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]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-14 14:25:24 +02:00
1 parent d99a697739
commit f468dc5f75
87 files changed
+6022 -510

No files matched your search

+125 -1
View File
@@ -5,6 +5,8 @@ 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;
@@ -22,6 +24,8 @@ describe('Queens (e2e)', () => {
let colonyId: string;
let queenId: string;
let daughterQueenId: string;
let secondColonyId: string;
let subscriptionId: string;
beforeAll(async () => {
app = await createTestApp();
@@ -53,7 +57,43 @@ describe('Queens (e2e)', () => {
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 } });
}
@@ -70,7 +110,9 @@ describe('Queens (e2e)', () => {
if (queenId) {
await prisma.queen.delete({ where: { id: queenId } });
}
await prisma.colony.deleteMany({ where: { id: colonyId } });
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 } });
@@ -94,6 +136,8 @@ describe('Queens (e2e)', () => {
.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}`)
@@ -110,6 +154,86 @@ describe('Queens (e2e)', () => {
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`)