Merge pull request #885 from cbcoutinho/feat/deck-move-card-between-boards
feat(deck): move cards between boards
This commit is contained in:
+2
-1
@@ -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 |
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user