first commit

This commit is contained in:
2026-10-03 13:40:26 +02:00
parent 732a5b5a16
commit 566f415fa7
555 changed files with 62704 additions and 0 deletions

View 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);
});
});