- 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>
670 lines
22 KiB
Python
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"
|