diff --git a/nextcloud_mcp_server/client/deck.py b/nextcloud_mcp_server/client/deck.py index c8c3cc2..62b6f84 100644 --- a/nextcloud_mcp_server/client/deck.py +++ b/nextcloud_mcp_server/client/deck.py @@ -285,28 +285,23 @@ class DeckClient(BaseNextcloudClient): archived: Optional[bool] = None, done: Optional[str] = None, ) -> None: - # First, get the current card to use existing values for required fields + # Deck PUT API is a full replacement - all required fields must be sent. + # Fetch current card to preserve values for fields not being updated. current_card = await self.get_card(board_id, stack_id, card_id) - json_data = {} - if title is not None: - json_data["title"] = title - if description is not None: - json_data["description"] = description - # Type is required by the API, use provided or keep current - json_data["type"] = type if type is not None else current_card.type - # Owner is required by the API, use provided or keep current - json_data["owner"] = ( - owner - if owner is not None - else ( - current_card.owner - if isinstance(current_card.owner, str) - else current_card.owner.uid - if hasattr(current_card.owner, "uid") - else current_card.owner.primaryKey - ) - ) + # Build payload with required fields always included + json_data = { + # Title is required by the API + "title": title if title is not None else current_card.title, + # Type is required by the API + "type": type if type is not None else current_card.type, + # Owner is required by the API (model validator ensures it's a string) + "owner": owner if owner is not None else current_card.owner, + # Description must be sent to preserve it (PUT clears omitted fields) + "description": description + if description is not None + else (current_card.description or ""), + } if order is not None: json_data["order"] = order if duedate is not None: diff --git a/tests/client/deck/test_deck_update_card_api.py b/tests/client/deck/test_deck_update_card_api.py new file mode 100644 index 0000000..d110d9f --- /dev/null +++ b/tests/client/deck/test_deck_update_card_api.py @@ -0,0 +1,194 @@ +""" +Integration tests for DeckClient.update_card API behavior. + +These tests define the EXPECTED behavior for partial card updates: +- Only fields explicitly passed should be modified +- All other fields should be preserved unchanged + +Related issues: +- nextcloud-mcp-server #452: DeckClient.update_card partial update bugs +- deck #3127: REST API Docs: missing parameter in "update cards" +- deck #4106: Provide a working example of API usage to update a cards details +""" + +import pytest + +pytestmark = [pytest.mark.integration] + + +@pytest.fixture +async def deck_test_card(nc_client): + """Create a board, stack, and card for testing, cleanup after.""" + board = await nc_client.deck.create_board("Test Update Card API", "FF0000") + stack = await nc_client.deck.create_stack(board.id, "Test Stack", 1) + card = await nc_client.deck.create_card( + board.id, + stack.id, + "Original Title", + type="plain", + description="Original description", + ) + + yield { + "board_id": board.id, + "stack_id": stack.id, + "card_id": card.id, + "card": card, + } + + # Cleanup + await nc_client.deck.delete_board(board.id) + + +class TestDeckClientUpdateCard: + """ + Test DeckClient.update_card() partial update behavior. + + Expected: Only explicitly provided fields are updated, all others preserved. + """ + + async def test_update_title_only_preserves_description( + self, nc_client, deck_test_card + ): + """Updating only the title should preserve the description.""" + await nc_client.deck.update_card( + board_id=deck_test_card["board_id"], + stack_id=deck_test_card["stack_id"], + card_id=deck_test_card["card_id"], + title="New Title", + ) + + updated = await nc_client.deck.get_card( + deck_test_card["board_id"], + deck_test_card["stack_id"], + deck_test_card["card_id"], + ) + assert updated.title == "New Title" + assert updated.description == "Original description" + + async def test_update_description_only(self, nc_client, deck_test_card): + """Updating only the description should work and preserve other fields.""" + await nc_client.deck.update_card( + board_id=deck_test_card["board_id"], + stack_id=deck_test_card["stack_id"], + card_id=deck_test_card["card_id"], + description="New description only", + ) + + updated = await nc_client.deck.get_card( + deck_test_card["board_id"], + deck_test_card["stack_id"], + deck_test_card["card_id"], + ) + assert updated.title == "Original Title" + assert updated.description == "New description only" + + async def test_update_title_and_description(self, nc_client, deck_test_card): + """Updating title and description together should work.""" + await nc_client.deck.update_card( + board_id=deck_test_card["board_id"], + stack_id=deck_test_card["stack_id"], + card_id=deck_test_card["card_id"], + title="New Title", + description="New description", + ) + + updated = await nc_client.deck.get_card( + deck_test_card["board_id"], + deck_test_card["stack_id"], + deck_test_card["card_id"], + ) + assert updated.title == "New Title" + assert updated.description == "New description" + + async def test_update_duedate_only(self, nc_client, deck_test_card): + """Updating only the duedate should work and preserve other fields.""" + await nc_client.deck.update_card( + board_id=deck_test_card["board_id"], + stack_id=deck_test_card["stack_id"], + card_id=deck_test_card["card_id"], + duedate="2025-12-31T23:59:59+00:00", + ) + + updated = await nc_client.deck.get_card( + deck_test_card["board_id"], + deck_test_card["stack_id"], + deck_test_card["card_id"], + ) + assert updated.title == "Original Title" + assert updated.description == "Original description" + assert updated.duedate is not None + + async def test_update_archived_only(self, nc_client, deck_test_card): + """Updating only the archived status should work and preserve other fields.""" + await nc_client.deck.update_card( + board_id=deck_test_card["board_id"], + stack_id=deck_test_card["stack_id"], + card_id=deck_test_card["card_id"], + archived=True, + ) + + updated = await nc_client.deck.get_card( + deck_test_card["board_id"], + deck_test_card["stack_id"], + deck_test_card["card_id"], + ) + assert updated.title == "Original Title" + assert updated.description == "Original description" + assert updated.archived is True + + async def test_update_order_only(self, nc_client, deck_test_card): + """Updating only the order should work and preserve other fields.""" + await nc_client.deck.update_card( + board_id=deck_test_card["board_id"], + stack_id=deck_test_card["stack_id"], + card_id=deck_test_card["card_id"], + order=99, + ) + + updated = await nc_client.deck.get_card( + deck_test_card["board_id"], + deck_test_card["stack_id"], + deck_test_card["card_id"], + ) + assert updated.title == "Original Title" + assert updated.description == "Original description" + assert updated.order == 99 + + async def test_update_preserves_type(self, nc_client, deck_test_card): + """Type should be preserved when not explicitly changed.""" + original = deck_test_card["card"] + + await nc_client.deck.update_card( + board_id=deck_test_card["board_id"], + stack_id=deck_test_card["stack_id"], + card_id=deck_test_card["card_id"], + title="Changed Title", + ) + + updated = await nc_client.deck.get_card( + deck_test_card["board_id"], + deck_test_card["stack_id"], + deck_test_card["card_id"], + ) + assert updated.type == original.type + assert updated.description == "Original description" + + async def test_update_preserves_owner(self, nc_client, deck_test_card): + """Owner should be preserved when not explicitly changed.""" + original = deck_test_card["card"] + + await nc_client.deck.update_card( + board_id=deck_test_card["board_id"], + stack_id=deck_test_card["stack_id"], + card_id=deck_test_card["card_id"], + title="Changed Title", + ) + + updated = await nc_client.deck.get_card( + deck_test_card["board_id"], + deck_test_card["stack_id"], + deck_test_card["card_id"], + ) + assert updated.owner == original.owner + assert updated.description == "Original description"