From 12a22aa8920f4afc2584d676257481f15ac792f2 Mon Sep 17 00:00:00 2001 From: Snider Date: Mon, 16 Mar 2026 05:55:15 +0000 Subject: [PATCH] =?UTF-8?q?test(issues):=20phase=205=20=E2=80=94=20feature?= =?UTF-8?q?=20tests=20for=20Issue=20and=20Sprint=20models=20and=20actions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit IssueTest covers: model CRUD, status transitions, slug generation, sprint association, comments, label management, scopes, MCP context, and all Issue Action classes with validation. SprintTest covers: model lifecycle, progress calculation, scopes, MCP context, and all Sprint Action classes with validation. Co-Authored-By: Virgil --- src/php/tests/Feature/IssueTest.php | 368 +++++++++++++++++++++++++++ src/php/tests/Feature/SprintTest.php | 286 +++++++++++++++++++++ 2 files changed, 654 insertions(+) create mode 100644 src/php/tests/Feature/IssueTest.php create mode 100644 src/php/tests/Feature/SprintTest.php diff --git a/src/php/tests/Feature/IssueTest.php b/src/php/tests/Feature/IssueTest.php new file mode 100644 index 0000000..224f01c --- /dev/null +++ b/src/php/tests/Feature/IssueTest.php @@ -0,0 +1,368 @@ +workspace = Workspace::factory()->create(); + } + + // -- Model tests -- + + public function test_issue_can_be_created(): void + { + $issue = Issue::create([ + 'workspace_id' => $this->workspace->id, + 'slug' => 'test-issue', + 'title' => 'Test Issue', + 'type' => Issue::TYPE_BUG, + 'status' => Issue::STATUS_OPEN, + 'priority' => Issue::PRIORITY_HIGH, + ]); + + $this->assertDatabaseHas('issues', [ + 'id' => $issue->id, + 'slug' => 'test-issue', + 'type' => 'bug', + 'priority' => 'high', + ]); + } + + public function test_issue_has_correct_default_status(): void + { + $issue = Issue::create([ + 'workspace_id' => $this->workspace->id, + 'slug' => 'defaults-test', + 'title' => 'Defaults', + ]); + + $this->assertEquals(Issue::STATUS_OPEN, $issue->fresh()->status); + } + + public function test_issue_can_be_closed(): void + { + $issue = Issue::create([ + 'workspace_id' => $this->workspace->id, + 'slug' => 'close-test', + 'title' => 'Close Me', + 'status' => Issue::STATUS_OPEN, + ]); + + $issue->close(); + + $fresh = $issue->fresh(); + $this->assertEquals(Issue::STATUS_CLOSED, $fresh->status); + $this->assertNotNull($fresh->closed_at); + } + + public function test_issue_can_be_reopened(): void + { + $issue = Issue::create([ + 'workspace_id' => $this->workspace->id, + 'slug' => 'reopen-test', + 'title' => 'Reopen Me', + 'status' => Issue::STATUS_CLOSED, + 'closed_at' => now(), + ]); + + $issue->reopen(); + + $fresh = $issue->fresh(); + $this->assertEquals(Issue::STATUS_OPEN, $fresh->status); + $this->assertNull($fresh->closed_at); + } + + public function test_issue_can_be_archived_with_reason(): void + { + $issue = Issue::create([ + 'workspace_id' => $this->workspace->id, + 'slug' => 'archive-test', + 'title' => 'Archive Me', + ]); + + $issue->archive('Duplicate'); + + $fresh = $issue->fresh(); + $this->assertEquals(Issue::STATUS_CLOSED, $fresh->status); + $this->assertNotNull($fresh->archived_at); + $this->assertEquals('Duplicate', $fresh->metadata['archive_reason']); + } + + public function test_issue_generates_unique_slugs(): void + { + Issue::create([ + 'workspace_id' => $this->workspace->id, + 'slug' => 'my-issue', + 'title' => 'My Issue', + ]); + + $slug = Issue::generateSlug('My Issue'); + + $this->assertEquals('my-issue-1', $slug); + } + + public function test_issue_belongs_to_sprint(): void + { + $sprint = Sprint::create([ + 'workspace_id' => $this->workspace->id, + 'slug' => 'sprint-1', + 'title' => 'Sprint 1', + ]); + + $issue = Issue::create([ + 'workspace_id' => $this->workspace->id, + 'sprint_id' => $sprint->id, + 'slug' => 'sprint-issue', + 'title' => 'Sprint Issue', + ]); + + $this->assertEquals($sprint->id, $issue->sprint->id); + } + + public function test_issue_has_comments(): void + { + $issue = Issue::create([ + 'workspace_id' => $this->workspace->id, + 'slug' => 'commented', + 'title' => 'Has Comments', + ]); + + IssueComment::create([ + 'issue_id' => $issue->id, + 'author' => 'claude', + 'body' => 'Investigating.', + ]); + + $this->assertCount(1, $issue->fresh()->comments); + } + + public function test_issue_label_management(): void + { + $issue = Issue::create([ + 'workspace_id' => $this->workspace->id, + 'slug' => 'label-test', + 'title' => 'Labels', + 'labels' => [], + ]); + + $issue->addLabel('agentic'); + $this->assertContains('agentic', $issue->fresh()->labels); + + $issue->addLabel('agentic'); // duplicate — should not add + $this->assertCount(1, $issue->fresh()->labels); + + $issue->removeLabel('agentic'); + $this->assertEmpty($issue->fresh()->labels); + } + + public function test_issue_scopes(): void + { + Issue::create(['workspace_id' => $this->workspace->id, 'slug' => 'open-1', 'title' => 'A', 'status' => Issue::STATUS_OPEN]); + Issue::create(['workspace_id' => $this->workspace->id, 'slug' => 'ip-1', 'title' => 'B', 'status' => Issue::STATUS_IN_PROGRESS]); + Issue::create(['workspace_id' => $this->workspace->id, 'slug' => 'closed-1', 'title' => 'C', 'status' => Issue::STATUS_CLOSED]); + + $this->assertCount(1, Issue::open()->get()); + $this->assertCount(1, Issue::inProgress()->get()); + $this->assertCount(1, Issue::closed()->get()); + $this->assertCount(2, Issue::notClosed()->get()); + } + + public function test_issue_to_mcp_context(): void + { + $issue = Issue::create([ + 'workspace_id' => $this->workspace->id, + 'slug' => 'mcp-test', + 'title' => 'MCP Context', + 'type' => Issue::TYPE_FEATURE, + ]); + + $context = $issue->toMcpContext(); + + $this->assertIsArray($context); + $this->assertEquals('mcp-test', $context['slug']); + $this->assertEquals('feature', $context['type']); + $this->assertArrayHasKey('status', $context); + $this->assertArrayHasKey('priority', $context); + $this->assertArrayHasKey('labels', $context); + } + + // -- Action tests -- + + public function test_create_issue_action(): void + { + $issue = CreateIssue::run([ + 'title' => 'New Bug', + 'type' => 'bug', + 'priority' => 'high', + 'labels' => ['agentic'], + ], $this->workspace->id); + + $this->assertInstanceOf(Issue::class, $issue); + $this->assertEquals('New Bug', $issue->title); + $this->assertEquals('bug', $issue->type); + $this->assertEquals('high', $issue->priority); + $this->assertEquals(Issue::STATUS_OPEN, $issue->status); + } + + public function test_create_issue_action_validates_title(): void + { + $this->expectException(\InvalidArgumentException::class); + + CreateIssue::run(['title' => ''], $this->workspace->id); + } + + public function test_create_issue_action_validates_type(): void + { + $this->expectException(\InvalidArgumentException::class); + + CreateIssue::run([ + 'title' => 'Bad Type', + 'type' => 'invalid', + ], $this->workspace->id); + } + + public function test_get_issue_action(): void + { + $created = Issue::create([ + 'workspace_id' => $this->workspace->id, + 'slug' => 'get-test', + 'title' => 'Get Me', + ]); + + $found = GetIssue::run('get-test', $this->workspace->id); + + $this->assertEquals($created->id, $found->id); + } + + public function test_get_issue_action_throws_for_missing(): void + { + $this->expectException(\InvalidArgumentException::class); + + GetIssue::run('nonexistent', $this->workspace->id); + } + + public function test_list_issues_action(): void + { + Issue::create(['workspace_id' => $this->workspace->id, 'slug' => 'list-1', 'title' => 'A', 'status' => Issue::STATUS_OPEN]); + Issue::create(['workspace_id' => $this->workspace->id, 'slug' => 'list-2', 'title' => 'B', 'status' => Issue::STATUS_CLOSED]); + + $all = ListIssues::run($this->workspace->id, includeClosed: true); + $this->assertCount(2, $all); + + $open = ListIssues::run($this->workspace->id); + $this->assertCount(1, $open); + } + + public function test_list_issues_filters_by_type(): void + { + Issue::create(['workspace_id' => $this->workspace->id, 'slug' => 'bug-1', 'title' => 'Bug', 'type' => Issue::TYPE_BUG]); + Issue::create(['workspace_id' => $this->workspace->id, 'slug' => 'feat-1', 'title' => 'Feature', 'type' => Issue::TYPE_FEATURE]); + + $bugs = ListIssues::run($this->workspace->id, type: 'bug'); + $this->assertCount(1, $bugs); + $this->assertEquals('bug', $bugs->first()->type); + } + + public function test_update_issue_action(): void + { + Issue::create([ + 'workspace_id' => $this->workspace->id, + 'slug' => 'update-test', + 'title' => 'Update Me', + 'status' => Issue::STATUS_OPEN, + ]); + + $updated = UpdateIssue::run('update-test', [ + 'status' => Issue::STATUS_IN_PROGRESS, + 'priority' => Issue::PRIORITY_URGENT, + ], $this->workspace->id); + + $this->assertEquals(Issue::STATUS_IN_PROGRESS, $updated->status); + $this->assertEquals(Issue::PRIORITY_URGENT, $updated->priority); + } + + public function test_update_issue_sets_closed_at_when_closing(): void + { + Issue::create([ + 'workspace_id' => $this->workspace->id, + 'slug' => 'close-via-update', + 'title' => 'Close Me', + 'status' => Issue::STATUS_OPEN, + ]); + + $updated = UpdateIssue::run('close-via-update', [ + 'status' => Issue::STATUS_CLOSED, + ], $this->workspace->id); + + $this->assertNotNull($updated->closed_at); + } + + public function test_archive_issue_action(): void + { + Issue::create([ + 'workspace_id' => $this->workspace->id, + 'slug' => 'archive-action', + 'title' => 'Archive Me', + ]); + + $archived = ArchiveIssue::run('archive-action', $this->workspace->id, 'Not needed'); + + $this->assertNotNull($archived->archived_at); + $this->assertEquals(Issue::STATUS_CLOSED, $archived->status); + } + + public function test_add_issue_comment_action(): void + { + Issue::create([ + 'workspace_id' => $this->workspace->id, + 'slug' => 'comment-action', + 'title' => 'Comment On Me', + ]); + + $comment = AddIssueComment::run( + 'comment-action', + $this->workspace->id, + 'gemini', + 'Found the root cause.', + ); + + $this->assertInstanceOf(IssueComment::class, $comment); + $this->assertEquals('gemini', $comment->author); + $this->assertEquals('Found the root cause.', $comment->body); + } + + public function test_add_comment_validates_empty_body(): void + { + Issue::create([ + 'workspace_id' => $this->workspace->id, + 'slug' => 'empty-comment', + 'title' => 'Empty Comment', + ]); + + $this->expectException(\InvalidArgumentException::class); + + AddIssueComment::run('empty-comment', $this->workspace->id, 'claude', ''); + } +} diff --git a/src/php/tests/Feature/SprintTest.php b/src/php/tests/Feature/SprintTest.php new file mode 100644 index 0000000..9df35af --- /dev/null +++ b/src/php/tests/Feature/SprintTest.php @@ -0,0 +1,286 @@ +workspace = Workspace::factory()->create(); + } + + // -- Model tests -- + + public function test_sprint_can_be_created(): void + { + $sprint = Sprint::create([ + 'workspace_id' => $this->workspace->id, + 'slug' => 'sprint-1', + 'title' => 'Sprint 1', + 'goal' => 'Ship MVP', + ]); + + $this->assertDatabaseHas('sprints', [ + 'id' => $sprint->id, + 'slug' => 'sprint-1', + 'goal' => 'Ship MVP', + ]); + } + + public function test_sprint_has_default_planning_status(): void + { + $sprint = Sprint::create([ + 'workspace_id' => $this->workspace->id, + 'slug' => 'defaults', + 'title' => 'Defaults', + ]); + + $this->assertEquals(Sprint::STATUS_PLANNING, $sprint->fresh()->status); + } + + public function test_sprint_can_be_activated(): void + { + $sprint = Sprint::create([ + 'workspace_id' => $this->workspace->id, + 'slug' => 'activate-test', + 'title' => 'Activate', + 'status' => Sprint::STATUS_PLANNING, + ]); + + $sprint->activate(); + + $fresh = $sprint->fresh(); + $this->assertEquals(Sprint::STATUS_ACTIVE, $fresh->status); + $this->assertNotNull($fresh->started_at); + } + + public function test_sprint_can_be_completed(): void + { + $sprint = Sprint::create([ + 'workspace_id' => $this->workspace->id, + 'slug' => 'complete-test', + 'title' => 'Complete', + 'status' => Sprint::STATUS_ACTIVE, + ]); + + $sprint->complete(); + + $fresh = $sprint->fresh(); + $this->assertEquals(Sprint::STATUS_COMPLETED, $fresh->status); + $this->assertNotNull($fresh->ended_at); + } + + public function test_sprint_can_be_cancelled_with_reason(): void + { + $sprint = Sprint::create([ + 'workspace_id' => $this->workspace->id, + 'slug' => 'cancel-test', + 'title' => 'Cancel', + ]); + + $sprint->cancel('Scope changed'); + + $fresh = $sprint->fresh(); + $this->assertEquals(Sprint::STATUS_CANCELLED, $fresh->status); + $this->assertNotNull($fresh->ended_at); + $this->assertNotNull($fresh->archived_at); + $this->assertEquals('Scope changed', $fresh->metadata['cancel_reason']); + } + + public function test_sprint_generates_unique_slugs(): void + { + Sprint::create([ + 'workspace_id' => $this->workspace->id, + 'slug' => 'sprint-1', + 'title' => 'Sprint 1', + ]); + + $slug = Sprint::generateSlug('Sprint 1'); + + $this->assertEquals('sprint-1-1', $slug); + } + + public function test_sprint_has_issues(): void + { + $sprint = Sprint::create([ + 'workspace_id' => $this->workspace->id, + 'slug' => 'with-issues', + 'title' => 'Has Issues', + ]); + + Issue::create([ + 'workspace_id' => $this->workspace->id, + 'sprint_id' => $sprint->id, + 'slug' => 'issue-1', + 'title' => 'First', + ]); + + $this->assertCount(1, $sprint->fresh()->issues); + } + + public function test_sprint_calculates_progress(): void + { + $sprint = Sprint::create([ + 'workspace_id' => $this->workspace->id, + 'slug' => 'progress-test', + 'title' => 'Progress', + ]); + + Issue::create(['workspace_id' => $this->workspace->id, 'sprint_id' => $sprint->id, 'slug' => 'p-1', 'title' => 'A', 'status' => Issue::STATUS_OPEN]); + Issue::create(['workspace_id' => $this->workspace->id, 'sprint_id' => $sprint->id, 'slug' => 'p-2', 'title' => 'B', 'status' => Issue::STATUS_IN_PROGRESS]); + Issue::create(['workspace_id' => $this->workspace->id, 'sprint_id' => $sprint->id, 'slug' => 'p-3', 'title' => 'C', 'status' => Issue::STATUS_CLOSED]); + Issue::create(['workspace_id' => $this->workspace->id, 'sprint_id' => $sprint->id, 'slug' => 'p-4', 'title' => 'D', 'status' => Issue::STATUS_CLOSED]); + + $progress = $sprint->getProgress(); + + $this->assertEquals(4, $progress['total']); + $this->assertEquals(2, $progress['closed']); + $this->assertEquals(1, $progress['in_progress']); + $this->assertEquals(1, $progress['open']); + $this->assertEquals(50, $progress['percentage']); + } + + public function test_sprint_scopes(): void + { + Sprint::create(['workspace_id' => $this->workspace->id, 'slug' => 's-1', 'title' => 'A', 'status' => Sprint::STATUS_PLANNING]); + Sprint::create(['workspace_id' => $this->workspace->id, 'slug' => 's-2', 'title' => 'B', 'status' => Sprint::STATUS_ACTIVE]); + Sprint::create(['workspace_id' => $this->workspace->id, 'slug' => 's-3', 'title' => 'C', 'status' => Sprint::STATUS_CANCELLED]); + + $this->assertCount(1, Sprint::active()->get()); + $this->assertCount(1, Sprint::planning()->get()); + $this->assertCount(2, Sprint::notCancelled()->get()); + } + + public function test_sprint_to_mcp_context(): void + { + $sprint = Sprint::create([ + 'workspace_id' => $this->workspace->id, + 'slug' => 'mcp-test', + 'title' => 'MCP Context', + 'goal' => 'Test goal', + ]); + + $context = $sprint->toMcpContext(); + + $this->assertIsArray($context); + $this->assertEquals('mcp-test', $context['slug']); + $this->assertEquals('Test goal', $context['goal']); + $this->assertArrayHasKey('status', $context); + $this->assertArrayHasKey('progress', $context); + } + + // -- Action tests -- + + public function test_create_sprint_action(): void + { + $sprint = CreateSprint::run([ + 'title' => 'New Sprint', + 'goal' => 'Deliver features', + ], $this->workspace->id); + + $this->assertInstanceOf(Sprint::class, $sprint); + $this->assertEquals('New Sprint', $sprint->title); + $this->assertEquals(Sprint::STATUS_PLANNING, $sprint->status); + } + + public function test_create_sprint_validates_title(): void + { + $this->expectException(\InvalidArgumentException::class); + + CreateSprint::run(['title' => ''], $this->workspace->id); + } + + public function test_get_sprint_action(): void + { + $created = Sprint::create([ + 'workspace_id' => $this->workspace->id, + 'slug' => 'get-test', + 'title' => 'Get Me', + ]); + + $found = GetSprint::run('get-test', $this->workspace->id); + + $this->assertEquals($created->id, $found->id); + } + + public function test_get_sprint_throws_for_missing(): void + { + $this->expectException(\InvalidArgumentException::class); + + GetSprint::run('nonexistent', $this->workspace->id); + } + + public function test_list_sprints_action(): void + { + Sprint::create(['workspace_id' => $this->workspace->id, 'slug' => 'ls-1', 'title' => 'A', 'status' => Sprint::STATUS_ACTIVE]); + Sprint::create(['workspace_id' => $this->workspace->id, 'slug' => 'ls-2', 'title' => 'B', 'status' => Sprint::STATUS_CANCELLED]); + + $all = ListSprints::run($this->workspace->id, includeCancelled: true); + $this->assertCount(2, $all); + + $notCancelled = ListSprints::run($this->workspace->id); + $this->assertCount(1, $notCancelled); + } + + public function test_update_sprint_action(): void + { + Sprint::create([ + 'workspace_id' => $this->workspace->id, + 'slug' => 'update-test', + 'title' => 'Update Me', + 'status' => Sprint::STATUS_PLANNING, + ]); + + $updated = UpdateSprint::run('update-test', [ + 'status' => Sprint::STATUS_ACTIVE, + ], $this->workspace->id); + + $this->assertEquals(Sprint::STATUS_ACTIVE, $updated->status); + $this->assertNotNull($updated->started_at); + } + + public function test_update_sprint_validates_status(): void + { + Sprint::create([ + 'workspace_id' => $this->workspace->id, + 'slug' => 'bad-status', + 'title' => 'Bad', + ]); + + $this->expectException(\InvalidArgumentException::class); + + UpdateSprint::run('bad-status', ['status' => 'invalid'], $this->workspace->id); + } + + public function test_archive_sprint_action(): void + { + Sprint::create([ + 'workspace_id' => $this->workspace->id, + 'slug' => 'archive-test', + 'title' => 'Archive Me', + ]); + + $archived = ArchiveSprint::run('archive-test', $this->workspace->id, 'Done'); + + $this->assertEquals(Sprint::STATUS_CANCELLED, $archived->status); + $this->assertNotNull($archived->archived_at); + } +}