import { NotFoundException } from '@nestjs/common'; import { NotificationsService } from './notifications.service'; import { assertAppLink } from './notification-link'; import { PrismaService } from '../prisma/prisma.service'; import { PermissionResolverService } from '../auth/permissions/permission-resolver.service'; import { NotificationDeliveryPlanner } from './delivery-planner'; type CreateManyArg = { data: { userId: string; message: string; dedupeKey: string | null; requiredPermissions: string[]; category: string; link: string | null; }[]; skipDuplicates: boolean; }; describe('NotificationsService', () => { let service: NotificationsService; let held: Set; let planner: { planIn: jest.Mock }; let prisma: { notification: { createManyAndReturn: jest.Mock; findMany: jest.Mock; findFirstOrThrow: jest.Mock; findFirst: jest.Mock; updateMany: jest.Mock; }; user: { findMany: jest.Mock }; $connect: jest.Mock; $transaction: jest.Mock; $queryRaw: jest.Mock; }; beforeEach(() => { planner = { planIn: jest.fn().mockResolvedValue(0) }; prisma = { notification: { createManyAndReturn: jest .fn() .mockImplementation(({ data }: CreateManyArg) => Promise.resolve(data.map((row, i) => ({ id: `n${i}`, ...row }))), ), findMany: jest.fn().mockResolvedValue([]), findFirstOrThrow: jest.fn(), findFirst: jest.fn(), updateMany: jest.fn(), }, user: { findMany: jest.fn().mockResolvedValue([]) }, // A transaction client lacks $connect; the root client has it. $connect: jest.fn(), $transaction: jest.fn(), // The FOR KEY SHARE lock on the recipients: every user still exists. $queryRaw: jest.fn().mockImplementation(() => { const found = prisma.user.findMany.mock.results.at(-1); return Promise.resolve(found ? (found.value as unknown) : []); }), }; prisma.$transaction.mockImplementation((work: (tx: unknown) => unknown) => { // A transaction client has no $connect; the code tells the two apart. const txClient = Object.fromEntries( Object.entries(prisma).filter(([key]) => key !== '$connect'), ); return Promise.resolve(work(txClient)); }); held = new Set(); service = new NotificationsService( prisma as unknown as PrismaService, { getPermissions: jest .fn() .mockImplementation(() => Promise.resolve(held)), } as unknown as PermissionResolverService, planner as unknown as NotificationDeliveryPlanner, ); }); const lastCreate = () => ( prisma.notification.createManyAndReturn.mock.calls[0] as [CreateManyArg] )[0]; it('record without userId does not compile', () => { // @ts-expect-error userId fehlt void service.record({ priority: 'INFO', category: 'SYSTEM', message: 'm' }); }); describe('recipient locking', () => { it('locks the recipients in the write transaction and skips a user deleted meanwhile', async () => { prisma.user.findMany.mockResolvedValue([{ id: 'u1' }, { id: 'u2' }]); prisma.$queryRaw.mockResolvedValue([{ id: 'u1' }]); const rows = await service.recordForUsers(['u1', 'u2'], { priority: 'INFO', category: 'SYSTEM', message: 'm', }); expect(prisma.$transaction).toHaveBeenCalledTimes(1); expect(prisma.$queryRaw).toHaveBeenCalledTimes(1); expect( (prisma.$queryRaw.mock.calls[0] as [{ sql: string }])[0].sql, ).toContain('FOR KEY SHARE'); expect(rows.map((row) => (row as { userId: string }).userId)).toEqual([ 'u1', ]); }); }); describe('record', () => { it('writes one row for an eligible recipient and returns it', async () => { prisma.user.findMany.mockResolvedValue([{ id: 'u1' }]); const result = await service.record({ userId: 'u1', priority: 'KRITISCH', category: 'SYSTEM', message: 'Keine Königin gesehen', dedupeKey: 'k:u1', }); expect(result).toMatchObject({ userId: 'u1', dedupeKey: 'k:u1' }); expect(lastCreate().skipDuplicates).toBe(true); expect(lastCreate().data).toEqual([ { userId: 'u1', priority: 'KRITISCH', category: 'SYSTEM', message: 'Keine Königin gesehen', relatedType: null, relatedId: null, link: null, requiredPermissions: [], dedupeKey: 'k:u1', }, ]); }); it('returns null for a recipient without the permission', async () => { await expect( service.record({ userId: 'u1', priority: 'INFO', category: 'SYSTEM', message: 'm', requiredPermissions: ['colonies:read'], }), ).resolves.toBeNull(); expect(prisma.notification.createManyAndReturn).not.toHaveBeenCalled(); }); it('returns null for a repeated dedupe key', async () => { prisma.user.findMany.mockResolvedValue([{ id: 'u1' }]); prisma.notification.createManyAndReturn.mockResolvedValue([]); await expect( service.record({ userId: 'u1', priority: 'INFO', category: 'SYSTEM', message: 'm', dedupeKey: 'k:u1', }), ).resolves.toBeNull(); }); it('writes through the transaction client when one is given', async () => { const tx = { user: { findMany: jest.fn().mockResolvedValue([{ id: 'u1' }]) }, $queryRaw: jest.fn().mockResolvedValue([{ id: 'u1' }]), notification: { createManyAndReturn: jest.fn().mockResolvedValue([{ id: 'n' }]), }, }; await service.record( { userId: 'u1', priority: 'INFO', category: 'SYSTEM', message: 'm' }, tx as never, ); expect(tx.notification.createManyAndReturn).toHaveBeenCalledTimes(1); expect(prisma.notification.createManyAndReturn).not.toHaveBeenCalled(); }); it('rejects an unsafe link before writing', async () => { prisma.user.findMany.mockResolvedValue([{ id: 'u1' }]); await expect( service.record({ userId: 'u1', priority: 'INFO', category: 'SYSTEM', message: 'm', link: 'https://evil.example', }), ).rejects.toThrow(); expect(prisma.notification.createManyAndReturn).not.toHaveBeenCalled(); }); }); describe('Telegram delivery planning', () => { const base = { priority: 'INFO' as const, category: 'SYSTEM' as const, message: 'm', }; it('plans the deliveries of the inserted rows right after the insert', async () => { prisma.user.findMany.mockResolvedValue([{ id: 'u1' }, { id: 'u2' }]); const rows = await service.recordForUsers(['u1', 'u2'], base); expect(planner.planIn).toHaveBeenCalledTimes(1); expect(planner.planIn).toHaveBeenCalledWith( expect.objectContaining({ notification: prisma.notification }), rows, ); expect( prisma.notification.createManyAndReturn.mock.invocationCallOrder[0], ).toBeLessThan(planner.planIn.mock.invocationCallOrder[0]); }); it('plans with the caller transaction and opens none of its own', async () => { const tx = { user: { findMany: jest.fn().mockResolvedValue([{ id: 'u1' }]) }, $queryRaw: jest.fn().mockResolvedValue([{ id: 'u1' }]), notification: { createManyAndReturn: jest.fn().mockResolvedValue([{ id: 'n' }]), }, }; await service.record({ userId: 'u1', ...base }, tx as never); expect(planner.planIn).toHaveBeenCalledWith(tx, [{ id: 'n' }]); expect(prisma.$transaction).not.toHaveBeenCalled(); }); it('without a caller transaction, rows and deliveries share one of their own', async () => { prisma.user.findMany.mockResolvedValue([{ id: 'u1' }]); await service.record({ userId: 'u1', ...base }); expect(prisma.$transaction).toHaveBeenCalledTimes(1); expect(planner.planIn).toHaveBeenCalledTimes(1); }); it('plans only what was inserted: a repeated dedupe key plans nothing new', async () => { prisma.user.findMany.mockResolvedValue([{ id: 'u1' }]); prisma.notification.createManyAndReturn.mockResolvedValue([]); await service.record({ userId: 'u1', ...base, dedupeKey: 'k:u1' }); expect(planner.planIn).toHaveBeenCalledWith(expect.anything(), []); }); it('plans nothing when no recipient is eligible', async () => { await service.recordForUsers(['u1'], base); expect(planner.planIn).not.toHaveBeenCalled(); }); }); describe('recordForPermission', () => { const input = { priority: 'WARNUNG' as const, category: 'SYSTEM' as const, message: 'Job „App-Backup“ fehlgeschlagen', }; it('writes one row per active user holding every permission, with a key per recipient', async () => { prisma.user.findMany.mockResolvedValue([{ id: 'u1' }, { id: 'u2' }]); const rows = await service.recordForPermission('backup:configure', { ...input, dedupeKeyPrefix: 'p', }); expect(rows).toHaveLength(2); expect(lastCreate().data.map((r) => [r.userId, r.dedupeKey])).toEqual([ ['u1', 'p:u1'], ['u2', 'p:u2'], ]); }); it('selects recipients like the permission guard: active user, active role, every key granted', async () => { await service.recordForPermission( ['backup:configure', 'colonies:read'], input, ); expect(prisma.user.findMany).toHaveBeenCalledWith({ where: { isActive: true, role: { status: 'ACTIVE', AND: [ { permissions: { some: { permission: { key: 'backup:configure' } }, }, }, { permissions: { some: { permission: { key: 'colonies:read' } } }, }, ], }, }, select: { id: true }, orderBy: { id: 'asc' }, }); }); it('stores the union of recipient and content permissions', async () => { prisma.user.findMany.mockResolvedValue([{ id: 'u1' }]); await service.recordForPermission('colonies:read', { ...input, requiredPermissions: ['inspections:read', 'colonies:read'], }); expect(lastCreate().data[0].requiredPermissions).toEqual([ 'colonies:read', 'inspections:read', ]); }); it('throws for an empty permission list instead of broadcasting', async () => { await expect(service.recordForPermission([], input)).rejects.toThrow(); expect(prisma.user.findMany).not.toHaveBeenCalled(); }); it('writes nothing when nobody holds the permission', async () => { await expect( service.recordForPermission('backup:configure', input), ).resolves.toEqual([]); expect(prisma.notification.createManyAndReturn).not.toHaveBeenCalled(); }); it('a repeated dedupe key writes nothing', async () => { prisma.user.findMany.mockResolvedValue([{ id: 'u1' }]); prisma.notification.createManyAndReturn.mockResolvedValue([]); await expect( service.recordForPermission('backup:configure', { ...input, dedupeKeyPrefix: 'p', }), ).resolves.toEqual([]); }); it('reads and writes through the transaction client when one is given', async () => { const tx = { user: { findMany: jest.fn().mockResolvedValue([{ id: 'u1' }]) }, $queryRaw: jest.fn().mockResolvedValue([{ id: 'u1' }]), notification: { createManyAndReturn: jest.fn().mockResolvedValue([{ id: 'n1' }]), }, }; await expect( service.recordForPermission('backup:configure', input, tx as never), ).resolves.toHaveLength(1); expect(prisma.user.findMany).not.toHaveBeenCalled(); expect(prisma.notification.createManyAndReturn).not.toHaveBeenCalled(); }); }); describe('recordForPermissionOnce', () => { const input = { priority: 'HINWEIS' as const, category: 'COMPLIANCE' as const, message: 'Bestand geändert', relatedType: 'ComplianceItem', relatedId: 'herd-change', link: '/compliance', }; let calls: string[]; let tx: { $executeRaw: jest.Mock; $queryRaw: jest.Mock; user: { findMany: jest.Mock }; notification: { findMany: jest.Mock; createManyAndReturn: jest.Mock }; }; beforeEach(() => { calls = []; tx = { $executeRaw: jest.fn().mockImplementation(() => { calls.push('lock'); return Promise.resolve(1); }), // The key-share lock keeps every user the read just returned. $queryRaw: jest.fn().mockImplementation(() => { const read = tx.user.findMany.mock.results.at(-1); return Promise.resolve(read ? (read.value as unknown) : []); }), user: { // Like the database: the id filter narrows the three eligible users. findMany: jest .fn() .mockImplementation( ({ where }: { where: { id?: { in: string[] } } }) => Promise.resolve( ['u1', 'u2', 'u3'] .filter((id) => !where.id || where.id.in.includes(id)) .map((id) => ({ id })), ), ), }, notification: { findMany: jest.fn().mockImplementation(() => { calls.push('unread'); return Promise.resolve([{ userId: 'u2' }]); }), createManyAndReturn: jest .fn() .mockImplementation(({ data }: CreateManyArg) => Promise.resolve(data.map((row, i) => ({ id: `n${i}`, ...row }))), ), }, }; }); it('skips users with an unread notification of the same topic', async () => { const rows = await service.recordForPermissionOnce( 'compliance:manage', input, tx as never, ); expect(rows.map((row) => (row as { userId: string }).userId)).toEqual([ 'u1', 'u3', ]); expect(tx.notification.findMany).toHaveBeenCalledWith( expect.objectContaining({ where: expect.objectContaining({ relatedType: 'ComplianceItem', relatedId: 'herd-change', read: false, }) as unknown, }), ); }); it('writes again after the user read the earlier one', async () => { tx.notification.findMany.mockResolvedValue([]); const rows = await service.recordForPermissionOnce( 'compliance:manage', input, tx as never, ); expect(rows).toHaveLength(3); }); it('takes the advisory lock before reading the unread rows', async () => { await service.recordForPermissionOnce( 'compliance:manage', input, tx as never, ); expect(calls).toEqual(['lock', 'unread']); }); it('stores no dedupe key and the recipient permission', async () => { await service.recordForPermissionOnce( 'compliance:manage', input, tx as never, ); const [arg] = tx.notification.createManyAndReturn.mock.calls[0] as [ CreateManyArg, ]; expect(arg.data.map((row) => row.dedupeKey)).toEqual([null, null]); expect(arg.data[0].requiredPermissions).toEqual(['compliance:manage']); }); it('plans the Telegram delivery for the new rows only', async () => { const rows = await service.recordForPermissionOnce( 'compliance:manage', input, tx as never, ); expect(planner.planIn).toHaveBeenCalledTimes(1); expect(planner.planIn).toHaveBeenCalledWith(tx, rows); expect(rows).toHaveLength(2); }); it('writes nothing and plans nothing when everybody has an unread one', async () => { tx.notification.findMany.mockResolvedValue([ { userId: 'u1' }, { userId: 'u2' }, { userId: 'u3' }, ]); await expect( service.recordForPermissionOnce( 'compliance:manage', input, tx as never, ), ).resolves.toEqual([]); expect(tx.notification.createManyAndReturn).not.toHaveBeenCalled(); expect(planner.planIn).not.toHaveBeenCalled(); }); it('throws for an empty permission list instead of broadcasting', async () => { await expect( service.recordForPermissionOnce([], input, tx as never), ).rejects.toThrow(); }); }); it('recordForPermission is unchanged: it never reads unread rows', async () => { prisma.user.findMany.mockResolvedValue([{ id: 'u1' }]); await service.recordForPermission('compliance:manage', { priority: 'INFO', category: 'SYSTEM', message: 'm', }); expect(prisma.notification.findMany).not.toHaveBeenCalled(); }); describe('recordForUsers', () => { it('skips users missing a required permission', async () => { prisma.user.findMany.mockResolvedValue([{ id: 'u2' }]); const rows = await service.recordForUsers(['u1', 'u2'], { priority: 'INFO', category: 'SUGGESTION', message: 'm', requiredPermissions: ['colonies:read'], }); expect(prisma.user.findMany).toHaveBeenCalledWith( expect.objectContaining({ where: expect.objectContaining({ id: { in: ['u1', 'u2'] }, }) as unknown, }), ); expect(rows.map((r) => r.userId)).toEqual(['u2']); }); it('does nothing for an empty user list', async () => { await expect( service.recordForUsers([], { priority: 'INFO', category: 'SYSTEM', message: 'm', }), ).resolves.toEqual([]); expect(prisma.user.findMany).not.toHaveBeenCalled(); }); }); describe('reading', () => { it('lists only the own notifications, newest first, at most 100', async () => { await service.findOwn('user-1', { unreadOnly: true }); expect(prisma.notification.findMany).toHaveBeenCalledWith( expect.objectContaining({ where: { userId: 'user-1', read: false }, orderBy: [{ createdAt: 'desc' }, { id: 'desc' }], take: 500, }), ); }); it('hides a row whose permissions the user no longer holds (revoked after the write)', async () => { const row = (id: string, requiredPermissions: string[]) => ({ id, priority: 'INFO', category: 'SYSTEM', message: 'm', relatedType: null, relatedId: null, link: null, read: false, createdAt: new Date(0), requiredPermissions, }); prisma.notification.findMany.mockResolvedValue([ row('open', []), row('revoked', ['colonies:read', 'varroa:read']), row('kept', ['colonies:read']), ]); held.add('colonies:read'); const views = await service.findOwn('user-1'); expect(views.map((view) => view.id)).toEqual(['open', 'kept']); expect(views[0]).not.toHaveProperty('requiredPermissions'); }); it('selects the view fields plus the permission list for filtering, never the dedupe key', async () => { await service.findOwn('user-1'); const [[arg]] = prisma.notification.findMany.mock.calls as [ [{ select: Record }], ]; expect(Object.keys(arg.select).sort()).toEqual([ 'category', 'createdAt', 'id', 'link', 'message', 'priority', 'read', 'relatedId', 'relatedType', 'requiredPermissions', ]); }); it('answers 404 when the notification is foreign or unknown', async () => { prisma.notification.findFirst.mockResolvedValue(null); await expect(service.markRead('n1', 'user-1')).rejects.toBeInstanceOf( NotFoundException, ); expect(prisma.notification.findFirst).toHaveBeenCalledWith({ where: { id: 'n1', userId: 'user-1' }, select: { requiredPermissions: true }, }); expect(prisma.notification.updateMany).not.toHaveBeenCalled(); }); it('answers 404 and changes nothing for a row whose permissions are no longer held', async () => { prisma.notification.findFirst.mockResolvedValue({ requiredPermissions: ['colonies:read'], }); await expect(service.markRead('n1', 'user-1')).rejects.toBeInstanceOf( NotFoundException, ); expect(prisma.notification.updateMany).not.toHaveBeenCalled(); expect(prisma.notification.findFirstOrThrow).not.toHaveBeenCalled(); }); it('marks the own visible notification as read', async () => { held.add('colonies:read'); prisma.notification.findFirst.mockResolvedValue({ requiredPermissions: ['colonies:read'], }); prisma.notification.updateMany.mockResolvedValue({ count: 1 }); prisma.notification.findFirstOrThrow.mockResolvedValue({ id: 'n1', read: true, }); await expect(service.markRead('n1', 'user-1')).resolves.toMatchObject({ read: true, }); expect(prisma.notification.updateMany).toHaveBeenCalledWith({ where: { id: 'n1', userId: 'user-1' }, data: { read: true }, }); }); it('marks only the own unread notifications the user may see', async () => { held.add('colonies:read'); prisma.notification.findMany.mockResolvedValue([ { id: 'a', requiredPermissions: [] }, { id: 'b', requiredPermissions: ['colonies:read'] }, { id: 'c', requiredPermissions: ['varroa:read'] }, ]); prisma.notification.updateMany.mockResolvedValue({ count: 2 }); await expect(service.markAllRead('user-1')).resolves.toEqual({ updated: 2, }); expect(prisma.notification.findMany).toHaveBeenCalledWith({ where: { userId: 'user-1', read: false }, select: { id: true, requiredPermissions: true }, }); expect(prisma.notification.updateMany).toHaveBeenCalledWith({ where: { id: { in: ['a', 'b'] }, userId: 'user-1', read: false }, data: { read: true }, }); }); }); }); describe('assertAppLink', () => { it('accepts a relative app path', () => { expect(() => assertAppLink('/tasks?status=OPEN&x=1')).not.toThrow(); }); it.each([ '//evil.example', 'https://evil.example', '/\\evil', 'tasks', '/a b', ])('rejects %s', (link) => expect(() => assertAppLink(link)).toThrow()); it('rejects a link over 300 characters', () => { expect(() => assertAppLink(`/${'a'.repeat(300)}`)).toThrow(); }); });