diff --git a/docs/deck.md b/docs/deck.md index 56c0978b..46dd9ae7 100644 --- a/docs/deck.md +++ b/docs/deck.md @@ -23,7 +23,8 @@ | `deck_delete_card` | Delete a card | | `deck_archive_card` | Archive a card | | `deck_unarchive_card` | Unarchive a card | -| `deck_reorder_card` | Move/reorder cards within or between stacks | +| `deck_reorder_card` | Reorder/move a card within a single board (between stacks on that board) | +| `deck_move_card_to_board` | Move a card to a stack on a different board, remapping board-scoped labels | | `deck_create_label` | Create a new label in a board | | `deck_update_label` | Update label title and color | | `deck_delete_label` | Delete a label | diff --git a/nextcloud_mcp_server/client/deck.py b/nextcloud_mcp_server/client/deck.py index 2b9d05ff..2a0c3081 100644 --- a/nextcloud_mcp_server/client/deck.py +++ b/nextcloud_mcp_server/client/deck.py @@ -1,5 +1,8 @@ +import logging from typing import Any, Dict, List, Optional +from httpx import HTTPStatusError, RequestError + from nextcloud_mcp_server.client.base import BaseNextcloudClient from nextcloud_mcp_server.models.deck import ( DeckACL, @@ -13,6 +16,8 @@ from nextcloud_mcp_server.models.deck import ( DeckStack, ) +logger = logging.getLogger(__name__) + class DeckClient(BaseNextcloudClient): """Client for Nextcloud Deck app operations.""" @@ -386,6 +391,22 @@ class DeckClient(BaseNextcloudClient): order: int, target_stack_id: int, ) -> None: + # Reorder is intentionally restricted to moves *within* a single board. + # Deck's reorder route (CardService::reorder) only reassigns stackId and + # does NOT remap board-scoped labels, so handing it a stack on another + # board silently orphans the card's labels (they keep the old boardId). + # Cross-board moves must go through move_card_to_board(), which uses the + # card-update route (CardService::update) and remaps labels by title. + # A same-stack reorder can't cross a board boundary, so skip the lookup. + if target_stack_id != stack_id: + board_stack_ids = {stack.id for stack in await self.get_stacks(board_id)} + if target_stack_id not in board_stack_ids: + raise ValueError( + f"target_stack_id {target_stack_id} is not a stack on board " + f"{board_id}; reorder_card only moves cards within a board. " + "Use move_card_to_board() to move a card to another board." + ) + # Use the non-API route /cards/{cardId}/reorder which correctly reads # stackId from the body. The API route /api/.../stacks/{stackId}/cards/... # has a parameter conflict where URL stackId overrides body stackId. @@ -399,6 +420,104 @@ class DeckClient(BaseNextcloudClient): headers=headers, ) + async def move_card_to_board( + self, + source_board_id: int, + source_stack_id: int, + card_id: int, + target_board_id: int, + target_stack_id: int, + order: int = 0, + ) -> DeckCard: + """Move a card to a stack on a different (or the same) board. + + Unlike :meth:`reorder_card`, this goes through the card-update route + (``CardService::update``). When the destination stack is on a different + board, Deck remaps the card's board-scoped labels by title — assigning + the same-titled label on the destination board, or cloning it there + when the user has board-manage permission — instead of leaving orphaned + labels behind. This mirrors Deck's native "Move/copy card" action. Card + identity (id, comments, attachments), ``archived`` state, the due date + and ``assignedUsers`` are preserved — the move does not re-validate + assignees against the destination board, so an assigned user without + access to the target board stays assigned but may not be able to act on + the card. + + The internal ``/apps/deck/cards/{cardId}`` route is used: it reads the + target ``stackId`` from the body (the board/stack-scoped API route + instead binds ``stackId`` from the URL — issue #469 — and 404s for a + card that isn't already on that board). The controller derives ``owner`` + from the session user and 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 done timestamp is not preserved (a limitation of + what this route exposes). + + The done re-mark is best-effort: the move itself has already committed + by then, so if that follow-up fails the card is left on the target + board without its done state (logged as a warning) rather than raising + and implying the move failed. + """ + # Validate the destination so target_board_id is load-bearing: the move + # itself is driven by stackId, so without this a stack on another board + # would relocate the card while the reported board is wrong. + target_stack_ids = { + stack.id for stack in await self.get_stacks(target_board_id) + } + if target_stack_id not in target_stack_ids: + raise ValueError( + f"target_stack_id {target_stack_id} is not a stack on target " + f"board {target_board_id}." + ) + + current = await self.get_card(source_board_id, source_stack_id, card_id) + + json_data: dict[str, Any] = { + # The route placeholder is {cardId} but the controller reads the + # card id from the body, so it must be sent explicitly. + "id": card_id, + "title": current.title, + "type": current.type, + "stackId": target_stack_id, + "order": order, + "description": current.description or "", + # Sent explicitly as None when absent (update_card omits the key); + # both are equivalent here — the route reads duedate and there's + # nothing to clear on a card that never had one. + "duedate": current.duedate.isoformat() if current.duedate else None, + # 0 keeps the card live (a positive value would soft-delete it) + "deletedAt": current.deletedAt or 0, + } + headers = self._get_deck_headers() + response = await self._make_request( + "PUT", + f"/apps/deck/cards/{card_id}", + json=json_data, + headers=headers, + ) + moved = DeckCard(**response.json()) + + # This route clears `done`; restore the done *state* if the card had it + # (the timestamp is refreshed to now — see the docstring note). The move + # has already committed, so this is best-effort: on failure, warn with + # the card's new location rather than raising as if the move failed. + if current.done is not None: + try: + await self._make_request( + "PUT", f"/apps/deck/cards/{card_id}/done", headers=headers + ) + moved = await self.get_card(target_board_id, target_stack_id, card_id) + except (HTTPStatusError, RequestError) as e: + logger.warning( + "Card %s moved to board %s stack %s but restoring done " + "state failed: %s", + card_id, + target_board_id, + target_stack_id, + e, + ) + + return moved + # Labels async def get_label(self, board_id: int, label_id: int) -> DeckLabel: headers = self._get_deck_headers() diff --git a/nextcloud_mcp_server/models/deck.py b/nextcloud_mcp_server/models/deck.py index 1d9890a1..98eb1a6b 100644 --- a/nextcloud_mcp_server/models/deck.py +++ b/nextcloud_mcp_server/models/deck.py @@ -337,6 +337,14 @@ class CardOperationResponse(StatusResponse): card_id: int = Field(description="ID of the affected card") stack_id: int = Field(description="ID of the stack containing the card") board_id: int = Field(description="ID of the board containing the card") + labels: list[str] | None = Field( + default=None, + description=( + "Label titles on the card after the operation, when relevant — " + "e.g. after a cross-board move that remaps board-scoped labels to " + "the destination board" + ), + ) # Label Response Models diff --git a/nextcloud_mcp_server/server/deck.py b/nextcloud_mcp_server/server/deck.py index 2638f099..68e0b4ed 100644 --- a/nextcloud_mcp_server/server/deck.py +++ b/nextcloud_mcp_server/server/deck.py @@ -1212,7 +1212,7 @@ def configure_deck_tools(mcp: FastMCP): ) @mcp.tool( - title="Reorder/Move Deck Card", + title="Reorder Deck Card", annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True), ) @require_scopes("deck.write") @@ -1225,14 +1225,19 @@ def configure_deck_tools(mcp: FastMCP): order: int, target_stack_id: int, ) -> CardOperationResponse: - """Reorder/move a Nextcloud Deck card + """Reorder a Nextcloud Deck card within a board. + + Moves a card to a new position, optionally into a different stack on + the SAME board. To move a card to a stack on a DIFFERENT board, use + deck_move_card_to_board instead — reordering across boards is rejected + because it would orphan the card's board-scoped labels. Args: board_id: The ID of the board stack_id: The ID of the current stack card_id: The ID of the card order: New position in the target stack - target_stack_id: The ID of the target stack + target_stack_id: The ID of the target stack (must be on board_id) """ client = await get_client(ctx) await client.deck.reorder_card( @@ -1246,6 +1251,67 @@ def configure_deck_tools(mcp: FastMCP): board_id=board_id, ) + @mcp.tool( + title="Move Deck Card to Another Board", + annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True), + ) + @require_scopes("deck.write") + @instrument_tool + async def deck_move_card_to_board( + ctx: Context, + source_board_id: int, + source_stack_id: int, + card_id: int, + target_board_id: int, + target_stack_id: int, + order: int = 0, + ) -> CardOperationResponse: + """Move a Nextcloud Deck card to a stack on a different board. + + The card keeps its identity (same id, comments, attachments), along + with its archived state, due date and user assignments (an assignee + without access to the target board stays assigned but cannot act on the + card). Deck remaps the card's board-scoped labels to the destination + board by title — reusing a same-titled label there, or cloning it when + you have board-manage permission. Use deck_reorder_card for moves + within a single board. + + Two caveats from Deck's move route: the card's owner is reassigned to + the user performing the move (the original owner is not preserved), and + a card marked done keeps its done state but its done timestamp is reset + to the time of the move. + + target_stack_id must be a stack on target_board_id; the move is + rejected otherwise. + + Args: + source_board_id: The ID of the board the card currently lives on + source_stack_id: The ID of the stack the card currently lives in + card_id: The ID of the card to move + target_board_id: The ID of the destination board + target_stack_id: The ID of the destination stack (must be on target_board_id) + order: Position within the destination stack (default 0 = top) + """ + client = await get_client(ctx) + moved = await client.deck.move_card_to_board( + source_board_id, + source_stack_id, + card_id, + target_board_id, + target_stack_id, + order, + ) + # Surface the post-move labels so callers can confirm the remap without + # a follow-up get_card (label remapping is this tool's whole point). + return CardOperationResponse( + success=True, + message="Card moved to board successfully", + card_id=card_id, + stack_id=target_stack_id, + board_id=target_board_id, + labels=[label.title for label in (moved.labels or [])], + ) + # Label Tools @mcp.tool( title="Create Deck Label", diff --git a/tests/client/deck/test_deck_move_card_api.py b/tests/client/deck/test_deck_move_card_api.py new file mode 100644 index 00000000..d4a29d38 --- /dev/null +++ b/tests/client/deck/test_deck_move_card_api.py @@ -0,0 +1,271 @@ +"""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 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, while skipping the lookup for same-stack reorders. +""" + +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(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=[ + {"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 lands on the internal card route with id + target stack in body.""" + mock_make_request = mocker.patch.object( + DeckClient, + "_make_request", + 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") + moved = 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, + ) + + assert isinstance(moved, DeckCard) + assert moved.stackId == 99 + + 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" + assert url == "/apps/deck/cards/42" + assert body["id"] == 42 + assert body["stackId"] == 99 + assert body["title"] == "Movable" + assert body["description"] == "keep me" + 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.""" + mock_make_request = mocker.patch.object( + DeckClient, + "_make_request", + 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") + await client.move_card_to_board( + 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[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_response(status_code=200, json_data={}), # done PUT (ignored) + 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") + result = 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" + # The returned card reflects the re-fetched (restored) done state + assert result.done is not None + + +async def test_move_card_to_board_done_restore_failure_is_swallowed(mocker): + """If the post-move done PUT fails, the move still succeeds (best-effort).""" + 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), # move + httpx.HTTPStatusError( # done PUT fails + "500 Server Error", + request=httpx.Request("PUT", "https://test.local"), + response=create_mock_response(status_code=500, json_data={}), + ), + ], + ) + + client = DeckClient(mocker.AsyncMock(spec=httpx.AsyncClient), "testuser") + # Does not raise — the move already committed + result = 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, + ) + + # Returns the moved card from the (successful) move PUT, done unrestored + assert result.stackId == 99 + assert result.done is None + # The done restore was attempted (and failed), with no re-fetch after it + assert mock_make_request.call_count == 4 + assert mock_make_request.call_args_list[3].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.""" + mock_make_request = mocker.patch.object( + DeckClient, + "_make_request", + return_value=_stacks_list_response([10, 11]), # target 99 on another board + ) + + 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_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([10, 11]), + 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} diff --git a/tests/integration/test_deck_move_card_to_board.py b/tests/integration/test_deck_move_card_to_board.py new file mode 100644 index 00000000..4d7e9844 --- /dev/null +++ b/tests/integration/test_deck_move_card_to_board.py @@ -0,0 +1,346 @@ +"""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_board_id=target_board_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 + ) + + # "Finished" is one of the four labels Deck auto-creates on every new + # board, so it is reliably present on both the source and target boards. + 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_board_id=target_board_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_move_card_to_board_preserves_done_status( + nc_client: NextcloudClient, two_boards_with_stacks: tuple +): + """A card marked done keeps its done state (not timestamp) across a move.""" + 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"Done card {suffix}" + ) + done_ts = "2030-01-02T03:04:05+00:00" + await nc_client.deck.update_card( + board_id=source_board_id, + stack_id=source_stack_id, + card_id=card.id, + done=done_ts, + ) + before = await nc_client.deck.get_card(source_board_id, source_stack_id, card.id) + assert before.done is not None, "Card should be done before the move" + + 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_board_id=target_board_id, + target_stack_id=target_stack_id, + ) + + after = await nc_client.deck.get_card(target_board_id, target_stack_id, card.id) + assert after.done is not None, "done status was cleared during the move" + + +async def test_move_card_to_board_preserves_archived_status( + nc_client: NextcloudClient, two_boards_with_stacks: tuple +): + """An archived card stays archived across a cross-board move.""" + 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"Archived card {suffix}" + ) + await nc_client.deck.archive_card(source_board_id, source_stack_id, card.id) + + 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_board_id=target_board_id, + target_stack_id=target_stack_id, + ) + + after = await nc_client.deck.get_card(target_board_id, target_stack_id, card.id) + assert after.stackId == target_stack_id + assert after.archived is True, "archived state was lost during the move" + + +async def test_move_card_to_board_preserves_done_and_archived( + nc_client: NextcloudClient, two_boards_with_stacks: tuple +): + """A done *and* archived card keeps both states across a cross-board move. + + This exercises the done-restore path (post-move done PUT + re-fetch) on an + archived card, confirming get_card on the destination handles it. + """ + 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"Done+archived {suffix}" + ) + await nc_client.deck.update_card( + board_id=source_board_id, + stack_id=source_stack_id, + card_id=card.id, + done="2030-01-02T03:04:05+00:00", + ) + await nc_client.deck.archive_card(source_board_id, source_stack_id, card.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_board_id=target_board_id, + target_stack_id=target_stack_id, + ) + assert moved.done is not None + assert moved.archived is True + + after = await nc_client.deck.get_card(target_board_id, target_stack_id, card.id) + assert after.stackId == target_stack_id + assert after.done is not None, "done state was lost during the move" + assert after.archived is True, "archived state was lost during the move" + + +async def test_move_card_to_board_preserves_assigned_users( + nc_client: NextcloudClient, two_boards_with_stacks: tuple +): + """Assigned users carry over a cross-board move (the route doesn't touch them). + + Deck's update route remaps labels on a board change but leaves the card's + user assignments untouched, so an assignee that exists on both boards stays + assigned. + """ + 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"Assigned {suffix}" + ) + # The test user owns both temporary boards, so it is a valid assignee on each + await nc_client.deck.assign_user_to_card( + source_board_id, source_stack_id, card.id, nc_client.username + ) + before = await nc_client.deck.get_card(source_board_id, source_stack_id, card.id) + assert before.assignedUsers, "Card should have an assignee before the move" + + 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_board_id=target_board_id, + target_stack_id=target_stack_id, + ) + + after = await nc_client.deck.get_card(target_board_id, target_stack_id, card.id) + assert after.assignedUsers, "Assigned users were dropped during the move" + + +async def test_move_card_to_board_rejects_stack_not_on_target_board( + nc_client: NextcloudClient, two_boards_with_stacks: tuple +): + """The move is rejected when target_stack_id is not on target_board_id.""" + 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"Mismatch {suffix}" + ) + + with pytest.raises(ValueError, match="not a stack on target board"): + 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_board_id=target_board_id, + target_stack_id=source_stack_id, # on the source board, not target + ) + + # 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 + + +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