c787abf2f3
The test_attachments_category_change_handling test was failing in CI with HTTP 412 Precondition Failed errors. This is caused by the background vector scanner (runs every 10 seconds) modifying notes between when the test fetches the ETag and when it attempts to update the category. Solution: Added retry logic (up to 3 attempts) that refetches the latest ETag and retries the update operation when encountering 412 errors. This handles the race condition gracefully while still catching genuine errors.
428 lines
18 KiB
Python
428 lines
18 KiB
Python
import logging
|
|
import time
|
|
import uuid
|
|
|
|
import pytest
|
|
from httpx import HTTPStatusError
|
|
|
|
from nextcloud_mcp_server.client import NextcloudClient
|
|
|
|
# Note: nc_client fixture is session-scoped in conftest.py
|
|
# Note: temporary_note and temporary_note_with_attachment fixtures are function-scoped in conftest.py
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Mark all tests in this module as integration tests
|
|
pytestmark = pytest.mark.integration
|
|
|
|
|
|
async def test_attachments_add_and_get(
|
|
nc_client: NextcloudClient, temporary_note_with_attachment: tuple
|
|
):
|
|
"""
|
|
Tests adding an attachment (via fixture) and retrieving it.
|
|
"""
|
|
note_data, attachment_filename, attachment_content = temporary_note_with_attachment
|
|
note_id = note_data["id"]
|
|
note_category = note_data.get("category") # Get category from fixture data
|
|
|
|
logger.info(
|
|
f"Attempting to retrieve attachment '{attachment_filename}' added by fixture for note ID: {note_id}"
|
|
)
|
|
# Pass category to get_note_attachment
|
|
retrieved_content, retrieved_mime = await nc_client.webdav.get_note_attachment(
|
|
note_id=note_id, filename=attachment_filename, category=note_category
|
|
)
|
|
logger.info(
|
|
f"Attachment retrieved. Mime type: {retrieved_mime}, Size: {len(retrieved_content)} bytes"
|
|
)
|
|
|
|
assert retrieved_content == attachment_content
|
|
assert "text/plain" in retrieved_mime # Fixture uses text/plain
|
|
logger.info("Retrieved attachment content and mime type verified successfully.")
|
|
|
|
|
|
async def test_attachments_add_to_note_with_category(
|
|
nc_client: NextcloudClient, temporary_note: dict
|
|
):
|
|
"""
|
|
Tests adding and retrieving an attachment specifically for a note that has a category.
|
|
Uses temporary_note fixture and adds attachment manually within the test.
|
|
"""
|
|
note_data = (
|
|
temporary_note # Note created by fixture (has category 'TemporaryTesting')
|
|
)
|
|
note_id = note_data["id"]
|
|
note_category = note_data["category"]
|
|
logger.info(
|
|
f"Using note ID: {note_id} with category '{note_category}' for attachment test."
|
|
)
|
|
|
|
# Add attachment within the test
|
|
unique_suffix = uuid.uuid4().hex[:8]
|
|
attachment_filename = f"category_attach_{unique_suffix}.txt"
|
|
attachment_content = f"Content for {attachment_filename}".encode("utf-8")
|
|
attachment_mime = "text/plain"
|
|
|
|
logger.info(
|
|
f"Attempting to add attachment '{attachment_filename}' to note ID: {note_id}"
|
|
)
|
|
# Pass category to add_note_attachment
|
|
upload_response = await nc_client.webdav.add_note_attachment(
|
|
note_id=note_id,
|
|
filename=attachment_filename,
|
|
content=attachment_content,
|
|
category=note_category, # Pass the note's category
|
|
mime_type=attachment_mime,
|
|
)
|
|
assert upload_response and "status_code" in upload_response
|
|
assert upload_response["status_code"] in [201, 204]
|
|
logger.info(
|
|
f"Attachment '{attachment_filename}' added successfully (Status: {upload_response['status_code']})."
|
|
)
|
|
time.sleep(1)
|
|
|
|
# Get and Verify Attachment
|
|
logger.info(
|
|
f"Attempting to retrieve attachment '{attachment_filename}' from note ID: {note_id}"
|
|
)
|
|
# Pass category to get_note_attachment
|
|
retrieved_content, retrieved_mime = await nc_client.webdav.get_note_attachment(
|
|
note_id=note_id,
|
|
filename=attachment_filename,
|
|
category=note_category, # Pass the note's category
|
|
)
|
|
logger.info(
|
|
f"Attachment retrieved. Mime type: {retrieved_mime}, Size: {len(retrieved_content)} bytes"
|
|
)
|
|
|
|
assert retrieved_content == attachment_content
|
|
assert attachment_mime in retrieved_mime
|
|
logger.info(
|
|
"Retrieved attachment content and mime type verified successfully for note with category."
|
|
)
|
|
# Cleanup is handled by the temporary_note fixture
|
|
|
|
|
|
async def test_attachments_cleanup_on_note_delete(
|
|
nc_client: NextcloudClient, temporary_note_with_attachment: tuple
|
|
):
|
|
"""
|
|
Tests that the attachment (and its directory) are deleted when the parent note is deleted.
|
|
Relies on the cleanup mechanism within notes_delete_note and the temporary_note fixture.
|
|
"""
|
|
note_data, attachment_filename, _ = temporary_note_with_attachment
|
|
note_id = note_data["id"]
|
|
note_category = note_data.get("category") # Get category from fixture data
|
|
|
|
# Fixture setup already added the attachment.
|
|
# Fixture teardown (from temporary_note) will delete the note.
|
|
# We just need to verify the attachment is gone *after* the test finishes
|
|
# and the fixture cleanup runs. However, pytest fixtures don't easily allow
|
|
# checking state *after* cleanup.
|
|
# Instead, we will manually delete the note here and verify the attachment is gone.
|
|
|
|
logger.info(
|
|
f"Attachment '{attachment_filename}' exists for note {note_id} (added by fixture)."
|
|
)
|
|
|
|
# Manually delete the note
|
|
logger.info(f"Manually deleting note ID: {note_id} within the test.")
|
|
await nc_client.notes.delete_note(note_id=note_id)
|
|
logger.info(f"Note ID: {note_id} deleted successfully.")
|
|
time.sleep(1)
|
|
|
|
# Verify Note Is Deleted
|
|
with pytest.raises(HTTPStatusError) as excinfo_note:
|
|
await nc_client.notes.get_note(note_id=note_id)
|
|
assert excinfo_note.value.response.status_code == 404
|
|
logger.info(f"Verified note {note_id} deletion (404 received).")
|
|
|
|
# Verify Attachment Is Deleted (via 404 on GET)
|
|
logger.info(
|
|
f"Verifying attachment '{attachment_filename}' is deleted for note ID: {note_id}"
|
|
)
|
|
with pytest.raises(HTTPStatusError) as excinfo_attach:
|
|
# Pass category to get_note_attachment - although it should fail anyway
|
|
# because the note (and thus details) are gone.
|
|
# The client method will raise 404 from the initial notes_get_note call.
|
|
await nc_client.webdav.get_note_attachment(
|
|
note_id=note_id,
|
|
filename=attachment_filename,
|
|
category=note_category, # Pass category, though note fetch should fail first
|
|
)
|
|
# Expect 404 because the note itself is gone
|
|
assert excinfo_attach.value.response.status_code == 404
|
|
logger.info(
|
|
f"Attachment '{attachment_filename}' correctly not found (404) after note deletion."
|
|
)
|
|
|
|
# Directly verify attachment directory doesn't exist using WebDAV PROPFIND
|
|
logger.info("Directly verifying attachment directory doesn't exist via PROPFIND")
|
|
webdav_base = nc_client._get_webdav_base_path()
|
|
category_path_part = f"{note_category}/" if note_category else ""
|
|
attachment_dir_path = (
|
|
f"{webdav_base}/Notes/{category_path_part}.attachments.{note_id}"
|
|
)
|
|
propfind_headers = {"Depth": "0", "OCS-APIRequest": "true"}
|
|
try:
|
|
propfind_resp = await nc_client._client.request(
|
|
"PROPFIND", attachment_dir_path, headers=propfind_headers
|
|
)
|
|
status = propfind_resp.status_code
|
|
if status in [200, 207]: # Successful PROPFIND means directory exists
|
|
logger.error(
|
|
f"Attachment directory still exists! PROPFIND returned {status}"
|
|
)
|
|
assert False, (
|
|
f"Expected attachment directory to be gone, but PROPFIND returned {status}!"
|
|
)
|
|
except HTTPStatusError as e:
|
|
assert e.response.status_code == 404, (
|
|
f"Expected PROPFIND to fail with 404, got {e.response.status_code}"
|
|
)
|
|
logger.info(
|
|
"Verified attachment directory does not exist via PROPFIND (404 received)"
|
|
)
|
|
|
|
# Note: The temporary_note fixture will still run its cleanup,
|
|
# but it will find the note already deleted (404) and handle it gracefully.
|
|
|
|
|
|
async def test_attachments_category_change_handling(nc_client: NextcloudClient):
|
|
"""
|
|
Tests attachment handling when a note's category is changed.
|
|
Verifies attachment retrieval works before and after category change,
|
|
and that cleanup targets the correct final location.
|
|
"""
|
|
note_id = None
|
|
initial_category = "CategoryA"
|
|
new_category = "CategoryB"
|
|
unique_suffix = uuid.uuid4().hex[:8]
|
|
note_title = f"Category Change Test {unique_suffix}"
|
|
attachment_filename = f"cat_change_{unique_suffix}.txt"
|
|
attachment_content = f"Content for {attachment_filename}".encode("utf-8")
|
|
|
|
try:
|
|
# 1. Create note with initial category
|
|
logger.info(f"Creating note '{note_title}' in category '{initial_category}'")
|
|
created_note = await nc_client.notes.create_note(
|
|
title=note_title, content="Initial content", category=initial_category
|
|
)
|
|
note_id = created_note["id"]
|
|
etag1 = created_note["etag"]
|
|
logger.info(f"Note created with ID: {note_id}, Etag: {etag1}")
|
|
time.sleep(1)
|
|
|
|
# 2. Add attachment (passing initial category)
|
|
logger.info(
|
|
f"Adding attachment '{attachment_filename}' to note {note_id} (in {initial_category})"
|
|
)
|
|
upload_response = await nc_client.webdav.add_note_attachment(
|
|
note_id=note_id,
|
|
filename=attachment_filename,
|
|
content=attachment_content,
|
|
category=initial_category,
|
|
mime_type="text/plain",
|
|
)
|
|
assert upload_response["status_code"] in [201, 204]
|
|
logger.info("Attachment added successfully.")
|
|
time.sleep(1)
|
|
|
|
# 3. Verify attachment retrieval from initial category (passing initial category)
|
|
logger.info(
|
|
f"Verifying attachment retrieval from initial category '{initial_category}'"
|
|
)
|
|
retrieved_content1, _ = await nc_client.webdav.get_note_attachment(
|
|
note_id=note_id, filename=attachment_filename, category=initial_category
|
|
)
|
|
assert retrieved_content1 == attachment_content
|
|
logger.info("Attachment retrieved successfully from initial category.")
|
|
|
|
# 4. Update note category (with retry for ETag conflicts from background scanner)
|
|
logger.info(
|
|
f"Updating note {note_id} category from '{initial_category}' to '{new_category}'"
|
|
)
|
|
# Retry logic for 412 Precondition Failed (ETag conflict)
|
|
# This can happen if the background vector scanner touches the note
|
|
max_update_attempts = 3
|
|
for attempt in range(max_update_attempts):
|
|
try:
|
|
# Fetch the latest etag
|
|
current_note_data = await nc_client.notes.get_note(note_id=note_id)
|
|
current_etag = current_note_data["etag"]
|
|
logger.info(
|
|
f"Update attempt {attempt + 1}/{max_update_attempts}, current etag: {current_etag}"
|
|
)
|
|
|
|
updated_note = await nc_client.notes.update(
|
|
note_id=note_id,
|
|
etag=current_etag,
|
|
category=new_category,
|
|
title=note_title,
|
|
content="Updated content", # Pass required fields
|
|
)
|
|
etag3 = updated_note["etag"]
|
|
assert updated_note["category"] == new_category
|
|
logger.info(f"Note category updated successfully. New Etag: {etag3}")
|
|
break # Success, exit retry loop
|
|
|
|
except HTTPStatusError as e:
|
|
if e.response.status_code == 412 and attempt < max_update_attempts - 1:
|
|
# ETag conflict (likely from background scanner), retry
|
|
logger.warning(
|
|
f"ETag conflict (412) on attempt {attempt + 1}, retrying..."
|
|
)
|
|
time.sleep(1) # Brief delay before retry
|
|
continue
|
|
else:
|
|
# Not a 412 or out of retries, re-raise
|
|
raise
|
|
|
|
time.sleep(1)
|
|
|
|
# 5. Verify attachment retrieval from *new* category (passing new category)
|
|
logger.info(
|
|
f"Verifying attachment retrieval from new category '{new_category}'"
|
|
)
|
|
retrieved_content2, _ = await nc_client.webdav.get_note_attachment(
|
|
note_id=note_id, filename=attachment_filename, category=new_category
|
|
)
|
|
assert retrieved_content2 == attachment_content
|
|
logger.info("Attachment retrieved successfully from new category.")
|
|
|
|
# 5.1 Verify old category attachment directory is gone via WebDAV PROPFIND
|
|
logger.info("Directly checking if old attachment directory exists in WebDAV")
|
|
webdav_base = nc_client._get_webdav_base_path()
|
|
old_attachment_dir_path = (
|
|
f"{webdav_base}/Notes/{initial_category}/.attachments.{note_id}"
|
|
)
|
|
propfind_headers = {"Depth": "0", "OCS-APIRequest": "true"}
|
|
try:
|
|
propfind_resp = await nc_client._client.request(
|
|
"PROPFIND", old_attachment_dir_path, headers=propfind_headers
|
|
)
|
|
status = propfind_resp.status_code
|
|
if status in [200, 207]: # Successful PROPFIND means directory exists
|
|
logger.error(
|
|
f"Old attachment directory still exists! PROPFIND returned {status}"
|
|
)
|
|
assert False, (
|
|
f"Expected old directory to be gone, but PROPFIND returned {status} - directory still exists!"
|
|
)
|
|
except HTTPStatusError as e:
|
|
assert e.response.status_code == 404, (
|
|
f"Expected PROPFIND to fail with 404, got {e.response.status_code}"
|
|
)
|
|
logger.info(
|
|
"Verified old attachment directory does not exist via PROPFIND (404 received)"
|
|
)
|
|
|
|
# 5.2 Verify new category attachment directory exists via WebDAV PROPFIND
|
|
logger.info("Directly checking if new attachment directory exists in WebDAV")
|
|
new_attachment_dir_path = (
|
|
f"{webdav_base}/Notes/{new_category}/.attachments.{note_id}"
|
|
)
|
|
try:
|
|
propfind_resp = await nc_client._client.request(
|
|
"PROPFIND", new_attachment_dir_path, headers=propfind_headers
|
|
)
|
|
status = propfind_resp.status_code
|
|
assert status in [
|
|
207,
|
|
200,
|
|
], f"Expected PROPFIND to return success (207/200), got {status}"
|
|
logger.info(
|
|
f"Verified new attachment directory exists via PROPFIND ({status} received)"
|
|
)
|
|
except HTTPStatusError as e:
|
|
logger.error(
|
|
f"New attachment directory not found! PROPFIND failed with {e.response.status_code}"
|
|
)
|
|
assert False, (
|
|
f"Expected new attachment directory to exist, but PROPFIND failed with {e.response.status_code}"
|
|
)
|
|
|
|
finally:
|
|
# 6. Cleanup: Delete the note (client should use the *final* category for cleanup path)
|
|
if note_id:
|
|
logger.info(
|
|
f"Cleaning up note ID: {note_id} (last known category: '{new_category}')"
|
|
)
|
|
try:
|
|
await nc_client.notes.delete_note(note_id=note_id)
|
|
logger.info(f"Note {note_id} deleted.")
|
|
time.sleep(1)
|
|
# Verify note deletion
|
|
with pytest.raises(HTTPStatusError) as excinfo_note_del:
|
|
await nc_client.notes.get_note(note_id=note_id)
|
|
assert excinfo_note_del.value.response.status_code == 404
|
|
logger.info("Verified note deleted (404).")
|
|
# Verify attachment deletion (should fail with 404 on the initial note fetch)
|
|
with pytest.raises(HTTPStatusError) as excinfo_attach_del:
|
|
# Pass the *last known* category, although the note fetch should fail first
|
|
await nc_client.webdav.get_note_attachment(
|
|
note_id=note_id,
|
|
filename=attachment_filename,
|
|
category=new_category,
|
|
)
|
|
assert excinfo_attach_del.value.response.status_code == 404
|
|
logger.info(
|
|
"Verified attachment cannot be retrieved after note deletion (404)."
|
|
)
|
|
|
|
# 6.1 Verify both old and new attachment directories are gone via WebDAV PROPFIND
|
|
logger.info(
|
|
"Directly verifying attachment directories don't exist via PROPFIND"
|
|
)
|
|
webdav_base = nc_client._get_webdav_base_path()
|
|
|
|
# Check new category attachment directory
|
|
new_attachment_dir_path = (
|
|
f"{webdav_base}/Notes/{new_category}/.attachments.{note_id}"
|
|
)
|
|
propfind_headers = {"Depth": "0", "OCS-APIRequest": "true"}
|
|
try:
|
|
resp = await nc_client._client.request(
|
|
"PROPFIND", new_attachment_dir_path, headers=propfind_headers
|
|
)
|
|
if resp.status_code in [
|
|
200,
|
|
207,
|
|
]: # Successful PROPFIND means directory exists
|
|
assert False, "New category attachment directory still exists!"
|
|
except HTTPStatusError as e:
|
|
assert e.response.status_code == 404, (
|
|
f"Expected PROPFIND to fail with 404, got {e.response.status_code}"
|
|
)
|
|
logger.info(
|
|
"Verified new category attachment directory is gone via PROPFIND"
|
|
)
|
|
|
|
# Check old category attachment directory
|
|
old_attachment_dir_path = (
|
|
f"{webdav_base}/Notes/{initial_category}/.attachments.{note_id}"
|
|
)
|
|
try:
|
|
resp = await nc_client._client.request(
|
|
"PROPFIND", old_attachment_dir_path, headers=propfind_headers
|
|
)
|
|
if resp.status_code in [
|
|
200,
|
|
207,
|
|
]: # Successful PROPFIND means directory exists
|
|
assert False, "Old category attachment directory still exists!"
|
|
except HTTPStatusError as e:
|
|
assert e.response.status_code == 404, (
|
|
f"Expected PROPFIND to fail with 404, got {e.response.status_code}"
|
|
)
|
|
logger.info(
|
|
"Verified old category attachment directory is gone via PROPFIND"
|
|
)
|
|
|
|
logger.info(
|
|
"Verified all attachment directories are properly cleaned up."
|
|
)
|
|
except Exception as e:
|
|
logger.error(f"Error during cleanup for note {note_id}: {e}")
|