Files
mcp-nextcloud/tests/unit/test_deck_server.py
T
Chris CoutinhoandClaude Opus 4.7 271904c227 fix(deck): address review — notesPath key, scopes, modernize types
PR #781 review round 1:

- 🔴 Fix notesPath key: the Notes API returns the folder under camelCase
  ``notesPath`` (see models/notes.py:43), but `deck_attach_note` was
  looking up snake_case ``notes_path`` and silently falling back to
  ``"Notes"``. Users with a non-default notes folder would have produced
  shares pointing at non-existent files (404 on click in Deck UI).

- 🔴 Add wire-through unit test that would have caught the above:
  extract `_resolve_note_attach_path(client, note_id)` as a testable
  helper that encapsulates the camelCase-key lookup. Three new tests:
  custom notesPath honored, missing key falls back to default, null
  category handled.

- 🟡 Modernize new fields on `DeckAttachmentExtendedData` to PEP 604
  (`X | None`) per CLAUDE.md.

- 🟡 Drop unnecessary string forward reference on
  `ListAttachmentsResponse.results` — DeckAttachment is defined earlier
  in the same module.

- 🟢 Move `pytestmark = pytest.mark.unit` to module level in
  test_sharing_client.py to match the convention in test_deck_server.py.

Per user request: `deck_attach_file` is now scoped `deck.write` +
``files.read`` (was just `deck.write`) so the generic file-share
permission story is consistent — only `deck_attach_note` keeps
`notes.read` since it specifically reads from the Notes app. Docstring
updated to emphasise the tool is generic over the user's Files
(PDFs/images/etc., not just markdown).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 23:28:37 +02:00

471 lines
15 KiB
Python

import pytest
from nextcloud_mcp_server.models.deck import (
DeckACL,
DeckBoard,
DeckCard,
DeckLabel,
DeckPermissions,
DeckStack,
DeckUser,
)
from nextcloud_mcp_server.server.deck import (
_SHARE_TYPE_DECK,
_apply_board_filters,
_apply_card_filters,
_apply_stack_filters,
_resolve_note_attach_path,
_resolve_note_path,
_truncate_card_descriptions,
_validate_description_max_length,
)
pytestmark = pytest.mark.unit
# Fixtures ------------------------------------------------------------------
def _make_card(
card_id: int,
description: str | None = "desc",
archived: bool = False,
) -> DeckCard:
return DeckCard(
id=card_id,
title=f"Card {card_id}",
stackId=1,
type="plain",
order=card_id,
archived=archived,
owner="testuser",
description=description,
)
def _make_user(uid: str = "testuser") -> DeckUser:
return DeckUser(primaryKey=uid, uid=uid, displayname=uid)
def _make_board(
board_id: int = 1,
*,
labels: list[DeckLabel] | None = None,
acl: list[DeckACL] | None = None,
users: list[DeckUser] | None = None,
) -> DeckBoard:
return DeckBoard(
id=board_id,
title=f"Board {board_id}",
owner=_make_user(),
color="FF0000",
archived=False,
labels=labels
if labels is not None
else [DeckLabel(id=1, title="L1", color="00FF00")],
acl=acl if acl is not None else [],
permissions=DeckPermissions(
PERMISSION_READ=True,
PERMISSION_EDIT=True,
PERMISSION_MANAGE=True,
PERMISSION_SHARE=True,
),
users=users if users is not None else [_make_user("alice"), _make_user("bob")],
deletedAt=0,
)
def _make_stack(
stack_id: int = 1,
*,
cards: list[DeckCard] | None = None,
) -> DeckStack:
return DeckStack(
id=stack_id,
title=f"Stack {stack_id}",
boardId=1,
order=stack_id,
deletedAt=0,
cards=cards,
)
# _truncate_card_descriptions ----------------------------------------------
def test_truncate_card_descriptions_no_op_when_limit_is_none():
"""When description_max_length is None, descriptions are left untouched."""
cards = [_make_card(1, "x" * 5000)]
_truncate_card_descriptions(cards, None)
assert cards[0].description is not None
assert len(cards[0].description) == 5000
def test_truncate_card_descriptions_truncates_long_descriptions():
"""Descriptions over the limit are truncated and marked with an ellipsis."""
cards = [_make_card(1, "x" * 5000), _make_card(2, "short")]
_truncate_card_descriptions(cards, 100)
assert cards[0].description is not None
assert len(cards[0].description) == 101 # 100 chars + ellipsis
assert cards[0].description.endswith("…")
assert cards[1].description == "short"
def test_truncate_card_descriptions_handles_none_description():
"""Cards with no description are skipped without error."""
cards = [_make_card(1, None)]
_truncate_card_descriptions(cards, 100)
assert cards[0].description is None
def test_truncate_card_descriptions_at_exact_boundary():
"""Descriptions at exactly the limit should not be truncated."""
cards = [_make_card(1, "x" * 100)]
_truncate_card_descriptions(cards, 100)
assert cards[0].description == "x" * 100
def test_truncate_card_descriptions_one_over_limit():
"""A description one character over the limit triggers truncation."""
cards = [_make_card(1, "x" * 101)]
_truncate_card_descriptions(cards, 100)
assert cards[0].description is not None
assert len(cards[0].description) == 101 # 100 chars + ellipsis
assert cards[0].description.endswith("…")
def test_truncate_card_descriptions_shorter_than_limit_no_ellipsis():
"""A description shorter than the limit must not have an ellipsis appended."""
cards = [_make_card(1, "hello")]
_truncate_card_descriptions(cards, 1000)
assert cards[0].description == "hello"
# _validate_description_max_length ----------------------------------------
def test_validate_description_max_length_accepts_none():
"""None is the documented sentinel for "no truncation"."""
_validate_description_max_length(None)
def test_validate_description_max_length_accepts_positive():
"""Positive values pass through silently."""
_validate_description_max_length(1)
_validate_description_max_length(1000)
def test_validate_description_max_length_rejects_zero():
"""Zero would wipe descriptions to a single ellipsis — reject at the boundary."""
with pytest.raises(ValueError, match="must be positive"):
_validate_description_max_length(0)
def test_validate_description_max_length_rejects_negative():
"""Negative values produce surprising slice semantics — reject at the boundary."""
with pytest.raises(ValueError, match="must be positive"):
_validate_description_max_length(-10)
# _apply_board_filters ------------------------------------------------------
def test_apply_board_filters_defaults_preserve_fields():
"""With all include_* flags True, no fields are cleared."""
board = _make_board()
result = _apply_board_filters(
board, include_acl=True, include_users=True, include_labels=True
)
assert len(result.labels) == 1
assert len(result.users) == 2
def test_apply_board_filters_excludes_acl():
"""include_acl=False clears the acl list."""
board = _make_board(
acl=[
DeckACL(
id=1,
participant=_make_user("alice"),
type=0,
boardId=1,
permissionEdit=True,
permissionShare=True,
permissionManage=False,
owner=False,
)
]
)
result = _apply_board_filters(
board, include_acl=False, include_users=True, include_labels=True
)
assert result.acl == []
def test_apply_board_filters_excludes_users():
"""include_users=False clears the users list."""
board = _make_board()
result = _apply_board_filters(
board, include_acl=True, include_users=False, include_labels=True
)
assert result.users == []
def test_apply_board_filters_excludes_labels():
"""include_labels=False clears the labels list."""
board = _make_board()
result = _apply_board_filters(
board, include_acl=True, include_users=True, include_labels=False
)
assert result.labels == []
def test_apply_board_filters_excludes_all():
"""All include_* flags False clears every filterable list."""
board = _make_board()
result = _apply_board_filters(
board, include_acl=False, include_users=False, include_labels=False
)
assert result.acl == []
assert result.users == []
assert result.labels == []
# _apply_stack_filters ------------------------------------------------------
def test_apply_stack_filters_include_cards_false_strips_cards():
"""include_cards=False sets cards to None regardless of other flags."""
stack = _make_stack(cards=[_make_card(1), _make_card(2, archived=True)])
result = _apply_stack_filters(
stack,
include_cards=False,
include_archived_cards=True,
description_max_length=None,
)
assert result.cards is None
def test_apply_stack_filters_excludes_archived_by_default():
"""include_archived_cards=False filters out archived cards."""
stack = _make_stack(
cards=[_make_card(1, archived=False), _make_card(2, archived=True)]
)
result = _apply_stack_filters(
stack,
include_cards=True,
include_archived_cards=False,
description_max_length=None,
)
assert result.cards is not None
assert [c.id for c in result.cards] == [1]
def test_apply_stack_filters_keeps_archived_when_requested():
"""include_archived_cards=True retains archived cards."""
stack = _make_stack(
cards=[_make_card(1, archived=False), _make_card(2, archived=True)]
)
result = _apply_stack_filters(
stack,
include_cards=True,
include_archived_cards=True,
description_max_length=None,
)
assert result.cards is not None
assert [c.id for c in result.cards] == [1, 2]
def test_apply_stack_filters_truncates_descriptions_after_archive_filter():
"""Truncation runs on the post-archive-filter card set."""
stack = _make_stack(
cards=[
_make_card(1, description="x" * 50, archived=False),
_make_card(2, description="y" * 50, archived=True),
]
)
result = _apply_stack_filters(
stack,
include_cards=True,
include_archived_cards=False,
description_max_length=10,
)
assert result.cards is not None
assert len(result.cards) == 1
assert result.cards[0].description is not None
assert result.cards[0].description.endswith("…")
def test_apply_stack_filters_handles_none_cards():
"""A stack with no cards (cards=None) is left untouched."""
stack = _make_stack(cards=None)
result = _apply_stack_filters(
stack,
include_cards=True,
include_archived_cards=False,
description_max_length=10,
)
assert result.cards is None
def test_apply_stack_filters_all_archived_yields_empty_list_not_none():
"""A stack whose cards are all archived yields cards == [], not None.
Pin the contract: include_cards=True with all cards filtered out
means "the stack was loaded but had nothing to show", which is
semantically distinct from include_cards=False (cards=None,
"explicitly suppressed"). Callers checking ``stack.cards is None``
can use that to distinguish the two states.
"""
stack = _make_stack(
cards=[_make_card(1, archived=True), _make_card(2, archived=True)]
)
result = _apply_stack_filters(
stack,
include_cards=True,
include_archived_cards=False,
description_max_length=None,
)
assert result.cards == []
assert result.cards is not None
# _apply_card_filters -------------------------------------------------------
def test_apply_card_filters_excludes_archived_by_default():
"""include_archived_cards=False filters archived cards out of the flat list."""
cards = [
_make_card(1, archived=False),
_make_card(2, archived=True),
_make_card(3, archived=False),
]
result = _apply_card_filters(
cards, include_archived_cards=False, description_max_length=None
)
assert [c.id for c in result] == [1, 3]
def test_apply_card_filters_keeps_archived_when_requested():
"""include_archived_cards=True retains archived cards."""
cards = [_make_card(1, archived=False), _make_card(2, archived=True)]
result = _apply_card_filters(
cards, include_archived_cards=True, description_max_length=None
)
assert [c.id for c in result] == [1, 2]
def test_apply_card_filters_truncates_descriptions():
"""description_max_length is honored on the returned cards."""
cards = [_make_card(1, description="x" * 50)]
result = _apply_card_filters(
cards, include_archived_cards=True, description_max_length=10
)
assert result[0].description is not None
assert result[0].description.endswith("…")
def test_apply_card_filters_empty_list_is_noop():
"""An empty input returns an empty output."""
result = _apply_card_filters(
[], include_archived_cards=False, description_max_length=10
)
assert result == []
# _resolve_note_path -------------------------------------------------------
def test_resolve_note_path_no_category():
"""Path is /<notes_folder>/<title>.md when no category."""
assert _resolve_note_path("Notes", "", "My Note") == "/Notes/My Note.md"
def test_resolve_note_path_with_category():
"""Category is inserted as a sub-path."""
assert _resolve_note_path("Notes", "Work", "Standup") == "/Notes/Work/Standup.md"
def test_resolve_note_path_with_nested_category():
"""Nested categories (Notes app supports `/`-separated) are preserved."""
assert _resolve_note_path("Notes", "Work/Q4", "Plan") == "/Notes/Work/Q4/Plan.md"
def test_resolve_note_path_strips_redundant_slashes():
"""Leading/trailing slashes on inputs do not produce `//` in the result."""
assert _resolve_note_path("/Notes/", "/Work/", "Title") == "/Notes/Work/Title.md"
def test_resolve_note_path_custom_notes_folder():
"""Honors a non-default notes_folder from Notes app settings."""
assert (
_resolve_note_path("Documents/Notes", "", "Idea") == "/Documents/Notes/Idea.md"
)
# Share-type constant -------------------------------------------------------
def test_share_type_deck_constant_matches_deck_app():
"""Deck UI uses shareType=12 (IShare::TYPE_DECK) — must not drift."""
assert _SHARE_TYPE_DECK == 12
# _resolve_note_attach_path (camelCase notesPath guard) ---------------------
async def test_resolve_note_attach_path_honors_camelcase_notes_path(mocker):
"""Custom notes folders configured in the Notes app must be honored.
Regression: the Notes API returns the folder under ``notesPath`` (camelCase,
see ``models/notes.py:43``). An earlier draft of this code looked up
``notes_path`` (snake_case) and silently fell back to the default ``"Notes"``,
producing 404s for users with a non-default folder. This test pins the
correct key so that bug can't reappear.
"""
client = mocker.AsyncMock()
client.notes.get_settings.return_value = {"notesPath": "Documents/MyNotes"}
client.notes.get_note.return_value = {
"id": 42,
"title": "Q4 Plan",
"category": "Work",
}
path = await _resolve_note_attach_path(client, note_id=42)
assert path == "/Documents/MyNotes/Work/Q4 Plan.md"
client.notes.get_settings.assert_awaited_once()
client.notes.get_note.assert_awaited_once_with(42)
async def test_resolve_note_attach_path_falls_back_to_default_when_setting_missing(
mocker,
):
"""Missing/empty ``notesPath`` falls back to the documented default."""
client = mocker.AsyncMock()
client.notes.get_settings.return_value = {}
client.notes.get_note.return_value = {
"id": 1,
"title": "Idea",
"category": "",
}
path = await _resolve_note_attach_path(client, note_id=1)
assert path == "/Notes/Idea.md"
async def test_resolve_note_attach_path_handles_null_category(mocker):
"""A note with ``category=None`` (rather than ``""``) must not crash."""
client = mocker.AsyncMock()
client.notes.get_settings.return_value = {"notesPath": "Notes"}
client.notes.get_note.return_value = {
"id": 7,
"title": "Bare",
"category": None,
}
path = await _resolve_note_attach_path(client, note_id=7)
assert path == "/Notes/Bare.md"