$auth */ private function newProject(array $auth, string $title = 'Board'): int { return $this->decode($this->request('POST', '/api/projects', ['title' => $title], $auth))['project']['id']; } /** @param array $auth @return array */ private function statuses(int $projectId, array $auth): array { return $this->decode( $this->request('GET', "/api/projects/{$projectId}/statuses", null, $auth), )['statuses']; } /** @param array $auth */ private function addToInbox(string $text, array $auth): int { return $this->decode( $this->request('POST', '/api/inbox/cards', ['text' => $text], $auth), )['card']['id']; } /** @param array $auth */ private function addToProject(int $projectId, string $text, array $auth): int { return $this->decode( $this->request('POST', "/api/projects/{$projectId}/cards", ['text' => $text], $auth), )['card']['id']; } /** @param int[] $cardIds @param array $auth */ private function reorder(?int $projectId, ?int $statusId, array $cardIds, array $auth): ResponseInterface { return $this->request('PUT', '/api/cards/order', [ 'project_id' => $projectId, 'status_id' => $statusId, 'card_ids' => $cardIds, ], $auth); } /** @param array $auth @return list */ private function inbox(array $auth): array { return array_map( static fn (array $c): array => [ 'text' => $c['text'], 'project_id' => $c['project_id'], 'status_id' => $c['status_id'], 'position' => $c['position'], ], $this->decode($this->request('GET', '/api/inbox/cards', null, $auth))['cards'], ); } // --- inbox CRUD ----------------------------------------------------- public function test_inbox_requires_authentication(): void { self::assertSame(401, $this->request('GET', '/api/inbox/cards')->getStatusCode()); self::assertSame(401, $this->request('POST', '/api/inbox/cards', ['text' => 'x'])->getStatusCode()); } public function test_creating_an_inbox_card(): void { $auth = $this->authHeader(); $response = $this->request('POST', '/api/inbox/cards', ['text' => 'Buy milk'], $auth); self::assertSame(201, $response->getStatusCode()); $card = $this->decode($response)['card']; self::assertSame('Buy milk', $card['text']); self::assertNull($card['project_id']); self::assertNull($card['status_id']); self::assertNull($card['status']); self::assertSame(0, $card['position']); } public function test_inbox_card_creation_validates_text(): void { $response = $this->request('POST', '/api/inbox/cards', ['text' => ' '], $this->authHeader()); self::assertSame(422, $response->getStatusCode()); self::assertArrayHasKey('text', $this->decode($response)['error']['details']); } public function test_inbox_is_isolated_per_owner(): void { $mine = $this->authHeader('mine@example.com'); $theirs = $this->authHeader('theirs@example.com'); $this->addToInbox('mine', $mine); $this->addToInbox('theirs', $theirs); self::assertSame(['mine'], array_column($this->inbox($mine), 'text')); self::assertSame(['theirs'], array_column($this->inbox($theirs), 'text')); } public function test_new_inbox_cards_are_ranked_densely(): void { $auth = $this->authHeader(); foreach (['A', 'B', 'C'] as $text) { $this->addToInbox($text, $auth); } self::assertSame([0, 1, 2], array_column($this->inbox($auth), 'position')); self::assertSame([null, null, null], array_column($this->inbox($auth), 'project_id')); } public function test_creating_a_project_card_in_a_given_status(): void { $auth = $this->authHeader(); $projectId = $this->newProject($auth); $doing = $this->statuses($projectId, $auth)[1]['id']; $this->addToProject($projectId, 'Already doing this', $auth); // lands in "To do", not "Doing" $response = $this->request( 'POST', "/api/projects/{$projectId}/cards", ['text' => 'Start this', 'status_id' => $doing], $auth, ); self::assertSame(201, $response->getStatusCode()); $card = $this->decode($response)['card']; self::assertSame($doing, $card['status_id']); self::assertSame(0, $card['position']); // first (and only) card in "Doing" so far } public function test_creating_a_project_card_appends_to_the_end_of_its_status(): void { $auth = $this->authHeader(); $projectId = $this->newProject($auth); $doing = $this->statuses($projectId, $auth)[1]['id']; $first = $this->request( 'POST', "/api/projects/{$projectId}/cards", ['text' => 'First', 'status_id' => $doing], $auth, ); $second = $this->request( 'POST', "/api/projects/{$projectId}/cards", ['text' => 'Second', 'status_id' => $doing], $auth, ); self::assertSame(0, $this->decode($first)['card']['position']); self::assertSame(1, $this->decode($second)['card']['position']); } public function test_creating_a_project_card_rejects_a_status_from_another_project(): void { $auth = $this->authHeader(); $projectId = $this->newProject($auth, 'A'); $otherStatus = $this->statuses($this->newProject($auth, 'B'), $auth)[0]['id']; $response = $this->request( 'POST', "/api/projects/{$projectId}/cards", ['text' => 'x', 'status_id' => $otherStatus], $auth, ); self::assertSame(422, $response->getStatusCode()); } // --- reordering within a column -------------------------------------- public function test_reorder_within_the_inbox(): void { $auth = $this->authHeader(); $ids = []; foreach (['A', 'B', 'C'] as $text) { $ids[$text] = $this->addToInbox($text, $auth); } $response = $this->reorder(null, null, [$ids['C'], $ids['A'], $ids['B']], $auth); self::assertSame(200, $response->getStatusCode()); self::assertSame(['C', 'A', 'B'], array_column($this->decode($response)['cards'], 'text')); self::assertSame(['C', 'A', 'B'], array_column($this->inbox($auth), 'text')); } public function test_reorder_within_a_project_status(): void { $auth = $this->authHeader(); $projectId = $this->newProject($auth); $todo = $this->statuses($projectId, $auth)[0]['id']; $ids = []; foreach (['A', 'B', 'C'] as $text) { $ids[$text] = $this->addToProject($projectId, $text, $auth); } $response = $this->reorder($projectId, $todo, [$ids['C'], $ids['A'], $ids['B']], $auth); self::assertSame(200, $response->getStatusCode()); self::assertSame(['C', 'A', 'B'], array_column($this->decode($response)['cards'], 'text')); } // --- moving between the inbox and a project -------------------------- public function test_moving_a_card_from_the_inbox_into_a_project_status(): void { $auth = $this->authHeader(); $projectId = $this->newProject($auth); $doing = $this->statuses($projectId, $auth)[1]['id']; $cardId = $this->addToInbox('Triage me', $auth); $response = $this->reorder($projectId, $doing, [$cardId], $auth); self::assertSame(200, $response->getStatusCode()); $card = $this->decode($response)['cards'][0]; self::assertSame($projectId, $card['project_id']); self::assertSame($doing, $card['status_id']); self::assertSame('Doing', $card['status']['name']); self::assertSame(0, $card['position']); self::assertSame([], $this->inbox($auth)); } public function test_moving_a_card_out_of_a_project_back_to_the_inbox(): void { $auth = $this->authHeader(); $projectId = $this->newProject($auth); $cardId = $this->addToProject($projectId, 'Rethink this', $auth); $response = $this->reorder(null, null, [$cardId], $auth); self::assertSame(200, $response->getStatusCode()); $card = $this->decode($response)['cards'][0]; self::assertNull($card['project_id']); self::assertNull($card['status_id']); self::assertSame(['Rethink this'], array_column($this->inbox($auth), 'text')); } public function test_a_card_moves_from_one_project_to_another_via_the_inbox(): void { $auth = $this->authHeader(); $projectA = $this->newProject($auth, 'A'); $projectB = $this->newProject($auth, 'B'); $bStatus = $this->statuses($projectB, $auth)[2]['id']; $cardId = $this->addToProject($projectA, 'Reassign me', $auth); // A -> inbox $this->reorder(null, null, [$cardId], $auth); self::assertSame(['Reassign me'], array_column($this->inbox($auth), 'text')); // inbox -> B $response = $this->reorder($projectB, $bStatus, [$cardId], $auth); self::assertSame(200, $response->getStatusCode()); $card = $this->decode($response)['cards'][0]; self::assertSame($projectB, $card['project_id']); self::assertSame($bStatus, $card['status_id']); self::assertSame([], $this->inbox($auth)); $projectACards = $this->decode($this->request('GET', "/api/projects/{$projectA}/cards", null, $auth))['cards']; self::assertSame([], $projectACards); } public function test_moving_a_card_repacks_the_column_it_left(): void { $auth = $this->authHeader(); $ids = []; foreach (['A', 'B', 'C'] as $text) { $ids[$text] = $this->addToInbox($text, $auth); // inbox: A@0, B@1, C@2 } $projectId = $this->newProject($auth); $todo = $this->statuses($projectId, $auth)[0]['id']; $this->reorder($projectId, $todo, [$ids['B']], $auth); // pull B out self::assertSame( [['text' => 'A', 'position' => 0], ['text' => 'C', 'position' => 1]], array_map( static fn (array $c) => ['text' => $c['text'], 'position' => $c['position']], $this->inbox($auth), ), ); } public function test_a_new_inbox_card_lands_after_the_repacked_survivors(): void { $auth = $this->authHeader(); $a = $this->addToInbox('A', $auth); $this->addToInbox('B', $auth); // inbox: A@0, B@1 $projectId = $this->newProject($auth); $todo = $this->statuses($projectId, $auth)[0]['id']; $this->reorder($projectId, $todo, [$a], $auth); // inbox now: B@0 $newId = $this->addToInbox('C', $auth); $byId = []; foreach ($this->inbox($auth) as $c) { $byId[$c['text']] = $c; } self::assertSame(0, $byId['B']['position']); self::assertSame(1, $byId['C']['position']); self::assertNotSame($newId, null); // sanity: card was actually created } // --- validation -------------------------------------------------------- public function test_order_rejects_status_id_without_project_id(): void { $auth = $this->authHeader(); $projectId = $this->newProject($auth); $status = $this->statuses($projectId, $auth)[0]['id']; $cardId = $this->addToInbox('x', $auth); $response = $this->reorder(null, $status, [$cardId], $auth); self::assertSame(422, $response->getStatusCode()); } public function test_order_requires_status_id_when_project_id_is_set(): void { $auth = $this->authHeader(); $projectId = $this->newProject($auth); $cardId = $this->addToInbox('x', $auth); $response = $this->reorder($projectId, null, [$cardId], $auth); self::assertSame(422, $response->getStatusCode()); } public function test_order_rejects_a_status_from_another_project(): void { $auth = $this->authHeader(); $mine = $this->newProject($auth, 'Mine'); $other = $this->newProject($auth, 'Other'); $foreignStatus = $this->statuses($other, $auth)[0]['id']; $cardId = $this->addToProject($mine, 'x', $auth); $response = $this->reorder($mine, $foreignStatus, [$cardId], $auth); self::assertSame(422, $response->getStatusCode()); } public function test_order_rejects_a_project_owned_by_someone_else(): void { $owner = $this->authHeader('owner@example.com'); $other = $this->authHeader('other@example.com'); $projectId = $this->newProject($owner); $status = $this->statuses($projectId, $owner)[0]['id']; $cardId = $this->addToInbox('x', $other); $response = $this->reorder($projectId, $status, [$cardId], $other); self::assertSame(404, $response->getStatusCode()); } public function test_order_rejects_a_card_owned_by_someone_else(): void { $owner = $this->authHeader('owner@example.com'); $other = $this->authHeader('other@example.com'); $foreignCardId = $this->addToInbox('not yours', $owner); $response = $this->reorder(null, null, [$foreignCardId], $other); self::assertSame(422, $response->getStatusCode()); } public function test_order_must_list_every_card_already_in_the_target_column(): void { $auth = $this->authHeader(); $a = $this->addToInbox('A', $auth); $this->addToInbox('B', $auth); $response = $this->reorder(null, null, [$a], $auth); self::assertSame(422, $response->getStatusCode()); } public function test_order_rejects_duplicate_ids(): void { $auth = $this->authHeader(); $a = $this->addToInbox('A', $auth); $response = $this->reorder(null, null, [$a, $a], $auth); self::assertSame(422, $response->getStatusCode()); } // --- single-card routes are global ------------------------------------- public function test_a_card_is_only_reachable_by_its_owner(): void { $owner = $this->authHeader('owner@example.com'); $other = $this->authHeader('other@example.com'); $cardId = $this->addToInbox('mine', $owner); self::assertSame(200, $this->request('GET', "/api/cards/{$cardId}", null, $owner)->getStatusCode()); self::assertSame(404, $this->request('GET', "/api/cards/{$cardId}", null, $other)->getStatusCode()); self::assertSame(404, $this->request('PATCH', "/api/cards/{$cardId}", ['text' => 'x'], $other)->getStatusCode()); self::assertSame(404, $this->request('DELETE', "/api/cards/{$cardId}", null, $other)->getStatusCode()); } public function test_patch_requires_a_recognised_field(): void { $auth = $this->authHeader(); $projectId = $this->newProject($auth); $status = $this->statuses($projectId, $auth)[1]['id']; $cardId = $this->addToProject($projectId, 'x', $auth); // status_id is no longer a PATCH field -- moves go through /cards/order. $response = $this->request('PATCH', "/api/cards/{$cardId}", ['status_id' => $status], $auth); self::assertSame(422, $response->getStatusCode()); } public function test_deleting_an_inbox_card(): void { $auth = $this->authHeader(); $cardId = $this->addToInbox('gone soon', $auth); self::assertSame(204, $this->request('DELETE', "/api/cards/{$cardId}", null, $auth)->getStatusCode()); self::assertSame([], $this->inbox($auth)); } }