import { INestApplication } from '@nestjs/common'; import * as bcrypt from 'bcryptjs'; import request from 'supertest'; import { App } from 'supertest/types'; import { berlinToday } from '../src/common/dates'; import { findBaselineIn } from '../src/compliance/herd-status'; import { NotificationsService } from '../src/notifications/notifications.service'; import { DomainEventRegistry } from '../src/domain-events/domain-event-registry'; import { PrismaService } from '../src/prisma/prisma.service'; import { login } from './utils/api'; import { createTestApp } from './utils/create-test-app'; import { acquireExclusive, EXCLUSIVE_KEYS } from './utils/exclusive-suite'; import { drainDomainEvents } from './utils/domain-events'; interface NoticeBody { id: string; totalColonies: number; } interface ColonyBody { id: string; } /** * The herd-change notification (T6 task 11): one unread notification per * permitted user and topic. "Permitted" are the users this file creates with * compliance:manage; other users of the shared test database do not count. */ describe('Authority notices: herd change notification (e2e)', () => { let app: INestApplication; let prisma: PrismaService; const suffix = Date.now(); const password = 'SuperSecret123!'; const managerEmails = [ `herd-e2e-manager-a-${suffix}@example.com`, `herd-e2e-manager-b-${suffix}@example.com`, ]; const staffEmail = `herd-e2e-staff-${suffix}@example.com`; let managerIds: string[]; let staffId: string; let managerToken: string; let apiaryId: string; let keyCounter = 0; const colonyIds: string[] = []; /** The notices this file created: the only ones it deletes. */ const noticeIds: string[] = []; /** The shared settings row as found; restored in afterAll. */ let savedSettings: { noticeOnColonyCountChange: boolean; noticeOnApiaryChange: boolean; }; const ONCE_TOPIC = `herd-e2e-once-${suffix}`; const server = () => app.getHttpServer(); const as = (token: string) => ({ Authorization: `Bearer ${token}` }); const nextKey = () => `herd-e2e-${suffix}-${(keyCounter += 1).toString().padStart(4, '0')}`; let releaseExclusive: () => Promise; beforeAll(async () => { // Suites that clear or create the global open notice drafts take turns. releaseExclusive = await acquireExclusive(EXCLUSIVE_KEYS.authorityNotices); app = await createTestApp(); prisma = app.get(PrismaService); const passwordHash = await bcrypt.hash(password, 12); const create = (email: string, roleKey: string) => prisma.user.create({ data: { email, passwordHash, name: roleKey, role: { connect: { key: roleKey } }, }, }); managerIds = []; for (const email of managerEmails) { managerIds.push((await create(email, 'BEEKEEPER')).id); } staffId = (await create(staffEmail, 'STAFF')).id; const settings = await prisma.complianceSettings.upsert({ where: { id: 'singleton' }, update: {}, create: { id: 'singleton' }, }); savedSettings = { noticeOnColonyCountChange: settings.noticeOnColonyCountChange, noticeOnApiaryChange: settings.noticeOnApiaryChange, }; managerToken = (await login(app, managerEmails[0], password)).accessToken; apiaryId = ( await prisma.apiary.create({ data: { name: `Herd Stand ${suffix}` } }) ).id; colonyIds.push( ( await prisma.colony.create({ data: { code: `H-${suffix}-0`, apiaryId, colonyType: 'PRODUCTION_COLONY', }, }) ).id, ); }); async function resetScenarioState(): Promise { await prisma.complianceSettings.update({ where: { id: 'singleton' }, data: { noticeOnColonyCountChange: true, noticeOnApiaryChange: true }, }); await prisma.notification.deleteMany({ where: { userId: { in: [...managerIds, staffId] } }, }); // A retried attempt must start without the notices (and the open draft) // of the failed one. await prisma.authorityNotice.deleteMany({ where: { id: { in: noticeIds.splice(0) } }, }); } beforeEach(resetScenarioState); /** * The herd deviation is computed over the whole shared database (the * baseline is the newest sent notice of ANY file, the herd is every * colony), so a parallel file that sends a notice or adds/ends a colony * between our baseline and our drain can flip an outcome. The notification * assertions themselves are scoped to this file's users; for the global * part the scenario is repeated from a clean state. A real defect fails * every attempt and surfaces with the last error. */ async function settled(scenario: () => Promise): Promise { const attempts = 3; for (let attempt = 1; ; attempt += 1) { try { await scenario(); return; } catch (error) { if (attempt >= attempts) { throw error; } await resetScenarioState(); } } } const scenario = (name: string, body: () => Promise) => it(name, () => settled(body)); afterEach(async () => { // The shared database holds other suites' notices: only own ids go. await prisma.authorityNotice.deleteMany({ where: { id: { in: noticeIds.splice(0) } }, }); await prisma.notification.deleteMany({ where: { relatedType: 'ComplianceItem', relatedId: ONCE_TOPIC }, }); }); afterAll(async () => { await prisma.complianceSettings.update({ where: { id: 'singleton' }, data: savedSettings, }); const userIds = [...managerIds, staffId]; await prisma.notification.deleteMany({ where: { userId: { in: userIds } }, }); await prisma.domainEvent.deleteMany({ where: { aggregateId: { in: colonyIds } }, }); await prisma.colony.deleteMany({ where: { apiaryId } }); await prisma.domainEvent.deleteMany({ where: { aggregateId: apiaryId } }); await prisma.apiary.deleteMany({ where: { id: apiaryId } }); await prisma.refreshToken.deleteMany({ where: { userId: { in: userIds } }, }); await prisma.user.deleteMany({ where: { id: { in: userIds } } }); await app.close(); await releaseExclusive(); }); /** A notice sent today is the baseline: the current herd. */ async function sendBaseline(): Promise { const kind = (await findBaselineIn(prisma)) ? 'HERD_CHANGE' : 'REGISTRATION'; const response = await request(server()) .post('/api/v1/authority-notices') .set(as(managerToken)) .set('Idempotency-Key', nextKey()) .send({ kind, sentOn: berlinToday() }) .expect(201); noticeIds.push((response.body as NoticeBody).id); } async function addColony(): Promise { const response = await request(server()) .post('/api/v1/colonies') .set(as(managerToken)) .send({ code: `H-${suffix}-${colonyIds.length}`, apiaryId, colonyType: 'PRODUCTION_COLONY', }) .expect(201); const { id } = response.body as ColonyBody; colonyIds.push(id); await drainDomainEvents(app, [id]); return id; } const unreadOf = (userIds: string[]) => prisma.notification.findMany({ where: { userId: { in: userIds }, relatedType: 'ComplianceItem', relatedId: 'herd-change', read: false, }, select: { userId: true, category: true, priority: true, message: true }, }); it('registers both handlers in the API', () => { expect(app.get(DomainEventRegistry).handlerIds()).toEqual( expect.arrayContaining([ 'compliance.herd-change', 'compliance.tsk-reference-day', ]), ); }); scenario( 'a new colony changes the herd: after draining one unread notification per permitted user', async () => { await sendBaseline(); await addColony(); const unread = await unreadOf(managerIds); expect(unread.map((row) => row.userId).sort()).toEqual( [...managerIds].sort(), ); expect(unread[0]).toMatchObject({ category: 'COMPLIANCE', priority: 'HINWEIS', }); expect(unread[0].message).toMatch( /^Bestand geändert: jetzt \d+ Völker an \d+ Ständen\. Angezeigt: \d+ an \d+\. Anzeige ans Veterinäramt prüfen\.$/, ); }, ); scenario( 'a second colony within one unread notice adds no second notification', async () => { await sendBaseline(); await addColony(); await addColony(); expect(await unreadOf(managerIds)).toHaveLength(managerIds.length); }, ); scenario( 'creating and sending a change notice removes the deviation; the next colony after reading notifies again', async () => { await sendBaseline(); await addColony(); expect(await unreadOf(managerIds)).toHaveLength(managerIds.length); const created = await request(server()) .post('/api/v1/authority-notices') .set(as(managerToken)) .set('Idempotency-Key', nextKey()) .send({ kind: 'HERD_CHANGE' }) .expect(201); const notice = created.body as NoticeBody; noticeIds.push(notice.id); await request(server()) .post(`/api/v1/authority-notices/${notice.id}/mark-sent`) .set(as(managerToken)) .send({}) .expect(201); await prisma.notification.updateMany({ where: { userId: { in: managerIds } }, data: { read: true }, }); // Not asserted: that this notice is THE baseline or that nothing deviates // - both are global and a parallel file's notice or colony changes them. expect( ( await prisma.authorityNotice.findUniqueOrThrow({ where: { id: notice.id }, }) ).status, ).toBe('SENT'); expect(await unreadOf(managerIds)).toHaveLength(0); await addColony(); expect(await unreadOf(managerIds)).toHaveLength(managerIds.length); }, ); scenario( 'a user without compliance:manage gets no herd notification', async () => { await sendBaseline(); await addColony(); expect(await unreadOf([staffId])).toHaveLength(0); expect(await unreadOf(managerIds)).toHaveLength(managerIds.length); }, ); async function patchApiary(body: Record): Promise { await request(server()) .patch(`/api/v1/apiaries/${apiaryId}`) .set(as(managerToken)) .send(body) .expect(200); await drainDomainEvents(app, [apiaryId]); } scenario('an apiary move (LOCATION_CHANGED) counts', async () => { await sendBaseline(); await patchApiary({ location: `Neuer Standort ${nextKey()}` }); expect(await unreadOf(managerIds)).toHaveLength(managerIds.length); }); scenario('a rename does not count', async () => { await sendBaseline(); await patchApiary({ name: `Herd Stand umbenannt ${nextKey()}` }); expect(await unreadOf(managerIds)).toHaveLength(0); }); scenario( 'noticeOnApiaryChange alone: a colony stays quiet, a moved apiary notifies', async () => { await prisma.complianceSettings.update({ where: { id: 'singleton' }, data: { noticeOnColonyCountChange: false }, }); await sendBaseline(); await addColony(); expect(await unreadOf(managerIds)).toHaveLength(0); await patchApiary({ location: `Ort wieder neu ${nextKey()}` }); expect(await unreadOf(managerIds)).toHaveLength(managerIds.length); }, ); it('two parallel recordForPermissionOnce transactions write one row per user', async () => { const notifications = app.get(NotificationsService); const once = () => prisma.$transaction((tx) => notifications.recordForPermissionOnce( 'compliance:manage', { priority: 'INFO', category: 'COMPLIANCE', message: 'Parallel', relatedType: 'ComplianceItem', relatedId: ONCE_TOPIC, link: '/compliance', }, tx, ), ); await Promise.all([once(), once()]); const rows = await prisma.notification.groupBy({ by: ['userId'], where: { relatedType: 'ComplianceItem', relatedId: ONCE_TOPIC, read: false, }, _count: { _all: true }, }); expect(rows.length).toBeGreaterThanOrEqual(managerIds.length); for (const row of rows) { expect(row._count._all).toBe(1); } expect(rows.map((row) => row.userId)).toEqual( expect.arrayContaining(managerIds), ); expect(rows.map((row) => row.userId)).not.toContain(staffId); }); });