Files
project-manager/tests/CardOrderTest.php
T
aneurinandClaude Sonnet 5 19ccb8f881 Add a "new card" form to each kanban column
Each column gets its own form at the bottom (styled like the sidebar
inbox's), creating the card directly in that status, appended after
its existing cards.

- API: POST /projects/{id}/cards takes an optional status_id, which
  must belong to the project (422 otherwise); omitted, it still
  defaults to the project's first status as before. Position is
  already "end of that status" for free -- createInProject() already
  ranks by (owner, project, status).
- cards store: add() takes an optional statusId, forwarded as
  status_id when given.
- ProjectView: one draft string per status (keyed by status id) so
  typing in one column doesn't touch another's, mirroring the
  per-status independence the columns already have for reordering.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 00:10:52 +01:00

435 lines
16 KiB
PHP

<?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'));
}
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));
}
}