Make the inbox global instead of per-project

A card either sits in its owner's inbox (project_id AND status_id both NULL)
or belongs to exactly one project with a status in it (both set) -- enforced
by a CHECK constraint, never one without the other. The inbox is global to a
user now, not per-project: cards can move from a project into the inbox and
back into any status column of any project.

Backend
- migrations/009: rebuilds `cards` (SQLite can't relax NOT NULL / add a CHECK
  in place) with a nullable project_id, a new owner_id (cards need direct
  ownership once they can have no project), and the CHECK constraint. Cards
  that had no status (the old per-project inbox) move to the new global inbox.
  status_id's FK is now ON DELETE RESTRICT, not SET NULL -- nulling it alone
  would violate the invariant, and there's no status-delete endpoint anyway.
- CardRepository: "column" is now (owner_id, project_id, status_id); every
  method that dealt with a project's columns is generalised to also cover the
  inbox and cross-project moves (orderColumn, idsInColumn, repack, ...).
- CardController/routes: single-card and ordering routes move to global,
  since a card may have no project to nest them under --
  GET/PATCH/DELETE /api/cards/{id}, PUT /api/cards/order (body now takes
  project_id + status_id, both null for the inbox). New GET/POST
  /api/inbox/cards. PATCH no longer accepts status_id -- moving a card, in or
  out of a project, is exclusively PUT /api/cards/order now. A card created
  directly in a project (POST /api/projects/{id}/cards) lands in its first
  status, since a project card can't have no status.
- Tests: ProjectTest/CardStatusTest updated for the new routes; CardOrderTest
  rewritten with full inbox/cross-project coverage. 57 tests pass.

Frontend
- New stores/inbox.ts (the global inbox) and lib/cardOrder.ts (the shared
  PUT /api/cards/order call, used by both the sidebar and a project's board).
- AppSidebar: an Inbox section under the project list -- a vuedraggable list
  in the same "kanban" drag group as every project's kanban columns, so a
  card drags straight from the sidebar into whichever project is open, or
  back out. (The empty-inbox state needed a real bugfix: it wasn't rendering
  a <draggable> at all, so there was nowhere to drop a card back into an
  empty inbox.) A drop reloads the inbox and, if a project is open, its cards.
- ProjectView's kanban board drops its synthetic Inbox column -- just the
  real statuses now.
- DashboardView simplified to a plain grid of project tiles (name + card
  count); its per-project "New" section is gone, since a project card can no
  longer have no status.
- stores/cards.ts: patch/remove move to the global /api/cards/{id} routes.

Verified end-to-end against the rebuilt container (existing per-project-inbox
cards correctly migrated to the global inbox, 0 invariant violations) and the
dev server via headless Chrome: sidebar inbox -> project A "To do" -> back to
inbox -> project B "Done", full journey confirmed via the API at each step.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-04 15:22:33 +01:00
co-authored by Claude Sonnet 5
parent c82bdbbf0e
commit 4ee0f24078
19 changed files with 957 additions and 674 deletions
+259 -140
View File
@@ -4,10 +4,13 @@ declare(strict_types=1);
namespace Tests;
use Psr\Http\Message\ResponseInterface;
/**
* `position` is a dense rank within a column the cards sharing a
* (project, status). PUT /api/projects/{id}/cards/order sets one column's
* contents and order.
* `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
{
@@ -26,231 +29,347 @@ final class CardOrderTest extends ApiTestCase
}
/** @param array<string, string> $auth */
private function addCard(int $projectId, string $text, array $auth): int
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 array<string, string> $auth @return list<array{text: string, status_id: int|null, position: int}> */
private function cards(int $projectId, array $auth): array
/** @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'],
'status_id' => $c['status_id'],
'position' => $c['position'],
'text' => $c['text'], 'project_id' => $c['project_id'],
'status_id' => $c['status_id'], 'position' => $c['position'],
],
$this->decode($this->request('GET', "/api/projects/{$projectId}/cards", null, $auth))['cards'],
$this->decode($this->request('GET', '/api/inbox/cards', null, $auth))['cards'],
);
}
public function test_new_cards_are_ranked_densely_within_the_inbox(): void
// --- 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();
$projectId = $this->newProject($auth);
$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->addCard($projectId, $text, $auth);
$this->addToInbox($text, $auth);
}
self::assertSame(
[['text' => 'A', 'status_id' => null, 'position' => 0],
['text' => 'B', 'status_id' => null, 'position' => 1],
['text' => 'C', 'status_id' => null, 'position' => 2]],
$this->cards($projectId, $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_reorder_within_the_inbox_column(): void
// --- reordering within a column --------------------------------------
public function test_reorder_within_the_inbox(): void
{
$auth = $this->authHeader();
$projectId = $this->newProject($auth);
$ids = [];
foreach (['A', 'B', 'C'] as $text) {
$ids[$text] = $this->addCard($projectId, $text, $auth);
$ids[$text] = $this->addToInbox($text, $auth);
}
$response = $this->request('PUT', "/api/projects/{$projectId}/cards/order", [
'status_id' => null,
'card_ids' => [$ids['C'], $ids['A'], $ids['B']],
], $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([0, 1, 2], array_column($this->cards($projectId, $auth), 'position'));
self::assertSame(['C', 'A', 'B'], array_column($this->inbox($auth), 'text'));
}
public function test_ordering_a_status_column_moves_cards_into_it_and_repacks_the_inbox(): void
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->addCard($projectId, $text, $auth);
$ids[$text] = $this->addToProject($projectId, $text, $auth);
}
// Move B and C into "To do" (C first), leaving A alone in the inbox.
$this->request('PUT', "/api/projects/{$projectId}/cards/order", [
'status_id' => $todo,
'card_ids' => [$ids['C'], $ids['B']],
], $auth);
$response = $this->reorder($projectId, $todo, [$ids['C'], $ids['A'], $ids['B']], $auth);
$byText = [];
foreach ($this->cards($projectId, $auth) as $c) {
$byText[$c['text']] = $c;
}
self::assertSame(['status_id' => null, 'position' => 0], ['status_id' => $byText['A']['status_id'], 'position' => $byText['A']['position']]);
self::assertSame(['status_id' => $todo, 'position' => 0], ['status_id' => $byText['C']['status_id'], 'position' => $byText['C']['position']]);
self::assertSame(['status_id' => $todo, 'position' => 1], ['status_id' => $byText['B']['status_id'], 'position' => $byText['B']['position']]);
self::assertSame(200, $response->getStatusCode());
self::assertSame(['C', 'A', 'B'], array_column($this->decode($response)['cards'], 'text'));
}
public function test_moving_a_card_out_of_the_inbox_repacks_the_survivors(): void
// --- 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);
$todo = $this->statuses($projectId, $auth)[0]['id'];
$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->addCard($projectId, $text, $auth); // inbox 0,1,2
$ids[$text] = $this->addToInbox($text, $auth); // inbox: A@0, B@1, C@2
}
$projectId = $this->newProject($auth);
$todo = $this->statuses($projectId, $auth)[0]['id'];
// Pull the middle card into "To do".
$this->request('PUT', "/api/projects/{$projectId}/cards/order", [
'status_id' => $todo,
'card_ids' => [$ids['B']],
], $auth);
$this->reorder($projectId, $todo, [$ids['B']], $auth); // pull B out
$inbox = array_values(array_filter($this->cards($projectId, $auth), static fn ($c) => $c['status_id'] === null));
self::assertSame(
[['text' => 'A', 'status_id' => null, 'position' => 0],
['text' => 'C', 'status_id' => null, 'position' => 1]],
$inbox,
[['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'];
$a = $this->addCard($projectId, 'A', $auth);
$b = $this->addCard($projectId, 'B', $auth); // inbox: A@0, B@1
$this->request('PUT', "/api/projects/{$projectId}/cards/order", [
'status_id' => $todo,
'card_ids' => [$a],
], $auth); // inbox now: B@0
$this->reorder($projectId, $todo, [$a], $auth); // inbox now: B@0
$newId = $this->addCard($projectId, 'C', $auth);
$newId = $this->addToInbox('C', $auth);
$byId = [];
foreach ($this->decode($this->request('GET', "/api/projects/{$projectId}/cards", null, $auth))['cards'] as $c) {
$byId[$c['id']] = $c;
foreach ($this->inbox($auth) as $c) {
$byId[$c['text']] = $c;
}
self::assertSame(0, $byId[$b]['position']);
self::assertSame(1, $byId[$newId]['position']);
self::assertNull($byId[$newId]['status_id']);
self::assertSame(0, $byId['B']['position']);
self::assertSame(1, $byId['C']['position']);
self::assertNotSame($newId, null); // sanity: card was actually created
}
public function test_patch_status_appends_the_card_to_the_destination_column(): void
// --- validation --------------------------------------------------------
public function test_order_rejects_status_id_without_project_id(): void
{
$auth = $this->authHeader();
$projectId = $this->newProject($auth);
$todo = $this->statuses($projectId, $auth)[0]['id'];
$first = $this->addCard($projectId, 'first', $auth);
$second = $this->addCard($projectId, 'second', $auth);
$status = $this->statuses($projectId, $auth)[0]['id'];
$cardId = $this->addToInbox('x', $auth);
$this->request('PATCH', "/api/projects/{$projectId}/cards/{$first}", ['status_id' => $todo], $auth);
$moved = $this->decode(
$this->request('PATCH', "/api/projects/{$projectId}/cards/{$second}", ['status_id' => $todo], $auth),
)['card'];
$response = $this->reorder(null, $status, [$cardId], $auth);
self::assertSame($todo, $moved['status_id']);
self::assertSame(1, $moved['position']); // after `first`, which took slot 0
self::assertSame(422, $response->getStatusCode());
}
public function test_reorder_rejects_a_foreign_status(): void
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'];
$card = $this->addCard($mine, 'x', $auth);
$cardId = $this->addToProject($mine, 'x', $auth);
$response = $this->request('PUT', "/api/projects/{$mine}/cards/order", [
'status_id' => $foreignStatus,
'card_ids' => [$card],
], $auth);
$response = $this->reorder($mine, $foreignStatus, [$cardId], $auth);
self::assertSame(422, $response->getStatusCode());
}
public function test_reorder_rejects_a_card_from_another_project(): void
{
$auth = $this->authHeader();
$mine = $this->newProject($auth, 'Mine');
$other = $this->newProject($auth, 'Other');
$foreignCard = $this->addCard($other, 'x', $auth);
$response = $this->request('PUT', "/api/projects/{$mine}/cards/order", [
'status_id' => null,
'card_ids' => [$foreignCard],
], $auth);
self::assertSame(422, $response->getStatusCode());
}
public function test_reorder_must_list_every_card_already_in_the_target_column(): void
{
$auth = $this->authHeader();
$projectId = $this->newProject($auth);
$todo = $this->statuses($projectId, $auth)[0]['id'];
$a = $this->addCard($projectId, 'A', $auth);
$b = $this->addCard($projectId, 'B', $auth);
// Put both in "To do".
$this->request('PUT', "/api/projects/{$projectId}/cards/order", [
'status_id' => $todo,
'card_ids' => [$a, $b],
], $auth);
// Now try to reorder "To do" mentioning only one of them.
$response = $this->request('PUT', "/api/projects/{$projectId}/cards/order", [
'status_id' => $todo,
'card_ids' => [$b],
], $auth);
self::assertSame(422, $response->getStatusCode());
}
public function test_reorder_rejects_duplicate_ids(): void
{
$auth = $this->authHeader();
$projectId = $this->newProject($auth);
$a = $this->addCard($projectId, 'A', $auth);
$response = $this->request('PUT', "/api/projects/{$projectId}/cards/order", [
'status_id' => null,
'card_ids' => [$a, $a],
], $auth);
self::assertSame(422, $response->getStatusCode());
}
public function test_reorder_is_scoped_to_the_owner(): void
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);
$card = $this->addCard($projectId, 'x', $owner);
$status = $this->statuses($projectId, $owner)[0]['id'];
$cardId = $this->addToInbox('x', $other);
self::assertSame(404, $this->request('PUT', "/api/projects/{$projectId}/cards/order", [
'status_id' => null,
'card_ids' => [$card],
], $other)->getStatusCode());
$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));
}
}
+13 -70
View File
@@ -4,6 +4,8 @@ declare(strict_types=1);
namespace Tests;
use PDOException;
final class CardStatusTest extends ApiTestCase
{
/** Create a project and return its id. */
@@ -63,93 +65,34 @@ final class CardStatusTest extends ApiTestCase
self::assertSame(404, $this->request('GET', "/api/projects/{$projectId}/statuses", null, $other)->getStatusCode());
}
public function test_a_new_card_has_no_status(): void
public function test_a_card_created_directly_in_a_project_starts_in_its_first_status(): void
{
$auth = $this->authHeader();
$projectId = $this->newProject($auth);
$firstStatus = $this->statuses($projectId, $auth)[0];
$card = $this->decode(
$this->request('POST', "/api/projects/{$projectId}/cards", ['text' => 'First'], $auth),
)['card'];
// New cards sit in the "inbox" — no status until the user assigns one.
self::assertArrayHasKey('status_id', $card);
self::assertNull($card['status_id']);
self::assertNull($card['status']);
self::assertSame($firstStatus['id'], $card['status_id']);
self::assertSame('To do', $card['status']['name']);
self::assertSame($projectId, $card['project_id']);
}
public function test_a_card_can_be_moved_between_statuses_and_back_to_the_inbox(): void
public function test_a_referenced_status_cannot_be_deleted(): void
{
// There's no delete-status endpoint; this exercises the FK directly.
// A card with a project must have a status (the CHECK constraint), so
// the FK is ON DELETE RESTRICT rather than SET NULL.
$auth = $this->authHeader();
$projectId = $this->newProject($auth);
$statuses = $this->statuses($projectId, $auth);
$cardId = $this->decode(
$this->request('POST', "/api/projects/{$projectId}/cards", ['text' => 'Move me'], $auth),
)['card']['id'];
$doing = $this->decode(
$this->request('PATCH', "/api/projects/{$projectId}/cards/{$cardId}", ['status_id' => $statuses[1]['id']], $auth),
)['card'];
self::assertSame($statuses[1]['id'], $doing['status_id']);
self::assertSame('Doing', $doing['status']['name']);
$backToInbox = $this->decode(
$this->request('PATCH', "/api/projects/{$projectId}/cards/{$cardId}", ['status_id' => null], $auth),
)['card'];
self::assertNull($backToInbox['status_id']);
self::assertNull($backToInbox['status']);
}
public function test_a_card_rejects_a_status_from_another_project(): void
{
$auth = $this->authHeader();
$mine = $this->newProject($auth, 'Mine');
$other = $this->newProject($auth, 'Other');
$foreignStatusId = $this->statuses($other, $auth)[0]['id'];
$cardId = $this->decode(
$this->request('POST', "/api/projects/{$mine}/cards", ['text' => 'x'], $auth),
)['card']['id'];
$response = $this->request('PATCH', "/api/projects/{$mine}/cards/{$cardId}", ['status_id' => $foreignStatusId], $auth);
self::assertSame(422, $response->getStatusCode());
self::assertArrayHasKey('status_id', $this->decode($response)['error']['details']);
}
public function test_a_card_rejects_an_unknown_status(): void
{
$auth = $this->authHeader();
$projectId = $this->newProject($auth);
$cardId = $this->decode(
$this->request('POST', "/api/projects/{$projectId}/cards", ['text' => 'x'], $auth),
)['card']['id'];
$response = $this->request('PATCH', "/api/projects/{$projectId}/cards/{$cardId}", ['status_id' => 999999], $auth);
self::assertSame(422, $response->getStatusCode());
}
public function test_deleting_a_status_clears_it_from_its_cards(): void
{
$auth = $this->authHeader();
$projectId = $this->newProject($auth);
$cardId = $this->decode(
$this->request('POST', "/api/projects/{$projectId}/cards", ['text' => 'Orphan me'], $auth),
)['card']['id'];
$statusId = $this->statuses($projectId, $auth)[0]['id'];
$this->request('POST', "/api/projects/{$projectId}/cards", ['text' => 'x'], $auth);
$this->request('PATCH', "/api/projects/{$projectId}/cards/{$cardId}", ['status_id' => $statusId], $auth);
// No delete endpoint for statuses yet — remove the row directly to
// exercise ON DELETE SET NULL.
$this->db()->exec('PRAGMA foreign_keys = ON');
$this->expectException(PDOException::class);
$this->db()->prepare('DELETE FROM card_statuses WHERE id = ?')->execute([$statusId]);
$reread = $this->decode(
$this->request('GET', "/api/projects/{$projectId}/cards/{$cardId}", null, $auth),
)['card'];
self::assertNull($reread['status_id']);
self::assertNull($reread['status']);
}
public function test_deleting_a_project_cascades_to_its_statuses(): void
+8 -69
View File
@@ -100,12 +100,15 @@ final class ProjectTest extends ApiTestCase
self::assertSame(404, $this->request('GET', "/api/projects/{$projectId}", null, $auth)->getStatusCode());
}
public function test_cards_append_in_order_and_track_completion(): void
public function test_cards_land_in_the_first_status_and_track_completion(): void
{
$auth = $this->authHeader();
$projectId = $this->decode(
$this->request('POST', '/api/projects', ['title' => 'Chores'], $auth),
)['project']['id'];
$firstStatus = $this->decode(
$this->request('GET', "/api/projects/{$projectId}/statuses", null, $auth),
)['statuses'][0];
foreach (['Wash up', 'Hoover', 'Bins'] as $text) {
$this->request('POST', "/api/projects/{$projectId}/cards", ['text' => $text], $auth);
@@ -114,10 +117,11 @@ final class ProjectTest extends ApiTestCase
$cards = $this->decode($this->request('GET', "/api/projects/{$projectId}/cards", null, $auth))['cards'];
self::assertSame(['Wash up', 'Hoover', 'Bins'], array_column($cards, 'text'));
self::assertSame([0, 1, 2], array_column($cards, 'position'));
self::assertSame([$firstStatus['id'], $firstStatus['id'], $firstStatus['id']], array_column($cards, 'status_id'));
self::assertFalse($cards[0]['complete']);
$done = $this->decode(
$this->request('PATCH', "/api/projects/{$projectId}/cards/{$cards[0]['id']}", ['complete' => true], $auth),
$this->request('PATCH', "/api/cards/{$cards[0]['id']}", ['complete' => true], $auth),
)['card'];
self::assertTrue($done['complete']);
@@ -143,68 +147,6 @@ final class ProjectTest extends ApiTestCase
self::assertArrayHasKey('text', $this->decode($bad)['error']['details']);
}
public function test_cards_can_be_reordered_in_bulk(): void
{
$auth = $this->authHeader();
$projectId = $this->decode(
$this->request('POST', '/api/projects', ['title' => 'Reorder'], $auth),
)['project']['id'];
$ids = [];
foreach (['A', 'B', 'C'] as $text) {
$ids[$text] = $this->decode(
$this->request('POST', "/api/projects/{$projectId}/cards", ['text' => $text], $auth),
)['card']['id'];
}
$response = $this->request('PUT', "/api/projects/{$projectId}/cards/order", [
'card_ids' => [$ids['C'], $ids['A'], $ids['B']],
], $auth);
self::assertSame(200, $response->getStatusCode());
$cards = $this->decode($response)['cards'];
self::assertSame(['C', 'A', 'B'], array_column($cards, 'text'));
self::assertSame([0, 1, 2], array_column($cards, 'position'));
// Order persists on a fresh read.
$reread = $this->decode($this->request('GET', "/api/projects/{$projectId}/cards", null, $auth))['cards'];
self::assertSame(['C', 'A', 'B'], array_column($reread, 'text'));
}
public function test_reorder_rejects_an_incomplete_id_set(): void
{
$auth = $this->authHeader();
$projectId = $this->decode(
$this->request('POST', '/api/projects', ['title' => 'Reorder'], $auth),
)['project']['id'];
$first = $this->decode(
$this->request('POST', "/api/projects/{$projectId}/cards", ['text' => 'one'], $auth),
)['card']['id'];
$this->request('POST', "/api/projects/{$projectId}/cards", ['text' => 'two'], $auth);
$response = $this->request('PUT', "/api/projects/{$projectId}/cards/order", [
'card_ids' => [$first],
], $auth);
self::assertSame(422, $response->getStatusCode());
}
public function test_reorder_is_scoped_to_the_owner(): void
{
$owner = $this->authHeader('ro@example.com');
$other = $this->authHeader('rx@example.com');
$projectId = $this->decode(
$this->request('POST', '/api/projects', ['title' => 'Mine'], $owner),
)['project']['id'];
$cardId = $this->decode(
$this->request('POST', "/api/projects/{$projectId}/cards", ['text' => 'x'], $owner),
)['card']['id'];
self::assertSame(404, $this->request('PUT', "/api/projects/{$projectId}/cards/order", [
'card_ids' => [$cardId],
], $other)->getStatusCode());
}
public function test_deleting_a_project_cascades_to_its_cards(): void
{
$auth = $this->authHeader();
@@ -217,11 +159,8 @@ final class ProjectTest extends ApiTestCase
$this->request('DELETE', "/api/projects/{$projectId}", null, $auth);
// The parent project is gone, so the card route 404s on the project check.
self::assertSame(
404,
$this->request('GET', "/api/projects/{$projectId}/cards/{$cardId}", null, $auth)->getStatusCode(),
);
// The card went with its project.
self::assertSame(404, $this->request('GET', "/api/cards/{$cardId}", null, $auth)->getStatusCode());
}
public function test_cards_under_another_users_project_are_not_reachable(): void