- Automation rules propose suggestions instead of creating tasks; notify per rule config; corrected varroa measurements refresh or resolve their suggestion - Inspection values can be taken over into the colony card (occupiedFrames 0-40, strength deprecated); warning counts use the same visibility rule as the notification list - Biotech chains: swarm check after queen cells, queen acceptance check, split schedules (raise own, queen cell, queen) - Season autopilot core with A1 inspection due, A2 varroa measurement, A3 recheck after treatment; colony sync and daily step - Frontend: suggestions page with groups, bulk accept, undo, paging, focus management and permission gating - Frontend: varroa traffic light from seasonal bands, sample size, one range request for the colony chart - Notifications: mark-read answers 404 for rows the user may no longer see Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
402 lines
12 KiB
TypeScript
402 lines
12 KiB
TypeScript
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';
|
|
|
|
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;
|
|
let notifications: NotificationsService;
|
|
const suffix = `${Date.now()}-${Math.round(Math.random() * 1e6)}`;
|
|
const password = 'SuperSecret123!';
|
|
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 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 () => {
|
|
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: 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 own = await seedNotification(ids.beekeeper);
|
|
|
|
const response = await request(server())
|
|
.get('/api/v1/notifications')
|
|
.set(as('beekeeper'))
|
|
.expect(200);
|
|
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('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('a row the user may not see cannot be read, marked or counted', async () => {
|
|
const hidden = await seedNotification(ids.bare, {
|
|
requiredPermissions: ['colonies:read'],
|
|
});
|
|
const open = await seedNotification(ids.bare);
|
|
|
|
await request(server())
|
|
.post(`/api/v1/notifications/${hidden.id}/read`)
|
|
.set(as('bare'))
|
|
.expect(404);
|
|
|
|
const response = await request(server())
|
|
.post('/api/v1/notifications/read-all')
|
|
.set(as('bare'))
|
|
.expect(201);
|
|
|
|
expect(
|
|
(response.body as { updated: number }).updated,
|
|
).toBeGreaterThanOrEqual(1);
|
|
expect(
|
|
(await prisma.notification.findUniqueOrThrow({ where: { id: open.id } }))
|
|
.read,
|
|
).toBe(true);
|
|
expect(
|
|
(
|
|
await prisma.notification.findUniqueOrThrow({
|
|
where: { id: hidden.id },
|
|
})
|
|
).read,
|
|
).toBe(false);
|
|
});
|
|
|
|
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);
|
|
|
|
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);
|
|
});
|
|
});
|
|
});
|