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, ValidationError, } from '@task-stream/api/shared/domain'; import { Comment, Issue, IssueStatus, Project, IssueType, ProjectRole, TRACKER_FACTORIES, defaultProjectSettings, } 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 { AddGitRepositoryCommand, GIT_HANDLERS, GitRepository, RecordGitMentionsCommand, RemoveGitRepositoryCommand, RotateGitRepositorySecretCommand, } 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, GitRepositoryConfig, } 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 { RestoreIssueCommand, TrashIssueCommand } from './trash'; import { BulkResult, BulkUpdateIssuesCommand } from './bulk-update-issues'; import { UpdateProjectSettingsCommand } from './update-project-settings.command'; import { ReadAccess } from '../queries/read-access'; import { FindProjectsRepository } from '../ports/find-projects.repository'; async function setup() { const store = new InMemoryEventStore(); // The project's Git repositories, as Mongo would keep them. const gitRepositories = new Map(); const git: GitIntegrationsRepository = { repositoriesOf: async (projectId) => gitRepositories.get(projectId) ?? [], saveAll: async (projectId, list) => { gitRepositories.set(projectId, list); }, }; const keys = new Map(); 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: git }, 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 => commandBus.execute(command as never); return { store, files, sprints, keys, git, 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); }); describe('epics, subtasks and checklists', () => { const create = ( run: (c: object) => Promise, 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', repository: null, author: 'Ana', at: '2026-10-08T10:00:00Z', }, }; expect( await run( new RecordGitMentionsCommand(projectId, 'web', [commit, commit]), ), ).toBe(1); const { devLinks } = await rehydrator.rehydrate(issue.id, Issue); expect(devLinks.map((l) => [l.ref, l.repository])).toEqual([ ['abc', 'web'], ]); }); it('keeps several Git repositories, each with its own secret', async () => { const { run, git } = await setup(); const projectId = await projectWithTeam(run); const add = (actor: string, name: string) => run( new AddGitRepositoryCommand(actor, projectId, name), ) as unknown as Promise; const api = await add('owner', 'api'); const web = await add('owner', ' web '); expect(web.name).toBe('web'); expect(api.secret).toMatch(/^[0-9a-f]{48}$/); expect(web.secret).not.toBe(api.secret); await expect(add('owner', 'API')).rejects.toThrow(/already/); await expect(add('owner', '')).rejects.toThrow(ValidationError); await expect(add('dev', 'admin')).rejects.toThrow(ForbiddenError); const rotated = (await run( new RotateGitRepositorySecretCommand('owner', projectId, api.id), )) as unknown as GitRepository; expect(rotated.secret).not.toBe(api.secret); await run(new RemoveGitRepositoryCommand('owner', projectId, web.id)); await expect( run(new RemoveGitRepositoryCommand('owner', projectId, web.id)), ).rejects.toThrow(NotFoundError); expect(await git.repositoriesOf(projectId)).toEqual([ expect.objectContaining({ name: 'api', secret: rotated.secret }), ]); }); 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("fills a new ticket with its type's template", async () => { const { run, rehydrator } = await setup(); const projectId = await projectWithTeam(run); const settings = defaultProjectSettings(); settings.issueTypes[0].template = { description: '## Pasos para reproducir', checklist: ['Probar en Safari', 'Adjuntar captura'], }; await run(new UpdateProjectSettingsCommand('owner', projectId, settings)); const plain = await run( new CreateIssueCommand('qa', projectId, IssueType.BUG, 'Crash', {}), ); const written = await run( new CreateIssueCommand('qa', projectId, IssueType.BUG, 'Crash 2', { description: 'Ya lo explico yo', }), ); const first = await rehydrator.rehydrate(plain.id, Issue); expect(first.description).toBe('## Pasos para reproducir'); expect(first.checklist.map((i) => i.text)).toEqual([ 'Probar en Safari', 'Adjuntar captura', ]); expect((await rehydrator.rehydrate(written.id, Issue)).description).toBe( 'Ya lo explico yo', ); }); it('changes several tickets at once and says which refused', async () => { const { run, rehydrator } = await setup(); const projectId = await projectWithTeam(run); const ids: string[] = []; for (const title of ['A', 'B']) ids.push( ( await run( new CreateIssueCommand('dev', projectId, IssueType.TASK, title, {}), ) ).id, ); for (const status of ['TODO', 'IN_PROGRESS']) await run(new ChangeIssueStatusCommand('dev', ids[1], status)); // A is in BACKLOG: the workflow does not let it jump to review. const result = (await run( new BulkUpdateIssuesCommand('dev', [...ids, ids[1]], { status: 'IN_REVIEW', assigneeId: 'qa', addLabels: ['sprint-14'], }), )) as unknown as BulkResult; expect(result.updated).toBe(1); expect(result.results.map((r) => [r.issueId, r.ok])).toEqual([ [ids[0], false], [ids[1], true], ]); const b = await rehydrator.rehydrate(ids[1], Issue); expect([b.status, b.assigneeId, b.labels]).toEqual([ 'IN_REVIEW', 'qa', ['sprint-14'], ]); await expect( run(new BulkUpdateIssuesCommand('dev', ids, {})), ).rejects.toThrow(/Nothing to change/); }); it('sends tickets to the trash and back; trashed ones cannot change', async () => { const { run, rehydrator } = await setup(); const projectId = await projectWithTeam(run); const issue = await run( new CreateIssueCommand('qa', projectId, IssueType.BUG, 'Crash', {}), ); await expect(run(new TrashIssueCommand('dev', issue.id))).rejects.toThrow( ForbiddenError, ); await run(new TrashIssueCommand('qa', issue.id)); await expect( run(new AssignIssueCommand('owner', issue.id, 'dev')), ).rejects.toThrow(/trash/); await run(new RestoreIssueCommand('owner', issue.id)); await run(new AssignIssueCommand('owner', issue.id, 'dev')); expect((await rehydrator.rehydrate(issue.id, Issue)).trashedAt).toBeNull(); }); 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); }); });