Files
mcp-nextcloud/tests/unit/test_deck_server.py
T
Chris CoutinhoandClaude Opus 4.8 d2da195172 refactor(deck): address PR #826 review feedback
- Modernize the new models (DeckCardSummary, DeckCommentSummary,
  StackOverview, BoardOverviewResponse + the loosened unions) to PEP 604
  syntax (list[...] / X | None), per CLAUDE.md.
- Make status="done" exclude archived cards so open/done/archived partition
  the board with no overlap (a done+archived card is reported only as
  "archived"); document the semantics in docstrings and docs/deck.md, add a
  partition unit test.
- deck_get_archived_stacks: pass through label/assigned_to filters (status
  stays archived-only by definition); note the limitation in the docstring.
- Rename _validate_description_max_length → _validate_positive_length (now a
  generic positive-length guard).
- Soften deck_get_board_overview docstring: it views board state and omits
  the ACL/user/label-management fields deck_get_board exposes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 17:40:46 +02:00

670 lines
22 KiB
Python

from datetime import datetime, timezone
import pytest
from nextcloud_mcp_server.models.deck import (
DeckACL,
DeckAssignedUser,
DeckBoard,
DeckCard,
DeckCardSummary,
DeckComment,
DeckCommentSummary,
DeckLabel,
DeckPermissions,
DeckStack,
DeckUser,
)
from nextcloud_mcp_server.server.deck import (
_SHARE_TYPE_DECK,
_apply_board_filters,
_apply_stack_filters,
_extract_uid,
_filter_cards,
_resolve_note_attach_path,
_resolve_note_path,
_shape_cards,
_shape_comments,
_summarize_card,
_truncate_card_descriptions,
_truncate_comment_message,
_validate_positive_length,
)
pytestmark = pytest.mark.unit
# Fixtures ------------------------------------------------------------------
def _make_card(
card_id: int,
description: str | None = "desc",
archived: bool = False,
*,
done: datetime | None = None,
labels: list[DeckLabel] | None = None,
assigned_users: list | None = None,
attachment_count: int | None = None,
comments_unread: int | None = None,
) -> DeckCard:
return DeckCard(
id=card_id,
title=f"Card {card_id}",
stackId=1,
type="plain",
order=card_id,
archived=archived,
owner="testuser",
description=description,
done=done,
labels=labels,
assignedUsers=assigned_users,
attachmentCount=attachment_count,
commentsUnread=comments_unread,
)
def _make_comment(
comment_id: int,
message: str = "hello",
*,
actor: str = "alice",
created: datetime | None = None,
) -> DeckComment:
return DeckComment(
id=comment_id,
objectId=1,
message=message,
actorId=actor,
actorType="users",
actorDisplayName=actor.title(),
creationDateTime=created or datetime(2024, 1, comment_id, tzinfo=timezone.utc),
mentions=[],
)
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_positive_length ----------------------------------------
def test_validate_positive_length_accepts_none():
"""None is the documented sentinel for "no truncation"."""
_validate_positive_length(None)
def test_validate_positive_length_accepts_positive():
"""Positive values pass through silently."""
_validate_positive_length(1)
_validate_positive_length(1000)
def test_validate_positive_length_rejects_zero():
"""Zero would wipe descriptions to a single ellipsis — reject at the boundary."""
with pytest.raises(ValueError, match="must be positive"):
_validate_positive_length(0)
def test_validate_positive_length_rejects_negative():
"""Negative values produce surprising slice semantics — reject at the boundary."""
with pytest.raises(ValueError, match="must be positive"):
_validate_positive_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 == []
# Shared default kwargs for _apply_stack_filters in summary mode ------------
_STACK_DEFAULTS = dict(
detail="summary",
status="open",
label=None,
assigned_to=None,
description_max_length=None,
description_preview_length=140,
)
# _filter_cards -------------------------------------------------------------
def test_filter_cards_open_excludes_archived_and_done():
"""status="open" drops both archived and explicitly-done cards."""
done_at = datetime(2024, 1, 1, tzinfo=timezone.utc)
cards = [
_make_card(1),
_make_card(2, archived=True),
_make_card(3, done=done_at),
]
result = _filter_cards(cards, status="open", label=None, assigned_to=None)
assert [c.id for c in result] == [1]
def test_filter_cards_done_keeps_only_done_and_not_archived():
"""status="done" keeps done cards that are not archived."""
done_at = datetime(2024, 1, 1, tzinfo=timezone.utc)
cards = [_make_card(1), _make_card(2, done=done_at)]
result = _filter_cards(cards, status="done", label=None, assigned_to=None)
assert [c.id for c in result] == [2]
def test_filter_cards_archived_keeps_only_archived():
"""status="archived" keeps only archived cards."""
cards = [_make_card(1), _make_card(2, archived=True)]
result = _filter_cards(cards, status="archived", label=None, assigned_to=None)
assert [c.id for c in result] == [2]
def test_filter_cards_statuses_partition_the_board():
"""open/done/archived are non-overlapping; a done+archived card counts
only as "archived", not "done"."""
done_at = datetime(2024, 1, 1, tzinfo=timezone.utc)
open_card = _make_card(1)
done_card = _make_card(2, done=done_at)
done_and_archived = _make_card(3, done=done_at, archived=True)
cards = [open_card, done_card, done_and_archived]
assert [
c.id for c in _filter_cards(cards, status="open", label=None, assigned_to=None)
] == [1]
assert [
c.id for c in _filter_cards(cards, status="done", label=None, assigned_to=None)
] == [2]
assert [
c.id
for c in _filter_cards(cards, status="archived", label=None, assigned_to=None)
] == [3]
def test_filter_cards_all_keeps_everything():
"""status="all" applies no status filter."""
cards = [_make_card(1), _make_card(2, archived=True)]
result = _filter_cards(cards, status="all", label=None, assigned_to=None)
assert [c.id for c in result] == [1, 2]
def test_filter_cards_by_label_matches_title_exactly():
"""label filtering matches the exact label title."""
a = _make_card(1, labels=[DeckLabel(id=1, title="bug", color="f00")])
b = _make_card(2, labels=[DeckLabel(id=2, title="feature", color="0f0")])
result = _filter_cards([a, b], status="all", label="bug", assigned_to=None)
assert [c.id for c in result] == [1]
def test_filter_cards_by_assignee_handles_both_user_shapes():
"""assigned_to matches DeckUser and DeckAssignedUser shapes alike."""
direct = _make_card(1, assigned_users=[_make_user("alice")])
wrapped = _make_card(
2,
assigned_users=[
DeckAssignedUser(id=9, participant=_make_user("bob"), cardId=2, type=0)
],
)
result = _filter_cards(
[direct, wrapped], status="all", label=None, assigned_to="bob"
)
assert [c.id for c in result] == [2]
# _extract_uid --------------------------------------------------------------
def test_extract_uid_from_deck_user():
assert _extract_uid(_make_user("alice")) == "alice"
def test_extract_uid_from_assigned_user():
assigned = DeckAssignedUser(id=1, participant=_make_user("bob"), cardId=1, type=0)
assert _extract_uid(assigned) == "bob"
# _summarize_card -----------------------------------------------------------
def test_summarize_card_projects_compact_fields():
"""Summary carries flat label titles, assignee uids, counts, and a preview."""
card = _make_card(
1,
description="x" * 50,
labels=[DeckLabel(id=1, title="bug", color="f00")],
assigned_users=[_make_user("alice")],
attachment_count=3,
comments_unread=2,
)
summary = _summarize_card(card, description_preview_length=10)
assert isinstance(summary, DeckCardSummary)
assert summary.labels == ["bug"]
assert summary.assignedUsers == ["alice"]
assert summary.attachmentCount == 3
assert summary.commentsUnread == 2
assert summary.hasDescription is True
assert summary.descriptionPreview is not None
assert summary.descriptionPreview.endswith("…")
assert len(summary.descriptionPreview) == 11 # 10 chars + ellipsis
def test_summarize_card_short_description_has_no_ellipsis():
"""A description within the preview length is carried verbatim."""
summary = _summarize_card(_make_card(1, description="hi"), 140)
assert summary.descriptionPreview == "hi"
assert summary.hasDescription is True
def test_summarize_card_empty_description():
"""An empty/whitespace description yields hasDescription=False, no preview."""
summary = _summarize_card(_make_card(1, description=" "), 140)
assert summary.hasDescription is False
assert summary.descriptionPreview is None
# _shape_cards --------------------------------------------------------------
def test_shape_cards_summary_returns_summaries():
"""detail="summary" projects every (filtered) card to a DeckCardSummary."""
cards = [_make_card(1), _make_card(2, archived=True)]
result = _shape_cards(
cards,
detail="summary",
status="open",
label=None,
assigned_to=None,
description_max_length=None,
description_preview_length=140,
)
assert [type(c) for c in result] == [DeckCardSummary]
assert result[0].id == 1
def test_shape_cards_full_returns_truncated_full_cards():
"""detail="full" returns DeckCard objects with descriptions truncated."""
cards = [_make_card(1, description="x" * 50)]
result = _shape_cards(
cards,
detail="full",
status="all",
label=None,
assigned_to=None,
description_max_length=10,
description_preview_length=140,
)
assert isinstance(result[0], DeckCard)
assert result[0].description is not None
assert result[0].description.endswith("…")
# _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, **_STACK_DEFAULTS)
assert result.cards is None
def test_apply_stack_filters_summary_excludes_archived_by_default():
"""Default status="open" filters out archived cards and returns summaries."""
stack = _make_stack(
cards=[_make_card(1, archived=False), _make_card(2, archived=True)]
)
result = _apply_stack_filters(stack, include_cards=True, **_STACK_DEFAULTS)
assert result.cards is not None
assert [c.id for c in result.cards] == [1]
assert all(isinstance(c, DeckCardSummary) for c in result.cards)
def test_apply_stack_filters_status_all_keeps_archived():
"""status="all" retains archived cards."""
stack = _make_stack(
cards=[_make_card(1, archived=False), _make_card(2, archived=True)]
)
kwargs = {**_STACK_DEFAULTS, "status": "all"}
result = _apply_stack_filters(stack, include_cards=True, **kwargs)
assert result.cards is not None
assert [c.id for c in result.cards] == [1, 2]
def test_apply_stack_filters_full_truncates_descriptions_after_filter():
"""detail="full" truncation runs on the post-status-filter card set."""
stack = _make_stack(
cards=[
_make_card(1, description="x" * 50, archived=False),
_make_card(2, description="y" * 50, archived=True),
]
)
kwargs = {**_STACK_DEFAULTS, "detail": "full", "description_max_length": 10}
result = _apply_stack_filters(stack, include_cards=True, **kwargs)
assert result.cards is not None
assert len(result.cards) == 1
assert isinstance(result.cards[0], DeckCard)
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, **_STACK_DEFAULTS)
assert result.cards is None
def test_apply_stack_filters_all_filtered_yields_empty_list_not_none():
"""A stack whose cards are all filtered out 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").
"""
stack = _make_stack(
cards=[_make_card(1, archived=True), _make_card(2, archived=True)]
)
result = _apply_stack_filters(stack, include_cards=True, **_STACK_DEFAULTS)
assert result.cards == []
assert result.cards is not None
# _shape_comments -----------------------------------------------------------
def test_shape_comments_summary_drops_actor_metadata():
"""detail="summary" projects comments to compact DeckCommentSummary rows."""
comments = [_make_comment(1, "hi", actor="alice")]
result = _shape_comments(
comments, detail="summary", message_max_length=None, order="newest"
)
assert isinstance(result[0], DeckCommentSummary)
assert result[0].actorId == "alice"
assert result[0].message == "hi"
def test_shape_comments_newest_first():
"""order="newest" sorts the page by creation time descending."""
comments = [_make_comment(1), _make_comment(3), _make_comment(2)]
result = _shape_comments(
comments, detail="summary", message_max_length=None, order="newest"
)
assert [c.id for c in result] == [3, 2, 1]
def test_shape_comments_oldest_first():
"""order="oldest" sorts the page by creation time ascending."""
comments = [_make_comment(3), _make_comment(1), _make_comment(2)]
result = _shape_comments(
comments, detail="summary", message_max_length=None, order="oldest"
)
assert [c.id for c in result] == [1, 2, 3]
def test_shape_comments_full_truncates_message():
"""detail="full" keeps DeckComment but truncates long messages when asked."""
comments = [_make_comment(1, "x" * 50)]
result = _shape_comments(
comments, detail="full", message_max_length=10, order="newest"
)
assert isinstance(result[0], DeckComment)
assert result[0].message.endswith("…")
assert len(result[0].message) == 11
def test_truncate_comment_message_no_op_when_within_limit():
assert _truncate_comment_message("short", 100) == "short"
assert _truncate_comment_message("short", None) == "short"
# _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"