feat(deck): add deck_move_card_to_board tool for cross-board moves

deck_reorder_card only relocated a card between stacks on the same board.
Moving a card to another board now has a dedicated tool that goes through
Deck's card-update route (CardService::update), which remaps the card's
board-scoped labels to the destination board by title instead of leaving
orphaned labels behind. Card identity (id, comments, attachments) is
preserved.

reorder_card is now restricted to same-board moves: it rejects a
target_stack_id on another board (which Deck's reorder route would accept
but with orphaned labels), steering clients to deck_move_card_to_board.

Verified empirically against Deck 1.15.9: the reorder route leaves a moved
card carrying its source board's label (boardId mismatch); the update route
remaps it to the destination board's same-titled label.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Chris Coutinho
2026-06-10 23:25:15 +02:00
co-authored by Claude Opus 4.8
parent ec075b4ed9
commit 437eaa0872
5 changed files with 452 additions and 4 deletions
@@ -0,0 +1,153 @@
"""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.
- 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.
"""
import httpx
import pytest
from nextcloud_mcp_server.client.deck import DeckClient
from nextcloud_mcp_server.models.deck import DeckCard
from tests.client.conftest import (
create_mock_deck_card_response,
create_mock_response,
)
pytestmark = pytest.mark.unit
def _stacks_list_response(stacks: list[dict]) -> httpx.Response:
"""Mock response for get_stacks (a JSON array of stack objects)."""
return create_mock_response(status_code=200, json_data=stacks)
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"
)
mock_make_request = mocker.patch.object(
DeckClient,
"_make_request",
side_effect=[get_card_response, put_response],
)
client = DeckClient(mocker.AsyncMock(spec=httpx.AsyncClient), "testuser")
moved = await client.move_card_to_board(
source_board_id=1,
source_stack_id=10,
card_id=42,
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]
method, url = put_call.args[0], put_call.args[1]
body = put_call.kwargs["json"]
assert method == "PUT"
assert url == "/apps/deck/cards/42"
assert body["id"] == 42
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
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],
)
client = DeckClient(mocker.AsyncMock(spec=httpx.AsyncClient), "testuser")
await client.move_card_to_board(
source_board_id=1,
source_stack_id=10,
card_id=7,
target_stack_id=99,
)
body = mock_make_request.call_args_list[1].kwargs["json"]
assert body["duedate"] == "2030-01-02T03:04:05+00:00"
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},
]
),
)
client = DeckClient(mocker.AsyncMock(spec=httpx.AsyncClient), "testuser")
with pytest.raises(ValueError, match="move_card_to_board"):
await client.reorder_card(
board_id=1,
stack_id=10,
card_id=42,
order=0,
target_stack_id=99,
)
# Only get_stacks was issued; the reorder PUT was never sent
mock_make_request.assert_called_once()
assert "/stacks" in mock_make_request.call_args.args[1]
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},
]
),
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=0,
target_stack_id=11,
)
assert mock_make_request.call_count == 2
reorder_call = mock_make_request.call_args_list[1]
assert reorder_call.args[0] == "PUT"
assert reorder_call.args[1] == "/apps/deck/cards/42/reorder"
assert reorder_call.kwargs["json"] == {"order": 0, "stackId": 11}
@@ -0,0 +1,177 @@
"""Integration tests for moving Deck cards between boards.
Covers ``move_card_to_board`` and the same-board restriction on
``reorder_card``. The key behaviour under test is that a cross-board move
remaps the card's board-scoped labels to the destination board (by title)
rather than leaving orphaned labels that still reference the source board.
"""
import logging
import uuid
import pytest
from nextcloud_mcp_server.client import NextcloudClient
logger = logging.getLogger(__name__)
pytestmark = pytest.mark.integration
@pytest.fixture
async def two_boards_with_stacks(nc_client: NextcloudClient):
"""Create two temporary boards, each with a single stack.
Yields:
tuple: (source_board_id, source_stack_id, target_board_id, target_stack_id)
"""
unique_suffix = uuid.uuid4().hex[:8]
source_board = None
target_board = None
try:
source_board = await nc_client.deck.create_board(
f"Move Test Source {unique_suffix}", "FF0000"
)
target_board = await nc_client.deck.create_board(
f"Move Test Target {unique_suffix}", "0000FF"
)
source_stack = await nc_client.deck.create_stack(
source_board.id, f"Source Stack {unique_suffix}", order=1
)
target_stack = await nc_client.deck.create_stack(
target_board.id, f"Target Stack {unique_suffix}", order=1
)
logger.info(
"Created source board %s/stack %s and target board %s/stack %s",
source_board.id,
source_stack.id,
target_board.id,
target_stack.id,
)
yield (source_board.id, source_stack.id, target_board.id, target_stack.id)
finally:
for board in (source_board, target_board):
if board:
try:
await nc_client.deck.delete_board(board.id)
except Exception as e:
logger.warning("Error cleaning up board %s: %s", board.id, e)
async def test_move_card_to_board_preserves_identity(
nc_client: NextcloudClient, two_boards_with_stacks: tuple
):
"""A cross-board move relocates the card (same id) to the target board."""
source_board_id, source_stack_id, target_board_id, target_stack_id = (
two_boards_with_stacks
)
suffix = uuid.uuid4().hex[:8]
card = await nc_client.deck.create_card(
source_board_id, source_stack_id, f"Move me {suffix}", description="payload"
)
logger.info("Created card %s on source board %s", card.id, source_board_id)
moved = await nc_client.deck.move_card_to_board(
source_board_id=source_board_id,
source_stack_id=source_stack_id,
card_id=card.id,
target_stack_id=target_stack_id,
)
# Same card id, now on the target stack, with its description preserved
assert moved.id == card.id
assert moved.stackId == target_stack_id
# The card is readable on the target board and gone from the source stack
on_target = await nc_client.deck.get_card(target_board_id, target_stack_id, card.id)
assert on_target.stackId == target_stack_id
assert on_target.description == "payload"
source_cards = await nc_client.deck.get_stack(source_board_id, source_stack_id)
source_card_ids = {c.id for c in (source_cards.cards or [])}
assert card.id not in source_card_ids
async def test_move_card_to_board_remaps_labels(
nc_client: NextcloudClient, two_boards_with_stacks: tuple
):
"""A board-scoped label is remapped to the destination board, not orphaned.
Deck auto-creates the same default labels (e.g. "Finished") on every board,
so the moved card's "Finished" label should end up pointing at the target
board's "Finished" label rather than keeping the source board's id.
"""
source_board_id, source_stack_id, target_board_id, target_stack_id = (
two_boards_with_stacks
)
# Pick a default label that exists on both boards by title
source_board = await nc_client.deck.get_board(source_board_id)
target_board = await nc_client.deck.get_board(target_board_id)
source_label = next(
label for label in source_board.labels if label.title == "Finished"
)
target_label = next(
label for label in target_board.labels if label.title == "Finished"
)
suffix = uuid.uuid4().hex[:8]
card = await nc_client.deck.create_card(
source_board_id, source_stack_id, f"Labeled {suffix}"
)
await nc_client.deck.assign_label_to_card(
source_board_id, source_stack_id, card.id, source_label.id
)
# Sanity check: the card carries the source board's label before the move
before = await nc_client.deck.get_card(source_board_id, source_stack_id, card.id)
assert any(label.id == source_label.id for label in (before.labels or []))
await nc_client.deck.move_card_to_board(
source_board_id=source_board_id,
source_stack_id=source_stack_id,
card_id=card.id,
target_stack_id=target_stack_id,
)
after = await nc_client.deck.get_card(target_board_id, target_stack_id, card.id)
labels = after.labels or []
assert labels, "Card lost its label during the move"
finished = [label for label in labels if label.title == "Finished"]
assert finished, "The 'Finished' label was not preserved across the move"
# Remapped to the target board's label; the source board's label is gone
assert all(label.boardId == target_board_id for label in finished), (
f"Label still references source board: {[label.boardId for label in finished]}"
)
assert any(label.id == target_label.id for label in finished)
assert all(label.id != source_label.id for label in finished)
async def test_reorder_card_rejects_cross_board_target(
nc_client: NextcloudClient, two_boards_with_stacks: tuple
):
"""reorder_card refuses a target stack on a different board."""
source_board_id, source_stack_id, _target_board_id, target_stack_id = (
two_boards_with_stacks
)
suffix = uuid.uuid4().hex[:8]
card = await nc_client.deck.create_card(
source_board_id, source_stack_id, f"No cross-board reorder {suffix}"
)
with pytest.raises(ValueError, match="move_card_to_board"):
await nc_client.deck.reorder_card(
board_id=source_board_id,
stack_id=source_stack_id,
card_id=card.id,
order=0,
target_stack_id=target_stack_id, # belongs to the other board
)
# The card stayed put
still_there = await nc_client.deck.get_card(
source_board_id, source_stack_id, card.id
)
assert still_there.stackId == source_stack_id