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

+6 -1
View File
@@ -266,7 +266,12 @@ describe('Automation rules (e2e)', () => {
const notifications = await prisma.notification.findMany({
where: { relatedId: colonyId },
});
expect(notifications).toHaveLength(1);
// One copy per user who may read tasks, never a broadcast.
expect(notifications.length).toBeGreaterThan(0);
expect(notifications.every((n) => n.category === 'SUGGESTION')).toBe(true);
expect(new Set(notifications.map((n) => n.userId)).size).toBe(
notifications.length,
);
});
it('does not trigger when the queen was seen', async () => {
@@ -44,6 +44,7 @@ describe('Calendar sync outbox (e2e)', () => {
syncFeeding: true,
syncHarvest: true,
syncQueenAssignment: true,
syncWithdrawal: true,
updatedAt: new Date(),
};
@@ -359,6 +360,37 @@ describe('Calendar sync outbox (e2e)', () => {
expect(JSON.stringify(response.body)).toContain('Gruppen-ID');
});
// GET /settings is mocked in this file (see beforeEach), so the stored
// row is read directly.
it('syncWithdrawal is on by default and can be switched off', async () => {
const fresh = await prisma.calendarSyncSettings.create({
data: { id: `default-check-${suffix}` },
});
await prisma.calendarSyncSettings.delete({ where: { id: fresh.id } });
expect(fresh.syncWithdrawal).toBe(true);
await prisma.calendarSyncSettings.upsert({
where: { id: 'singleton' },
create: { id: 'singleton' },
update: {},
});
const response = await request(app.getHttpServer())
.patch('/api/v1/calendar/sync/settings')
.set(auth())
.send({ syncWithdrawal: false })
.expect(200);
expect(response.body).toMatchObject({ syncWithdrawal: false });
const stored = await prisma.calendarSyncSettings.findUniqueOrThrow({
where: { id: 'singleton' },
});
expect(stored.syncWithdrawal).toBe(false);
await prisma.calendarSyncSettings.update({
where: { id: 'singleton' },
data: { syncWithdrawal: true },
});
});
// PATCH null rule: the switches cannot hold null - 400, not a 500.
it('rejects null on a switch with 400', async () => {
await request(app.getHttpServer())
+51
View File
@@ -35,6 +35,7 @@ describe('Colonies (e2e)', () => {
let apiaryBId: string;
let colonyId: string;
let childColonyId: string;
const splitColonyIds: string[] = [];
let colonyToMergeId: string;
let healthColonyId: string;
let raceColonyId: string;
@@ -83,6 +84,7 @@ describe('Colonies (e2e)', () => {
colonyToMergeId,
healthColonyId,
raceColonyId,
...splitColonyIds,
].filter(Boolean);
// Rule tasks are written without a user - their audit entry and
// TaskCreated are not covered by the userId cleanup below.
@@ -190,6 +192,55 @@ describe('Colonies (e2e)', () => {
expect((history.body as ColonyHistoryBody).lineageAsSource).toHaveLength(1);
});
it('records ColonySplit with the given day and queen source, and refuses a future day', async () => {
await request(app.getHttpServer())
.post(`/api/v1/colonies/${colonyId}/split`)
.set('Authorization', `Bearer ${beekeeperToken}`)
.send({
code: `V-${Date.now()}-F`,
colonyType: 'NUCLEUS_COLONY',
date: '2999-01-01',
})
.expect(400);
await request(app.getHttpServer())
.post(`/api/v1/colonies/${colonyId}/split`)
.set('Authorization', `Bearer ${beekeeperToken}`)
.send({
code: `V-${Date.now()}-T`,
colonyType: 'NUCLEUS_COLONY',
date: '2026-05-05T23:30:00+02:00',
})
.expect(400);
const response = await request(app.getHttpServer())
.post(`/api/v1/colonies/${colonyId}/split`)
.set('Authorization', `Bearer ${beekeeperToken}`)
.send({
code: `V-${Date.now()}-B`,
colonyType: 'NUCLEUS_COLONY',
date: '2026-05-05',
queenSource: 'QUEEN_CELL',
})
.expect(201);
const splitId = (response.body as ColonyBody).id;
splitColonyIds.push(splitId);
const lineage = await prisma.colonyLineage.findFirstOrThrow({
where: { toColonyId: splitId },
});
expect(lineage.queenSource).toBe('QUEEN_CELL');
expect(lineage.date.toISOString().slice(0, 10)).toBe('2026-05-05');
const event = await prisma.domainEvent.findFirstOrThrow({
where: { type: 'ColonySplit', aggregateId: splitId },
});
expect(event.payload).toEqual({
parentColonyId: colonyId,
lineageId: lineage.id,
queenSource: 'QUEEN_CELL',
splitOn: '2026-05-05',
});
});
it('merges another colony into the surviving one', async () => {
const otherColony = await request(app.getHttpServer())
.post('/api/v1/colonies')
+327 -65
View File
@@ -2,6 +2,7 @@ import { INestApplication } from '@nestjs/common';
import * as bcrypt from 'bcryptjs';
import request from 'supertest';
import { App } from 'supertest/types';
import { NotificationsService } from '../src/notifications/notifications.service';
import { PrismaService } from '../src/prisma/prisma.service';
import { createTestApp } from './utils/create-test-app';
import { login } from './utils/api';
@@ -9,99 +10,360 @@ import { login } from './utils/api';
interface NotificationBody {
id: string;
priority: string;
category: string;
read: boolean;
}
interface SettingsBody {
taskDueEnabled: boolean;
digestEnabled: boolean;
digestTime: string;
quietFrom: string | null;
quietUntil: string | null;
telegramCategories: string[];
telegramDetail: boolean;
}
describe('Notifications (e2e)', () => {
let app: INestApplication<App>;
let prisma: PrismaService;
const beekeeperEmail = `notifications-e2e-beekeeper-${Date.now()}@example.com`;
let notifications: NotificationsService;
const suffix = `${Date.now()}-${Math.round(Math.random() * 1e6)}`;
const password = 'SuperSecret123!';
const staffEmail = `notifications-e2e-staff-${Date.now()}@example.com`;
let beekeeperId: string;
let staffId: string;
let beekeeperToken: string;
let staffToken: string;
let notificationId: string;
const emails = {
beekeeper: `notifications-e2e-beekeeper-${suffix}@example.com`,
staff: `notifications-e2e-staff-${suffix}@example.com`,
bare: `notifications-e2e-bare-${suffix}@example.com`,
admin: `notifications-e2e-admin-${suffix}@example.com`,
};
const roleKey = `NOTIF_BARE_${suffix}`;
const ids = {} as Record<keyof typeof emails, string>;
const tokens = {} as Record<keyof typeof emails, string>;
const createdNotificationIds: string[] = [];
let apiKeyId: string | undefined;
const server = () => app.getHttpServer();
const as = (who: keyof typeof emails) => ({
Authorization: `Bearer ${tokens[who]}`,
});
async function seedNotification(
userId: string,
overrides: Record<string, unknown> = {},
) {
const row = await prisma.notification.create({
data: {
userId,
priority: 'KRITISCH',
category: 'SYSTEM',
message: `Kritischer Testalarm ${suffix}`,
...overrides,
},
});
createdNotificationIds.push(row.id);
return row;
}
beforeAll(async () => {
app = await createTestApp();
prisma = app.get(PrismaService);
notifications = app.get(NotificationsService);
await prisma.role.create({
data: { key: roleKey, name: 'Notifications bare role' },
});
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;
const staff = await prisma.user.create({
data: {
email: staffEmail,
passwordHash,
name: 'Staff',
role: { connect: { key: 'STAFF' } },
},
});
staffId = staff.id;
beekeeperToken = (await login(app, beekeeperEmail, password)).accessToken;
staffToken = (await login(app, staffEmail, password)).accessToken;
const notification = await prisma.notification.create({
data: {
userId: beekeeperId,
priority: 'KRITISCH',
message: `Kritischer Testalarm ${Date.now()}`,
},
});
notificationId = notification.id;
const roleOf: Record<keyof typeof emails, string> = {
beekeeper: 'BEEKEEPER',
staff: 'STAFF',
bare: roleKey,
admin: 'ADMIN',
};
for (const who of Object.keys(emails) as (keyof typeof emails)[]) {
const user = await prisma.user.create({
data: {
email: emails[who],
passwordHash,
name: who,
role: { connect: { key: roleOf[who] } },
},
});
ids[who] = user.id;
tokens[who] = (await login(app, emails[who], password)).accessToken;
}
});
afterAll(async () => {
await prisma.notification.deleteMany({ where: { id: notificationId } });
const userIds = Object.values(ids);
if (apiKeyId) {
await prisma.apiKey.deleteMany({ where: { id: apiKeyId } });
}
await prisma.notification.deleteMany({
where: {
OR: [
{ id: { in: createdNotificationIds } },
{ userId: { in: userIds } },
],
},
});
await prisma.notificationPreference.deleteMany({
where: { userId: { in: userIds } },
});
await prisma.refreshToken.deleteMany({
where: { userId: { in: [beekeeperId, staffId] } },
});
await prisma.auditLog.deleteMany({
where: { userId: { in: [beekeeperId, staffId] } },
});
await prisma.user.deleteMany({
where: { id: { in: [beekeeperId, staffId] } },
where: { userId: { in: userIds } },
});
await prisma.auditLog.deleteMany({ where: { userId: { in: userIds } } });
await prisma.user.deleteMany({ where: { id: { in: userIds } } });
await prisma.role.deleteMany({ where: { key: roleKey } });
await app.close();
});
it('lists notifications for the authenticated user', async () => {
const response = await request(app.getHttpServer())
const own = await seedNotification(ids.beekeeper);
const response = await request(server())
.get('/api/v1/notifications')
.set('Authorization', `Bearer ${beekeeperToken}`)
.set(as('beekeeper'))
.expect(200);
const notifications = response.body as NotificationBody[];
expect(
notifications.some(
(notification) =>
notification.id === notificationId &&
notification.priority === 'KRITISCH',
),
).toBe(true);
const list = response.body as NotificationBody[];
expect(list.some((n) => n.id === own.id && n.priority === 'KRITISCH')).toBe(
true,
);
expect(list[0]).not.toHaveProperty('requiredPermissions');
expect(list[0]).not.toHaveProperty('dedupeKey');
});
it("rejects marking another user's notification as read", () => {
return request(app.getHttpServer())
.post(`/api/v1/notifications/${notificationId}/read`)
.set('Authorization', `Bearer ${staffToken}`)
.expect(403);
it('hides a row whose permissions the user does not hold (any more)', async () => {
const open = await seedNotification(ids.bare);
const hidden = await seedNotification(ids.bare, {
requiredPermissions: ['colonies:read'],
});
const response = await request(server())
.get('/api/v1/notifications?unreadOnly=true')
.set(as('bare'))
.expect(200);
const listed = (response.body as NotificationBody[]).map((n) => n.id);
expect(listed).toContain(open.id);
expect(listed).not.toContain(hidden.id);
});
it('marks a notification as read', async () => {
const response = await request(app.getHttpServer())
.post(`/api/v1/notifications/${notificationId}/read`)
.set('Authorization', `Bearer ${beekeeperToken}`)
it('reading is per user: marking one copy read leaves the other unread', async () => {
const [first, second] = await notifications.recordForUsers(
[ids.beekeeper, ids.staff],
{
priority: 'INFO',
category: 'SYSTEM',
message: `Rundmeldung ${suffix}`,
dedupeKeyPrefix: `e2e-read:${suffix}`,
},
);
createdNotificationIds.push(first.id, second.id);
const mine = [first, second].find((n) => n.userId === ids.beekeeper)!;
const theirs = [first, second].find((n) => n.userId === ids.staff)!;
const response = await request(server())
.post(`/api/v1/notifications/${mine.id}/read`)
.set(as('beekeeper'))
.expect(201);
const notification = response.body as NotificationBody;
expect(notification.read).toBe(true);
expect((response.body as NotificationBody).read).toBe(true);
expect(
(
await prisma.notification.findUniqueOrThrow({
where: { id: theirs.id },
})
).read,
).toBe(false);
});
it('a fan-out for colonies:read never reaches a user without it', async () => {
const rows = await notifications.recordForPermission('colonies:read', {
priority: 'INFO',
category: 'SUGGESTION',
message: `Nur mit Volksrecht ${suffix}`,
dedupeKeyPrefix: `e2e-fanout:${suffix}`,
});
createdNotificationIds.push(...rows.map((row) => row.id));
const recipients = rows.map((row) => row.userId);
expect(recipients).toContain(ids.beekeeper);
expect(recipients).not.toContain(ids.bare);
expect(
rows.every((row) => row.requiredPermissions.includes('colonies:read')),
).toBe(true);
const none = await notifications.recordForUsers([ids.bare], {
priority: 'INFO',
category: 'SUGGESTION',
message: 'x',
requiredPermissions: ['colonies:read'],
});
expect(none).toEqual([]);
});
it('a repeated dedupe key writes nothing the second time', async () => {
const input = {
priority: 'INFO' as const,
category: 'SYSTEM' as const,
message: `Einmalig ${suffix}`,
dedupeKeyPrefix: `e2e-dedupe:${suffix}`,
};
const first = await notifications.recordForUsers([ids.beekeeper], input);
createdNotificationIds.push(...first.map((row) => row.id));
const second = await notifications.recordForUsers([ids.beekeeper], input);
expect(first).toHaveLength(1);
expect(second).toEqual([]);
});
it('read-all marks only own notifications', async () => {
const own = await seedNotification(ids.staff);
const foreign = await seedNotification(ids.beekeeper);
const response = await request(server())
.post('/api/v1/notifications/read-all')
.set(as('staff'))
.expect(201);
expect(
(response.body as { updated: number }).updated,
).toBeGreaterThanOrEqual(1);
expect(
(await prisma.notification.findUniqueOrThrow({ where: { id: own.id } }))
.read,
).toBe(true);
expect(
(
await prisma.notification.findUniqueOrThrow({
where: { id: foreign.id },
})
).read,
).toBe(false);
});
it('reading a foreign notification answers 404', async () => {
const foreign = await seedNotification(ids.beekeeper);
await request(server())
.post(`/api/v1/notifications/${foreign.id}/read`)
.set(as('staff'))
.expect(404);
await request(server())
.post('/api/v1/notifications/does-not-exist/read')
.set(as('staff'))
.expect(404);
expect(
(
await prisma.notification.findUniqueOrThrow({
where: { id: foreign.id },
})
).read,
).toBe(false);
});
describe('notification settings', () => {
it('an API key gets 403 on /profile/notification-settings', async () => {
const created = await request(server())
.post('/api/v1/api-keys')
.set(as('admin'))
.send({ name: `notif-e2e ${suffix}`, scopes: ['shop-sync:read'] })
.expect(201);
const body = created.body as { apiKey: { id: string }; token: string };
apiKeyId = body.apiKey.id;
await request(server())
.get('/api/v1/profile/notification-settings')
.set('Authorization', `Bearer ${body.token}`)
.expect(403);
});
it('returns the defaults on first read', async () => {
const response = await request(server())
.get('/api/v1/profile/notification-settings')
.set(as('bare'))
.expect(200);
expect(response.body).toEqual({
taskDueEnabled: true,
digestEnabled: true,
digestTime: '06:30',
quietFrom: '21:00',
quietUntil: '06:30',
telegramCategories: ['TASK_DUE', 'DIGEST'],
telegramDetail: true,
});
});
it('PATCH notification-settings stores digest time, quiet hours and Telegram categories', async () => {
const response = await request(server())
.patch('/api/v1/profile/notification-settings')
.set(as('beekeeper'))
.send({
digestTime: '07:15',
quietFrom: '22:00',
quietUntil: '05:30',
telegramCategories: ['SYSTEM', 'WITHDRAWAL'],
telegramDetail: false,
})
.expect(200);
const settings = response.body as SettingsBody;
expect(settings).toMatchObject({
digestTime: '07:15',
quietFrom: '22:00',
quietUntil: '05:30',
telegramCategories: ['SYSTEM', 'WITHDRAWAL'],
telegramDetail: false,
taskDueEnabled: true,
});
const stored = await prisma.notificationPreference.findUniqueOrThrow({
where: { userId: ids.beekeeper },
});
expect(stored.telegramEnabled).toBe(false);
expect(stored.digestTime).toBe('07:15');
});
it('rejects a digest time outside 05:00 to 12:00 and a lone quiet-hour value', async () => {
const early = await request(server())
.patch('/api/v1/profile/notification-settings')
.set(as('staff'))
.send({ digestTime: '04:30' })
.expect(400);
expect(JSON.stringify(early.body)).toContain(
'Bitte eine Uhrzeit zwischen 05:00 und 12:00 wählen.',
);
const lone = await request(server())
.patch('/api/v1/profile/notification-settings')
.set(as('staff'))
.send({ quietFrom: null })
.expect(400);
expect(JSON.stringify(lone.body)).toContain(
'Bitte Beginn und Ende der Ruhezeit zusammen leeren.',
);
await request(server())
.patch('/api/v1/profile/notification-settings')
.set(as('staff'))
.send({ quietFrom: null, quietUntil: null })
.expect(200);
await request(server())
.patch('/api/v1/profile/notification-settings')
.set(as('staff'))
.send({ digestEnabled: null })
.expect(400);
});
it('does not accept telegramEnabled', async () => {
await request(server())
.patch('/api/v1/profile/notification-settings')
.set(as('staff'))
.send({ telegramEnabled: true })
.expect(400);
});
});
});
@@ -0,0 +1,57 @@
import { Test, TestingModule } from '@nestjs/testing';
import { Prisma } from '../generated/prisma';
import { claimBatch } from '../src/common/outbox/outbox-claims';
import { PrismaModule } from '../src/prisma/prisma.module';
import { PrismaService } from '../src/prisma/prisma.service';
const ROUNDS = 1000;
/**
* "availableAt" is TIMESTAMP(3): a value written from SQL now() (stale
* release, retry backoff, requeue) is ROUNDED to milliseconds, so it can lie
* up to half a millisecond after the next statement's now(). A claim right
* behind such a write must still find the row.
*/
describe('claimBatch and millisecond rounding (e2e)', () => {
const correlationId = `claim-rounding-${Date.now()}-${Math.round(Math.random() * 1e6)}`;
let moduleRef: TestingModule;
let prisma: PrismaService;
beforeAll(async () => {
moduleRef = await Test.createTestingModule({
imports: [PrismaModule],
}).compile();
await moduleRef.init();
prisma = moduleRef.get(PrismaService);
});
afterAll(async () => {
await prisma.domainEvent.deleteMany({ where: { correlationId } });
await moduleRef.close();
});
it('claims an entry whose availableAt was just set to now()', async () => {
const event = await prisma.domainEvent.create({
data: {
type: 'TaskDue',
aggregateType: 'Task',
aggregateId: correlationId,
payload: {},
correlationId,
},
});
let missed = 0;
for (let round = 0; round < ROUNDS; round++) {
await prisma.$executeRaw`UPDATE "DomainEvent" SET "status" = 'PENDING', "claimedAt" = NULL, "availableAt" = now() WHERE "id" = ${event.id}`;
const claimed = await claimBatch(prisma, 'DomainEvent', {
limit: 1,
filter: Prisma.sql`"correlationId" = ${correlationId}`,
orderBy: 'occurredAt',
});
if (claimed.length !== 1) {
missed++;
}
}
expect(missed).toBe(0);
});
});
@@ -213,7 +213,7 @@ describe('Post-commit side effects (e2e)', () => {
.mockImplementation(() => undefined);
// Automation: the warning cannot be written.
jest
.spyOn(app.get(NotificationsService), 'record')
.spyOn(app.get(NotificationsService), 'recordForPermission')
.mockRejectedValue(new Error('notifications down'));
// Calendar sync: enqueue cannot read its settings.
jest
@@ -290,7 +290,7 @@ describe('Post-commit side effects (e2e)', () => {
jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
const colonyId = (await createColony('PC-RETRY')).id;
jest
.spyOn(app.get(NotificationsService), 'record')
.spyOn(app.get(NotificationsService), 'recordForPermission')
.mockRejectedValueOnce(new Error('notifications down'));
const { body } = (await post('/api/v1/inspections', {
@@ -311,9 +311,13 @@ describe('Post-commit side effects (e2e)', () => {
await prisma.$executeRaw`UPDATE "DomainEvent" SET "availableAt" = now() WHERE "aggregateId" = ${body.id}`;
await drainDomainEvents(app, [body.id]);
expect(await taskCount('QUEENLESS_NO_EGGS', colonyId)).toBe(1);
expect(
await prisma.notification.count({ where: { relatedId: colonyId } }),
).toBe(1);
// One copy per recipient (everybody who may read tasks), written once.
const warnings = await prisma.notification.findMany({
where: { relatedId: colonyId },
select: { userId: true },
});
expect(warnings.length).toBeGreaterThan(0);
expect(new Set(warnings.map((w) => w.userId)).size).toBe(warnings.length);
const deliveries = await prisma.domainEventDelivery.findMany({
where: { event: { aggregateId: body.id } },
+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`)
@@ -0,0 +1,648 @@
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 type { ProposeInput } from '../src/suggestions/suggestion.types';
import { login } from './utils/api';
import { createTestApp } from './utils/create-test-app';
import { drainDomainEvents } from './utils/domain-events';
import { proposeForTest } from './utils/suggestions';
interface ViewBody {
id: string;
title: string;
reasons: string[];
colonyCode: string | null;
apiaryName: string | null;
status: string;
}
const VIEW_KEYS = [
'apiaryId',
'apiaryName',
'cleanup',
'colonyCode',
'colonyId',
'createdAt',
'decidedAt',
'decisionNote',
'dueDate',
'expiresAt',
'id',
'intent',
'mode',
'plan',
'priority',
'reasons',
'relatedId',
'relatedType',
'ruleKey',
'source',
'status',
'title',
];
const INTERNAL_KEYS = [
'evidence',
'dedupeKey',
'requiredPermission',
'decidedByUserId',
'reasonCode',
'sourceEventId',
];
function expectViewShape(view: object): void {
expect(Object.keys(view).sort()).toEqual(VIEW_KEYS);
for (const key of INTERNAL_KEYS) {
expect(view).not.toHaveProperty(key);
}
}
/** The suggestion routes against a real PostgreSQL, as real roles. */
describe('Suggestions API (e2e)', () => {
let app: INestApplication<App>;
let prisma: PrismaService;
const suffix = `${Date.now()}-${Math.round(Math.random() * 1e6)}`;
const password = 'SuperSecret123!';
const base = '/api/v1/suggestions';
const ADMIN_PERMISSIONS = [
'tasks:read',
'tasks:create',
'tasks:update',
'inspections:read',
'varroa:read',
'colonies:read',
];
let apiaryId: string;
let colonyId: string;
let inactiveUserId: string;
const roleIds: string[] = [];
const userIds: string[] = [];
const tokens: Record<
'full' | 'noVarroa' | 'readOnly' | 'noUpdate' | 'noTasks',
string
> = { full: '', noVarroa: '', readOnly: '', noUpdate: '', noTasks: '' };
let keyCounter = 0;
const nextKey = () => `suggestions-api-${suffix}-${(keyCounter += 1)}-key`;
const proposal = (anchor: string): ProposeInput => ({
ruleKey: 'INSPECTION_INTERVAL',
source: 'RULE',
mode: 'DO',
intent: 'INSPECT',
relatedType: 'COLONY',
relatedId: colonyId,
colonyId,
reasonCode: `SUGGESTIONS_API_${suffix}`,
evidence: { colonyId },
requiredPermission: 'inspections:read',
dueDate: '2026-05-12',
anchor,
});
const get = (token: string, path: string) =>
request(app.getHttpServer())
.get(`${base}${path}`)
.set('Authorization', `Bearer ${token}`);
const post = (token: string, path: string, body: object = {}, key?: string) =>
request(app.getHttpServer())
.post(`${base}${path}`)
.set('Authorization', `Bearer ${token}`)
.set('Idempotency-Key', key ?? nextKey())
.send(body);
const tasksOf = (suggestionId: string) =>
prisma.task.findMany({ where: { sourceSuggestionId: suggestionId } });
async function createUser(
label: string,
permissions: string[],
isActive = true,
): Promise<{ id: string; token: string }> {
const role = await prisma.role.create({
data: {
key: `SUGGESTIONS_API_${label.toUpperCase()}_${suffix}`,
name: `Suggestions API ${label}`,
permissions: {
create: permissions.map((key) => ({
permission: { connect: { key } },
})),
},
},
});
roleIds.push(role.id);
const email = `suggestions-api-${label}-${suffix}@example.com`;
const user = await prisma.user.create({
data: {
email,
passwordHash: await bcrypt.hash(password, 12),
name: `Suggestions API ${label}`,
isActive,
role: { connect: { id: role.id } },
},
});
userIds.push(user.id);
return {
id: user.id,
token: isActive ? (await login(app, email, password)).accessToken : '',
};
}
beforeAll(async () => {
app = await createTestApp();
prisma = app.get(PrismaService);
apiaryId = (
await prisma.apiary.create({ data: { name: `Sug API ${suffix}` } })
).id;
colonyId = (
await prisma.colony.create({
data: {
code: `SA-${suffix}`,
apiaryId,
colonyType: 'PRODUCTION_COLONY',
},
})
).id;
tokens.full = (await createUser('full', ADMIN_PERMISSIONS)).token;
tokens.noVarroa = (
await createUser(
'novarroa',
ADMIN_PERMISSIONS.filter((key) => key !== 'varroa:read'),
)
).token;
tokens.readOnly = (await createUser('readonly', ['tasks:read'])).token;
tokens.noUpdate = (
await createUser('noupdate', ['tasks:read', 'tasks:create'])
).token;
tokens.noTasks = (await createUser('notasks', ['colonies:read'])).token;
inactiveUserId = (await createUser('inactive', ['tasks:read'], false)).id;
});
const cleanDb = async () => {
const ids = (
await prisma.suggestion.findMany({
where: { colonyId },
select: { id: true },
})
).map((row) => row.id);
const taskIds = (
await prisma.task.findMany({
where: { sourceSuggestionId: { in: ids } },
select: { id: true },
})
).map((row) => row.id);
await prisma.domainEvent.deleteMany({
where: { aggregateId: { in: taskIds } },
});
await prisma.calendarSyncOutboxEntry.deleteMany({
where: { sourceType: 'TASK', sourceId: { in: taskIds } },
});
await prisma.task.deleteMany({ where: { id: { in: taskIds } } });
await prisma.suggestion.deleteMany({ where: { colonyId } });
};
afterEach(cleanDb);
afterAll(async () => {
await cleanDb();
await prisma.colony.delete({ where: { id: colonyId } });
await prisma.apiary.delete({ where: { id: apiaryId } });
await prisma.idempotencyRecord.deleteMany({
where: { userId: { in: userIds } },
});
await prisma.refreshToken.deleteMany({
where: { userId: { in: userIds } },
});
await prisma.auditLog.deleteMany({ where: { userId: { in: userIds } } });
await prisma.user.deleteMany({ where: { id: { in: userIds } } });
await prisma.role.deleteMany({ where: { id: { in: roleIds } } });
await app.close();
});
it('lists open suggestions with rendered title and reasons', async () => {
const created = await proposeForTest(app, proposal('a1'));
const response = await get(tokens.full, `?colonyId=${colonyId}`).expect(
200,
);
const [view] = response.body as ViewBody[];
expect(view).toMatchObject({
id: created.id,
title: 'Durchsicht fällig',
reasons: [],
colonyCode: `SA-${suffix}`,
apiaryName: `Sug API ${suffix}`,
status: 'OPEN',
dueDate: '2026-05-12',
});
const byApiary = await get(tokens.full, `?apiaryId=${apiaryId}`).expect(
200,
);
expect(byApiary.body).toHaveLength(1);
const other = await get(tokens.full, '?apiaryId=none').expect(200);
expect(other.body).toEqual([]);
});
it('a user without varroa:read sees neither the varroa suggestion nor its count', async () => {
await proposeForTest(app, {
...proposal('v1'),
ruleKey: 'VARROA_THRESHOLD',
intent: 'VARROA_TREAT',
requiredPermission: 'varroa:read',
});
const hidden = await get(tokens.noVarroa, `?colonyId=${colonyId}`);
const hiddenSummary = await get(tokens.noVarroa, '/summary');
const seen = await get(tokens.full, `?colonyId=${colonyId}`);
const seenSummary = await get(tokens.full, '/summary');
expect(hidden.body).toEqual([]);
expect((hiddenSummary.body as { open: number }).open).toBe(0);
expect(seen.body).toHaveLength(1);
expect((seenSummary.body as { open: number }).open).toBeGreaterThanOrEqual(
1,
);
expect(
(seenSummary.body as { byPriority: Record<string, number> }).byPriority,
).toEqual(
expect.objectContaining({ LOW: 0, NORMAL: expect.any(Number) as number }),
);
});
it('answers 404 to a decision on a suggestion the user may not see', async () => {
const created = await proposeForTest(app, {
...proposal('v2'),
ruleKey: 'VARROA_THRESHOLD',
intent: 'VARROA_TREAT',
requiredPermission: 'varroa:read',
});
await post(tokens.noVarroa, `/${created.id}/accept`).expect(404);
await post(tokens.noVarroa, `/${created.id}/dismiss`).expect(404);
await post(tokens.noVarroa, '/bulk-accept', { ids: [created.id] }).expect(
404,
);
await post(tokens.noVarroa, '/bulk-dismiss', { ids: [created.id] }).expect(
404,
);
await post(tokens.full, `/${created.id}/dismiss`).expect(201);
await post(tokens.noVarroa, `/${created.id}/reopen`).expect(404);
expect(await tasksOf(created.id)).toHaveLength(0);
});
it('accept creates exactly one task with sourceSuggestionId and a calendar delivery', async () => {
const created = await proposeForTest(app, proposal('c1'));
const response = await post(tokens.full, `/${created.id}/accept`).expect(
201,
);
const tasks = await tasksOf(created.id);
expect(tasks).toHaveLength(1);
expect(tasks[0]).toMatchObject({
sourceSuggestionId: created.id,
title: 'Durchsicht fällig',
});
const body = response.body as {
suggestion: object & { status: string };
tasks: { id: string }[];
};
expect(Object.keys(body).sort()).toEqual(['suggestion', 'tasks']);
expectViewShape(body.suggestion);
expect(body.suggestion.status).toBe('ACCEPTED');
expect(body.tasks.map((task) => task.id)).toEqual([tasks[0].id]);
expect(body.tasks[0]).toMatchObject({
sourceSuggestionId: created.id,
title: 'Durchsicht fällig',
});
await drainDomainEvents(app, [tasks[0].id]);
const events = await prisma.domainEvent.findMany({
where: { aggregateType: 'Task', aggregateId: tasks[0].id },
include: { deliveries: true },
});
expect(events).toHaveLength(1);
expect(events[0].deliveries).toEqual(
expect.arrayContaining([
expect.objectContaining({
handler: 'calendar-sync.task',
status: 'DONE',
}),
]),
);
});
it('a second accept with another key answers 409 and leaves exactly one task', async () => {
const created = await proposeForTest(app, proposal('c2'));
await post(tokens.full, `/${created.id}/accept`).expect(201);
const second = await post(tokens.full, `/${created.id}/accept`).expect(409);
expect(second.body).toMatchObject({
message: 'Der Vorschlag ist nicht mehr offen.',
status: 'ACCEPTED',
});
expect(await tasksOf(created.id)).toHaveLength(1);
});
it('the same Idempotency-Key replays the first accept', async () => {
const created = await proposeForTest(app, proposal('c3'));
const key = nextKey();
const first = await post(
tokens.full,
`/${created.id}/accept`,
{},
key,
).expect(201);
const replay = await post(
tokens.full,
`/${created.id}/accept`,
{},
key,
).expect(201);
expect(replay.body).toEqual(first.body);
expect(await tasksOf(created.id)).toHaveLength(1);
});
it('bulk-accept with one non-OPEN id answers 409 and creates no task', async () => {
const open = await proposeForTest(app, proposal('b1'));
const dismissed = await proposeForTest(app, {
...proposal('b2'),
ruleKey: 'QUEEN_ACCEPTANCE_CHECK',
intent: 'QUEEN_ACCEPTANCE',
});
await post(tokens.full, `/${dismissed.id}/dismiss`).expect(201);
await post(tokens.full, '/bulk-accept', {
ids: [open.id, dismissed.id],
}).expect(409);
expect(await tasksOf(open.id)).toHaveLength(0);
expect(
(await prisma.suggestion.findUniqueOrThrow({ where: { id: open.id } }))
.status,
).toBe('OPEN');
});
it('bulk endpoints reject 0 or 51 ids', async () => {
const ids51 = Array.from({ length: 51 }, (_, index) => `id-${index}`);
for (const path of ['/bulk-accept', '/bulk-dismiss']) {
await post(tokens.full, path, { ids: [] }).expect(400);
await post(tokens.full, path, { ids: ids51 }).expect(400);
}
});
it('bulk-dismiss dismisses all given suggestions', async () => {
const first = await proposeForTest(app, proposal('d1'));
const second = await proposeForTest(app, {
...proposal('d2'),
ruleKey: 'QUEEN_ACCEPTANCE_CHECK',
intent: 'QUEEN_ACCEPTANCE',
});
const response = await post(tokens.full, '/bulk-dismiss', {
ids: [first.id, second.id],
}).expect(201);
expect(response.body).toEqual({ dismissed: 2 });
});
it('accept with an unknown or inactive assignedUserId answers 400 and creates no task', async () => {
const created = await proposeForTest(app, proposal('u1'));
await post(tokens.full, `/${created.id}/accept`, {
assignedUserId: 'does-not-exist',
}).expect(400);
const inactive = await post(tokens.full, `/${created.id}/accept`, {
assignedUserId: inactiveUserId,
}).expect(400);
expect(inactive.body).toMatchObject({
issues: [{ field: 'assignedUserId' }],
});
expect(await tasksOf(created.id)).toHaveLength(0);
});
it('dismiss then reopen restores OPEN; the dismissed one is listed for 30 days', async () => {
const created = await proposeForTest(app, proposal('r1'));
const dismissResponse = await post(tokens.full, `/${created.id}/dismiss`, {
note: 'später',
}).expect(201);
expectViewShape(dismissResponse.body as object);
expect(dismissResponse.body).toMatchObject({
status: 'DISMISSED',
decisionNote: 'später',
});
const dismissed = await get(
tokens.full,
`?status=DISMISSED&colonyId=${colonyId}`,
).expect(200);
expect(await get(tokens.full, `?colonyId=${colonyId}`)).toMatchObject({
body: [],
});
await prisma.$executeRaw`UPDATE "Suggestion" SET "decidedAt" = now() - interval '31 days' WHERE id = ${created.id}`;
const stale = await get(
tokens.full,
`?status=DISMISSED&colonyId=${colonyId}`,
).expect(200);
await prisma.$executeRaw`UPDATE "Suggestion" SET "decidedAt" = now() WHERE id = ${created.id}`;
const reopened = await post(tokens.full, `/${created.id}/reopen`).expect(
201,
);
expect(dismissed.body).toHaveLength(1);
expect((dismissed.body as { decisionNote: string }[])[0]).toMatchObject({
decisionNote: 'später',
});
expect(stale.body).toEqual([]);
expectViewShape(reopened.body as object);
expect(reopened.body).toMatchObject({ status: 'OPEN' });
expect(await get(tokens.full, `?colonyId=${colonyId}`)).toMatchObject({
body: [expect.objectContaining({ id: created.id })],
});
});
it('rejects a dismiss note over 500 characters', async () => {
const created = await proposeForTest(app, proposal('n1'));
await post(tokens.full, `/${created.id}/dismiss`, {
note: 'x'.repeat(501),
}).expect(400);
});
it('403 without tasks:create on accept and without tasks:update on dismiss', async () => {
const created = await proposeForTest(app, proposal('p1'));
await post(tokens.readOnly, `/${created.id}/accept`).expect(403);
await post(tokens.readOnly, '/bulk-accept', { ids: [created.id] }).expect(
403,
);
await post(tokens.noUpdate, `/${created.id}/dismiss`).expect(403);
await post(tokens.noUpdate, `/${created.id}/reopen`).expect(403);
await post(tokens.noUpdate, '/bulk-dismiss', { ids: [created.id] }).expect(
403,
);
await request(app.getHttpServer()).get(base).expect(401);
});
it('bulk-accept answers one result per suggestion in the view shape', async () => {
const first = await proposeForTest(app, proposal('ba1'));
const second = await proposeForTest(app, {
...proposal('ba2'),
ruleKey: 'QUEEN_ACCEPTANCE_CHECK',
intent: 'QUEEN_ACCEPTANCE',
});
const response = await post(tokens.full, '/bulk-accept', {
ids: [first.id, second.id],
}).expect(201);
const results = response.body as {
suggestion: { id: string };
tasks: object[];
}[];
expect(results.map((result) => result.suggestion.id)).toEqual([
first.id,
second.id,
]);
for (const result of results) {
expectViewShape(result.suggestion);
expect(result.tasks).toHaveLength(1);
}
});
it('403 on the list and the summary without tasks:read', async () => {
await get(tokens.noTasks, '').expect(403);
await get(tokens.noTasks, '/summary').expect(403);
});
it('rejects an invalid status, a datetime dueDate and a bad limit with 400', async () => {
const created = await proposeForTest(app, proposal('v400'));
await get(tokens.full, '?status=NOPE').expect(400);
await get(tokens.full, '?limit=501').expect(400);
await get(tokens.full, '?limit=0').expect(400);
await get(tokens.full, '?offset=-1').expect(400);
const response = await post(tokens.full, `/${created.id}/accept`, {
dueDate: '2026-05-12T10:00:00Z',
}).expect(400);
expect(JSON.stringify(response.body)).toContain('JJJJ-MM-TT');
expect(await tasksOf(created.id)).toHaveLength(0);
});
it('limit and offset page through the list', async () => {
await proposeForTest(app, proposal('l1'));
await proposeForTest(app, {
...proposal('l2'),
ruleKey: 'QUEEN_ACCEPTANCE_CHECK',
intent: 'QUEEN_ACCEPTANCE',
});
const all = (await get(tokens.full, `?colonyId=${colonyId}`).expect(200))
.body as ViewBody[];
const first = (
await get(tokens.full, `?colonyId=${colonyId}&limit=1`).expect(200)
).body as ViewBody[];
const second = (
await get(tokens.full, `?colonyId=${colonyId}&limit=1&offset=1`).expect(
200,
)
).body as ViewBody[];
expect(all).toHaveLength(2);
expect(first.map((view) => view.id)).toEqual([all[0].id]);
expect(second.map((view) => view.id)).toEqual([all[1].id]);
});
it('combines the apiaryId filter with status and colonyId', async () => {
const created = await proposeForTest(app, proposal('f1'));
await post(tokens.full, `/${created.id}/dismiss`).expect(201);
const hit = await get(
tokens.full,
`?apiaryId=${apiaryId}&colonyId=${colonyId}&status=DISMISSED`,
).expect(200);
const wrongStatus = await get(
tokens.full,
`?apiaryId=${apiaryId}&colonyId=${colonyId}&status=OPEN`,
).expect(200);
const wrongApiary = await get(
tokens.full,
`?apiaryId=none&colonyId=${colonyId}&status=DISMISSED`,
).expect(200);
expect((hit.body as ViewBody[]).map((view) => view.id)).toEqual([
created.id,
]);
expect(wrongStatus.body).toEqual([]);
expect(wrongApiary.body).toEqual([]);
});
it('sorts by due date (none last), then priority (highest first)', async () => {
const mk = (
anchor: string,
intent: ProposeInput['intent'],
priority: ProposeInput['priority'],
dueDate: string | null,
) =>
proposeForTest(app, { ...proposal(anchor), intent, priority, dueDate });
const undated = await mk('s1', 'QUEEN_CHECK', 'URGENT', null);
const normal = await mk('s2', 'INSPECT', 'NORMAL', '2026-05-12');
const urgent = await mk('s3', 'SWARM_CHECK', 'URGENT', '2026-05-12');
const early = await mk('s4', 'HEALTH_CHECK', 'LOW', '2026-05-10');
const response = await get(tokens.full, `?colonyId=${colonyId}`).expect(
200,
);
expect((response.body as ViewBody[]).map((view) => view.id)).toEqual([
early.id,
urgent.id,
normal.id,
undated.id,
]);
});
it('shows the cleanup payload from the openTaskIds evidence', async () => {
await proposeForTest(app, {
...proposal('cl1'),
ruleKey: 'COLONY_ENDED_CLEANUP',
intent: 'CLEANUP',
requiredPermission: 'colonies:read',
evidence: {
colonyId,
status: 'DECEASED',
endedOn: '2026-03-12',
via: 'winter',
openTaskIds: ['t_a', 't_b'],
queenId: 'q_1',
},
});
await proposeForTest(app, { ...proposal('cl2'), intent: 'QUEEN_CHECK' });
const views = (await get(tokens.full, `?colonyId=${colonyId}`).expect(200))
.body as { intent: string; cleanup: unknown }[];
expect(views.find((v) => v.intent === 'CLEANUP')?.cleanup).toEqual({
taskIds: ['t_a', 't_b'],
queenId: 'q_1',
});
expect(views.find((v) => v.intent === 'QUEEN_CHECK')?.cleanup).toBeNull();
});
it('bulk-dismiss answers { dismissed } only', async () => {
const created = await proposeForTest(app, proposal('bd1'));
const response = await post(tokens.full, '/bulk-dismiss', {
ids: [created.id],
}).expect(201);
expect(response.body).toEqual({ dismissed: 1 });
});
});
+257 -1
View File
@@ -6,8 +6,11 @@ import {
const FEED_VOCABULARY = '20261014100200_t4_feed_vocabulary';
const SPLIT_QUEEN_SOURCE = '20261014100300_t4_split_queen_source';
const VARROA_SAMPLE_SIZE = '20261014100400_t4_varroa_sample_size';
const NOTIFICATION_FANOUT = '20261014100500_t4_notification_fanout';
const OCCUPIED_FRAMES = '20261014100700_t4_occupied_frames';
const NOTIFICATION_SETTINGS = '20261014100800_t4_notification_settings';
const TIMEOUT_MS = 120_000;
const TIMEOUT_MS = 240_000;
interface InspectionSeed {
id: string;
@@ -36,6 +39,48 @@ async function seedColony(db: ScratchDatabase): Promise<void> {
);
}
/** Two active users and one inactive one, plus the role they need. */
async function seedUsers(db: ScratchDatabase): Promise<void> {
await db.query(
`INSERT INTO "Role" ("id", "key", "name", "updatedAt")
VALUES ('role-1', 'MIG_TEST', 'Migration test', now())`,
);
for (const [id, active] of [
['user-a', true],
['user-b', true],
['user-c', false],
] as const) {
await db.query(
`INSERT INTO "User" ("id", "email", "passwordHash", "name", "roleId", "isActive", "updatedAt")
VALUES ($1, $2, 'x', $1, 'role-1', $3, now())`,
[id, `${id}@example.com`, active],
);
}
}
async function seedNotification(
db: ScratchDatabase,
row: {
id: string;
userId: string | null;
relatedType: string | null;
read?: boolean;
createdAt?: string;
},
): Promise<void> {
await db.query(
`INSERT INTO "Notification" ("id", "userId", "priority", "message", "relatedType", "read", "createdAt")
VALUES ($1, $2, 'WARNUNG', 'm', $3, $4, $5)`,
[
row.id,
row.userId,
row.relatedType,
row.read ?? false,
row.createdAt ?? '2026-05-01T10:00:00Z',
],
);
}
async function seedInspections(
db: ScratchDatabase,
seeds: InspectionSeed[],
@@ -215,4 +260,215 @@ describe('T4 schema II migrations (e2e)', () => {
},
TIMEOUT_MS,
);
it(
'broadcasts are copied per active user with their read flag',
async () => {
db = await createScratchDatabase(NOTIFICATION_FANOUT);
await seedUsers(db);
await seedNotification(db, {
id: 'broadcast',
userId: null,
relatedType: 'COLONY',
read: true,
createdAt: '2026-04-01T08:00:00Z',
});
await seedNotification(db, {
id: 'personal',
userId: 'user-c',
relatedType: 'COLONY',
});
const [{ createdAt }] = await db.query<{ createdAt: string }>(
`SELECT "createdAt"::text AS "createdAt" FROM "Notification" WHERE "id" = 'broadcast'`,
);
await db.migrateThrough(NOTIFICATION_FANOUT);
const rows = await db.query<{
userId: string;
read: boolean;
createdAt: string;
}>(
`SELECT "userId", "read", "createdAt"::text AS "createdAt"
FROM "Notification" `,
);
rows.sort((a, b) => (a.userId < b.userId ? -1 : 1));
expect(rows.map(({ userId, read }) => ({ userId, read }))).toEqual([
{ userId: 'user-a', read: true },
{ userId: 'user-b', read: true },
{ userId: 'user-c', read: false },
]);
expect(rows[0].createdAt).toBe(createdAt);
expect(rows[1].createdAt).toBe(createdAt);
expect(
await db.query(
`SELECT is_nullable FROM information_schema.columns
WHERE table_schema = current_schema() AND table_name = 'Notification' AND column_name = 'userId'`,
),
).toEqual([{ is_nullable: 'NO' }]);
},
TIMEOUT_MS,
);
it(
'drops broadcasts when no user is active and keeps the personal rows',
async () => {
db = await createScratchDatabase(NOTIFICATION_FANOUT);
await db.query(
`INSERT INTO "Role" ("id", "key", "name", "updatedAt")
VALUES ('role-1', 'MIG_TEST', 'Migration test', now())`,
);
await db.query(
`INSERT INTO "User" ("id", "email", "passwordHash", "name", "roleId", "isActive", "updatedAt")
VALUES ('user-x', '[email protected]', 'x', 'x', 'role-1', false, now())`,
);
await seedNotification(db, {
id: 'broadcast',
userId: null,
relatedType: null,
});
await seedNotification(db, {
id: 'personal',
userId: 'user-x',
relatedType: null,
});
await db.migrateThrough(NOTIFICATION_FANOUT);
expect(await db.query('SELECT "id" FROM "Notification"')).toEqual([
{ id: 'personal' },
]);
},
TIMEOUT_MS,
);
it(
'copies carry no dedupeKey, old rows get empty requiredPermissions, and the preference defaults hold',
async () => {
db = await createScratchDatabase(NOTIFICATION_FANOUT);
await seedUsers(db);
await seedNotification(db, {
id: 'broadcast',
userId: null,
relatedType: 'COLONY',
});
await seedNotification(db, {
id: 'personal',
userId: 'user-a',
relatedType: 'COLONY',
});
await db.migrateThrough(NOTIFICATION_SETTINGS);
expect(
await db.query(
`SELECT count(*)::int AS "rows",
count("dedupeKey")::int AS "keys",
count(*) FILTER (WHERE "requiredPermissions" = '{}')::int AS "emptyPermissions",
count("link")::int AS "links"
FROM "Notification"`,
),
).toEqual([{ rows: 3, keys: 0, emptyPermissions: 3, links: 0 }]);
await db.query(
`INSERT INTO "NotificationPreference" ("userId", "updatedAt") VALUES ('user-a', now())`,
);
expect(
await db.query(
`SELECT "taskDueEnabled", "digestEnabled", "digestTime", "quietFrom", "quietUntil",
"telegramEnabled", "telegramCategories"::text AS "telegramCategories", "telegramDetail"
FROM "NotificationPreference"`,
),
).toEqual([
{
taskDueEnabled: true,
digestEnabled: true,
digestTime: '06:30',
quietFrom: '21:00',
quietUntil: '06:30',
telegramEnabled: false,
telegramCategories: '{TASK_DUE,DIGEST}',
telegramDetail: true,
},
]);
},
TIMEOUT_MS,
);
it(
'legacy job-failure rows (relatedType JobRun) and rows without relatedType get SYSTEM, all others SUGGESTION, before NOT NULL',
async () => {
db = await createScratchDatabase(NOTIFICATION_FANOUT);
await seedUsers(db);
await seedNotification(db, {
id: 'n-job',
userId: 'user-a',
relatedType: 'JobRun',
});
await seedNotification(db, {
id: 'n-none',
userId: 'user-a',
relatedType: null,
});
await seedNotification(db, {
id: 'n-colony',
userId: 'user-a',
relatedType: 'COLONY',
});
await seedNotification(db, {
id: 'n-broadcast',
userId: null,
relatedType: 'JobRun',
});
await db.migrateThrough(NOTIFICATION_SETTINGS);
expect(
await db.query(
`SELECT "id", "category" FROM "Notification" WHERE "id" LIKE 'n-%' ORDER BY "id"`,
),
).toEqual([
{ id: 'n-colony', category: 'SUGGESTION' },
{ id: 'n-job', category: 'SYSTEM' },
{ id: 'n-none', category: 'SYSTEM' },
]);
// The broadcast copies carry relatedType JobRun too.
expect(
await db.query(
`SELECT DISTINCT "category" FROM "Notification" WHERE "relatedType" = 'JobRun' AND "id" NOT LIKE 'n-%'`,
),
).toEqual([{ category: 'SYSTEM' }]);
expect(
await db.query(
`SELECT is_nullable FROM information_schema.columns
WHERE table_schema = current_schema() AND table_name = 'Notification' AND column_name = 'category'`,
),
).toEqual([{ is_nullable: 'NO' }]);
},
TIMEOUT_MS,
);
it(
'occupied frames migration leaves strength untouched',
async () => {
db = await createScratchDatabase(OCCUPIED_FRAMES);
await seedColony(db);
await db.query(`UPDATE "Colony" SET "strength" = 7`);
await db.migrateThrough(OCCUPIED_FRAMES);
expect(
await db.query('SELECT "strength", "occupiedFrames" FROM "Colony"'),
).toEqual([{ strength: 7, occupiedFrames: null }]);
await db.query(`UPDATE "Colony" SET "occupiedFrames" = 40`);
await expect(
db.query(`UPDATE "Colony" SET "occupiedFrames" = 41`),
).rejects.toThrow();
await expect(
db.query(`UPDATE "Colony" SET "occupiedFrames" = -1`),
).rejects.toThrow();
},
TIMEOUT_MS,
);
});
+1 -1
View File
@@ -260,7 +260,7 @@ describe('Treatments (e2e)', () => {
it('accepts a full ISO timestamp', async () => {
await expect(
withdrawalStatusAt('2026-07-14T23:59:59.000Z'),
withdrawalStatusAt('2026-07-14T23:59:59+02:00'),
).resolves.toHaveLength(1);
});
+25
View File
@@ -0,0 +1,25 @@
import { INestApplicationContext } from '@nestjs/common';
import type { Suggestion } from '../../generated/prisma';
import { PrismaService } from '../../src/prisma/prisma.service';
import { SuggestionsService } from '../../src/suggestions/suggestions.service';
import type { ProposeInput } from '../../src/suggestions/suggestion.types';
/**
* Proposes a suggestion in its own transaction, as a rule handler would.
* Throws when nothing was proposed (anchor or intent already taken), so a
* test never works with a suggestion that does not exist.
*/
export async function proposeForTest(
app: INestApplicationContext,
input: ProposeInput,
): Promise<Suggestion> {
const prisma = app.get(PrismaService);
const service = app.get(SuggestionsService);
const created = await prisma.$transaction((tx) =>
service.proposeIn(tx, input),
);
if (created === null) {
throw new Error(`proposeForTest: nothing proposed for ${input.anchor}`);
}
return created;
}
@@ -0,0 +1,224 @@
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';
interface BandBody {
yellowFrom: number | null;
redAbove: number;
reason: string;
youngColony: boolean;
}
interface ThresholdsBody {
day: string;
active: boolean;
bands: Record<string, BandBody | undefined>;
postTreatmentUntil: string | null;
}
interface RatedMeasurementBody {
id: string;
rating: {
light: string | null;
reasonCode: string | null;
band: BandBody | null;
postTreatmentUntil: string | null;
};
}
describe('Varroa seasonal bands (e2e)', () => {
let app: INestApplication<App>;
let prisma: PrismaService;
const suffix = Date.now();
const email = `varroa-seasonal-${suffix}@example.com`;
const password = 'SuperSecret123!';
let userId: string;
let token: string;
let apiaryId: string;
let productionColonyId: string;
let youngColonyId: string;
const get = (path: string) =>
request(app.getHttpServer())
.get(`/api/v1${path}`)
.set('Authorization', `Bearer ${token}`);
beforeAll(async () => {
app = await createTestApp();
prisma = app.get(PrismaService);
const user = await prisma.user.create({
data: {
email,
passwordHash: await bcrypt.hash(password, 12),
name: 'Varroa seasonal',
role: { connect: { key: 'BEEKEEPER' } },
},
});
userId = user.id;
token = (await login(app, email, password)).accessToken;
apiaryId = (
await prisma.apiary.create({ data: { name: `Stand ${suffix}` } })
).id;
productionColonyId = (
await prisma.colony.create({
data: {
code: `VS-P-${suffix}`,
apiaryId,
colonyType: 'PRODUCTION_COLONY',
},
})
).id;
youngColonyId = (
await prisma.colony.create({
data: { code: `VS-Y-${suffix}`, apiaryId, colonyType: 'YOUNG_COLONY' },
})
).id;
});
afterAll(async () => {
const colonyIds = [productionColonyId, youngColonyId];
await prisma.treatment.deleteMany({
where: { colonyId: { in: colonyIds } },
});
await prisma.varroaMeasurement.deleteMany({
where: { colonyId: { in: colonyIds } },
});
await prisma.colony.deleteMany({ where: { id: { in: colonyIds } } });
await prisma.apiary.deleteMany({ where: { id: apiaryId } });
await prisma.refreshToken.deleteMany({ where: { userId } });
await prisma.auditLog.deleteMany({ where: { userId } });
await prisma.user.deleteMany({ where: { id: userId } });
await app.close();
});
it('thresholds for a young colony are halved', async () => {
const full = (
await get(
`/varroa-measurements/thresholds?colonyId=${productionColonyId}&date=2026-06-10`,
).expect(200)
).body as ThresholdsBody;
const young = (
await get(
`/varroa-measurements/thresholds?colonyId=${youngColonyId}&date=2026-06-10`,
).expect(200)
).body as ThresholdsBody;
expect(full.bands.NATURAL_MITE_FALL).toMatchObject({
yellowFrom: 5,
redAbove: 10,
reason: 'NATURAL_FALL_SEASON',
youngColony: false,
});
expect(young.bands.NATURAL_MITE_FALL).toMatchObject({
yellowFrom: 2.5,
redAbove: 5,
youngColony: true,
});
});
it('thresholds return no band for powdered sugar in April', async () => {
const body = (
await get(
`/varroa-measurements/thresholds?colonyId=${productionColonyId}&date=2026-04-10`,
).expect(200)
).body as ThresholdsBody;
expect(body.bands.POWDERED_SUGAR).toBeUndefined();
expect(body.bands.NATURAL_MITE_FALL).toBeDefined();
});
it('thresholds reject an unknown colony and an invalid date', async () => {
await get('/varroa-measurements/thresholds?colonyId=unknown').expect(404);
await get('/varroa-measurements/thresholds?date=2026-02-30').expect(400);
await get('/varroa-measurements/thresholds?date=10.08.2026').expect(400);
});
it('the old /threshold route is gone (404)', async () => {
await get('/varroa-measurements/threshold').expect(404);
});
it('colony measurements carry a rating with band and light', async () => {
const create = (body: object) =>
request(app.getHttpServer())
.post('/api/v1/varroa-measurements')
.set('Authorization', `Bearer ${token}`)
.send(body)
.expect(201);
const red = (
await create({
colonyId: productionColonyId,
date: '2026-08-10T00:00:00.000Z',
method: 'NATURAL_MITE_FALL',
miteCount: 22,
durationMinutes: 1440,
})
).body as { id: string; calculatedValue: number };
const percent = (
await create({
colonyId: productionColonyId,
date: '2026-07-10T00:00:00.000Z',
method: 'POWDERED_SUGAR',
miteCount: 10,
sampleBees: 500,
})
).body as { id: string; calculatedValue: number };
expect(percent.calculatedValue).toBe(2);
const rated = (
await get(`/colonies/${productionColonyId}/varroa-measurements`).expect(
200,
)
).body as RatedMeasurementBody[];
const byId = new Map(rated.map((entry) => [entry.id, entry.rating]));
expect(byId.get(red.id)).toMatchObject({
light: 'red',
reasonCode: 'NATURAL_FALL_SEASON',
band: { yellowFrom: 5, redAbove: 10 },
postTreatmentUntil: null,
});
expect(byId.get(percent.id)).toMatchObject({
light: 'yellow',
reasonCode: 'INFESTATION_SUMMER',
});
});
it('a measurement after a treatment has no light and names the recheck day', async () => {
await prisma.treatment.create({
data: {
colonyId: youngColonyId,
userId,
date: new Date('2026-08-02T00:00:00.000Z'),
startDate: new Date('2026-07-20T00:00:00.000Z'),
product: 'Oxuvar',
},
});
const created = (
await request(app.getHttpServer())
.post('/api/v1/varroa-measurements')
.set('Authorization', `Bearer ${token}`)
.send({
colonyId: youngColonyId,
date: '2026-08-07T00:00:00.000Z',
method: 'NATURAL_MITE_FALL',
calculatedValue: 12,
})
.expect(201)
).body as { id: string };
const rated = (
await get(`/colonies/${youngColonyId}/varroa-measurements`).expect(200)
).body as RatedMeasurementBody[];
expect(rated.find((entry) => entry.id === created.id)?.rating).toEqual({
light: 'POST_TREATMENT',
reasonCode: 'POST_TREATMENT',
band: null,
postTreatmentUntil: '2026-08-16',
});
});
});
+12 -13
View File
@@ -232,28 +232,27 @@ describe('Varroa measurements (e2e)', () => {
expect(measurements.some((entry) => entry.id === measurementId)).toBe(true);
});
describe('GET /varroa-measurements/threshold', () => {
const thresholdBody = (token: string) =>
describe('GET /varroa-measurements/thresholds', () => {
const thresholdsBody = (token: string, query = '') =>
request(app.getHttpServer())
.get('/api/v1/varroa-measurements/threshold')
.get(`/api/v1/varroa-measurements/thresholds${query}`)
.set('Authorization', `Bearer ${token}`);
it('returns the threshold shape and is not shadowed by :id', async () => {
const response = await thresholdBody(staffToken).expect(200);
it('returns the bands shape and is not shadowed by :id', async () => {
const response = await thresholdsBody(staffToken).expect(200);
const body = response.body as { thresholdMitesPerDay: number | null };
expect(Object.keys(body)).toEqual(['thresholdMitesPerDay']);
expect(
body.thresholdMitesPerDay === null ||
typeof body.thresholdMitesPerDay === 'number',
).toBe(true);
const body = response.body as { day: string; active: boolean };
expect(Object.keys(body).sort()).toEqual(
['active', 'bands', 'day', 'postTreatmentUntil'].sort(),
);
expect(typeof body.active).toBe('boolean');
});
// The shared rule is never written here (parallel e2e workers); the
// inactive / custom value cases are covered by varroa-threshold.spec.ts.
// inactive / custom value cases are covered by the unit specs.
it('rejects a role without any varroa permission', () =>
thresholdBody(noVarroaToken).expect(403));
thresholdsBody(noVarroaToken).expect(403));
});
describe('POST /varroa-measurements/bulk', () => {