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:
+259
-140
@@ -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
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user