Add support for attachments in notes

This commit is contained in:
Chris Coutinho
2025-05-06 02:52:51 +02:00
parent 973caefb74
commit 04e4a8e0a8
21 changed files with 1493 additions and 6 deletions
+255
View File
@@ -156,3 +156,258 @@ def test_delete_nonexistent_note(nc_client: NextcloudClient):
print(
f"Deleting non-existent note ID: {non_existent_id} correctly failed with 404."
)
@pytest.mark.integration
def test_note_attachment_integration(nc_client: NextcloudClient):
"""
Integration test for adding and retrieving a note attachment via WebDAV.
This test is conditional on WebDAV permissions being available.
"""
# --- Create Note ---
unique_id = str(uuid.uuid4())
note_title = f"Attachment Test Note {unique_id}"
note_content = "Note for testing attachments."
note_category = "AttachmentTesting"
created_note = None
note_id = None
try:
print(f"\nCreating note for attachment test: {note_title}")
created_note = nc_client.notes_create_note(
title=note_title, content=note_content, category=note_category
)
assert created_note and "id" in created_note
note_id = created_note["id"]
print(f"Note created with ID: {note_id}")
time.sleep(1) # Allow time for note creation
# --- Try to Add Attachment ---
attachment_filename = f"test_attachment_{unique_id}.txt"
attachment_content = f"This is the content of {attachment_filename}".encode('utf-8')
attachment_mime = "text/plain"
print(f"Attempting to add attachment '{attachment_filename}' to note ID: {note_id}")
try:
# Try to add the attachment, but don't fail the test if WebDAV isn't available
upload_response = nc_client.add_note_attachment(
note_id=note_id,
filename=attachment_filename,
content=attachment_content,
mime_type=attachment_mime
)
# If we get here, WebDAV is working - continue with attachment tests
assert upload_response and "status_code" in upload_response
assert upload_response["status_code"] in [201, 204]
print(f"Attachment '{attachment_filename}' added successfully (Status: {upload_response['status_code']}).")
time.sleep(1) # Allow time for upload processing
# --- Get and Verify Attachment ---
print(f"Attempting to retrieve attachment '{attachment_filename}' from note ID: {note_id}")
retrieved_content, retrieved_mime = nc_client.get_note_attachment(
note_id=note_id,
filename=attachment_filename
)
print(f"Attachment retrieved. Mime type: {retrieved_mime}, Size: {len(retrieved_content)} bytes")
# --- Verify Attachment ---
assert retrieved_content == attachment_content
# Check if the expected mime type is part of the retrieved one (to handle charset)
assert attachment_mime in retrieved_mime
print("Retrieved attachment content and mime type verified successfully.")
except HTTPStatusError as e:
if e.response.status_code == 401:
pytest.skip("Skipping attachment tests due to WebDAV permission issues (401 Unauthorized)")
else:
raise # Re-raise other HTTP errors
finally:
# --- Delete Note (Cleanup) ---
if note_id:
print(f"Attempting cleanup: deleting note ID: {note_id}")
try:
nc_client.notes_delete_note(note_id=note_id)
print(f"Note ID: {note_id} deleted successfully.")
# Verify deletion
time.sleep(1)
with pytest.raises(HTTPStatusError) as excinfo_del:
nc_client.notes_get_note(note_id=note_id)
assert excinfo_del.value.response.status_code == 404
print(f"Verified note {note_id} deletion (404 received).")
except Exception as e:
print(f"Error during cleanup (deleting note {note_id}): {e}")
else:
print("Skipping cleanup as note ID was not obtained.")
@pytest.mark.integration
def test_note_attachment_with_category_integration(nc_client: NextcloudClient):
"""
Explicitly tests adding/retrieving an attachment for a note WITH a category.
Functionally similar to test_note_attachment_integration but emphasizes the category.
"""
# --- Create Note with Category ---
unique_id = str(uuid.uuid4())
note_title = f"Category Attachment Test Note {unique_id}"
note_content = "Note with category for testing attachments."
note_category = "CategoryTest" # Explicitly using a category
created_note = None
note_id = None
try:
print(f"\nCreating note with category '{note_category}' for attachment test: {note_title}")
created_note = nc_client.notes_create_note(
title=note_title, content=note_content, category=note_category
)
assert created_note and "id" in created_note
note_id = created_note["id"]
print(f"Note with category created with ID: {note_id}")
time.sleep(1)
# --- Try to Add Attachment ---
attachment_filename = f"category_test_attachment_{unique_id}.txt"
attachment_content = f"Content for {attachment_filename}".encode('utf-8')
attachment_mime = "text/plain"
print(f"Attempting to add attachment '{attachment_filename}' to note ID: {note_id}")
try:
# Try to add the attachment, but don't fail the test if WebDAV isn't available
upload_response = nc_client.add_note_attachment(
note_id=note_id,
filename=attachment_filename,
content=attachment_content,
mime_type=attachment_mime
)
# If we get here, WebDAV is working - continue with attachment tests
assert upload_response and "status_code" in upload_response
assert upload_response["status_code"] in [201, 204]
print(f"Attachment '{attachment_filename}' added successfully (Status: {upload_response['status_code']}).")
time.sleep(1)
# --- Get and Verify Attachment ---
print(f"Attempting to retrieve attachment '{attachment_filename}' from note ID: {note_id}")
retrieved_content, retrieved_mime = nc_client.get_note_attachment(
note_id=note_id,
filename=attachment_filename
)
print(f"Attachment retrieved. Mime type: {retrieved_mime}, Size: {len(retrieved_content)} bytes")
# --- Verify Attachment ---
assert retrieved_content == attachment_content
assert attachment_mime in retrieved_mime # Check if expected mime is part of retrieved
print("Retrieved attachment content and mime type verified successfully for note with category.")
except HTTPStatusError as e:
if e.response.status_code == 401:
pytest.skip("Skipping attachment tests due to WebDAV permission issues (401 Unauthorized)")
else:
raise # Re-raise other HTTP errors
finally:
# --- Delete Note (Cleanup) ---
if note_id:
print(f"Attempting cleanup: deleting note ID: {note_id}")
try:
nc_client.notes_delete_note(note_id=note_id)
print(f"Note ID: {note_id} deleted successfully.")
time.sleep(1)
with pytest.raises(HTTPStatusError) as excinfo_del:
nc_client.notes_get_note(note_id=note_id)
assert excinfo_del.value.response.status_code == 404
print(f"Verified note {note_id} deletion (404 received).")
except Exception as e:
print(f"Error during cleanup (deleting note {note_id}): {e}")
else:
print("Skipping cleanup as note ID was not obtained.")
@pytest.mark.integration
def test_attachment_cleanup_behavior(nc_client: NextcloudClient):
"""
Test to document the behavior regarding note attachment cleanup.
This test confirms that when a note is deleted, its attachments remain in the system.
This matches the behavior of the official Nextcloud Notes app, which also leaves
orphaned attachments when notes are deleted.
"""
# --- Create Note ---
unique_id = str(uuid.uuid4())
note_title = f"Attachment Cleanup Test {unique_id}"
note_content = "Test note for attachments cleanup."
note_category = "AttachmentCleanupTest"
print(f"\nCreating test note: {note_title}")
created_note = nc_client.notes_create_note(
title=note_title, content=note_content, category=note_category
)
assert created_note and "id" in created_note
note_id = created_note["id"]
print(f"Test note created with ID: {note_id}")
time.sleep(1)
# Check authentication type
auth_type = type(nc_client._client.auth).__name__
print(f"Client authentication type: {auth_type}")
# --- Try to Add Attachment ---
attachment_filename = f"cleanup_test_{unique_id}.txt"
attachment_content = f"Content for cleanup test".encode('utf-8')
print(f"Adding attachment '{attachment_filename}' to note ID: {note_id}")
try:
upload_response = nc_client.add_note_attachment(
note_id=note_id,
filename=attachment_filename,
content=attachment_content,
mime_type="text/plain"
)
assert upload_response["status_code"] in [201, 204]
print(f"Attachment added successfully (Status: {upload_response['status_code']}).")
time.sleep(1)
# --- Verify Attachment Exists ---
retrieved_content, _ = nc_client.get_note_attachment(
note_id=note_id,
filename=attachment_filename
)
assert retrieved_content == attachment_content
print("Verified attachment exists and can be retrieved")
# Attachment operations successful - continue with test
has_webdav_access = True
except HTTPStatusError as e:
if e.response.status_code == 401:
print(f"WebDAV access denied (401 Unauthorized). Skipping attachment tests.")
pytest.skip("WebDAV access denied (401 Unauthorized)")
else:
raise # Re-raise other HTTP errors
# --- Delete Note ---
print(f"Deleting note ID: {note_id}")
nc_client.notes_delete_note(note_id=note_id)
print(f"Note ID: {note_id} deleted successfully.")
time.sleep(1)
# --- Verify Note Is Deleted ---
with pytest.raises(HTTPStatusError) as excinfo:
nc_client.notes_get_note(note_id=note_id)
assert excinfo.value.response.status_code == 404
print(f"Verified note deletion (404 received)")
# --- Document the expected behavior: attachments remain after note deletion ---
try:
# Try to get the attachment - expected to still exist
retrieved_content, _ = nc_client.get_note_attachment(
note_id=note_id,
filename=attachment_filename
)
print("EXPECTED BEHAVIOR: Attachment still exists after note deletion")
print("This matches the behavior of the official Nextcloud Notes app")
except HTTPStatusError as e:
if e.response.status_code == 404:
print("NOTE: Attachment was deleted with the note (unexpected but not a problem)")
else:
print(f"Unexpected error when checking attachment: {e.response.status_code}")
+126
View File
@@ -0,0 +1,126 @@
import pytest
import os
import time
import uuid
import tempfile
from PIL import Image, ImageDraw
from io import BytesIO
from nextcloud_mcp_server.client import NextcloudClient
@pytest.fixture(scope="module")
def nc_client() -> NextcloudClient:
"""
Fixture to create a NextcloudClient instance for integration tests.
"""
assert os.getenv("NEXTCLOUD_HOST"), "NEXTCLOUD_HOST env var not set"
assert os.getenv("NEXTCLOUD_USERNAME"), "NEXTCLOUD_USERNAME env var not set"
assert os.getenv("NEXTCLOUD_PASSWORD"), "NEXTCLOUD_PASSWORD env var not set"
return NextcloudClient.from_env()
@pytest.fixture
def test_image():
"""Generate a test image with embedded text for attachment tests"""
# Create a temporary file to store the test image
fd, image_path = tempfile.mkstemp(suffix='.png')
os.close(fd)
# Create a test image with text
img = Image.new('RGB', (300, 200), color=(255, 255, 255))
draw = ImageDraw.Draw(img)
draw.rectangle([(20, 20), (280, 180)], fill=(0, 120, 212))
draw.text((50, 90), "Nextcloud Notes Test Image", fill=(255, 255, 255))
img.save(image_path)
try:
yield image_path
finally:
# Clean up the temporary image file
if os.path.exists(image_path):
os.unlink(image_path)
@pytest.mark.integration
def test_note_with_embedded_image(nc_client: NextcloudClient, test_image):
"""
Test creating a note with an embedded image and verify the process works end-to-end.
This test documents how images should be embedded in Nextcloud Notes.
"""
# Generate a unique identifier for this test run
unique_id = str(uuid.uuid4())[:8]
note_title = f"Embedded Image Test {unique_id}"
initial_content = "# Embedded Image Test\n\nThis note demonstrates how to properly embed images in Nextcloud Notes."
# Create the note
print(f"Creating test note: {note_title}")
note = nc_client.notes_create_note(
title=note_title,
content=initial_content,
category="Documentation"
)
note_id = note["id"]
note_etag = note["etag"]
print(f"Note created with ID: {note_id}")
try:
# Read the test image content
with open(test_image, 'rb') as f:
image_content = f.read()
# Generate a unique filename for the attachment
attachment_filename = f"test_image_{unique_id}.png"
# Upload the image as an attachment
print(f"Uploading image attachment '{attachment_filename}' to note {note_id}...")
upload_response = nc_client.add_note_attachment(
note_id=note_id,
filename=attachment_filename,
content=image_content,
mime_type="image/png"
)
print(f"Image uploaded: {upload_response}")
# Update the note content to include the embedded image using Markdown syntax
# This is the correct syntax for embedding images in Nextcloud Notes
updated_content = f"""# Embedded Image Test
This note demonstrates how to properly embed images in Nextcloud Notes.
## Method 1: Markdown Image Syntax
![Test Image](.attachments.{note_id}/{attachment_filename})
## Method 2: HTML Image Tag
<img src=".attachments.{note_id}/{attachment_filename}" alt="Test Image HTML" width="300" />
## Notes on Image Embedding
- Images must be stored in the .attachments.{note_id} directory
- Images are referenced using relative paths
- Both Markdown and HTML image tags work in Nextcloud Notes
- The Nextcloud Notes UI will display these images inline when viewing the note
"""
# Update the note with the image references
print("Updating note content with image references...")
updated_note = nc_client.notes_update_note(
note_id=note_id,
etag=note_etag,
content=updated_content
)
# Verify the updated note has the correct content
retrieved_note = nc_client.notes_get_note(note_id=note_id)
assert ".attachments." in retrieved_note["content"], "Image reference not found in note content"
print("Note updated successfully with image references")
# Verify we can retrieve the image attachment
retrieved_content, mime_type = nc_client.get_note_attachment(
note_id=note_id,
filename=attachment_filename
)
assert len(retrieved_content) > 0, "Retrieved image content is empty"
assert mime_type.startswith("image/"), f"Expected image mime type, got {mime_type}"
print("Test completed successfully - image was embedded in the note and can be retrieved")
finally:
# Clean up - delete the test note
print(f"Cleaning up - deleting test note {note_id}")
nc_client.notes_delete_note(note_id=note_id)
+100
View File
@@ -0,0 +1,100 @@
import pytest
import os
import time
import uuid
from httpx import HTTPStatusError
from nextcloud_mcp_server.client import NextcloudClient
# Tests assume NEXTCLOUD_HOST, NEXTCLOUD_USERNAME, NEXTCLOUD_PASSWORD env vars are set
@pytest.fixture(scope="module")
def nc_client() -> NextcloudClient:
"""
Fixture to create a NextcloudClient instance for integration tests.
"""
assert os.getenv("NEXTCLOUD_HOST"), "NEXTCLOUD_HOST env var not set"
assert os.getenv("NEXTCLOUD_USERNAME"), "NEXTCLOUD_USERNAME env var not set"
assert os.getenv("NEXTCLOUD_PASSWORD"), "NEXTCLOUD_PASSWORD env var not set"
return NextcloudClient.from_env()
@pytest.mark.integration
def test_attachment_remains_after_note_deletion(nc_client: NextcloudClient):
"""
Test to verify and document that when a note is deleted, its attachments remain
in the system. This is the expected behavior of the Nextcloud Notes app.
"""
# --- Create Note ---
unique_id = str(uuid.uuid4())
note_title = f"Attachment Cleanup Test {unique_id}"
note_content = f"# Test for attachment cleanup behavior\n\nThis note will be deleted, but attachments should remain."
note_category = "CleanupTests"
created_note = None
note_id = None
try:
# Create the note
print(f"Creating note: {note_title}")
created_note = nc_client.notes_create_note(
title=note_title,
content=note_content,
category=note_category
)
assert created_note and "id" in created_note
note_id = created_note["id"]
print(f"Note created with ID: {note_id}")
time.sleep(1)
# Create a simple text attachment
attachment_filename = f"orphan_test_{unique_id}.txt"
attachment_content = f"This is a test attachment for note {note_id}".encode('utf-8')
# Attach the file to the note
print(f"Attaching text file to note {note_id}...")
upload_response = nc_client.add_note_attachment(
note_id=note_id,
filename=attachment_filename,
content=attachment_content,
mime_type="text/plain"
)
assert upload_response["status_code"] in [201, 204]
print(f"Attachment added successfully (Status: {upload_response['status_code']}).")
time.sleep(1)
# Verify the attachment exists
content, mime_type = nc_client.get_note_attachment(
note_id=note_id,
filename=attachment_filename
)
assert content == attachment_content, "Attachment content mismatch"
print("Attachment verified")
# Now delete the note
print(f"Deleting note ID: {note_id}")
nc_client.notes_delete_note(note_id=note_id)
print(f"Note deleted successfully.")
time.sleep(1)
# Verify the note is deleted
with pytest.raises(HTTPStatusError) as excinfo:
nc_client.notes_get_note(note_id=note_id)
assert excinfo.value.response.status_code == 404
print(f"Verified note deletion (404 Not Found)")
# Now check if the attachment still exists (expected behavior: it should)
print(f"Checking if attachment still exists after note deletion...")
orphaned_content, orphaned_mime = nc_client.get_note_attachment(
note_id=note_id,
filename=attachment_filename
)
# If we get here without an exception, the attachment still exists
print("CONFIRMED: Attachment still exists after note deletion")
print("This is the expected behavior of the Nextcloud Notes app")
assert orphaned_content == attachment_content, "Orphaned attachment content mismatch"
finally:
# No cleanup needed since we've already deleted the note
pass
+133
View File
@@ -0,0 +1,133 @@
import pytest
import os
import time
import uuid
import tempfile
from httpx import HTTPStatusError
from PIL import Image, ImageDraw
from nextcloud_mcp_server.client import NextcloudClient
# Tests assume NEXTCLOUD_HOST, NEXTCLOUD_USERNAME, NEXTCLOUD_PASSWORD env vars are set
@pytest.fixture(scope="module")
def nc_client() -> NextcloudClient:
"""
Fixture to create a NextcloudClient instance for integration tests.
"""
assert os.getenv("NEXTCLOUD_HOST"), "NEXTCLOUD_HOST env var not set"
assert os.getenv("NEXTCLOUD_USERNAME"), "NEXTCLOUD_USERNAME env var not set"
assert os.getenv("NEXTCLOUD_PASSWORD"), "NEXTCLOUD_PASSWORD env var not set"
return NextcloudClient.from_env()
@pytest.fixture
def test_image():
"""Generate a test image for attachment tests"""
# Create a temporary file to store the test image
fd, image_path = tempfile.mkstemp(suffix='.png')
os.close(fd)
# Create a simple test image
img = Image.new('RGB', (200, 200), color = (255, 255, 255))
draw = ImageDraw.Draw(img)
draw.rectangle([(20, 20), (180, 180)], fill=(255, 0, 0))
draw.text((40, 100), "Nextcloud MCP Test", fill=(255, 255, 255))
img.save(image_path)
try:
yield image_path
finally:
# Clean up the temporary image file
if os.path.exists(image_path):
os.unlink(image_path)
@pytest.mark.integration
def test_note_with_image_attachment(nc_client: NextcloudClient, test_image):
"""
Test creating a note with an image attachment and properly embedding it
in the note content using Nextcloud Notes' syntax.
"""
# --- Create Note ---
unique_id = str(uuid.uuid4())
note_title = f"Note with Embedded Image {unique_id}"
note_content = "# Note with Embedded Image\n\nThis note contains an embedded image."
note_category = "ImageTests"
created_note = None
note_id = None
try:
# Create the note
print(f"Creating note: {note_title}")
created_note = nc_client.notes_create_note(
title=note_title,
content=note_content,
category=note_category
)
assert created_note and "id" in created_note
note_id = created_note["id"]
print(f"Note created with ID: {note_id}")
time.sleep(1)
# Read the test image
with open(test_image, 'rb') as f:
image_content = f.read()
# Attach the image to the note
attachment_filename = f"test_image_{unique_id}.png"
print(f"Attaching image to note {note_id}...")
upload_response = nc_client.add_note_attachment(
note_id=note_id,
filename=attachment_filename,
content=image_content,
mime_type="image/png"
)
assert upload_response["status_code"] in [201, 204]
print(f"Image attached successfully (Status: {upload_response['status_code']}).")
time.sleep(1)
# Update the note content to include a reference to the attached image
# Try embedding using Markdown image syntax
updated_content = f"""# Note with Embedded Image
This note contains an embedded image.
## Embedded Image (Markdown Syntax)
![Test Image](.attachments.{note_id}/{attachment_filename})
## WebDAV URL
Files path: `/Notes/.attachments.{note_id}/{attachment_filename}`
"""
# Update the note content
print("Updating note content to include image reference...")
updated_note = nc_client.notes_update_note(
note_id=note_id,
etag=created_note["etag"],
content=updated_content
)
# Retrieve the note to verify content
retrieved_note = nc_client.notes_get_note(note_id=note_id)
print("Retrieved note content:")
print(retrieved_note["content"])
# Verify the image attachment can be retrieved
content, mime_type = nc_client.get_note_attachment(
note_id=note_id,
filename=attachment_filename
)
assert content == image_content, "Attachment content mismatch"
assert mime_type.startswith("image/"), f"Expected image mime type, got {mime_type}"
print("Image attachment verified")
finally:
# Cleanup
if note_id:
print(f"Cleaning up - deleting note ID: {note_id}")
try:
nc_client.notes_delete_note(note_id=note_id)
print(f"Note {note_id} deleted")
except Exception as e:
print(f"Error during cleanup: {e}")