Files
ApiCore/ApiCore-Backend/test/notifications.e2e-spec.ts
Nick SzaunigandClaude Opus 5.5 8a31c42578
Backend Image / build-and-push (push) Successful in 6m39s
Frontend Image / build-and-push (push) Successful in 11m25s
feat: T6 wave 1 – compliance schema, permissions, lock order, backup
- Migrations (additive): veterinary medicines with lots and movement
  ledger, treatment medicine/lot/dose links, label particulars, authority
  notices, apiary restrictions, compliance settings and acknowledgements;
  notification category COMPLIANCE, added to non-empty Telegram lists.
- Permissions veterinary-medicines:* and compliance:* with role grants.
- LOCKABLE_TABLES: Apiary locks before Colony; T6 tables appended.
- Backup exports and restores the new tables in FK order; unsealed files
  skip them and report dropped medicine links; an open TSK draft in the
  target wins.
- describeNullContracts shared helper; Telegram backlog e2e no longer
  depends on the time of day.

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

460 lines
14 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('deleting a user removes their notifications and settings', async () => {
const user = await prisma.user.create({
data: {
email: `notifications-e2e-cascade-${suffix}@example.com`,
passwordHash: 'not-used',
name: 'Cascade',
role: { connect: { key: roleKey } },
},
});
await prisma.notification.create({
data: {
userId: user.id,
priority: 'INFO',
category: 'SYSTEM',
message: 'weg',
},
});
await prisma.notificationPreference.create({ data: { userId: user.id } });
await prisma.user.delete({ where: { id: user.id } });
expect(
await prisma.notification.count({ where: { userId: user.id } }),
).toBe(0);
expect(
await prisma.notificationPreference.count({ where: { userId: user.id } }),
).toBe(0);
});
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);
const { telegram, ...settings } = response.body as {
telegram: Record<string, unknown>;
};
expect(settings).toEqual({
taskDueEnabled: true,
digestEnabled: true,
digestTime: '06:30',
quietFrom: '21:00',
quietUntil: '06:30',
telegramCategories: ['TASK_DUE', 'DIGEST', 'COMPLIANCE'],
telegramDetail: true,
});
// configured / botUsername follow the environment of the process.
expect(telegram).toMatchObject({
linked: false,
firstName: null,
linkedAt: null,
enabled: false,
});
expect(typeof telegram.botNameSet).toBe('boolean');
});
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('accepts COMPLIANCE as a Telegram category and rejects an unknown one', async () => {
const response = await request(server())
.patch('/api/v1/profile/notification-settings')
.set(as('staff'))
.send({ telegramCategories: ['COMPLIANCE', 'DIGEST'] })
.expect(200);
expect((response.body as SettingsBody).telegramCategories).toEqual([
'COMPLIANCE',
'DIGEST',
]);
await request(server())
.patch('/api/v1/profile/notification-settings')
.set(as('staff'))
.send({ telegramCategories: ['AUTHORITY'] })
.expect(400);
});
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);
});
});
});