Files
project-manager/tests/CardOrderTest.php
T

376 lines
14 KiB
PHP
Raw Normal View History

<?php
declare(strict_types=1);
namespace Tests;
use Psr\Http\Message\ResponseInterface;
/**
* `position` is a dense rank within a "column" -- the cards sharing an
* (owner, project, status). The inbox is the column where project and status
* are both null, and it's global to the owner rather than per-project.
* PUT /api/cards/order sets one column's contents and order.
*/
final class CardOrderTest extends ApiTestCase
{
/** @param array<string, string> $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<string, string> $auth @return array<int, mixed> */
private function statuses(int $projectId, array $auth): array
{
return $this->decode(
$this->request('GET', "/api/projects/{$projectId}/statuses", null, $auth),
)['statuses'];
}
/** @param array<string, string> $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<string, string> $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<string, string> $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<string, string> $auth @return list<array{text: string, project_id: int|null, status_id: int|null, position: int}> */
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'));
}
// --- 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));
}
}