import { CommentAddedEvent, IssueAssignedEvent, IssuePriority, IssueSprintChangedEvent, IssueStatusChangedEvent, IssueUpdatedEvent, NotificationKind, NotificationReadModel, NotificationSettingsReadModel, ProjectMemberAddedEvent, ProjectRole, StatusCategory, TrackerEvent, } from '@task-stream/api/tracker/domain'; import { FakeUserDirectory } from '../../testing/in-memory'; import { ChangeFeed } from '../ports/change-feed'; import { FindCommentsRepository } from '../ports/find-comments.repository'; import { FindIssuesRepository } from '../ports/find-issues.repository'; import { FindSprintsRepository } from '../ports/find-sprints.repository'; import { FindProjectsRepository } from '../ports/find-projects.repository'; import { NotificationMailer } from '../ports/notification-mailer'; import { NotificationsRepository } from '../ports/notifications.repository'; import { NotificationsEventHandler } from './notifications.event-handler'; import { Notifier } from './notifier'; class InMemoryNotifications extends NotificationsRepository { readonly all: NotificationReadModel[] = []; readonly emailOn = new Set(); async insert(n: NotificationReadModel) { if (this.all.some((x) => x.id === n.id)) return false; this.all.push(n); return true; } async findForUser(userId: string) { return this.all.filter((n) => n.userId === userId); } async countUnread(userId: string) { return this.all.filter((n) => n.userId === userId && !n.readAt).length; } async markRead() { return; } async settings(userId: string): Promise { return { userId, email: this.emailOn.has(userId) }; } async saveSettings() { return; } } const at = (event: T, version = 2): T => Object.assign(event, { aggregateVersion: version, occurredAt: '2026-10-08T10:00:00.000Z', }); function setup(mailEnabled = false) { const repo = new InMemoryNotifications(); const published: NotificationReadModel[] = []; const mailed: string[] = []; const users = new FakeUserDirectory() .add('ana') .add('bob') .add('qa') .add('pm'); const issue = { id: 'i-1', key: 'WEB-1', title: 'Login roto', reporter: { id: 'qa', name: 'QA' }, assignee: { id: 'bob', name: 'Bob' }, watchers: [ { id: 'pm', name: 'PM' }, { id: 'bob', name: 'Bob' }, ], }; const notifier = new Notifier( repo, { publishNotification: async (n: NotificationReadModel) => { published.push(n); }, } as unknown as ChangeFeed, { enabled: mailEnabled, send: async (to: { email: string }) => { mailed.push(to.email); }, } as unknown as NotificationMailer, users, ); const handler = new NotificationsEventHandler( notifier, { findById: async () => issue } as unknown as FindIssuesRepository, { findById: async () => ({ id: 'p-1', key: 'WEB', name: 'Web', members: ['ana', 'bob', 'qa', 'pm'].map((id) => ({ user: { id, name: id }, role: ProjectRole.MEMBER, })), }), } as unknown as FindProjectsRepository, users, { // A comment of the PM's, that others answer. findById: async (id: string) => id === 'c-pm' ? { id, author: { id: 'pm', name: 'PM' }, deleted: false } : null, } as unknown as FindCommentsRepository, { findById: async (id: string) => id === 's-14' ? { id, name: 'Sprint 14' } : null, } as unknown as FindSprintsRepository, ); return { repo, published, mailed, handler }; } describe('notifications', () => { it('tells whoever a ticket is assigned to, not whoever assigned it', async () => { const { handler, repo, published } = setup(); await handler.handle( at(new IssueAssignedEvent('ana', 'i-1', 'p-1', 'bob', null)), ); await handler.handle( at(new IssueAssignedEvent('ana', 'i-1', 'p-1', 'ana', null), 3), ); expect(repo.all.map((n) => [n.userId, n.kind])).toEqual([ ['bob', NotificationKind.ASSIGNED], // Bob follows the ticket too, but is told once: as the assignee. ['pm', NotificationKind.WATCHED], ['pm', NotificationKind.WATCHED], ['bob', NotificationKind.WATCHED], ]); expect(published).toHaveLength(4); expect(repo.all[0].summary).toContain('WEB-1'); }); it('marks a ticket sent back from QA as such, for whoever works on it and reported it', async () => { const { handler, repo } = setup(); await handler.handle( at( new IssueStatusChangedEvent( 'ana', 'i-1', 'p-1', 'IN_QA', 'QA_FAILED', 'Falla en Safari', { fromName: 'En QA', toName: 'QA fallido', fromCategory: StatusCategory.QA, toCategory: StatusCategory.IN_PROGRESS, }, ), ), ); expect(repo.all.map((n) => [n.userId, n.kind])).toEqual([ ['bob', NotificationKind.QA_FAILED], ['qa', NotificationKind.QA_FAILED], ['pm', NotificationKind.WATCHED], ]); expect(repo.all[0].summary).toContain('Falla en Safari'); }); it('tells about a comment everyone but its author, once even when redelivered', async () => { const { handler, repo } = setup(); const comment = at( new CommentAddedEvent('bob', { id: 'c-1', issueId: 'i-1', projectId: 'p-1', authorId: 'bob', body: 'Ya está arreglado', replyTo: null, deleted: false, createdAt: '2026-10-08T10:00:00.000Z', editedAt: null, }), 1, ); await handler.handle(comment); await handler.handle(comment); expect(repo.all.map((n) => n.userId)).toEqual(['qa', 'pm']); }); it('tells a mentioned member first, and nobody outside the project', async () => { const { handler, repo } = setup(); await handler.handle( at( new CommentAddedEvent('bob', { id: 'c-2', issueId: 'i-1', projectId: 'p-1', authorId: 'bob', body: '@[PM](pm) mira esto, y @[Fuera](stranger) también', replyTo: null, deleted: false, createdAt: '2026-10-08T10:00:00.000Z', editedAt: null, }), 1, ), ); expect(repo.all.map((n) => [n.userId, n.kind])).toEqual([ ['pm', NotificationKind.MENTIONED], ['qa', NotificationKind.COMMENTED], ]); expect(repo.all[0].summary).toContain('«@PM mira esto'); }); it('tells whoever wrote a comment that someone answered it', async () => { const { handler, repo } = setup(); await handler.handle( at( new CommentAddedEvent('bob', { id: 'c-3', issueId: 'i-1', projectId: 'p-1', authorId: 'bob', body: 'Lo miro', replyTo: 'c-pm', deleted: false, createdAt: '2026-10-08T10:00:00.000Z', editedAt: null, }), 1, ), ); expect(repo.all.map((n) => [n.userId, n.kind])).toEqual([ ['pm', NotificationKind.REPLIED], ['qa', NotificationKind.COMMENTED], ]); expect(repo.all[0].summary).toContain('respondió a tu comentario'); }); it('tells followers of a new priority, due date or sprint, not of smaller edits', async () => { const { handler, repo } = setup(); await handler.handle( at( new IssueUpdatedEvent('ana', 'i-1', 'p-1', { priority: IssuePriority.HIGH, dueDate: '2026-11-14', }), ), ); await handler.handle( at(new IssueUpdatedEvent('ana', 'i-1', 'p-1', { title: 'Login' }), 3), ); await handler.handle( at(new IssueSprintChangedEvent('ana', 'i-1', 'p-1', 's-14', null), 4), ); await handler.handle( at(new IssueSprintChangedEvent('ana', 'i-1', 'p-1', null, 's-14'), 5), ); expect(repo.all.map((n) => [n.userId, n.kind, n.summary])).toEqual([ [ 'pm', NotificationKind.WATCHED, 'user ana cambió WEB-1: Login roto: prioridad Alta, fecha límite el 14 de noviembre de 2026', ], [ 'bob', NotificationKind.WATCHED, 'user ana cambió WEB-1: Login roto: prioridad Alta, fecha límite el 14 de noviembre de 2026', ], [ 'pm', NotificationKind.WATCHED, 'user ana metió WEB-1: Login roto en el sprint Sprint 14', ], [ 'bob', NotificationKind.WATCHED, 'user ana metió WEB-1: Login roto en el sprint Sprint 14', ], [ 'pm', NotificationKind.WATCHED, 'user ana sacó WEB-1: Login roto del sprint y lo devolvió al backlog', ], [ 'bob', NotificationKind.WATCHED, 'user ana sacó WEB-1: Login roto del sprint y lo devolvió al backlog', ], ]); }); it('copies by e-mail only to whoever asked for it, when mail is configured', async () => { const { handler, repo, mailed } = setup(true); repo.emailOn.add('bob'); await handler.handle( at(new ProjectMemberAddedEvent('ana', 'p-1', 'bob', ProjectRole.MEMBER)), ); await handler.handle( at( new ProjectMemberAddedEvent('ana', 'p-1', 'qa', ProjectRole.MEMBER), 3, ), ); expect(mailed).toEqual(['bob@example.org']); expect(repo.all.map((n) => n.kind)).toEqual([ NotificationKind.ADDED_TO_PROJECT, NotificationKind.ADDED_TO_PROJECT, ]); }); });