Files
task-stream/libs/api/tracker/application/src/lib/commands/commands.spec.ts
Josep Piguillem 8994590b54 feat: automation rules
Owners and admins can set rules of the kind «when a ticket is created,
moves, gets assigned, gets a comment or its pull request merges; if it is
of this type, priority, area or label; then move it, assign it, label it,
change its priority, comment or put it in the active sprint». Rules act as
TaskStream within the workflow, run once per event and trigger no others.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 15:53:31 +02:00

705 lines
23 KiB
TypeScript

import { CommandBus, CqrsModule, EventBus } from '@nestjs/cqrs';
import { Test } from '@nestjs/testing';
import {
AggregateRehydrator,
EventStore,
} from '@task-stream/api/shared/application';
import {
BusinessRuleError,
ConcurrencyError,
ForbiddenError,
NotFoundError,
} from '@task-stream/api/shared/domain';
import {
Comment,
Issue,
IssueStatus,
Project,
IssueType,
ProjectRole,
TRACKER_FACTORIES,
} from '@task-stream/api/tracker/domain';
import {
FakeUserDirectory,
InMemoryAttachmentStorage,
InMemoryEventStore,
InMemoryEventStorePublisher,
InMemoryIssueNumbers,
InMemoryProjectKeys,
InMemoryReleaseNames,
InMemoryReleaseIssues,
InMemoryTrackerStreams,
} from '../../testing/in-memory';
import { TrackerStreamsRepository } from '../ports/tracker-streams.repository';
import { FindSprintsRepository } from '../ports/find-sprints.repository';
import { FindIssuesRepository } from '../ports/find-issues.repository';
import {
CreateSprintCommand,
SetIssueSprintCommand,
StartSprintCommand,
} from './sprint.commands';
import { IssueTree } from './issue-tree';
import { GIT_HANDLERS, RecordGitMentionsCommand } from '../git/git';
import { RunAutomationCommand } from '../automations/automations';
import { RunAutomationCommandHandler } from '../automations/automations';
import {
AutomationActionKind,
AutomationTriggerKind,
} from '../automations/automation-rules';
import { Automation } from '../ports/automations.repository';
import { GitIntegrationsRepository } from '../ports/git-integrations.repository';
import { DevLinkKind } from '@task-stream/api/tracker/domain';
import { SetIssueParentCommand } from './set-issue-parent.command';
import { RankIssueCommand } from './rank-issue.command';
import {
AddChecklistItemCommand,
UpdateChecklistItemCommand,
} from './change-checklist.command';
import { ReleaseIssuesRepository } from '../ports/release-issues.repository';
import { IssueNumbersRepository } from '../ports/issue-numbers.repository';
import { ProjectKeysRepository } from '../ports/project-keys.repository';
import { ReleaseNamesRepository } from '../ports/release-names.repository';
import { UserDirectory } from '../ports/user-directory';
import { AttachmentStorage } from '../ports/attachment-storage';
import { AddCommentCommand } from './add-comment.command';
import { AddProjectMemberCommand } from './add-project-member.command';
import { AssignIssueCommand } from './assign-issue.command';
import { AttachFileCommand } from './attach-file.command';
import { DetachFileCommand } from './detach-file.command';
import { WatchIssueCommand } from './watch-issue.command';
import { ChangeIssueStatusCommand } from './change-issue-status.command';
import { COMMAND_HANDLERS } from './command-handlers';
import { CommandResult } from './command-result';
import { CreateIssueCommand } from './create-issue.command';
import { CreateProjectCommand } from './create-project.command';
import { CreateReleaseCommand } from './create-release.command';
import { DeleteCommentCommand } from './delete-comment.command';
import { PublishReleaseCommand } from './publish-release.command';
import { SetIssueReleaseCommand } from './set-issue-release.command';
import { WriteAccess } from './write-access';
import { ReadAccess } from '../queries/read-access';
import { FindProjectsRepository } from '../ports/find-projects.repository';
async function setup() {
const store = new InMemoryEventStore();
const keys = new Map<string, { id: string; projectId: string }>();
const sprints = {
active: null as { id: string; name: string } | null,
findActive: async () => sprints.active,
};
const files = new InMemoryAttachmentStorage();
const users = new FakeUserDirectory()
.add('owner')
.add('dev')
.add('qa')
.add('pm')
.add('gone', 'Gone', false);
const moduleRef = await Test.createTestingModule({
imports: [CqrsModule.forRoot()],
providers: [
...COMMAND_HANDLERS,
...GIT_HANDLERS,
RunAutomationCommandHandler,
...TRACKER_FACTORIES,
WriteAccess,
IssueTree,
ReadAccess,
{
provide: FindProjectsRepository,
useValue: { findRole: async () => null, findById: async () => null },
},
{ provide: GitIntegrationsRepository, useValue: {} },
AggregateRehydrator,
{ provide: EventStore, useValue: store },
{ provide: AttachmentStorage, useValue: files },
{ provide: UserDirectory, useValue: users },
{ provide: ProjectKeysRepository, useClass: InMemoryProjectKeys },
{ provide: IssueNumbersRepository, useClass: InMemoryIssueNumbers },
{ provide: ReleaseNamesRepository, useClass: InMemoryReleaseNames },
{
provide: ReleaseIssuesRepository,
useValue: new InMemoryReleaseIssues(store),
},
{
provide: TrackerStreamsRepository,
useValue: new InMemoryTrackerStreams(store),
},
// Read-model lookups: the sprint under way is set by each test.
{ provide: FindSprintsRepository, useValue: sprints },
{
provide: FindIssuesRepository,
useValue: {
// Tickets by key, as the read model would answer (filled by tests).
findByKey: async (key: string) => keys.get(key) ?? null,
sprintProgress: async () => ({
total: 0,
finished: 0,
percent: 0,
storyPoints: 0,
finishedStoryPoints: 0,
}),
},
},
],
}).compile();
await moduleRef.init();
InMemoryEventStorePublisher.install(moduleRef.get(EventBus), store);
const commandBus = moduleRef.get(CommandBus);
const run = (command: object): Promise<CommandResult> =>
commandBus.execute(command as never);
return {
store,
files,
sprints,
keys,
run,
rehydrator: moduleRef.get(AggregateRehydrator),
};
}
async function projectWithTeam(run: (c: object) => Promise<CommandResult>) {
const { id } = await run(
new CreateProjectCommand('owner', 'ts', 'Task Stream'),
);
await run(
new AddProjectMemberCommand('owner', id, 'dev', ProjectRole.MEMBER),
);
await run(new AddProjectMemberCommand('owner', id, 'qa', ProjectRole.MEMBER));
await run(new AddProjectMemberCommand('owner', id, 'pm', ProjectRole.VIEWER));
return id;
}
describe('tracker commands (event sourced)', () => {
it('appends events to the store and returns the new version', async () => {
const { store, run } = await setup();
const created = await run(
new CreateProjectCommand('owner', 'ts', 'Task Stream'),
);
const added = await run(
new AddProjectMemberCommand(
'owner',
created.id,
'dev',
ProjectRole.MEMBER,
),
);
expect(created).toMatchObject({ key: 'TS', version: 1 });
expect(added.version).toBe(2);
expect(store.types()).toEqual([
'ProjectCreatedEvent',
'ProjectMemberAddedEvent',
]);
});
it('rejects duplicated project keys', async () => {
const { run } = await setup();
await run(new CreateProjectCommand('owner', 'TS', 'One'));
await expect(
run(new CreateProjectCommand('dev', 'ts', 'Two')),
).rejects.toThrow(BusinessRuleError);
});
it('only adds known, active Pireon users', async () => {
const { run } = await setup();
const projectId = await projectWithTeam(run);
await expect(
run(
new AddProjectMemberCommand(
'owner',
projectId,
'ghost',
ProjectRole.MEMBER,
),
),
).rejects.toThrow(NotFoundError);
await expect(
run(
new AddProjectMemberCommand(
'owner',
projectId,
'gone',
ProjectRole.MEMBER,
),
),
).rejects.toThrow(/deactivated/);
});
it('numbers tickets per project and assigns members only', async () => {
const { run } = await setup();
const projectId = await projectWithTeam(run);
const first = await run(
new CreateIssueCommand(
'qa',
projectId,
IssueType.BUG,
'Crash on login',
{},
'dev',
),
);
const second = await run(
new CreateIssueCommand(
'dev',
projectId,
IssueType.FEATURE,
'Dark mode',
{},
),
);
expect([first.key, second.key]).toEqual(['TS-1', 'TS-2']);
await expect(
run(new AssignIssueCommand('dev', second.id, 'stranger')),
).rejects.toThrow(BusinessRuleError);
});
describe('attachments', () => {
const png = {
fileName: 'captura.png',
mimeType: 'image/png',
content: Buffer.from('fake png'),
};
it('stores the file and records it on the ticket', async () => {
const { run, files, rehydrator } = await setup();
const projectId = await projectWithTeam(run);
const issue = await run(
new CreateIssueCommand('qa', projectId, IssueType.BUG, 'Crash', {}),
);
await run(new AttachFileCommand('qa', issue.id, png));
const rebuilt = await rehydrator.rehydrate(issue.id, Issue);
expect(rebuilt.attachments).toHaveLength(1);
expect(rebuilt.attachments[0]).toMatchObject({
fileName: 'captura.png',
size: 8,
uploadedBy: 'qa',
});
expect([...files.files.keys()]).toEqual([
`${projectId}/${issue.id}/${rebuilt.attachments[0].id}`,
]);
});
it('keeps no file when the ticket cannot take it', async () => {
const { run, files } = await setup();
const projectId = await projectWithTeam(run);
const issue = await run(
new CreateIssueCommand('qa', projectId, IssueType.BUG, 'Crash', {}),
);
await expect(
run(new AttachFileCommand('pm', issue.id, png)),
).rejects.toThrow(ForbiddenError);
await expect(
run(
new AttachFileCommand('qa', issue.id, {
...png,
content: Buffer.alloc(0),
}),
),
).rejects.toThrow();
expect(files.files.size).toBe(0);
});
it('lets the uploader or an admin remove it, and deletes the bytes', async () => {
const { run, files, rehydrator } = await setup();
const projectId = await projectWithTeam(run);
const issue = await run(
new CreateIssueCommand('qa', projectId, IssueType.BUG, 'Crash', {}),
);
await run(new AttachFileCommand('qa', issue.id, png));
const [attachment] = (await rehydrator.rehydrate(issue.id, Issue))
.attachments;
await expect(
run(new DetachFileCommand('dev', issue.id, attachment.id)),
).rejects.toThrow(ForbiddenError);
await run(new DetachFileCommand('owner', issue.id, attachment.id));
expect(files.files.size).toBe(0);
});
});
it('lets any member, viewers included, follow a ticket for themselves', async () => {
const { run, rehydrator } = await setup();
const projectId = await projectWithTeam(run);
const issue = await run(
new CreateIssueCommand('qa', projectId, IssueType.BUG, 'Crash', {}),
);
await run(new WatchIssueCommand('pm', issue.id, 'pm'));
await run(new WatchIssueCommand('dev', issue.id, 'dev'));
await run(new WatchIssueCommand('dev', issue.id, 'dev', false));
expect((await rehydrator.rehydrate(issue.id, Issue)).watchers).toEqual([
'pm',
]);
await expect(
run(new WatchIssueCommand('dev', issue.id, 'pm', false)),
).rejects.toThrow(ForbiddenError);
await expect(
run(new WatchIssueCommand('gone', issue.id, 'gone')),
).rejects.toThrow(ForbiddenError);
});
describe('epics, subtasks and checklists', () => {
const create = (
run: (c: object) => Promise<CommandResult>,
projectId: string,
title: string,
parentId?: string,
) =>
run(
new CreateIssueCommand(
'dev',
projectId,
IssueType.TASK,
title,
{},
null,
null,
parentId,
),
);
it('nests tickets three levels deep at most, without loops', async () => {
const { run, rehydrator } = await setup();
const projectId = await projectWithTeam(run);
const epic = await create(run, projectId, 'Checkout');
const story = await create(run, projectId, 'Pay by card', epic.id);
const subtask = await create(run, projectId, 'Stripe form', story.id);
const loose = await create(run, projectId, 'Loose ticket');
expect((await rehydrator.rehydrate(subtask.id, Issue)).parentId).toBe(
story.id,
);
// A fourth level, and a loop.
await expect(
create(run, projectId, 'Too deep', subtask.id),
).rejects.toThrow(/at most 2 levels/);
await expect(
run(new SetIssueParentCommand('dev', epic.id, subtask.id)),
).rejects.toThrow(BusinessRuleError);
// A ticket with subtasks cannot go under one that has a parent.
await run(new SetIssueParentCommand('dev', loose.id, epic.id));
await expect(
run(new SetIssueParentCommand('dev', story.id, loose.id)),
).rejects.toThrow(/at most 2 levels/);
// ...but it can move to another epic, and leave.
await run(new SetIssueParentCommand('dev', loose.id, null));
await run(new SetIssueParentCommand('dev', story.id, loose.id));
expect((await rehydrator.rehydrate(story.id, Issue)).parentId).toBe(
loose.id,
);
await expect(
run(new SetIssueParentCommand('pm', story.id, null)),
).rejects.toThrow(ForbiddenError);
});
it('keeps a checklist on the ticket', async () => {
const { run, rehydrator } = await setup();
const projectId = await projectWithTeam(run);
const issue = await create(run, projectId, 'Release 1.0');
await run(new AddChecklistItemCommand('dev', issue.id, 'Changelog'));
await run(new AddChecklistItemCommand('qa', issue.id, 'Smoke test'));
const [first] = (await rehydrator.rehydrate(issue.id, Issue)).checklist;
await run(
new UpdateChecklistItemCommand('qa', issue.id, first.id, {
done: true,
}),
);
const { checklist } = await rehydrator.rehydrate(issue.id, Issue);
expect(checklist.map((i) => [i.text, i.done])).toEqual([
['Changelog', true],
['Smoke test', false],
]);
await expect(
run(new AddChecklistItemCommand('pm', issue.id, 'Nope')),
).rejects.toThrow(ForbiddenError);
});
});
it('orders the backlog from the ranks in the event store', async () => {
const { run, rehydrator } = await setup();
const projectId = await projectWithTeam(run);
const ids = [];
for (const title of ['A', 'B', 'C'])
ids.push(
(
await run(
new CreateIssueCommand('dev', projectId, IssueType.TASK, title, {}),
)
).id,
);
const [a, b, c] = ids;
// C to the top, then B between C and A: C, B, A.
await run(new RankIssueCommand('dev', c, null, a));
await run(new RankIssueCommand('dev', b, c, a));
const ranks = await Promise.all(
[c, b, a].map(
async (id) => (await rehydrator.rehydrate(id, Issue)).backlogRank,
),
);
expect([...ranks].sort()).toEqual(ranks);
// Neighbours given the wrong way round: the order changed meanwhile.
await expect(run(new RankIssueCommand('dev', a, b, c))).rejects.toThrow(
BusinessRuleError,
);
await expect(run(new RankIssueCommand('pm', a, null, c))).rejects.toThrow(
ForbiddenError,
);
});
it('plans and starts sprints, one under way at a time, and fills them', async () => {
const { run, rehydrator, sprints } = await setup();
const projectId = await projectWithTeam(run);
const plan = (name: string) =>
run(
new CreateSprintCommand(
'owner',
projectId,
name,
'2026-10-12',
'2026-10-23',
),
);
const first = await plan('Sprint 1');
const second = await plan('Sprint 2');
await expect(
run(
new CreateSprintCommand(
'dev',
projectId,
'X',
'2026-10-12',
'2026-10-23',
),
),
).rejects.toThrow(ForbiddenError);
await run(new StartSprintCommand('owner', first.id));
sprints.active = { id: first.id, name: 'Sprint 1' };
await expect(
run(new StartSprintCommand('owner', second.id)),
).rejects.toThrow(/Sprint 1 is under way/);
const issue = await run(
new CreateIssueCommand('dev', projectId, IssueType.TASK, 'Login', {}),
);
await run(new SetIssueSprintCommand('dev', issue.id, first.id));
expect((await rehydrator.rehydrate(issue.id, Issue)).sprintId).toBe(
first.id,
);
await expect(
run(new SetIssueSprintCommand('pm', issue.id, null)),
).rejects.toThrow(ForbiddenError);
});
it('links commits and pull requests to the tickets they name', async () => {
const { run, rehydrator, keys } = await setup();
const projectId = await projectWithTeam(run);
const issue = await run(
new CreateIssueCommand('dev', projectId, IssueType.TASK, 'Pay', {}),
);
keys.set(issue.key ?? '', { id: issue.id, projectId });
const commit = {
text: `${issue.key}: card form (refs TS-999, OTHER-1)`,
link: {
kind: DevLinkKind.COMMIT,
url: 'https://git.example.org/acme/web/commit/abc',
title: 'card form',
ref: 'abc',
state: null,
provider: 'gitea',
author: 'Ana',
at: '2026-10-08T10:00:00Z',
},
};
expect(
await run(new RecordGitMentionsCommand(projectId, [commit, commit])),
).toBe(1);
const { devLinks } = await rehydrator.rehydrate(issue.id, Issue);
expect(devLinks.map((l) => l.ref)).toEqual(['abc']);
});
it('runs automation actions as TaskStream, within the workflow', async () => {
const { run, rehydrator, store } = await setup();
const projectId = await projectWithTeam(run);
const issue = await run(
new CreateIssueCommand('dev', projectId, IssueType.BUG, 'Crash', {}),
);
const rule = (kind: AutomationActionKind, value: string | null) =>
({
id: 'r1',
projectId,
name: 'R',
active: true,
trigger: { kind: AutomationTriggerKind.ISSUE_CREATED },
conditions: {},
action: { kind, value },
}) as Automation;
await run(
new RunAutomationCommand(
rule(AutomationActionKind.SET_STATUS, 'TODO'),
issue.id,
),
);
await run(
new RunAutomationCommand(
rule(AutomationActionKind.ADD_LABEL, 'triage'),
issue.id,
),
);
await run(
new RunAutomationCommand(
rule(AutomationActionKind.ASSIGN_REPORTER, null),
issue.id,
),
);
await run(
new RunAutomationCommand(
rule(AutomationActionKind.ADD_COMMENT, 'Gracias'),
issue.id,
),
);
const rebuilt = await rehydrator.rehydrate(issue.id, Issue);
expect([rebuilt.status, rebuilt.labels, rebuilt.assigneeId]).toEqual([
'TODO',
['triage'],
'dev',
]);
expect(store.types()).toContain('CommentAddedEvent');
// The workflow still decides: TODO cannot jump to DONE.
await expect(
run(
new RunAutomationCommand(
rule(AutomationActionKind.SET_STATUS, 'DONE'),
issue.id,
),
),
).rejects.toThrow(BusinessRuleError);
expect(
await run(
new RunAutomationCommand(
rule(AutomationActionKind.ADD_TO_ACTIVE_SPRINT, null),
issue.id,
),
),
).toBe('No sprint is under way');
});
it('does not let viewers modify tickets', async () => {
const { run } = await setup();
const projectId = await projectWithTeam(run);
await expect(
run(new CreateIssueCommand('pm', projectId, IssueType.TASK, 'Nope', {})),
).rejects.toThrow(ForbiddenError);
});
it('rebuilds the issue from its events on every command (QA round trip)', async () => {
const { run, store } = await setup();
const projectId = await projectWithTeam(run);
const { id } = await run(
new CreateIssueCommand('qa', projectId, IssueType.BUG, 'Crash', {}),
);
for (const status of [
IssueStatus.TODO,
IssueStatus.IN_PROGRESS,
IssueStatus.READY_FOR_QA,
IssueStatus.IN_QA,
]) {
await run(new ChangeIssueStatusCommand('dev', id, status));
}
const failed = await run(
new ChangeIssueStatusCommand(
'qa',
id,
IssueStatus.QA_FAILED,
'Still crashes on iOS',
),
);
expect(failed.version).toBe(6);
expect(store.events.at(-1)?.data).toMatchObject({
from: IssueStatus.IN_QA,
to: IssueStatus.QA_FAILED,
reason: 'Still crashes on iOS',
aggregateVersion: 6,
});
});
it('controls which tickets ship in a release', async () => {
const { run } = await setup();
const projectId = await projectWithTeam(run);
const release = await run(
new CreateReleaseCommand('owner', projectId, 'v1.0.0'),
);
const issue = await run(
new CreateIssueCommand(
'dev',
projectId,
IssueType.FEATURE,
'Export CSV',
{},
null,
release.id,
),
);
await expect(
run(new PublishReleaseCommand('owner', release.id)),
).rejects.toThrow(/unfinished/);
await expect(
run(new PublishReleaseCommand('dev', release.id, true)),
).rejects.toThrow(ForbiddenError);
await run(new SetIssueReleaseCommand('dev', issue.id, null));
await run(new PublishReleaseCommand('owner', release.id));
await expect(
run(new SetIssueReleaseCommand('dev', issue.id, release.id)),
).rejects.toThrow(/locked/);
});
it('lets admins moderate comments', async () => {
const { run, store } = await setup();
const projectId = await projectWithTeam(run);
const issue = await run(
new CreateIssueCommand('dev', projectId, IssueType.TASK, 'Spike', {}),
);
const comment = await run(
new AddCommentCommand('dev', issue.id, 'Decision: use Redis streams'),
);
await expect(
run(new DeleteCommentCommand('qa', comment.id)),
).rejects.toThrow(ForbiddenError);
await run(new DeleteCommentCommand('owner', comment.id));
const [rebuilt] = await store.all(Comment, 'Comment');
expect(rebuilt.deleted).toBe(true);
});
it('detects concurrent modifications through stream positions', async () => {
const { run, rehydrator } = await setup();
const projectId = await projectWithTeam(run);
// Two requests load the same version of the project at the same time...
const first = await rehydrator.rehydrate(projectId, Project);
const second = await rehydrator.rehydrate(projectId, Project);
first.updateDetails('owner', { name: 'Renamed by first' });
second.updateDetails('owner', { name: 'Renamed by second' });
// ...the first one wins, the second is stale.
await first.commitAndWait();
await expect(second.commitAndWait()).rejects.toThrow(ConcurrencyError);
expect(first.version.value).toBe(5);
});
});