createProject(); $response = $this->callApi( 'public/api/board-state.php', 'GET', ['project' => $projectId] ); self::assertSame(200, $response['status']); self::assertSame('Demo Project', $response['payload']['project']['title']); self::assertContains('trash', array_column($response['payload']['columns'], 'id')); } /** * Ensure create-project creates markdown storage and returns the new project state. * * @return void */ public function testCreateProjectEndpointCreatesProject(): void { $token = csrf_token(); $response = $this->callApi( 'public/api/create-project.php', 'POST', [], [ 'title' => 'Release Planning', 'slug' => 'release-planning', 'body' => 'Track launch work.', '_token' => $token, ], ['HTTP_X_CSRF_TOKEN' => $token] ); self::assertSame(200, $response['status']); self::assertTrue($response['payload']['success']); self::assertSame('release-planning', $response['payload']['state']['project']['id']); self::assertSame('/?project=release-planning', $response['payload']['state']['projectUrl']); self::assertContains('trash', array_column($response['payload']['state']['columns'], 'id')); self::assertFileExists($this->projectRoot . DIRECTORY_SEPARATOR . 'release-planning' . DIRECTORY_SEPARATOR . 'index.md'); } /** * Ensure state-changing endpoints reject missing CSRF tokens. * * @return void */ public function testCreateTaskEndpointRejectsMissingCsrfToken(): void { $projectId = $this->createProject(); $response = $this->callApi( 'public/api/create-task.php', 'POST', [], [ 'projectId' => $projectId, 'title' => 'Write docs', 'column' => 'backlog', ] ); self::assertSame(403, $response['status']); self::assertFalse($response['payload']['success']); self::assertSame('invalid_csrf', $response['payload']['error']); } /** * Ensure create-task returns a successful JSON state payload. * * @return void */ public function testCreateTaskEndpointCreatesTaskWithValidCsrfToken(): void { $projectId = $this->createProject(); $token = csrf_token(); $response = $this->callApi( 'public/api/create-task.php', 'POST', [], [ 'projectId' => $projectId, 'title' => 'Write docs', 'column' => 'backlog', 'body' => 'Add the release checklist.', '_token' => $token, ], ['HTTP_X_CSRF_TOKEN' => $token] ); self::assertSame(200, $response['status']); self::assertTrue($response['payload']['success']); self::assertSame('Write docs', $response['payload']['state']['task']['title']); self::assertSame('backlog', $response['payload']['state']['task']['column']); self::assertCount(1, $response['payload']['state']['tasks']); } /** * Ensure stale revisions return a conflict response. * * @return void */ public function testCreateTaskEndpointReturnsConflictForStaleRevision(): void { $projectId = $this->createProject(); $service = new BoardService(); $current = $service->getBoardState($projectId); $token = csrf_token(); $service->createTask($projectId, 'Existing task', 'backlog'); $response = $this->callApi( 'public/api/create-task.php', 'POST', [], [ 'projectId' => $projectId, 'title' => 'Conflicting task', 'column' => 'ready', 'revision' => (string) $current['revision'], '_token' => $token, ], ['HTTP_X_CSRF_TOKEN' => $token] ); self::assertSame(409, $response['status']); self::assertFalse($response['payload']['success']); self::assertSame('conflict', $response['payload']['error']); } /** * Ensure move-task updates the task column and preserves a success response. * * @return void */ public function testMoveTaskEndpointMovesTaskToRequestedColumn(): void { $projectId = $this->createProject(); $service = new BoardService(); $created = $service->createTask($projectId, 'Move me', 'backlog'); $token = csrf_token(); $response = $this->callApi( 'public/api/move-task.php', 'POST', [], [ 'projectId' => $projectId, 'taskId' => $created['task']['id'], 'column' => 'done', 'index' => 0, 'revision' => (string) $created['revision'], '_token' => $token, ], ['HTTP_X_CSRF_TOKEN' => $token] ); self::assertSame(200, $response['status']); self::assertTrue($response['payload']['success']); self::assertSame('done', $response['payload']['state']['tasks'][0]['column']); } /** * Ensure delete-task trashes a task by default. * * @return void */ public function testDeleteTaskEndpointMovesTaskToTrash(): void { $projectId = $this->createProject(); $service = new BoardService(); $created = $service->createTask($projectId, 'Archive me', 'review'); $token = csrf_token(); $response = $this->callApi( 'public/api/delete-task.php', 'POST', [], [ 'projectId' => $projectId, 'taskId' => $created['task']['id'], 'revision' => (string) $created['revision'], '_token' => $token, ], ['HTTP_X_CSRF_TOKEN' => $token] ); self::assertSame(200, $response['status']); self::assertTrue($response['payload']['success']); self::assertSame('trash', $response['payload']['state']['tasks'][0]['column']); } /** * Ensure save-note creates a note and returns it in the response state. * * @return void */ public function testSaveNoteEndpointCreatesNote(): void { $projectId = $this->createProject(); $service = new BoardService(); $state = $service->getBoardState($projectId); $token = csrf_token(); $response = $this->callApi( 'public/api/save-note.php', 'POST', [], [ 'projectId' => $projectId, 'title' => 'Sprint Notes', 'body' => 'Capture follow-up items.', 'revision' => (string) $state['revision'], '_token' => $token, ], ['HTTP_X_CSRF_TOKEN' => $token] ); self::assertSame(200, $response['status']); self::assertTrue($response['payload']['success']); self::assertSame('Sprint Notes', $response['payload']['state']['note']['title']); self::assertCount(1, $response['payload']['state']['notes']); } /** * Ensure create-column appends a new column to the board. * * @return void */ public function testCreateColumnEndpointCreatesNewColumn(): void { $projectId = $this->createProject(); $service = new BoardService(); $state = $service->getBoardState($projectId); $token = csrf_token(); $response = $this->callApi( 'public/api/create-column.php', 'POST', [], [ 'projectId' => $projectId, 'label' => 'Blocked', 'revision' => (string) $state['revision'], '_token' => $token, ], ['HTTP_X_CSRF_TOKEN' => $token] ); self::assertSame(200, $response['status']); self::assertTrue($response['payload']['success']); self::assertContains('blocked', array_column($response['payload']['state']['columns'], 'id')); } /** * Ensure save-task updates persisted task fields. * * @return void */ public function testSaveTaskEndpointUpdatesTaskFields(): void { $projectId = $this->createProject(); $service = new BoardService(); $created = $service->createTask($projectId, 'Initial title', 'backlog'); $token = csrf_token(); $response = $this->callApi( 'public/api/save-task.php', 'POST', [], [ 'projectId' => $projectId, 'taskId' => $created['task']['id'], 'title' => 'Updated title', 'body' => 'Updated body', 'priority' => 'urgent', 'completed' => true, 'revision' => (string) $created['revision'], '_token' => $token, ], ['HTTP_X_CSRF_TOKEN' => $token] ); self::assertSame(200, $response['status']); self::assertTrue($response['payload']['success']); self::assertSame('Updated title', $response['payload']['state']['tasks'][0]['title']); self::assertSame("Updated body\n", $response['payload']['state']['tasks'][0]['body']); self::assertSame('urgent', $response['payload']['state']['tasks'][0]['priority']); self::assertTrue($response['payload']['state']['tasks'][0]['completed']); } /** * Ensure update-board accepts column replacements and keeps trash available. * * @return void */ public function testUpdateBoardEndpointReplacesColumnDefinitions(): void { $projectId = $this->createProject(); $service = new BoardService(); $state = $service->getBoardState($projectId); $token = csrf_token(); $response = $this->callApi( 'public/api/update-board.php', 'POST', [], [ 'projectId' => $projectId, 'columns' => [ ['id' => 'ideas', 'label' => 'Ideas'], ['id' => 'doing', 'label' => 'Doing'], ], 'revision' => (string) $state['revision'], '_token' => $token, ], ['HTTP_X_CSRF_TOKEN' => $token] ); self::assertSame(200, $response['status']); self::assertTrue($response['payload']['success']); self::assertSame(['ideas', 'doing', 'trash'], array_column($response['payload']['state']['columns'], 'id')); } }