Files
task-stream/libs/api/tracker/application/src/lib/commands/commands.spec.ts
Josep Piguillem 88928b402f feat: mentions and following tickets
Mention project members in comments with @, follow and unfollow tickets,
and get told when something changes on the tickets you follow. Commenting
on a ticket starts following it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 09:41:32 +02:00

391 lines
12 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,
} from '../../testing/in-memory';
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';
async function setup() {
const store = new InMemoryEventStore();
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,
...TRACKER_FACTORIES,
WriteAccess,
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),
},
],
}).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,
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);
});
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);
});
});