Skip to content

Commit 72cb62a

Browse files
author
Chris Coutinho
committed
test(contacts): Add unit/integration tests for a few tools
1 parent 21fc553 commit 72cb62a

File tree

6 files changed

+345
-1
lines changed

6 files changed

+345
-1
lines changed

nextcloud_mcp_server/app.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,10 @@
99
from nextcloud_mcp_server.config import setup_logging
1010
from nextcloud_mcp_server.server import (
1111
configure_calendar_tools,
12+
configure_contacts_tools,
1213
configure_notes_tools,
1314
configure_tables_tools,
1415
configure_webdav_tools,
15-
configure_contacts_tools,
1616
)
1717

1818
setup_logging()

nextcloud_mcp_server/client/contacts.py

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,62 @@ async def list_addressbooks(self):
8787
logger.debug(f"Found {len(addressbooks)} addressbooks")
8888
return addressbooks
8989

90+
async def create_addressbook(self, *, name: str, display_name: str):
91+
"""Create a new addressbook."""
92+
carddav_path = self._get_carddav_base_path()
93+
url = f"{carddav_path}/{name}/"
94+
95+
prop_body = f"""<?xml version="1.0" encoding="utf-8"?>
96+
<d:mkcol xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:carddav">
97+
<d:set>
98+
<d:prop>
99+
<d:resourcetype>
100+
<d:collection/>
101+
<c:addressbook/>
102+
</d:resourcetype>
103+
<d:displayname>{display_name}</d:displayname>
104+
</d:prop>
105+
</d:set>
106+
</d:mkcol>"""
107+
108+
headers = {
109+
"Content-Type": "application/xml",
110+
}
111+
112+
await self._make_request("MKCOL", url, content=prop_body, headers=headers)
113+
114+
async def delete_addressbook(self, *, name: str):
115+
"""Delete an addressbook."""
116+
carddav_path = self._get_carddav_base_path()
117+
url = f"{carddav_path}/{name}/"
118+
await self._make_request("DELETE", url)
119+
120+
async def create_contact(self, *, addressbook: str, uid: str, contact_data: dict):
121+
"""Create a new contact."""
122+
carddav_path = self._get_carddav_base_path()
123+
url = f"{carddav_path}/{addressbook}/{uid}.vcf"
124+
125+
contact = Contact(fn=contact_data.get("fn"), uid=uid)
126+
if "email" in contact_data:
127+
contact.email = [{"value": contact_data["email"], "type": ["HOME"]}]
128+
if "tel" in contact_data:
129+
contact.tel = [{"value": contact_data["tel"], "type": ["HOME"]}]
130+
131+
vcard = contact.to_vcard()
132+
133+
headers = {
134+
"Content-Type": "text/vcard; charset=utf-8",
135+
"If-None-Match": "*",
136+
}
137+
138+
await self._make_request("PUT", url, content=vcard, headers=headers)
139+
140+
async def delete_contact(self, *, addressbook: str, uid: str):
141+
"""Delete a contact."""
142+
carddav_path = self._get_carddav_base_path()
143+
url = f"{carddav_path}/{addressbook}/{uid}.vcf"
144+
await self._make_request("DELETE", url)
145+
90146
async def list_contacts(self, *, addressbook: str):
91147
"""List all available contacts for addressbook."""
92148

@@ -135,6 +191,7 @@ async def list_contacts(self, *, addressbook: str):
135191
if not vcard_id:
136192
logger.info("Skip missing vcard_id")
137193
continue
194+
vcard_id = vcard_id.replace(".vcf", "")
138195

139196
# Get properties
140197
propstat = response_elem.find(".//d:propstat", ns)
@@ -162,6 +219,7 @@ async def list_contacts(self, *, addressbook: str):
162219

163220
contacts.append(
164221
{
222+
"vcard_id": vcard_id,
165223
"getetag": getetag,
166224
"contact": {
167225
"fullname": contact.fn,

nextcloud_mcp_server/server/contacts.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,3 +20,35 @@ async def nc_contacts_list_contacts(ctx: Context, *, addressbook: str):
2020
"""List all addressbooks for the user."""
2121
client: NextcloudClient = ctx.request_context.lifespan_context.client
2222
return await client.contacts.list_contacts(addressbook=addressbook)
23+
24+
@mcp.tool()
25+
async def nc_contacts_create_addressbook(
26+
ctx: Context, *, name: str, display_name: str
27+
):
28+
"""Create a new addressbook."""
29+
client: NextcloudClient = ctx.request_context.lifespan_context.client
30+
return await client.contacts.create_addressbook(
31+
name=name, display_name=display_name
32+
)
33+
34+
@mcp.tool()
35+
async def nc_contacts_delete_addressbook(ctx: Context, *, name: str):
36+
"""Delete an addressbook."""
37+
client: NextcloudClient = ctx.request_context.lifespan_context.client
38+
return await client.contacts.delete_addressbook(name=name)
39+
40+
@mcp.tool()
41+
async def nc_contacts_create_contact(
42+
ctx: Context, *, addressbook: str, uid: str, contact_data: dict
43+
):
44+
"""Create a new contact."""
45+
client: NextcloudClient = ctx.request_context.lifespan_context.client
46+
return await client.contacts.create_contact(
47+
addressbook=addressbook, uid=uid, contact_data=contact_data
48+
)
49+
50+
@mcp.tool()
51+
async def nc_contacts_delete_contact(ctx: Context, *, addressbook: str, uid: str):
52+
"""Delete a contact."""
53+
client: NextcloudClient = ctx.request_context.lifespan_context.client
54+
return await client.contacts.delete_contact(addressbook=addressbook, uid=uid)

tests/conftest.py

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,3 +170,83 @@ async def temporary_note_with_attachment(
170170

171171
# Note: The temporary_note fixture's finally block will handle note deletion,
172172
# which should also trigger the WebDAV directory deletion attempt.
173+
174+
175+
@pytest.fixture
176+
async def temporary_addressbook(nc_client: NextcloudClient):
177+
"""
178+
Fixture to create a temporary addressbook for a test and ensure its deletion afterward.
179+
Yields the created addressbook dictionary.
180+
"""
181+
addressbook_name = f"test-addressbook-{uuid.uuid4().hex[:8]}"
182+
logger.info(f"Creating temporary addressbook: {addressbook_name}")
183+
try:
184+
await nc_client.contacts.create_addressbook(
185+
name=addressbook_name, display_name=f"Test Addressbook {addressbook_name}"
186+
)
187+
logger.info(f"Temporary addressbook created: {addressbook_name}")
188+
yield addressbook_name
189+
finally:
190+
logger.info(f"Cleaning up temporary addressbook: {addressbook_name}")
191+
try:
192+
await nc_client.contacts.delete_addressbook(name=addressbook_name)
193+
logger.info(
194+
f"Successfully deleted temporary addressbook: {addressbook_name}"
195+
)
196+
except HTTPStatusError as e:
197+
if e.response.status_code != 404:
198+
logger.error(
199+
f"HTTP error deleting temporary addressbook {addressbook_name}: {e}"
200+
)
201+
else:
202+
logger.warning(
203+
f"Temporary addressbook {addressbook_name} already deleted (404)."
204+
)
205+
except Exception as e:
206+
logger.error(
207+
f"Unexpected error deleting temporary addressbook {addressbook_name}: {e}"
208+
)
209+
210+
211+
@pytest.fixture
212+
async def temporary_contact(nc_client: NextcloudClient, temporary_addressbook: str):
213+
"""
214+
Fixture to create a temporary contact in a temporary addressbook and ensure its deletion.
215+
Yields the created contact's UID.
216+
"""
217+
contact_uid = f"test-contact-{uuid.uuid4().hex[:8]}"
218+
addressbook_name = temporary_addressbook
219+
contact_data = {
220+
"fn": "John Doe",
221+
"email": "[email protected]",
222+
"tel": "1234567890",
223+
}
224+
logger.info(f"Creating temporary contact in addressbook: {addressbook_name}")
225+
try:
226+
await nc_client.contacts.create_contact(
227+
addressbook=addressbook_name,
228+
uid=contact_uid,
229+
contact_data=contact_data,
230+
)
231+
logger.info(f"Temporary contact created with UID: {contact_uid}")
232+
yield contact_uid
233+
finally:
234+
logger.info(f"Cleaning up temporary contact: {contact_uid}")
235+
try:
236+
await nc_client.contacts.delete_contact(
237+
addressbook=addressbook_name, uid=contact_uid
238+
)
239+
logger.info(f"Successfully deleted temporary contact: {contact_uid}")
240+
except HTTPStatusError as e:
241+
if e.response.status_code != 404:
242+
logger.error(
243+
f"HTTP error deleting temporary contact {contact_uid}: {e}"
244+
)
245+
else:
246+
logger.warning(
247+
f"Temporary contact {contact_uid} already deleted (404)."
248+
)
249+
except Exception as e:
250+
logger.error(
251+
f"Unexpected error deleting temporary contact {contact_uid}: {e}"
252+
)
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
"""Integration tests for Contacts MCP tools."""
2+
3+
import logging
4+
import uuid
5+
6+
import pytest
7+
from mcp import ClientSession
8+
9+
from nextcloud_mcp_server.client import NextcloudClient
10+
11+
logger = logging.getLogger(__name__)
12+
pytestmark = pytest.mark.integration
13+
14+
15+
async def test_mcp_contacts_workflow(
16+
nc_mcp_client: ClientSession, nc_client: NextcloudClient
17+
):
18+
"""Test complete Contacts workflow via MCP tools with verification via NextcloudClient."""
19+
20+
addressbook_name = f"mcp-test-addressbook-{uuid.uuid4().hex[:8]}"
21+
unique_suffix = uuid.uuid4().hex[:8]
22+
contact_uid = f"mcp-contact-{unique_suffix}"
23+
contact_data = {
24+
"fn": f"MCP Contact {unique_suffix}",
25+
"email": f"mcp.contact.{unique_suffix}@example.com",
26+
"tel": "1234567890",
27+
}
28+
29+
try:
30+
# 1. Create address book via MCP
31+
logger.info(f"Creating address book via MCP: {addressbook_name}")
32+
create_ab_result = await nc_mcp_client.call_tool(
33+
"nc_contacts_create_addressbook",
34+
{"name": addressbook_name, "display_name": f"MCP Test {addressbook_name}"},
35+
)
36+
assert create_ab_result.isError is False
37+
38+
# 2. Verify address book creation
39+
addressbooks = await nc_client.contacts.list_addressbooks()
40+
assert any(ab["name"] == addressbook_name for ab in addressbooks)
41+
42+
# 3. Create contact via MCP
43+
logger.info(f"Creating contact in {addressbook_name} via MCP")
44+
create_c_result = await nc_mcp_client.call_tool(
45+
"nc_contacts_create_contact",
46+
{
47+
"addressbook": addressbook_name,
48+
"uid": contact_uid,
49+
"contact_data": contact_data,
50+
},
51+
)
52+
assert create_c_result.isError is False
53+
54+
# 4. Verify contact creation
55+
contacts = await nc_client.contacts.list_contacts(addressbook=addressbook_name)
56+
assert any(c["vcard_id"] == contact_uid for c in contacts)
57+
58+
# 5. Delete contact via MCP
59+
logger.info(f"Deleting contact {contact_uid} via MCP")
60+
delete_c_result = await nc_mcp_client.call_tool(
61+
"nc_contacts_delete_contact",
62+
{"addressbook": addressbook_name, "uid": contact_uid},
63+
)
64+
assert delete_c_result.isError is False
65+
66+
# 6. Verify contact deletion
67+
contacts = await nc_client.contacts.list_contacts(addressbook=addressbook_name)
68+
assert not any(c["vcard_id"] == contact_uid for c in contacts)
69+
70+
# 7. Delete address book via MCP
71+
logger.info(f"Deleting address book {addressbook_name} via MCP")
72+
delete_ab_result = await nc_mcp_client.call_tool(
73+
"nc_contacts_delete_addressbook", {"name": addressbook_name}
74+
)
75+
assert delete_ab_result.isError is False
76+
77+
# 8. Verify address book deletion
78+
addressbooks = await nc_client.contacts.list_addressbooks()
79+
assert not any(ab["name"] == addressbook_name for ab in addressbooks)
80+
81+
finally:
82+
# Cleanup in case of failure
83+
try:
84+
await nc_client.contacts.delete_addressbook(name=addressbook_name)
85+
except Exception:
86+
pass
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
"""Integration tests for Contacts CardDAV operations."""
2+
3+
import logging
4+
import uuid
5+
6+
import pytest
7+
8+
from nextcloud_mcp_server.client import NextcloudClient
9+
10+
logger = logging.getLogger(__name__)
11+
12+
# Mark all tests in this module as integration tests
13+
pytestmark = pytest.mark.integration
14+
15+
16+
async def test_list_addressbooks(nc_client: NextcloudClient):
17+
"""Test listing available addressbooks."""
18+
addressbooks = await nc_client.contacts.list_addressbooks()
19+
20+
assert isinstance(addressbooks, list)
21+
22+
if not addressbooks:
23+
pytest.skip("No addressbooks available - Contacts app may not be enabled")
24+
25+
logger.info(f"Found {len(addressbooks)} addressbooks")
26+
27+
# Check structure of addressbooks
28+
for addressbook in addressbooks:
29+
assert "name" in addressbook
30+
assert "display_name" in addressbook
31+
assert "getctag" in addressbook
32+
33+
logger.info(
34+
f"Addressbook: {addressbook['name']} - {addressbook['display_name']}"
35+
)
36+
37+
38+
async def test_create_and_delete_addressbook(
39+
nc_client: NextcloudClient, temporary_addressbook: str
40+
):
41+
"""Test creating and deleting a basic addressbook."""
42+
addressbooks = await nc_client.contacts.list_addressbooks()
43+
addressbook_names = [ab["name"] for ab in addressbooks]
44+
assert temporary_addressbook in addressbook_names
45+
46+
47+
async def test_list_contacts(
48+
nc_client: NextcloudClient, temporary_addressbook: str, temporary_contact: str
49+
):
50+
"""Test listing contacts in an addressbook."""
51+
contacts = await nc_client.contacts.list_contacts(addressbook=temporary_addressbook)
52+
contact_uids = [c["vcard_id"] for c in contacts]
53+
assert temporary_contact in contact_uids
54+
55+
56+
async def test_full_contact_workflow(
57+
nc_client: NextcloudClient, temporary_addressbook: str
58+
):
59+
"""Test the full workflow of creating, retrieving, and deleting a contact."""
60+
addressbook_name = temporary_addressbook
61+
contact_uid = f"test-contact-{uuid.uuid4().hex[:8]}"
62+
contact_data = {
63+
"fn": "Jane Doe",
64+
"email": "[email protected]",
65+
"tel": "9876543210",
66+
}
67+
68+
# Create contact
69+
await nc_client.contacts.create_contact(
70+
addressbook=addressbook_name,
71+
uid=contact_uid,
72+
contact_data=contact_data,
73+
)
74+
75+
# Verify contact was created by listing
76+
contacts = await nc_client.contacts.list_contacts(addressbook=addressbook_name)
77+
contact_uids = [c["vcard_id"] for c in contacts]
78+
assert contact_uid in contact_uids
79+
80+
# Delete contact
81+
await nc_client.contacts.delete_contact(
82+
addressbook=addressbook_name, uid=contact_uid
83+
)
84+
85+
# Verify contact was deleted
86+
contacts = await nc_client.contacts.list_contacts(addressbook=addressbook_name)
87+
contact_uids = [c["vcard_id"] for c in contacts]
88+
assert contact_uid not in contact_uids

0 commit comments

Comments
 (0)