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 => commandBus.execute(command as never); return { store, files, run, rehydrator: moduleRef.get(AggregateRehydrator), }; } async function projectWithTeam(run: (c: object) => Promise) { 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); }); });