first commit
This commit is contained in:
287
libs/api/tracker/application/src/lib/commands/commands.spec.ts
Normal file
287
libs/api/tracker/application/src/lib/commands/commands.spec.ts
Normal file
@@ -0,0 +1,287 @@
|
||||
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,
|
||||
IssueStatus,
|
||||
Project,
|
||||
IssueType,
|
||||
ProjectRole,
|
||||
TRACKER_FACTORIES,
|
||||
} from '@task-stream/api/tracker/domain';
|
||||
import {
|
||||
FakeUserDirectory,
|
||||
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 { AddCommentCommand } from './add-comment.command';
|
||||
import { AddProjectMemberCommand } from './add-project-member.command';
|
||||
import { AssignIssueCommand } from './assign-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 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: 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, 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);
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user