"Que sigo" on the home page lists the open tickets I follow, whoever works on them; `watching` filters my issues (REST, GraphQL, MCP) and a project's search (none without a session). Followers now also hear when a ticket's priority or due date changes, and when it enters or leaves a sprint. Smaller edits (title, labels…) still do not notify. Docs (with the REPLIED row the replies change missed), Word and PDF updated. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
321 lines
9.4 KiB
TypeScript
321 lines
9.4 KiB
TypeScript
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<string>();
|
|
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<NotificationSettingsReadModel> {
|
|
return { userId, email: this.emailOn.has(userId) };
|
|
}
|
|
async saveSettings() {
|
|
return;
|
|
}
|
|
}
|
|
|
|
const at = <T extends TrackerEvent>(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,
|
|
]);
|
|
});
|
|
});
|