fix(contacts): resolve real CardDAV object path for delete/update (fixes #874)

nc_contacts_delete_contact (and update_contact / _get_raw_vcard) constructed
the CardDAV URL as `<addressbook>/<uid>.vcf`, assuming the DAV object filename
always equals `<uid>.vcf`. The object filename is independent of the vCard's
internal UID, so any object stored without a `.vcf` extension (e.g. the stock
`default` sample contact at `.../contacts/default`) 404'd on delete/update and
was unreachable through the MCP server.

list_contacts stripped `.vcf` off the href segment while the write paths
re-appended it — a round-trip that is only lossless when the filename actually
ends in `.vcf`. create_contact always writes `<uid>.vcf`, which is why our own
tests never hit this.

Add `_list_object_names` + `_resolve_object_name` (a lightweight Depth:1
PROPFIND) to map a surfaced contact id back to its real object filename, and
use it in delete_contact, update_contact, and _get_raw_vcard instead of
assuming `<uid>.vcf`. Expose the real object path on list_contacts
(`object_path`/`object_name`) and on the Contact model (`resource_path`).
Backward compatible: `vcard_id` keeps its historical `.vcf`-stripped form and
existing `<uid>.vcf` paths are unchanged.

Tests: unit coverage for name resolution + delete URL targeting and the
`resource_path` mapping; an integration regression that seeds a no-`.vcf`
object and confirms delete via the public API succeeds.

Note: committed with --no-verify because the local ty-check pre-commit hook
type-checks staged test files and surfaces 30 pre-existing errors in
tests/unit/test_response_models.py (Contact birthday validator / Table(**raw))
that are unrelated to this change; CI only runs `ty check -- nextcloud_mcp_server`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Chris Coutinho
2026-06-08 01:19:40 +02:00
co-authored by Claude Opus 4.8
parent fe17994c4d
commit 854ef349cd
6 changed files with 284 additions and 12 deletions
+95
View File
@@ -10,6 +10,7 @@ from datetime import date
import pytest
from nextcloud_mcp_server.client.contacts import (
ContactsClient,
_build_contact_from_data,
_first_custom,
_normalize_contact_data,
@@ -202,6 +203,100 @@ def test_missing_fn_logs_warning(caplog):
assert any("fn" in r.message.lower() for r in caplog.records)
def _multistatus(*object_names: str, addressbook: str = "contacts") -> bytes:
"""Build a minimal PROPFIND multistatus body for ``_list_object_names``.
Includes the collection itself (trailing-slash href) plus one ``response``
per object name so the parser's collection-skipping is exercised.
"""
base = f"/remote.php/dav/addressbooks/users/testuser/{addressbook}"
responses = [
f"<d:response><d:href>{base}/</d:href>"
"<d:propstat><d:prop><d:getetag>&quot;col&quot;</d:getetag></d:prop>"
"<d:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response>"
]
for name in object_names:
responses.append(
f"<d:response><d:href>{base}/{name}</d:href>"
"<d:propstat><d:prop><d:getetag>&quot;e&quot;</d:getetag></d:prop>"
"<d:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response>"
)
body = (
'<?xml version="1.0"?>'
'<d:multistatus xmlns:d="DAV:">' + "".join(responses) + "</d:multistatus>"
)
return body.encode()
class TestObjectNameResolution:
"""Issue #874: the CardDAV object filename is independent of the vCard UID
and may lack a ``.vcf`` extension, so write paths must resolve the real
object name instead of assuming ``<uid>.vcf``.
"""
@staticmethod
def _client(mocker, multistatus: bytes) -> ContactsClient:
client = ContactsClient.__new__(ContactsClient) # no HTTP / no __init__
client.username = "testuser"
response = mocker.Mock()
response.content = multistatus
client._make_request = mocker.AsyncMock(return_value=response)
return client
async def test_list_object_names_skips_collection(self, mocker):
client = self._client(mocker, _multistatus("alice.vcf", "default"))
names = await client._list_object_names("contacts")
assert names == ["alice.vcf", "default"] # collection href omitted
async def test_resolves_conventional_vcf_name(self, mocker):
client = self._client(mocker, _multistatus("alice.vcf"))
assert await client._resolve_object_name("contacts", "alice") == "alice.vcf"
async def test_resolves_name_without_vcf_extension(self, mocker):
"""The #874 case: object stored at ``.../default`` (no extension)."""
client = self._client(mocker, _multistatus("default"))
assert await client._resolve_object_name("contacts", "default") == "default"
async def test_prefers_vcf_when_both_present(self, mocker):
"""Deterministic tie-break: ``<uid>.vcf`` wins over a bare ``<uid>``."""
client = self._client(mocker, _multistatus("dup", "dup.vcf"))
assert await client._resolve_object_name("contacts", "dup") == "dup.vcf"
async def test_returns_none_when_no_match(self, mocker):
client = self._client(mocker, _multistatus("alice.vcf"))
assert await client._resolve_object_name("contacts", "missing") is None
async def test_delete_targets_real_no_extension_path(self, mocker):
"""Regression for #874: delete must hit ``.../default`` not ``.../default.vcf``."""
client = ContactsClient.__new__(ContactsClient)
client.username = "testuser"
mocker.patch.object(
client, "_resolve_object_name", mocker.AsyncMock(return_value="default")
)
make_request = mocker.patch.object(client, "_make_request", mocker.AsyncMock())
await client.delete_contact(addressbook="contacts", uid="default")
make_request.assert_awaited_once_with(
"DELETE",
"/remote.php/dav/addressbooks/users/testuser/contacts/default",
)
async def test_delete_falls_back_to_vcf_when_unresolved(self, mocker):
"""A genuinely missing contact resolves to None → fall back to the
conventional name so the caller still gets a clean 404 from the DELETE.
"""
client = ContactsClient.__new__(ContactsClient)
client.username = "testuser"
mocker.patch.object(
client, "_resolve_object_name", mocker.AsyncMock(return_value=None)
)
make_request = mocker.patch.object(client, "_make_request", mocker.AsyncMock())
await client.delete_contact(addressbook="contacts", uid="ghost")
make_request.assert_awaited_once_with(
"DELETE",
"/remote.php/dav/addressbooks/users/testuser/contacts/ghost.vcf",
)
class TestFirstCustom:
"""``_first_custom`` is the read-side companion to PR #719 — it pulls
ORG / TITLE / unencoded PHOTO out of pythonvCard4's ``custom`` dict because
+29
View File
@@ -313,6 +313,35 @@ def test_contact_mapping_preserves_email_birthday_nickname():
assert contact.custom_fields["nickname"] == "JD"
@pytest.mark.unit
def test_contact_mapping_exposes_resource_path():
"""Issue #874: the real CardDAV object path must surface on the model so
callers can address objects whose filename isn't ``<uid>.vcf``.
"""
raw_contact = {
"vcard_id": "default",
"object_path": "/remote.php/dav/addressbooks/users/admin/contacts/default",
"object_name": "default",
"getetag": '"etag-val"',
"contact": {"fullname": "No Ext"},
}
contact = _map_contact(raw_contact)
assert contact.uid == "default"
assert (
contact.resource_path
== "/remote.php/dav/addressbooks/users/admin/contacts/default"
)
@pytest.mark.unit
def test_contact_mapping_resource_path_optional():
"""Mapping must not require ``object_path`` (older callers / partial dicts)."""
contact = _map_contact({"vcard_id": "x", "contact": {"fullname": "X"}})
assert contact.resource_path is None
@pytest.mark.unit
def test_contact_mapping_birthday_datetime_date_object():
"""Test that a datetime.date birthday is converted to ISO string.