Skip to content

Commit bf5879d

Browse files
neovaskyclaude
andcommitted
test: add comprehensive WebDAV integration tests
- Add 8 core WebDAV operation tests covering CRUD operations - Add complex attachment cleanup test for category changes - Fix ruff formatting violations in webdav.py and server.py - Address PR feedback requirements for expanded WebDAV functionality Tests focus on WebDAV client functionality and run locally with docker-compose. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <[email protected]>
1 parent 9e96999 commit bf5879d

File tree

3 files changed

+380
-89
lines changed

3 files changed

+380
-89
lines changed

nextcloud_mcp_server/client/webdav.py

Lines changed: 80 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -249,10 +249,10 @@ async def list_directory(self, path: str = "") -> List[Dict[str, Any]]:
249249
webdav_path = f"{self._get_webdav_base_path()}/{path.lstrip('/')}"
250250
if not webdav_path.endswith("/"):
251251
webdav_path += "/"
252-
252+
253253
logger.info(f"Listing directory: {webdav_path}")
254-
255-
propfind_body = '''<?xml version="1.0"?>
254+
255+
propfind_body = """<?xml version="1.0"?>
256256
<d:propfind xmlns:d="DAV:">
257257
<d:prop>
258258
<d:displayname/>
@@ -261,73 +261,80 @@ async def list_directory(self, path: str = "") -> List[Dict[str, Any]]:
261261
<d:getlastmodified/>
262262
<d:resourcetype/>
263263
</d:prop>
264-
</d:propfind>'''
265-
266-
headers = {
267-
"Depth": "1",
268-
"Content-Type": "text/xml",
269-
"OCS-APIRequest": "true"
270-
}
271-
264+
</d:propfind>"""
265+
266+
headers = {"Depth": "1", "Content-Type": "text/xml", "OCS-APIRequest": "true"}
267+
272268
try:
273269
response = await self._client.request(
274270
"PROPFIND", webdav_path, content=propfind_body, headers=headers
275271
)
276272
response.raise_for_status()
277-
273+
278274
# Parse the XML response
279275
root = ET.fromstring(response.content)
280276
items = []
281-
277+
282278
# Skip the first response (the directory itself)
283279
responses = root.findall(".//{DAV:}response")[1:]
284-
280+
285281
for response_elem in responses:
286282
href = response_elem.find(".//{DAV:}href")
287283
if href is None:
288284
continue
289-
285+
290286
# Extract file/directory name from href
291287
href_text = href.text or ""
292288
name = href_text.rstrip("/").split("/")[-1]
293289
if not name:
294290
continue
295-
291+
296292
# Get properties
297293
propstat = response_elem.find(".//{DAV:}propstat")
298294
if propstat is None:
299295
continue
300-
296+
301297
prop = propstat.find(".//{DAV:}prop")
302298
if prop is None:
303299
continue
304-
300+
305301
# Determine if it's a directory
306302
resourcetype = prop.find(".//{DAV:}resourcetype")
307-
is_directory = resourcetype is not None and resourcetype.find(".//{DAV:}collection") is not None
308-
303+
is_directory = (
304+
resourcetype is not None
305+
and resourcetype.find(".//{DAV:}collection") is not None
306+
)
307+
309308
# Get other properties
310309
size_elem = prop.find(".//{DAV:}getcontentlength")
311-
size = int(size_elem.text) if size_elem is not None and size_elem.text else 0
312-
310+
size = (
311+
int(size_elem.text)
312+
if size_elem is not None and size_elem.text
313+
else 0
314+
)
315+
313316
content_type_elem = prop.find(".//{DAV:}getcontenttype")
314-
content_type = content_type_elem.text if content_type_elem is not None else None
315-
317+
content_type = (
318+
content_type_elem.text if content_type_elem is not None else None
319+
)
320+
316321
modified_elem = prop.find(".//{DAV:}getlastmodified")
317322
modified = modified_elem.text if modified_elem is not None else None
318-
319-
items.append({
320-
"name": name,
321-
"path": f"{path.rstrip('/')}/{name}" if path else name,
322-
"is_directory": is_directory,
323-
"size": size if not is_directory else None,
324-
"content_type": content_type,
325-
"last_modified": modified
326-
})
327-
323+
324+
items.append(
325+
{
326+
"name": name,
327+
"path": f"{path.rstrip('/')}/{name}" if path else name,
328+
"is_directory": is_directory,
329+
"size": size if not is_directory else None,
330+
"content_type": content_type,
331+
"last_modified": modified,
332+
}
333+
)
334+
328335
logger.info(f"Found {len(items)} items in directory: {webdav_path}")
329336
return items
330-
337+
331338
except HTTPStatusError as e:
332339
logger.error(f"HTTP error listing directory '{webdav_path}': {e}")
333340
raise e
@@ -338,49 +345,56 @@ async def list_directory(self, path: str = "") -> List[Dict[str, Any]]:
338345
async def read_file(self, path: str) -> Tuple[bytes, str]:
339346
"""Read a file's content via WebDAV GET."""
340347
webdav_path = f"{self._get_webdav_base_path()}/{path.lstrip('/')}"
341-
348+
342349
logger.info(f"Reading file: {webdav_path}")
343-
350+
344351
try:
345352
response = await self._client.get(webdav_path)
346353
response.raise_for_status()
347-
354+
348355
content = response.content
349-
content_type = response.headers.get("content-type", "application/octet-stream")
350-
351-
logger.info(f"Successfully read file '{path}' ({content_type}, {len(content)} bytes)")
356+
content_type = response.headers.get(
357+
"content-type", "application/octet-stream"
358+
)
359+
360+
logger.info(
361+
f"Successfully read file '{path}' ({content_type}, {len(content)} bytes)"
362+
)
352363
return content, content_type
353-
364+
354365
except HTTPStatusError as e:
355366
logger.error(f"HTTP error reading file '{path}': {e}")
356367
raise e
357368
except Exception as e:
358369
logger.error(f"Unexpected error reading file '{path}': {e}")
359370
raise e
360371

361-
async def write_file(self, path: str, content: bytes, content_type: Optional[str] = None) -> Dict[str, Any]:
372+
async def write_file(
373+
self, path: str, content: bytes, content_type: Optional[str] = None
374+
) -> Dict[str, Any]:
362375
"""Write content to a file via WebDAV PUT."""
363376
webdav_path = f"{self._get_webdav_base_path()}/{path.lstrip('/')}"
364-
377+
365378
logger.info(f"Writing file: {webdav_path}")
366-
379+
367380
if not content_type:
368381
content_type, _ = mimetypes.guess_type(path)
369382
if not content_type:
370383
content_type = "application/octet-stream"
371-
372-
headers = {
373-
"Content-Type": content_type,
374-
"OCS-APIRequest": "true"
375-
}
376-
384+
385+
headers = {"Content-Type": content_type, "OCS-APIRequest": "true"}
386+
377387
try:
378-
response = await self._client.put(webdav_path, content=content, headers=headers)
388+
response = await self._client.put(
389+
webdav_path, content=content, headers=headers
390+
)
379391
response.raise_for_status()
380-
381-
logger.info(f"Successfully wrote file '{path}' (Status: {response.status_code})")
392+
393+
logger.info(
394+
f"Successfully wrote file '{path}' (Status: {response.status_code})"
395+
)
382396
return {"status_code": response.status_code}
383-
397+
384398
except HTTPStatusError as e:
385399
logger.error(f"HTTP error writing file '{path}': {e}")
386400
raise e
@@ -393,20 +407,24 @@ async def create_directory(self, path: str) -> Dict[str, Any]:
393407
webdav_path = f"{self._get_webdav_base_path()}/{path.lstrip('/')}"
394408
if not webdav_path.endswith("/"):
395409
webdav_path += "/"
396-
410+
397411
logger.info(f"Creating directory: {webdav_path}")
398-
412+
399413
headers = {"OCS-APIRequest": "true"}
400-
414+
401415
try:
402416
response = await self._client.request("MKCOL", webdav_path, headers=headers)
403417
response.raise_for_status()
404-
405-
logger.info(f"Successfully created directory '{path}' (Status: {response.status_code})")
418+
419+
logger.info(
420+
f"Successfully created directory '{path}' (Status: {response.status_code})"
421+
)
406422
return {"status_code": response.status_code}
407-
423+
408424
except HTTPStatusError as e:
409-
if e.response.status_code == 405: # Method Not Allowed - directory already exists
425+
if (
426+
e.response.status_code == 405
427+
): # Method Not Allowed - directory already exists
410428
logger.info(f"Directory '{path}' already exists")
411429
return {"status_code": 405, "message": "Directory already exists"}
412430
logger.error(f"HTTP error creating directory '{path}': {e}")

nextcloud_mcp_server/server.py

Lines changed: 31 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -195,17 +195,17 @@ async def nc_notes_get_attachment(note_id: int, attachment_filename: str):
195195
@mcp.tool()
196196
async def nc_webdav_list_directory(ctx: Context, path: str = ""):
197197
"""List files and directories in the specified NextCloud path.
198-
198+
199199
Args:
200200
path: Directory path to list (empty string for root directory)
201-
201+
202202
Returns:
203203
List of items with metadata including name, path, is_directory, size, content_type, last_modified
204-
204+
205205
Examples:
206206
# List root directory
207207
await nc_webdav_list_directory("")
208-
208+
209209
# List a specific folder
210210
await nc_webdav_list_directory("Documents/Projects")
211211
"""
@@ -216,26 +216,26 @@ async def nc_webdav_list_directory(ctx: Context, path: str = ""):
216216
@mcp.tool()
217217
async def nc_webdav_read_file(path: str, ctx: Context):
218218
"""Read the content of a file from NextCloud.
219-
219+
220220
Args:
221221
path: Full path to the file to read
222-
222+
223223
Returns:
224224
Dict with path, content, content_type, size, and encoding (if binary)
225225
Text files are decoded to UTF-8, binary files are base64 encoded
226-
226+
227227
Examples:
228228
# Read a text file
229229
result = await nc_webdav_read_file("Documents/readme.txt")
230230
print(result['content']) # Decoded text content
231-
231+
232232
# Read a binary file
233233
result = await nc_webdav_read_file("Images/photo.jpg")
234234
print(result['encoding']) # 'base64'
235235
"""
236236
client: NextcloudClient = ctx.request_context.lifespan_context.client
237237
content, content_type = await client.webdav.read_file(path)
238-
238+
239239
# For text files, decode content for easier viewing
240240
if content_type and content_type.startswith("text/"):
241241
try:
@@ -244,68 +244,72 @@ async def nc_webdav_read_file(path: str, ctx: Context):
244244
"path": path,
245245
"content": decoded_content,
246246
"content_type": content_type,
247-
"size": len(content)
247+
"size": len(content),
248248
}
249249
except UnicodeDecodeError:
250250
pass
251-
251+
252252
# For binary files, return metadata and base64 encoded content
253253
import base64
254+
254255
return {
255256
"path": path,
256257
"content": base64.b64encode(content).decode("ascii"),
257258
"content_type": content_type,
258259
"size": len(content),
259-
"encoding": "base64"
260+
"encoding": "base64",
260261
}
261262

262263

263264
@mcp.tool()
264-
async def nc_webdav_write_file(path: str, content: str, ctx: Context, content_type: str | None = None):
265+
async def nc_webdav_write_file(
266+
path: str, content: str, ctx: Context, content_type: str | None = None
267+
):
265268
"""Write content to a file in NextCloud.
266-
269+
267270
Args:
268271
path: Full path where to write the file
269272
content: File content (text or base64 for binary)
270273
content_type: MIME type (auto-detected if not provided, use 'type;base64' for binary)
271-
274+
272275
Returns:
273276
Dict with status_code indicating success
274-
277+
275278
Examples:
276279
# Write a text file
277280
await nc_webdav_write_file("Documents/notes.md", "# My Notes\nContent here...")
278-
281+
279282
# Write binary data (base64 encoded)
280283
await nc_webdav_write_file("files/data.bin", base64_content, "application/octet-stream;base64")
281284
"""
282285
client: NextcloudClient = ctx.request_context.lifespan_context.client
283-
286+
284287
# Handle base64 encoded content
285288
if content_type and "base64" in content_type.lower():
286289
import base64
290+
287291
content_bytes = base64.b64decode(content)
288292
content_type = content_type.replace(";base64", "")
289293
else:
290294
content_bytes = content.encode("utf-8")
291-
295+
292296
return await client.webdav.write_file(path, content_bytes, content_type)
293297

294298

295299
@mcp.tool()
296300
async def nc_webdav_create_directory(path: str, ctx: Context):
297301
"""Create a directory in NextCloud.
298-
302+
299303
Args:
300304
path: Full path of the directory to create
301-
305+
302306
Returns:
303307
Dict with status_code (201 for created, 405 if already exists)
304-
308+
305309
Examples:
306310
# Create a single directory
307311
await nc_webdav_create_directory("NewProject")
308-
312+
309313
# Create nested directories (parent must exist)
310314
await nc_webdav_create_directory("Projects/MyApp/docs")
311315
"""
@@ -316,17 +320,17 @@ async def nc_webdav_create_directory(path: str, ctx: Context):
316320
@mcp.tool()
317321
async def nc_webdav_delete_resource(path: str, ctx: Context):
318322
"""Delete a file or directory in NextCloud.
319-
323+
320324
Args:
321325
path: Full path of the file or directory to delete
322-
326+
323327
Returns:
324328
Dict with status_code indicating result (404 if not found)
325-
329+
326330
Examples:
327331
# Delete a file
328332
await nc_webdav_delete_resource("old_document.txt")
329-
333+
330334
# Delete a directory (will delete all contents)
331335
await nc_webdav_delete_resource("temp_folder")
332336
"""

0 commit comments

Comments
 (0)