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:
1 parent
d99a697739
commit
f468dc5f75
87 files changed
+6022
-510
No files matched your search
@@ -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())
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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 } },
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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', () => {
|
||||
|
||||
Reference in new issue
Block a user