fix(deck): preserve done/archived and validate target board on move

Addresses the round-1 review on PR #885:

- Preserve `done` across a cross-board move. The internal card-update route
  (the only one that works cross-board — the board/stack-scoped route 404s for
  a card not already on that board) does not accept a done value, so a "done"
  card is re-marked done after the move. Deck stamps the current time there, so
  the original timestamp isn't preserved — documented as a route limitation.
  (`archived` is already preserved: CardService only mutates it when sent.)
- Validate that target_stack_id is on target_board_id before moving, so the
  parameter is load-bearing and a mismatch fails loudly instead of misreporting.
- Skip the same-board guard's get_stacks round-trip on a same-stack reorder.
- Add unit coverage (done-restore call, destination validation, same-stack
  skip) and integration coverage (done preservation, target-board mismatch).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Chris Coutinho
2026-06-10 23:41:07 +02:00
co-authored by Claude Opus 4.8
parent 437eaa0872
commit 798a00d89d
4 changed files with 243 additions and 67 deletions
+117 -38
View File
@@ -1,11 +1,12 @@
"""Unit tests for DeckClient.move_card_to_board and the reorder same-board guard.
These mock the HTTP layer to assert request construction without a live server:
- move_card_to_board must send the card ``id`` and target ``stackId`` in the
body (the route placeholder is ``{cardId}`` but the controller reads ``id``
from the body), and preserve due/deleted state.
- move_card_to_board must validate the destination board, then PUT to the
internal card route with the card id and target stackId in the body (a
cross-board move the board/stack-scoped route can't do), restoring done state
afterwards since that route clears it.
- reorder_card must reject a target stack that is not on the given board before
issuing the reorder request, steering callers to move_card_to_board.
issuing the reorder request, while skipping the lookup for same-stack reorders.
"""
import httpx
@@ -21,24 +22,31 @@ from tests.client.conftest import (
pytestmark = pytest.mark.unit
def _stacks_list_response(stacks: list[dict]) -> httpx.Response:
def _stacks_list_response(stack_ids: list[int]) -> httpx.Response:
"""Mock response for get_stacks (a JSON array of stack objects)."""
return create_mock_response(status_code=200, json_data=stacks)
return create_mock_response(
status_code=200,
json_data=[
{"id": sid, "title": f"S{sid}", "boardId": 1, "order": i, "deletedAt": 0}
for i, sid in enumerate(stack_ids)
],
)
async def test_move_card_to_board_sends_id_and_target_stack(mocker):
"""The PUT body carries the card id and the destination stack id."""
get_card_response = create_mock_deck_card_response(
card_id=42, title="Movable", stack_id=10, description="keep me"
)
put_response = create_mock_deck_card_response(
card_id=42, title="Movable", stack_id=99, description="keep me"
)
"""The PUT lands on the internal card route with id + target stack in body."""
mock_make_request = mocker.patch.object(
DeckClient,
"_make_request",
side_effect=[get_card_response, put_response],
side_effect=[
_stacks_list_response([99]), # destination validation
create_mock_deck_card_response( # get_card (source)
card_id=42, title="Movable", stack_id=10, description="keep me"
),
create_mock_deck_card_response( # move PUT
card_id=42, title="Movable", stack_id=99, description="keep me"
),
],
)
client = DeckClient(mocker.AsyncMock(spec=httpx.AsyncClient), "testuser")
@@ -46,14 +54,14 @@ async def test_move_card_to_board_sends_id_and_target_stack(mocker):
source_board_id=1,
source_stack_id=10,
card_id=42,
target_board_id=2,
target_stack_id=99,
)
assert isinstance(moved, DeckCard)
assert moved.stackId == 99
# Second call is the update PUT to the internal card route
put_call = mock_make_request.call_args_list[1]
put_call = mock_make_request.call_args_list[2]
method, url = put_call.args[0], put_call.args[1]
body = put_call.kwargs["json"]
assert method == "PUT"
@@ -62,22 +70,24 @@ async def test_move_card_to_board_sends_id_and_target_stack(mocker):
assert body["stackId"] == 99
assert body["title"] == "Movable"
assert body["description"] == "keep me"
# No live due/deleted state on the source card
assert body["duedate"] is None
assert body["deletedAt"] == 0
# Not-done card: no follow-up done call
assert mock_make_request.call_count == 3
async def test_move_card_to_board_preserves_duedate(mocker):
"""A due date on the source card is forwarded as an ISO-8601 string."""
get_card_response = create_mock_deck_card_response(
card_id=7, stack_id=10, duedate="2030-01-02T03:04:05+00:00"
)
put_response = create_mock_deck_card_response(card_id=7, stack_id=99)
mock_make_request = mocker.patch.object(
DeckClient,
"_make_request",
side_effect=[get_card_response, put_response],
side_effect=[
_stacks_list_response([99]),
create_mock_deck_card_response(
card_id=7, stack_id=10, duedate="2030-01-02T03:04:05+00:00"
),
create_mock_deck_card_response(card_id=7, stack_id=99),
],
)
client = DeckClient(mocker.AsyncMock(spec=httpx.AsyncClient), "testuser")
@@ -85,25 +95,75 @@ async def test_move_card_to_board_preserves_duedate(mocker):
source_board_id=1,
source_stack_id=10,
card_id=7,
target_board_id=2,
target_stack_id=99,
)
body = mock_make_request.call_args_list[1].kwargs["json"]
body = mock_make_request.call_args_list[2].kwargs["json"]
assert body["duedate"] == "2030-01-02T03:04:05+00:00"
async def test_move_card_to_board_restores_done_state(mocker):
"""A done card triggers a follow-up PUT to the done endpoint after the move."""
mock_make_request = mocker.patch.object(
DeckClient,
"_make_request",
side_effect=[
_stacks_list_response([99]),
create_mock_deck_card_response( # source card is done
card_id=8, stack_id=10, done="2029-12-31T23:59:00+00:00"
),
create_mock_deck_card_response(card_id=8, stack_id=99, done=None),
create_mock_deck_card_response(card_id=8, stack_id=99), # done PUT
create_mock_deck_card_response( # re-fetch after restore
card_id=8, stack_id=99, done="2031-01-01T00:00:00+00:00"
),
],
)
client = DeckClient(mocker.AsyncMock(spec=httpx.AsyncClient), "testuser")
await client.move_card_to_board(
source_board_id=1,
source_stack_id=10,
card_id=8,
target_board_id=2,
target_stack_id=99,
)
# 4th call restores done on the moved card
done_call = mock_make_request.call_args_list[3]
assert done_call.args[0] == "PUT"
assert done_call.args[1] == "/apps/deck/cards/8/done"
async def test_move_card_to_board_rejects_stack_not_on_target_board(mocker):
"""A target stack absent from the target board is rejected before any move."""
mock_make_request = mocker.patch.object(
DeckClient,
"_make_request",
return_value=_stacks_list_response([50, 51]), # 99 not present
)
client = DeckClient(mocker.AsyncMock(spec=httpx.AsyncClient), "testuser")
with pytest.raises(ValueError, match="not a stack on target board"):
await client.move_card_to_board(
source_board_id=1,
source_stack_id=10,
card_id=42,
target_board_id=2,
target_stack_id=99,
)
# Only the destination validation ran; no get_card / move PUT
mock_make_request.assert_called_once()
async def test_reorder_card_rejects_cross_board_target(mocker):
"""A target stack absent from the board is rejected before any reorder PUT."""
# get_stacks returns stacks 10 and 11 — target 99 is on another board
mock_make_request = mocker.patch.object(
DeckClient,
"_make_request",
return_value=_stacks_list_response(
[
{"id": 10, "title": "A", "boardId": 1, "order": 1, "deletedAt": 0},
{"id": 11, "title": "B", "boardId": 1, "order": 2, "deletedAt": 0},
]
),
return_value=_stacks_list_response([10, 11]), # target 99 on another board
)
client = DeckClient(mocker.AsyncMock(spec=httpx.AsyncClient), "testuser")
@@ -121,18 +181,37 @@ async def test_reorder_card_rejects_cross_board_target(mocker):
assert "/stacks" in mock_make_request.call_args.args[1]
async def test_reorder_card_same_stack_skips_lookup(mocker):
"""A same-stack reorder issues only the PUT — no get_stacks round-trip."""
mock_make_request = mocker.patch.object(
DeckClient,
"_make_request",
return_value=create_mock_response(status_code=200, json_data={}),
)
client = DeckClient(mocker.AsyncMock(spec=httpx.AsyncClient), "testuser")
await client.reorder_card(
board_id=1,
stack_id=10,
card_id=42,
order=3,
target_stack_id=10, # same stack — pure reorder
)
mock_make_request.assert_called_once()
call = mock_make_request.call_args
assert call.args[0] == "PUT"
assert call.args[1] == "/apps/deck/cards/42/reorder"
assert call.kwargs["json"] == {"order": 3, "stackId": 10}
async def test_reorder_card_allows_same_board_target(mocker):
"""A target stack on the same board passes the guard and issues the PUT."""
mock_make_request = mocker.patch.object(
DeckClient,
"_make_request",
side_effect=[
_stacks_list_response(
[
{"id": 10, "title": "A", "boardId": 1, "order": 1, "deletedAt": 0},
{"id": 11, "title": "B", "boardId": 1, "order": 2, "deletedAt": 0},
]
),
_stacks_list_response([10, 11]),
create_mock_response(status_code=200, json_data={}),
],
)