ci: [skip ci] Move tests to subdirs
This commit is contained in:
@@ -0,0 +1,426 @@
|
||||
"""Integration tests for Calendar CalDAV operations."""
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import pytest
|
||||
from httpx import HTTPStatusError
|
||||
|
||||
from nextcloud_mcp_server.client import NextcloudClient
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Mark all tests in this module as integration tests
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_calendar_name():
|
||||
"""Unique calendar name for testing."""
|
||||
return f"test_calendar_{uuid.uuid4().hex[:8]}"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def temporary_calendar(nc_client: NextcloudClient, test_calendar_name: str):
|
||||
"""Create a temporary calendar for testing and clean up afterward."""
|
||||
calendar_name = test_calendar_name
|
||||
|
||||
try:
|
||||
# Create a test calendar
|
||||
logger.info(f"Creating temporary calendar: {calendar_name}")
|
||||
result = await nc_client.calendar.create_calendar(
|
||||
calendar_name=calendar_name,
|
||||
display_name=f"Test Calendar {calendar_name}",
|
||||
description="Temporary calendar for integration testing",
|
||||
color="#FF5722",
|
||||
)
|
||||
|
||||
if result["status_code"] not in [200, 201]:
|
||||
pytest.skip(f"Failed to create temporary calendar: {result}")
|
||||
|
||||
logger.info(f"Created temporary calendar: {calendar_name}")
|
||||
yield calendar_name
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error setting up temporary calendar: {e}")
|
||||
pytest.skip(f"Calendar setup failed: {e}")
|
||||
|
||||
finally:
|
||||
# Cleanup: Delete the temporary calendar
|
||||
try:
|
||||
logger.info(f"Cleaning up temporary calendar: {calendar_name}")
|
||||
await nc_client.calendar.delete_calendar(calendar_name)
|
||||
logger.info(f"Successfully deleted temporary calendar: {calendar_name}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error deleting temporary calendar {calendar_name}: {e}")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def temporary_event(nc_client: NextcloudClient, temporary_calendar: str):
|
||||
"""Create a temporary event for testing and clean up afterward."""
|
||||
event_uid = None
|
||||
calendar_name = temporary_calendar
|
||||
|
||||
# Create a test event
|
||||
tomorrow = datetime.now() + timedelta(days=1)
|
||||
event_data = {
|
||||
"title": f"Test Event {uuid.uuid4().hex[:8]}",
|
||||
"start_datetime": tomorrow.strftime("%Y-%m-%dT14:00:00"),
|
||||
"end_datetime": tomorrow.strftime("%Y-%m-%dT15:00:00"),
|
||||
"description": "Test event created by integration tests",
|
||||
"location": "Test Location",
|
||||
"categories": "testing",
|
||||
"status": "CONFIRMED",
|
||||
"priority": 5,
|
||||
}
|
||||
|
||||
try:
|
||||
logger.info(f"Creating temporary event in calendar: {calendar_name}")
|
||||
result = await nc_client.calendar.create_event(calendar_name, event_data)
|
||||
event_uid = result.get("uid")
|
||||
|
||||
if not event_uid:
|
||||
pytest.fail("Failed to create temporary event")
|
||||
|
||||
logger.info(f"Created temporary event with UID: {event_uid}")
|
||||
yield {"uid": event_uid, "calendar_name": calendar_name, "data": event_data}
|
||||
|
||||
finally:
|
||||
# Cleanup
|
||||
if event_uid:
|
||||
try:
|
||||
logger.info(f"Cleaning up temporary event: {event_uid}")
|
||||
await nc_client.calendar.delete_event(calendar_name, event_uid)
|
||||
logger.info(f"Successfully deleted temporary event: {event_uid}")
|
||||
except HTTPStatusError as e:
|
||||
if e.response.status_code != 404:
|
||||
logger.error(f"Error deleting temporary event {event_uid}: {e}")
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Unexpected error deleting temporary event {event_uid}: {e}"
|
||||
)
|
||||
|
||||
|
||||
async def test_list_calendars(nc_client: NextcloudClient):
|
||||
"""Test listing available calendars."""
|
||||
calendars = await nc_client.calendar.list_calendars()
|
||||
|
||||
assert isinstance(calendars, list)
|
||||
|
||||
if not calendars:
|
||||
pytest.skip("No calendars available - Calendar app may not be enabled")
|
||||
|
||||
logger.info(f"Found {len(calendars)} calendars")
|
||||
|
||||
# Check structure of calendars
|
||||
for calendar in calendars:
|
||||
assert "name" in calendar
|
||||
assert "display_name" in calendar
|
||||
assert "href" in calendar
|
||||
# Optional fields
|
||||
assert "description" in calendar
|
||||
assert "color" in calendar
|
||||
|
||||
logger.info(f"Calendar: {calendar['name']} - {calendar['display_name']}")
|
||||
|
||||
|
||||
async def test_create_and_delete_event(
|
||||
nc_client: NextcloudClient, temporary_calendar: str
|
||||
):
|
||||
"""Test creating and deleting a basic event."""
|
||||
calendar_name = temporary_calendar
|
||||
|
||||
# Create event
|
||||
tomorrow = datetime.now() + timedelta(days=1)
|
||||
event_data = {
|
||||
"title": "Integration Test Event",
|
||||
"start_datetime": tomorrow.strftime("%Y-%m-%dT10:00:00"),
|
||||
"end_datetime": tomorrow.strftime("%Y-%m-%dT11:00:00"),
|
||||
"description": "Test event for integration testing",
|
||||
"location": "Test Room",
|
||||
"categories": "testing,integration",
|
||||
"status": "CONFIRMED",
|
||||
"priority": 3,
|
||||
}
|
||||
|
||||
try:
|
||||
result = await nc_client.calendar.create_event(calendar_name, event_data)
|
||||
assert "uid" in result
|
||||
assert result["status_code"] in [200, 201, 204]
|
||||
|
||||
event_uid = result["uid"]
|
||||
logger.info(f"Created event with UID: {event_uid}")
|
||||
|
||||
# Verify event was created by retrieving it
|
||||
retrieved_event, etag = await nc_client.calendar.get_event(
|
||||
calendar_name, event_uid
|
||||
)
|
||||
assert retrieved_event["uid"] == event_uid
|
||||
assert retrieved_event["title"] == "Integration Test Event"
|
||||
assert retrieved_event["location"] == "Test Room"
|
||||
|
||||
# Delete event
|
||||
delete_result = await nc_client.calendar.delete_event(calendar_name, event_uid)
|
||||
assert delete_result["status_code"] in [200, 204, 404]
|
||||
|
||||
logger.info(f"Successfully deleted event: {event_uid}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Test failed: {e}")
|
||||
raise
|
||||
|
||||
|
||||
async def test_create_all_day_event(
|
||||
nc_client: NextcloudClient, temporary_calendar: str
|
||||
):
|
||||
"""Test creating an all-day event."""
|
||||
calendar_name = temporary_calendar
|
||||
|
||||
tomorrow = datetime.now() + timedelta(days=1)
|
||||
event_data = {
|
||||
"title": "All Day Test Event",
|
||||
"start_datetime": tomorrow.strftime("%Y-%m-%d"),
|
||||
"all_day": True,
|
||||
"description": "Test all-day event",
|
||||
"categories": "testing",
|
||||
}
|
||||
|
||||
try:
|
||||
result = await nc_client.calendar.create_event(calendar_name, event_data)
|
||||
event_uid = result["uid"]
|
||||
logger.info(f"Created all-day event with UID: {event_uid}")
|
||||
|
||||
# Verify event
|
||||
retrieved_event, _ = await nc_client.calendar.get_event(
|
||||
calendar_name, event_uid
|
||||
)
|
||||
assert retrieved_event["title"] == "All Day Test Event"
|
||||
assert retrieved_event.get("all_day") is True
|
||||
|
||||
# Cleanup
|
||||
await nc_client.calendar.delete_event(calendar_name, event_uid)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"All-day event test failed: {e}")
|
||||
raise
|
||||
|
||||
|
||||
async def test_create_recurring_event(
|
||||
nc_client: NextcloudClient, temporary_calendar: str
|
||||
):
|
||||
"""Test creating a recurring event."""
|
||||
calendar_name = temporary_calendar
|
||||
|
||||
tomorrow = datetime.now() + timedelta(days=1)
|
||||
event_data = {
|
||||
"title": "Weekly Recurring Test",
|
||||
"start_datetime": tomorrow.strftime("%Y-%m-%dT14:00:00"),
|
||||
"end_datetime": tomorrow.strftime("%Y-%m-%dT15:00:00"),
|
||||
"description": "Test recurring event",
|
||||
"recurring": True,
|
||||
"recurrence_rule": "FREQ=WEEKLY;BYDAY=MO,WE,FR",
|
||||
"reminder_minutes": 30,
|
||||
}
|
||||
|
||||
try:
|
||||
result = await nc_client.calendar.create_event(calendar_name, event_data)
|
||||
event_uid = result["uid"]
|
||||
logger.info(f"Created recurring event with UID: {event_uid}")
|
||||
|
||||
# Verify event
|
||||
retrieved_event, _ = await nc_client.calendar.get_event(
|
||||
calendar_name, event_uid
|
||||
)
|
||||
assert retrieved_event["title"] == "Weekly Recurring Test"
|
||||
assert retrieved_event.get("recurring") is True
|
||||
|
||||
# Cleanup
|
||||
await nc_client.calendar.delete_event(calendar_name, event_uid)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Recurring event test failed: {e}")
|
||||
raise
|
||||
|
||||
|
||||
async def test_list_events_in_range(nc_client: NextcloudClient, temporary_event: dict):
|
||||
"""Test listing events within a date range."""
|
||||
calendar_name = temporary_event["calendar_name"]
|
||||
|
||||
# Get events for the next week
|
||||
start_datetime = datetime.now()
|
||||
end_datetime = datetime.now() + timedelta(days=7)
|
||||
|
||||
events = await nc_client.calendar.get_calendar_events(
|
||||
calendar_name=calendar_name,
|
||||
start_datetime=start_datetime,
|
||||
end_datetime=end_datetime,
|
||||
limit=50,
|
||||
)
|
||||
|
||||
assert isinstance(events, list)
|
||||
logger.info(f"Found {len(events)} events in date range")
|
||||
|
||||
# Our temporary event should be in the list
|
||||
event_uids = [event.get("uid") for event in events]
|
||||
assert temporary_event["uid"] in event_uids
|
||||
|
||||
# Check event structure
|
||||
for event in events:
|
||||
assert "uid" in event
|
||||
assert "title" in event
|
||||
assert "start_datetime" in event
|
||||
|
||||
|
||||
async def test_update_event(nc_client: NextcloudClient, temporary_event: dict):
|
||||
"""Test updating an existing event."""
|
||||
calendar_name = temporary_event["calendar_name"]
|
||||
event_uid = temporary_event["uid"]
|
||||
|
||||
# Update event data
|
||||
updated_data = {
|
||||
"title": "Updated Test Event Title",
|
||||
"description": "Updated description for test event",
|
||||
"location": "Updated Location",
|
||||
"priority": 1, # High priority
|
||||
}
|
||||
|
||||
try:
|
||||
result = await nc_client.calendar.update_event(
|
||||
calendar_name, event_uid, updated_data
|
||||
)
|
||||
assert result["uid"] == event_uid
|
||||
|
||||
# Verify updates
|
||||
updated_event, _ = await nc_client.calendar.get_event(calendar_name, event_uid)
|
||||
assert updated_event["title"] == "Updated Test Event Title"
|
||||
assert updated_event["description"] == "Updated description for test event"
|
||||
assert updated_event["location"] == "Updated Location"
|
||||
assert updated_event["priority"] == 1
|
||||
|
||||
logger.info(f"Successfully updated event: {event_uid}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Event update test failed: {e}")
|
||||
raise
|
||||
|
||||
|
||||
async def test_create_event_with_attendees(
|
||||
nc_client: NextcloudClient, temporary_calendar: str
|
||||
):
|
||||
"""Test creating an event with attendees."""
|
||||
calendar_name = temporary_calendar
|
||||
|
||||
tomorrow = datetime.now() + timedelta(days=1)
|
||||
event_data = {
|
||||
"title": "Meeting with Attendees",
|
||||
"start_datetime": tomorrow.strftime("%Y-%m-%dT16:00:00"),
|
||||
"end_datetime": tomorrow.strftime("%Y-%m-%dT17:00:00"),
|
||||
"description": "Test meeting with multiple attendees",
|
||||
"location": "Conference Room A",
|
||||
"attendees": "test1@example.com,test2@example.com",
|
||||
"reminder_minutes": 15,
|
||||
"status": "TENTATIVE",
|
||||
}
|
||||
|
||||
try:
|
||||
result = await nc_client.calendar.create_event(calendar_name, event_data)
|
||||
event_uid = result["uid"]
|
||||
logger.info(f"Created event with attendees, UID: {event_uid}")
|
||||
|
||||
# Verify event
|
||||
retrieved_event, _ = await nc_client.calendar.get_event(
|
||||
calendar_name, event_uid
|
||||
)
|
||||
assert retrieved_event["title"] == "Meeting with Attendees"
|
||||
assert "test1@example.com" in retrieved_event.get("attendees", "")
|
||||
assert retrieved_event["status"] == "TENTATIVE"
|
||||
|
||||
# Cleanup
|
||||
await nc_client.calendar.delete_event(calendar_name, event_uid)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Event with attendees test failed: {e}")
|
||||
raise
|
||||
|
||||
|
||||
async def test_get_nonexistent_event(
|
||||
nc_client: NextcloudClient, temporary_calendar: str
|
||||
):
|
||||
"""Test retrieving a non-existent event."""
|
||||
calendar_name = temporary_calendar
|
||||
fake_uid = f"nonexistent-{uuid.uuid4()}"
|
||||
|
||||
with pytest.raises(HTTPStatusError) as exc_info:
|
||||
await nc_client.calendar.get_event(calendar_name, fake_uid)
|
||||
|
||||
assert exc_info.value.response.status_code == 404
|
||||
logger.info(f"Correctly got 404 for nonexistent event: {fake_uid}")
|
||||
|
||||
|
||||
async def test_delete_nonexistent_event(
|
||||
nc_client: NextcloudClient, temporary_calendar: str
|
||||
):
|
||||
"""Test deleting a non-existent event."""
|
||||
calendar_name = temporary_calendar
|
||||
fake_uid = f"nonexistent-{uuid.uuid4()}"
|
||||
|
||||
result = await nc_client.calendar.delete_event(calendar_name, fake_uid)
|
||||
assert result["status_code"] == 404
|
||||
logger.info(f"Correctly got 404 for deleting nonexistent event: {fake_uid}")
|
||||
|
||||
|
||||
async def test_event_with_url_and_categories(
|
||||
nc_client: NextcloudClient, temporary_calendar: str
|
||||
):
|
||||
"""Test creating an event with URL and multiple categories."""
|
||||
calendar_name = temporary_calendar
|
||||
|
||||
tomorrow = datetime.now() + timedelta(days=1)
|
||||
event_data = {
|
||||
"title": "Event with URL and Categories",
|
||||
"start_datetime": tomorrow.strftime("%Y-%m-%dT09:00:00"),
|
||||
"end_datetime": tomorrow.strftime("%Y-%m-%dT10:30:00"),
|
||||
"description": "Test event with additional metadata",
|
||||
"categories": "work,meeting,important,quarterly",
|
||||
"url": "https://zoom.us/j/123456789",
|
||||
"privacy": "PRIVATE",
|
||||
"priority": 2,
|
||||
}
|
||||
|
||||
try:
|
||||
result = await nc_client.calendar.create_event(calendar_name, event_data)
|
||||
event_uid = result["uid"]
|
||||
logger.info(f"Created event with metadata, UID: {event_uid}")
|
||||
|
||||
# Verify event
|
||||
retrieved_event, _ = await nc_client.calendar.get_event(
|
||||
calendar_name, event_uid
|
||||
)
|
||||
assert retrieved_event["title"] == "Event with URL and Categories"
|
||||
assert "work" in retrieved_event.get("categories", "")
|
||||
assert "important" in retrieved_event.get("categories", "")
|
||||
assert retrieved_event.get("url") == "https://zoom.us/j/123456789"
|
||||
assert retrieved_event.get("privacy") == "PRIVATE"
|
||||
assert retrieved_event.get("priority") == 2
|
||||
|
||||
# Cleanup
|
||||
await nc_client.calendar.delete_event(calendar_name, event_uid)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Event with metadata test failed: {e}")
|
||||
raise
|
||||
|
||||
|
||||
async def test_calendar_operations_error_handling(
|
||||
nc_client: NextcloudClient,
|
||||
):
|
||||
"""Test error handling for calendar operations."""
|
||||
|
||||
# Test with non-existent calendar
|
||||
fake_calendar = f"nonexistent_calendar_{uuid.uuid4().hex}"
|
||||
|
||||
with pytest.raises(HTTPStatusError):
|
||||
await nc_client.calendar.get_calendar_events(fake_calendar)
|
||||
|
||||
logger.info("Error handling tests completed successfully")
|
||||
@@ -0,0 +1,437 @@
|
||||
"""Integration tests for CalDAV and CardDAV field preservation.
|
||||
|
||||
This test module demonstrates data loss issues when non-supported fields
|
||||
are present in calendar events and contacts during round-trip operations.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import pytest
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_calendar_event_custom_fields_preservation(nc_client):
|
||||
"""Test that demonstrates loss of non-supported iCal fields during round-trip operations."""
|
||||
calendar_name = "personal"
|
||||
|
||||
# Create an event with standard fields
|
||||
event_data = {
|
||||
"title": "Test Event with Custom Fields",
|
||||
"description": "Event to test custom field preservation",
|
||||
"start_datetime": (datetime.now() + timedelta(days=1)).isoformat(),
|
||||
"end_datetime": (datetime.now() + timedelta(days=1, hours=1)).isoformat(),
|
||||
"location": "Test Location",
|
||||
}
|
||||
|
||||
# Create the event
|
||||
result = await nc_client.calendar.create_event(calendar_name, event_data)
|
||||
event_uid = result["uid"]
|
||||
|
||||
try:
|
||||
# Now manually inject a custom iCal property by creating a new version with raw iCal
|
||||
# This simulates what would happen if the event was created by another CalDAV client
|
||||
# with extended properties
|
||||
custom_ical = f"""BEGIN:VCALENDAR
|
||||
VERSION:2.0
|
||||
PRODID:-//Test Client//EN
|
||||
BEGIN:VEVENT
|
||||
UID:{event_uid}
|
||||
DTSTART:{(datetime.now() + timedelta(days=1)).strftime("%Y%m%dT%H%M%SZ")}
|
||||
DTEND:{(datetime.now() + timedelta(days=1, hours=1)).strftime("%Y%m%dT%H%M%SZ")}
|
||||
SUMMARY:Test Event with Custom Fields
|
||||
DESCRIPTION:Event to test custom field preservation
|
||||
LOCATION:Test Location
|
||||
X-CUSTOM-FIELD:This is a custom field that should be preserved
|
||||
X-VENDOR-SPECIFIC:Vendor specific data
|
||||
CATEGORIES:work,testing
|
||||
STATUS:CONFIRMED
|
||||
PRIORITY:5
|
||||
CLASS:PUBLIC
|
||||
CREATED:{datetime.now().strftime("%Y%m%dT%H%M%SZ")}
|
||||
DTSTAMP:{datetime.now().strftime("%Y%m%dT%H%M%SZ")}
|
||||
LAST-MODIFIED:{datetime.now().strftime("%Y%m%dT%H%M%SZ")}
|
||||
END:VEVENT
|
||||
END:VCALENDAR"""
|
||||
|
||||
# Direct CalDAV PUT to inject the custom iCal
|
||||
event_path = f"/remote.php/dav/calendars/{nc_client.calendar.username}/{calendar_name}/{event_uid}.ics"
|
||||
await nc_client.calendar._make_request(
|
||||
"PUT",
|
||||
event_path,
|
||||
content=custom_ical,
|
||||
headers={"Content-Type": "text/calendar; charset=utf-8"},
|
||||
)
|
||||
|
||||
logger.info(f"Injected custom iCal properties into event {event_uid}")
|
||||
|
||||
# Retrieve the event to confirm custom fields are present in raw iCal
|
||||
response = await nc_client.calendar._make_request(
|
||||
"GET", event_path, headers={"Accept": "text/calendar"}
|
||||
)
|
||||
raw_ical_before = response.text
|
||||
|
||||
logger.info("Raw iCal before update:")
|
||||
logger.info(raw_ical_before)
|
||||
|
||||
# Verify custom fields exist in raw iCal
|
||||
assert (
|
||||
"X-CUSTOM-FIELD:This is a custom field that should be preserved"
|
||||
in raw_ical_before
|
||||
)
|
||||
assert "X-VENDOR-SPECIFIC:Vendor specific data" in raw_ical_before
|
||||
|
||||
# Now update the event through the MCP client (simulating normal usage)
|
||||
update_data = {
|
||||
"title": "Updated Test Event with Custom Fields",
|
||||
"description": "Updated description - custom fields should be preserved",
|
||||
}
|
||||
|
||||
await nc_client.calendar.update_event(calendar_name, event_uid, update_data)
|
||||
logger.info(f"Updated event {event_uid} through MCP client")
|
||||
|
||||
# Retrieve the event again to see if custom fields survived
|
||||
response_after = await nc_client.calendar._make_request(
|
||||
"GET", event_path, headers={"Accept": "text/calendar"}
|
||||
)
|
||||
raw_ical_after = response_after.text
|
||||
|
||||
logger.info("Raw iCal after update:")
|
||||
logger.info(raw_ical_after)
|
||||
|
||||
# THIS IS THE TEST THAT SHOULD FAIL - custom fields should be preserved but won't be
|
||||
try:
|
||||
assert (
|
||||
"X-CUSTOM-FIELD:This is a custom field that should be preserved"
|
||||
in raw_ical_after
|
||||
), "Custom field X-CUSTOM-FIELD was lost during round-trip update"
|
||||
assert "X-VENDOR-SPECIFIC:Vendor specific data" in raw_ical_after, (
|
||||
"Custom field X-VENDOR-SPECIFIC was lost during round-trip update"
|
||||
)
|
||||
logger.info(
|
||||
"✓ Custom fields were preserved (unexpected - this should fail with current implementation)"
|
||||
)
|
||||
except AssertionError as e:
|
||||
logger.error(f"✗ Custom fields were lost during round-trip update: {e}")
|
||||
# Re-raise to show the test failure
|
||||
raise
|
||||
|
||||
finally:
|
||||
# Cleanup
|
||||
try:
|
||||
await nc_client.calendar.delete_event(calendar_name, event_uid)
|
||||
except Exception as cleanup_error:
|
||||
logger.warning(f"Failed to cleanup event {event_uid}: {cleanup_error}")
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_contact_extended_fields_preservation(nc_client):
|
||||
"""Test that demonstrates loss of extended vCard fields during round-trip operations."""
|
||||
addressbook_name = f"test_preservation_{uuid.uuid4().hex[:8]}"
|
||||
|
||||
# Create a temporary addressbook
|
||||
await nc_client.contacts.create_addressbook(
|
||||
name=addressbook_name, display_name="Test Preservation Addressbook"
|
||||
)
|
||||
|
||||
try:
|
||||
contact_uid = str(uuid.uuid4())
|
||||
|
||||
# Create a contact with minimal data first
|
||||
basic_contact_data = {
|
||||
"fn": "John Extended Doe",
|
||||
"email": "john.extended@example.com",
|
||||
}
|
||||
|
||||
await nc_client.contacts.create_contact(
|
||||
addressbook=addressbook_name,
|
||||
uid=contact_uid,
|
||||
contact_data=basic_contact_data,
|
||||
)
|
||||
|
||||
logger.info(f"Created basic contact {contact_uid}")
|
||||
|
||||
# Now inject a rich vCard with extended fields directly via CardDAV
|
||||
extended_vcard = f"""BEGIN:VCARD
|
||||
VERSION:4.0
|
||||
UID:{contact_uid}
|
||||
FN:John Extended Doe
|
||||
N:Doe;John;Extended;;
|
||||
NICKNAME:Johnny,JD
|
||||
EMAIL;TYPE=work:john.work@company.com
|
||||
EMAIL;TYPE=home:john.extended@example.com
|
||||
TEL;TYPE=cell:+1-555-123-4567
|
||||
TEL;TYPE=work:+1-555-987-6543
|
||||
ADR;TYPE=home:;;123 Main St;Hometown;ST;12345;USA
|
||||
ADR;TYPE=work:;;456 Work Ave;Worktown;ST;54321;USA
|
||||
ORG:Example Corporation
|
||||
TITLE:Senior Developer
|
||||
URL;TYPE=work:https://company.com/john
|
||||
URL;TYPE=personal:https://johndoe.dev
|
||||
BDAY:1985-06-15
|
||||
NOTE:This is a note with important information that should be preserved.
|
||||
CATEGORIES:colleagues,developers,friends
|
||||
X-CUSTOM-FIELD:This should be preserved
|
||||
X-SKYPE:john.doe.skype
|
||||
X-LINKEDIN:https://linkedin.com/in/johndoe
|
||||
REV:{datetime.now().strftime("%Y%m%dT%H%M%SZ")}
|
||||
END:VCARD"""
|
||||
|
||||
# Direct CardDAV PUT to inject the extended vCard
|
||||
contact_path = f"/remote.php/dav/addressbooks/users/{nc_client.contacts.username}/{addressbook_name}/{contact_uid}.vcf"
|
||||
await nc_client.contacts._make_request(
|
||||
"PUT",
|
||||
contact_path,
|
||||
content=extended_vcard,
|
||||
headers={"Content-Type": "text/vcard; charset=utf-8"},
|
||||
)
|
||||
|
||||
logger.info(f"Injected extended vCard for contact {contact_uid}")
|
||||
|
||||
# Retrieve the contact to confirm extended fields are present in raw vCard
|
||||
response = await nc_client.contacts._make_request("GET", contact_path)
|
||||
raw_vcard_before = response.text
|
||||
|
||||
logger.info("Raw vCard before any operations:")
|
||||
logger.info(raw_vcard_before)
|
||||
|
||||
# Verify extended fields exist in raw vCard
|
||||
assert "TEL;TYPE=cell:+1-555-123-4567" in raw_vcard_before
|
||||
assert "ADR;TYPE=home:;;123 Main St;Hometown;ST;12345;USA" in raw_vcard_before
|
||||
assert "ORG:Example Corporation" in raw_vcard_before
|
||||
assert "TITLE:Senior Developer" in raw_vcard_before
|
||||
assert "X-CUSTOM-FIELD:This should be preserved" in raw_vcard_before
|
||||
assert "X-LINKEDIN:https://linkedin.com/in/johndoe" in raw_vcard_before
|
||||
assert "NOTE:This is a note with important information" in raw_vcard_before
|
||||
|
||||
# List contacts through the MCP client (this will parse and return limited fields)
|
||||
contacts = await nc_client.contacts.list_contacts(addressbook=addressbook_name)
|
||||
our_contact = next((c for c in contacts if c["vcard_id"] == contact_uid), None)
|
||||
|
||||
assert our_contact is not None
|
||||
logger.info("Contact as parsed by MCP client:")
|
||||
logger.info(our_contact)
|
||||
|
||||
# Check what fields are accessible through the parsed contact
|
||||
parsed_contact = our_contact["contact"]
|
||||
|
||||
# These should be available (basic fields that are parsed)
|
||||
assert parsed_contact["fullname"] == "John Extended Doe"
|
||||
assert parsed_contact["email"] is not None # Some email should be present
|
||||
|
||||
# The raw vCard should still be available in addressdata
|
||||
raw_addressdata = our_contact["addressdata"]
|
||||
assert "X-CUSTOM-FIELD:This should be preserved" in raw_addressdata
|
||||
assert "ORG:Example Corporation" in raw_addressdata
|
||||
|
||||
# The key test: Can we update this contact without losing extended field data?
|
||||
logger.info("Testing contact update preservation...")
|
||||
|
||||
# Update the contact through the MCP client with a simple change
|
||||
try:
|
||||
await nc_client.contacts.update_contact(
|
||||
addressbook=addressbook_name,
|
||||
uid=contact_uid,
|
||||
contact_data={"email": "john.updated@example.com"},
|
||||
)
|
||||
logger.info("✓ Contact updated successfully")
|
||||
except Exception as e:
|
||||
logger.error(f"✗ Failed to update contact: {e}")
|
||||
raise
|
||||
|
||||
# Retrieve the contact again to see if extended fields survived
|
||||
contacts_after = await nc_client.contacts.list_contacts(
|
||||
addressbook=addressbook_name
|
||||
)
|
||||
updated_contact = next(
|
||||
(c for c in contacts_after if c["vcard_id"] == contact_uid), None
|
||||
)
|
||||
|
||||
assert updated_contact is not None, "Contact not found after update"
|
||||
updated_addressdata = updated_contact["addressdata"]
|
||||
|
||||
logger.info("Raw vCard after contact update:")
|
||||
logger.info(updated_addressdata)
|
||||
|
||||
# THIS IS THE CRITICAL TEST - extended fields should be preserved during updates
|
||||
extended_field_checks = [
|
||||
("ORG:Example Corporation", "organization field"),
|
||||
("TITLE:Senior Developer", "title field"),
|
||||
("TEL;TYPE=cell:+1-555-123-4567", "cell phone"),
|
||||
("TEL;TYPE=work:+1-555-987-6543", "work phone"),
|
||||
("ADR;TYPE=home:;;123 Main St;Hometown;ST;12345;USA", "home address"),
|
||||
("ADR;TYPE=work:;;456 Work Ave;Worktown;ST;54321;USA", "work address"),
|
||||
("URL;TYPE=work;VALUE=URI:https://company.com/john", "work URL"),
|
||||
("NOTE:This is a note with important information", "note field"),
|
||||
("CATEGORIES:colleagues,developers,friends", "categories"),
|
||||
("X-CUSTOM-FIELD:This should be preserved", "custom field"),
|
||||
("X-LINKEDIN:https://linkedin.com/in/johndoe", "LinkedIn custom field"),
|
||||
("john.updated@example.com", "updated email"),
|
||||
]
|
||||
|
||||
all_preserved = True
|
||||
for field_pattern, field_name in extended_field_checks:
|
||||
if field_pattern in updated_addressdata:
|
||||
logger.info(f"✓ {field_name} preserved")
|
||||
else:
|
||||
logger.error(f"✗ {field_name} was lost during update")
|
||||
all_preserved = False
|
||||
|
||||
# The test should PASS - field preservation should work
|
||||
assert all_preserved, (
|
||||
"Contact update lost extended field data - this indicates the preservation mechanism failed"
|
||||
)
|
||||
|
||||
logger.info("🎉 SUCCESS: All extended fields preserved during contact update!")
|
||||
|
||||
finally:
|
||||
# Cleanup
|
||||
try:
|
||||
await nc_client.contacts.delete_addressbook(name=addressbook_name)
|
||||
except Exception as cleanup_error:
|
||||
logger.warning(
|
||||
f"Failed to cleanup addressbook {addressbook_name}: {cleanup_error}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_calendar_event_roundtrip_data_loss_demonstration(nc_client):
|
||||
"""Demonstrates specific data loss scenarios in calendar events."""
|
||||
calendar_name = "personal"
|
||||
|
||||
event_data = {
|
||||
"title": "Roundtrip Test Event",
|
||||
"description": "Testing data preservation",
|
||||
"start_datetime": (datetime.now() + timedelta(days=2)).isoformat(),
|
||||
"end_datetime": (datetime.now() + timedelta(days=2, hours=1)).isoformat(),
|
||||
}
|
||||
|
||||
result = await nc_client.calendar.create_event(calendar_name, event_data)
|
||||
event_uid = result["uid"]
|
||||
|
||||
try:
|
||||
# Inject additional iCal properties that are valid but not supported by our parser
|
||||
extended_ical = f"""BEGIN:VCALENDAR
|
||||
VERSION:2.0
|
||||
PRODID:-//Extended Client//EN
|
||||
BEGIN:VEVENT
|
||||
UID:{event_uid}
|
||||
DTSTART:{(datetime.now() + timedelta(days=2)).strftime("%Y%m%dT%H%M%SZ")}
|
||||
DTEND:{(datetime.now() + timedelta(days=2, hours=1)).strftime("%Y%m%dT%H%M%SZ")}
|
||||
SUMMARY:Roundtrip Test Event
|
||||
DESCRIPTION:Testing data preservation
|
||||
STATUS:CONFIRMED
|
||||
PRIORITY:5
|
||||
CLASS:PUBLIC
|
||||
SEQUENCE:1
|
||||
X-MICROSOFT-CDO-ALLDAYEVENT:FALSE
|
||||
X-MICROSOFT-CDO-IMPORTANCE:1
|
||||
X-CUSTOM-MEETING-ID:12345-67890
|
||||
X-ZOOM-MEETING-URL:https://zoom.us/j/1234567890
|
||||
ORGANIZER;CN=Test Organizer:mailto:organizer@example.com
|
||||
COMMENT:This is a comment that should be preserved
|
||||
LOCATION:Conference Room A
|
||||
GEO:40.7128;-74.0060
|
||||
TRANSP:OPAQUE
|
||||
CREATED:{datetime.now().strftime("%Y%m%dT%H%M%SZ")}
|
||||
DTSTAMP:{datetime.now().strftime("%Y%m%dT%H%M%SZ")}
|
||||
LAST-MODIFIED:{datetime.now().strftime("%Y%m%dT%H%M%SZ")}
|
||||
END:VEVENT
|
||||
END:VCALENDAR"""
|
||||
|
||||
# Inject the extended iCal
|
||||
event_path = f"/remote.php/dav/calendars/{nc_client.calendar.username}/{calendar_name}/{event_uid}.ics"
|
||||
await nc_client.calendar._make_request(
|
||||
"PUT",
|
||||
event_path,
|
||||
content=extended_ical,
|
||||
headers={"Content-Type": "text/calendar; charset=utf-8"},
|
||||
)
|
||||
|
||||
# Verify extended properties are present
|
||||
response = await nc_client.calendar._make_request(
|
||||
"GET", event_path, headers={"Accept": "text/calendar"}
|
||||
)
|
||||
original_ical = response.text
|
||||
|
||||
# Confirm extended properties exist
|
||||
extended_properties = [
|
||||
"SEQUENCE:1",
|
||||
"X-MICROSOFT-CDO-ALLDAYEVENT:FALSE",
|
||||
"X-CUSTOM-MEETING-ID:12345-67890",
|
||||
"X-ZOOM-MEETING-URL:https://zoom.us/j/1234567890",
|
||||
"ORGANIZER;CN=Test Organizer:mailto:organizer@example.com",
|
||||
"COMMENT:This is a comment that should be preserved",
|
||||
"GEO:40.7128;-74.0060",
|
||||
"TRANSP:OPAQUE",
|
||||
]
|
||||
|
||||
# More flexible patterns for properties that might be reformatted
|
||||
flexible_patterns = {
|
||||
"ORGANIZER;CN=Test Organizer:mailto:organizer@example.com": [
|
||||
"ORGANIZER;CN=Test Organizer:mailto:organizer@example.com",
|
||||
'ORGANIZER;CN="Test Organizer":mailto:organizer@example.com',
|
||||
],
|
||||
"GEO:40.7128;-74.0060": [
|
||||
"GEO:40.7128;-74.0060",
|
||||
"GEO:40.7128;-74.006", # May lose trailing zero
|
||||
],
|
||||
}
|
||||
|
||||
for prop in extended_properties:
|
||||
assert prop in original_ical, (
|
||||
f"Extended property {prop} not found in original iCal"
|
||||
)
|
||||
|
||||
logger.info("✓ All extended properties confirmed in original iCal")
|
||||
|
||||
# Now perform a simple update through MCP
|
||||
update_data = {"location": "Conference Room B"} # Simple location change
|
||||
await nc_client.calendar.update_event(calendar_name, event_uid, update_data)
|
||||
|
||||
# Check what survived the round-trip
|
||||
response_after = await nc_client.calendar._make_request(
|
||||
"GET", event_path, headers={"Accept": "text/calendar"}
|
||||
)
|
||||
updated_ical = response_after.text
|
||||
|
||||
logger.info("Checking which properties survived the update...")
|
||||
|
||||
# Check which extended properties survived
|
||||
survived = []
|
||||
lost = []
|
||||
|
||||
for prop in extended_properties:
|
||||
# Check if this property has flexible patterns
|
||||
if prop in flexible_patterns:
|
||||
# Check if any of the flexible patterns match
|
||||
found = any(
|
||||
pattern in updated_ical for pattern in flexible_patterns[prop]
|
||||
)
|
||||
if found:
|
||||
survived.append(prop)
|
||||
else:
|
||||
lost.append(prop)
|
||||
else:
|
||||
# Standard exact match
|
||||
if prop in updated_ical:
|
||||
survived.append(prop)
|
||||
else:
|
||||
lost.append(prop)
|
||||
|
||||
logger.info(f"Properties that SURVIVED: {survived}")
|
||||
logger.error(f"Properties that were LOST: {lost}")
|
||||
|
||||
# This test should fail - we expect data loss
|
||||
assert len(lost) == 0, (
|
||||
f"Round-trip update lost {len(lost)} extended properties: {lost}"
|
||||
)
|
||||
|
||||
finally:
|
||||
try:
|
||||
await nc_client.calendar.delete_event(calendar_name, event_uid)
|
||||
except Exception as cleanup_error:
|
||||
logger.warning(f"Failed to cleanup event {event_uid}: {cleanup_error}")
|
||||
@@ -0,0 +1,88 @@
|
||||
"""Integration tests for Contacts CardDAV operations."""
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
|
||||
from nextcloud_mcp_server.client import NextcloudClient
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Mark all tests in this module as integration tests
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
|
||||
async def test_list_addressbooks(nc_client: NextcloudClient):
|
||||
"""Test listing available addressbooks."""
|
||||
addressbooks = await nc_client.contacts.list_addressbooks()
|
||||
|
||||
assert isinstance(addressbooks, list)
|
||||
|
||||
if not addressbooks:
|
||||
pytest.skip("No addressbooks available - Contacts app may not be enabled")
|
||||
|
||||
logger.info(f"Found {len(addressbooks)} addressbooks")
|
||||
|
||||
# Check structure of addressbooks
|
||||
for addressbook in addressbooks:
|
||||
assert "name" in addressbook
|
||||
assert "display_name" in addressbook
|
||||
assert "getctag" in addressbook
|
||||
|
||||
logger.info(
|
||||
f"Addressbook: {addressbook['name']} - {addressbook['display_name']}"
|
||||
)
|
||||
|
||||
|
||||
async def test_create_and_delete_addressbook(
|
||||
nc_client: NextcloudClient, temporary_addressbook: str
|
||||
):
|
||||
"""Test creating and deleting a basic addressbook."""
|
||||
addressbooks = await nc_client.contacts.list_addressbooks()
|
||||
addressbook_names = [ab["name"] for ab in addressbooks]
|
||||
assert temporary_addressbook in addressbook_names
|
||||
|
||||
|
||||
async def test_list_contacts(
|
||||
nc_client: NextcloudClient, temporary_addressbook: str, temporary_contact: str
|
||||
):
|
||||
"""Test listing contacts in an addressbook."""
|
||||
contacts = await nc_client.contacts.list_contacts(addressbook=temporary_addressbook)
|
||||
contact_uids = [c["vcard_id"] for c in contacts]
|
||||
assert temporary_contact in contact_uids
|
||||
|
||||
|
||||
async def test_full_contact_workflow(
|
||||
nc_client: NextcloudClient, temporary_addressbook: str
|
||||
):
|
||||
"""Test the full workflow of creating, retrieving, and deleting a contact."""
|
||||
addressbook_name = temporary_addressbook
|
||||
contact_uid = f"test-contact-{uuid.uuid4().hex[:8]}"
|
||||
contact_data = {
|
||||
"fn": "Jane Doe",
|
||||
"email": "jane.doe@example.com",
|
||||
"tel": "9876543210",
|
||||
}
|
||||
|
||||
# Create contact
|
||||
await nc_client.contacts.create_contact(
|
||||
addressbook=addressbook_name,
|
||||
uid=contact_uid,
|
||||
contact_data=contact_data,
|
||||
)
|
||||
|
||||
# Verify contact was created by listing
|
||||
contacts = await nc_client.contacts.list_contacts(addressbook=addressbook_name)
|
||||
contact_uids = [c["vcard_id"] for c in contacts]
|
||||
assert contact_uid in contact_uids
|
||||
|
||||
# Delete contact
|
||||
await nc_client.contacts.delete_contact(
|
||||
addressbook=addressbook_name, uid=contact_uid
|
||||
)
|
||||
|
||||
# Verify contact was deleted
|
||||
contacts = await nc_client.contacts.list_contacts(addressbook=addressbook_name)
|
||||
contact_uids = [c["vcard_id"] for c in contacts]
|
||||
assert contact_uid not in contact_uids
|
||||
@@ -0,0 +1,327 @@
|
||||
import logging
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
from httpx import HTTPStatusError
|
||||
|
||||
from nextcloud_mcp_server.client import NextcloudClient
|
||||
from nextcloud_mcp_server.models.deck import DeckCard, DeckLabel, DeckStack
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
|
||||
# Board CRUD Tests
|
||||
|
||||
|
||||
async def test_deck_board_crud_workflow(
|
||||
nc_client: NextcloudClient, temporary_board: dict
|
||||
):
|
||||
"""
|
||||
Test complete board CRUD workflow using the temporary_board fixture.
|
||||
"""
|
||||
board_data = temporary_board
|
||||
board_id = board_data["id"]
|
||||
original_title = board_data["title"]
|
||||
original_color = board_data["color"]
|
||||
|
||||
logger.info(f"Testing CRUD operations on board ID: {board_id}")
|
||||
|
||||
# Read the board
|
||||
read_board = await nc_client.deck.get_board(board_id)
|
||||
assert read_board.id == board_id
|
||||
assert read_board.title == original_title
|
||||
assert read_board.color == original_color
|
||||
logger.info(f"Successfully read board ID: {board_id}")
|
||||
|
||||
# Update the board
|
||||
updated_title = f"Updated {original_title}"
|
||||
updated_color = "00FF00" # Green color
|
||||
await nc_client.deck.update_board(
|
||||
board_id, title=updated_title, color=updated_color
|
||||
)
|
||||
|
||||
# Verify the update
|
||||
updated_board = await nc_client.deck.get_board(board_id)
|
||||
assert updated_board.title == updated_title
|
||||
assert updated_board.color == updated_color
|
||||
logger.info(f"Successfully updated board ID: {board_id}")
|
||||
|
||||
|
||||
async def test_deck_list_boards(nc_client: NextcloudClient):
|
||||
"""
|
||||
Test listing all boards with different options.
|
||||
"""
|
||||
# Test basic listing
|
||||
boards = await nc_client.deck.get_boards()
|
||||
assert isinstance(boards, list)
|
||||
logger.info(f"Found {len(boards)} boards")
|
||||
|
||||
# Test with details
|
||||
detailed_boards = await nc_client.deck.get_boards(details=True)
|
||||
assert isinstance(detailed_boards, list)
|
||||
logger.info(f"Found {len(detailed_boards)} boards with details")
|
||||
|
||||
|
||||
async def test_deck_board_operations_nonexistent(nc_client: NextcloudClient):
|
||||
"""
|
||||
Test operations on non-existent board return appropriate errors.
|
||||
"""
|
||||
non_existent_id = 999999999
|
||||
|
||||
# Test get non-existent board
|
||||
with pytest.raises(HTTPStatusError) as excinfo:
|
||||
await nc_client.deck.get_board(non_existent_id)
|
||||
assert excinfo.value.response.status_code in [
|
||||
404,
|
||||
403,
|
||||
] # 403 might be returned for access denied
|
||||
logger.info(
|
||||
f"Get non-existent board correctly failed with {excinfo.value.response.status_code}"
|
||||
)
|
||||
|
||||
# Test update non-existent board
|
||||
with pytest.raises(HTTPStatusError) as excinfo:
|
||||
await nc_client.deck.update_board(non_existent_id, title="Should Fail")
|
||||
assert excinfo.value.response.status_code in [
|
||||
404,
|
||||
403,
|
||||
400,
|
||||
] # 400 for bad request on invalid board ID
|
||||
logger.info(
|
||||
f"Update non-existent board correctly failed with {excinfo.value.response.status_code}"
|
||||
)
|
||||
|
||||
|
||||
# Stack CRUD Tests
|
||||
|
||||
|
||||
async def test_deck_stack_crud_workflow(
|
||||
nc_client: NextcloudClient, temporary_board: dict
|
||||
):
|
||||
"""
|
||||
Test complete stack CRUD workflow.
|
||||
"""
|
||||
board_id = temporary_board["id"]
|
||||
stack_title = f"Test Stack {uuid.uuid4().hex[:8]}"
|
||||
stack_order = 1
|
||||
stack = None
|
||||
|
||||
try:
|
||||
# Create stack
|
||||
stack = await nc_client.deck.create_stack(board_id, stack_title, stack_order)
|
||||
assert isinstance(stack, DeckStack)
|
||||
assert stack.title == stack_title
|
||||
assert stack.order == stack_order
|
||||
stack_id = stack.id
|
||||
logger.info(f"Created stack ID: {stack_id}")
|
||||
|
||||
# Read stack
|
||||
read_stack = await nc_client.deck.get_stack(board_id, stack_id)
|
||||
assert read_stack.id == stack_id
|
||||
assert read_stack.title == stack_title
|
||||
logger.info(f"Successfully read stack ID: {stack_id}")
|
||||
|
||||
# Update stack
|
||||
updated_title = f"Updated {stack_title}"
|
||||
updated_order = 2
|
||||
await nc_client.deck.update_stack(
|
||||
board_id, stack_id, title=updated_title, order=updated_order
|
||||
)
|
||||
|
||||
# Verify update
|
||||
updated_stack = await nc_client.deck.get_stack(board_id, stack_id)
|
||||
assert updated_stack.title == updated_title
|
||||
assert updated_stack.order == updated_order
|
||||
logger.info(f"Successfully updated stack ID: {stack_id}")
|
||||
|
||||
# List stacks
|
||||
stacks = await nc_client.deck.get_stacks(board_id)
|
||||
assert isinstance(stacks, list)
|
||||
assert any(s.id == stack_id for s in stacks)
|
||||
logger.info(f"Found stack ID: {stack_id} in board stacks list")
|
||||
|
||||
finally:
|
||||
# Clean up - delete stack
|
||||
if stack and hasattr(stack, "id"):
|
||||
try:
|
||||
await nc_client.deck.delete_stack(board_id, stack.id)
|
||||
logger.info(f"Cleaned up stack ID: {stack.id}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to clean up stack ID: {stack.id}: {e}")
|
||||
|
||||
|
||||
# Card CRUD Tests
|
||||
|
||||
|
||||
async def test_deck_card_crud_workflow(
|
||||
nc_client: NextcloudClient, temporary_board_with_stack: tuple
|
||||
):
|
||||
"""
|
||||
Test complete card CRUD workflow.
|
||||
"""
|
||||
board_data, stack_data = temporary_board_with_stack
|
||||
board_id = board_data["id"]
|
||||
stack_id = stack_data["id"]
|
||||
|
||||
card_title = f"Test Card {uuid.uuid4().hex[:8]}"
|
||||
card_description = f"Test description for card {uuid.uuid4().hex[:8]}"
|
||||
card = None
|
||||
|
||||
try:
|
||||
# Create card
|
||||
card = await nc_client.deck.create_card(
|
||||
board_id, stack_id, card_title, description=card_description
|
||||
)
|
||||
assert isinstance(card, DeckCard)
|
||||
assert card.title == card_title
|
||||
assert card.description == card_description
|
||||
card_id = card.id
|
||||
logger.info(f"Created card ID: {card_id}")
|
||||
|
||||
# Read card
|
||||
read_card = await nc_client.deck.get_card(board_id, stack_id, card_id)
|
||||
assert read_card.id == card_id
|
||||
assert read_card.title == card_title
|
||||
logger.info(f"Successfully read card ID: {card_id}")
|
||||
|
||||
# Update card
|
||||
updated_title = f"Updated {card_title}"
|
||||
updated_description = f"Updated description for {card_title}"
|
||||
await nc_client.deck.update_card(
|
||||
board_id,
|
||||
stack_id,
|
||||
card_id,
|
||||
title=updated_title,
|
||||
description=updated_description,
|
||||
)
|
||||
|
||||
# Verify update
|
||||
updated_card = await nc_client.deck.get_card(board_id, stack_id, card_id)
|
||||
assert updated_card.title == updated_title
|
||||
assert updated_card.description == updated_description
|
||||
logger.info(f"Successfully updated card ID: {card_id}")
|
||||
|
||||
# Archive and unarchive card
|
||||
await nc_client.deck.archive_card(board_id, stack_id, card_id)
|
||||
logger.info(f"Archived card ID: {card_id}")
|
||||
|
||||
await nc_client.deck.unarchive_card(board_id, stack_id, card_id)
|
||||
logger.info(f"Unarchived card ID: {card_id}")
|
||||
|
||||
finally:
|
||||
# Clean up - delete card
|
||||
if card and hasattr(card, "id"):
|
||||
try:
|
||||
await nc_client.deck.delete_card(board_id, stack_id, card.id)
|
||||
logger.info(f"Cleaned up card ID: {card.id}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to clean up card ID: {card.id}: {e}")
|
||||
|
||||
|
||||
# Label CRUD Tests
|
||||
|
||||
|
||||
async def test_deck_label_crud_workflow(
|
||||
nc_client: NextcloudClient, temporary_board: dict
|
||||
):
|
||||
"""
|
||||
Test complete label CRUD workflow.
|
||||
"""
|
||||
board_id = temporary_board["id"]
|
||||
label_title = f"Test Label {uuid.uuid4().hex[:8]}"
|
||||
label_color = "FF0000" # Red
|
||||
label = None
|
||||
|
||||
try:
|
||||
# Create label
|
||||
label = await nc_client.deck.create_label(board_id, label_title, label_color)
|
||||
assert isinstance(label, DeckLabel)
|
||||
assert label.title == label_title
|
||||
assert label.color == label_color
|
||||
label_id = label.id
|
||||
logger.info(f"Created label ID: {label_id}")
|
||||
|
||||
# Read label
|
||||
read_label = await nc_client.deck.get_label(board_id, label_id)
|
||||
assert read_label.id == label_id
|
||||
assert read_label.title == label_title
|
||||
logger.info(f"Successfully read label ID: {label_id}")
|
||||
|
||||
# Update label
|
||||
updated_title = f"Updated {label_title}"
|
||||
updated_color = "00FF00" # Green
|
||||
await nc_client.deck.update_label(
|
||||
board_id, label_id, title=updated_title, color=updated_color
|
||||
)
|
||||
|
||||
# Verify update
|
||||
updated_label = await nc_client.deck.get_label(board_id, label_id)
|
||||
assert updated_label.title == updated_title
|
||||
assert updated_label.color == updated_color
|
||||
logger.info(f"Successfully updated label ID: {label_id}")
|
||||
|
||||
finally:
|
||||
# Clean up - delete label
|
||||
if label and hasattr(label, "id"):
|
||||
try:
|
||||
await nc_client.deck.delete_label(board_id, label.id)
|
||||
logger.info(f"Cleaned up label ID: {label.id}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to clean up label ID: {label.id}: {e}")
|
||||
|
||||
|
||||
# Configuration and Comments Tests
|
||||
|
||||
|
||||
async def test_deck_config_operations(nc_client: NextcloudClient):
|
||||
"""
|
||||
Test deck configuration operations.
|
||||
"""
|
||||
# Get config
|
||||
config = await nc_client.deck.get_config()
|
||||
assert config is not None
|
||||
logger.info(f"Retrieved deck config: {config}")
|
||||
|
||||
|
||||
async def test_deck_comments_workflow(
|
||||
nc_client: NextcloudClient, temporary_board_with_card: tuple
|
||||
):
|
||||
"""
|
||||
Test comment operations on a card.
|
||||
"""
|
||||
board_data, stack_data, card_data = temporary_board_with_card
|
||||
card_id = card_data["id"]
|
||||
|
||||
comment_message = f"Test comment {uuid.uuid4().hex[:8]}"
|
||||
comment = None
|
||||
|
||||
try:
|
||||
# Create comment
|
||||
comment = await nc_client.deck.create_comment(card_id, comment_message)
|
||||
assert comment.message == comment_message
|
||||
comment_id = comment.id
|
||||
logger.info(f"Created comment ID: {comment_id}")
|
||||
|
||||
# List comments
|
||||
comments = await nc_client.deck.get_comments(card_id)
|
||||
assert isinstance(comments, list)
|
||||
assert any(c.id == comment_id for c in comments)
|
||||
logger.info(f"Found comment ID: {comment_id} in card comments")
|
||||
|
||||
# Update comment
|
||||
updated_message = f"Updated {comment_message}"
|
||||
updated_comment = await nc_client.deck.update_comment(
|
||||
card_id, comment_id, updated_message
|
||||
)
|
||||
assert updated_comment.message == updated_message
|
||||
logger.info(f"Successfully updated comment ID: {comment_id}")
|
||||
|
||||
finally:
|
||||
# Clean up - delete comment
|
||||
if comment and hasattr(comment, "id"):
|
||||
try:
|
||||
await nc_client.deck.delete_comment(card_id, comment.id)
|
||||
logger.info(f"Cleaned up comment ID: {comment.id}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to clean up comment ID: {comment.id}: {e}")
|
||||
@@ -0,0 +1,404 @@
|
||||
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
|
||||
logger.info(
|
||||
f"Updating note {note_id} category from '{initial_category}' to '{new_category}'"
|
||||
)
|
||||
# Need to fetch the latest etag after attachment add (WebDAV ops don't update note etag)
|
||||
current_note_data = await nc_client.notes.get_note(note_id=note_id)
|
||||
current_etag = current_note_data["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}")
|
||||
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}")
|
||||
@@ -0,0 +1,187 @@
|
||||
import logging
|
||||
import time
|
||||
import uuid
|
||||
from io import BytesIO
|
||||
|
||||
import pytest
|
||||
from httpx import HTTPStatusError # Import if needed for specific error checks
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
from nextcloud_mcp_server.client import NextcloudClient
|
||||
|
||||
# Note: nc_client fixture is session-scoped in conftest.py
|
||||
# Note: temporary_note fixture is function-scoped in conftest.py
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Mark all tests in this module as integration tests
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
|
||||
# Keep the test_image fixture as it's specific to generating image data
|
||||
@pytest.fixture(scope="module") # Keep module scope if image generation is slow
|
||||
def test_image_data() -> tuple[bytes, str]:
|
||||
"""
|
||||
Generate test image data (bytes) and suggest a filename.
|
||||
Returns (image_bytes, suggested_filename).
|
||||
"""
|
||||
logger.info("Generating test image data in memory.")
|
||||
img = Image.new("RGB", (300, 200), color=(255, 255, 255))
|
||||
draw = ImageDraw.Draw(img)
|
||||
draw.rectangle([(20, 20), (280, 180)], fill=(0, 120, 212)) # Blue rectangle
|
||||
draw.text(
|
||||
(50, 90), "Nextcloud Notes Test Image", fill=(255, 255, 255)
|
||||
) # White text
|
||||
|
||||
img_byte_arr = BytesIO()
|
||||
img.save(img_byte_arr, format="PNG")
|
||||
image_bytes = img_byte_arr.getvalue()
|
||||
suggested_filename = "test_image.png"
|
||||
logger.info(f"Generated test image data ({len(image_bytes)} bytes).")
|
||||
return image_bytes, suggested_filename
|
||||
|
||||
|
||||
async def test_note_with_embedded_image(
|
||||
nc_client: NextcloudClient, temporary_note: dict, test_image_data: tuple
|
||||
):
|
||||
"""
|
||||
Tests creating a note, attaching an image, embedding it in the content,
|
||||
and verifying the attachment can be retrieved.
|
||||
"""
|
||||
note_data = temporary_note # Use fixture for note creation/cleanup
|
||||
note_id = note_data["id"]
|
||||
note_etag = note_data["etag"]
|
||||
image_content, suggested_filename = test_image_data # Get image data from fixture
|
||||
|
||||
unique_suffix = uuid.uuid4().hex[:8]
|
||||
attachment_filename = (
|
||||
f"test_image_{unique_suffix}.png" # Make filename unique per run
|
||||
)
|
||||
|
||||
# 1. Upload the image as an attachment
|
||||
note_category = note_data.get("category") # Get category from fixture data
|
||||
logger.info(
|
||||
f"Uploading image attachment '{attachment_filename}' to note {note_id} (category: '{note_category or ''}')..."
|
||||
)
|
||||
upload_response = await nc_client.webdav.add_note_attachment(
|
||||
note_id=note_id,
|
||||
filename=attachment_filename,
|
||||
content=image_content,
|
||||
category=note_category, # Pass the category
|
||||
mime_type="image/png",
|
||||
)
|
||||
assert upload_response and upload_response.get("status_code") in [201, 204]
|
||||
logger.info(
|
||||
f"Image uploaded successfully (Status: {upload_response.get('status_code')})."
|
||||
)
|
||||
time.sleep(1) # Allow potential processing time
|
||||
|
||||
# 1.1 Verify attachment directory exists via WebDAV PROPFIND
|
||||
logger.info("Directly checking if attachment directory exists in WebDAV")
|
||||
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
|
||||
assert status in [
|
||||
207,
|
||||
200,
|
||||
], f"Expected PROPFIND to return success (207/200), got {status}"
|
||||
logger.info(
|
||||
f"Verified attachment directory exists via PROPFIND ({status} received)"
|
||||
)
|
||||
except HTTPStatusError as e:
|
||||
logger.error(
|
||||
f"Attachment directory not found! PROPFIND failed with {e.response.status_code}"
|
||||
)
|
||||
assert False, (
|
||||
f"Expected attachment directory to exist, but PROPFIND failed with {e.response.status_code}"
|
||||
)
|
||||
|
||||
# 2. Update the note content to include the embedded image references
|
||||
updated_content = f"""{note_data["content"]}
|
||||
|
||||
## Image Embedding Test
|
||||
|
||||
### Markdown Syntax
|
||||

|
||||
|
||||
### HTML Syntax
|
||||
<img src=".attachments.{note_id}/{attachment_filename}" alt="Test Image HTML" width="150" />
|
||||
"""
|
||||
logger.info("Updating note content with image references...")
|
||||
updated_note = await nc_client.notes.update(
|
||||
note_id=note_id,
|
||||
etag=note_etag, # Use etag from the created note
|
||||
content=updated_content,
|
||||
title=note_data["title"], # Pass required fields
|
||||
category=note_data["category"], # Pass required fields
|
||||
)
|
||||
new_etag = updated_note["etag"]
|
||||
assert new_etag != note_etag
|
||||
logger.info("Note content updated with image references.")
|
||||
time.sleep(1)
|
||||
|
||||
# 3. Verify the updated note content
|
||||
retrieved_note = await nc_client.notes.get_note(note_id=note_id)
|
||||
assert f".attachments.{note_id}/{attachment_filename}" in retrieved_note["content"]
|
||||
logger.info("Verified image reference exists in updated note content.")
|
||||
|
||||
# 4. Verify the image attachment can be retrieved
|
||||
logger.info(
|
||||
f"Retrieving image attachment '{attachment_filename}' (category: '{note_category or ''}')..."
|
||||
)
|
||||
# Pass category to get_note_attachment
|
||||
retrieved_img_content, mime_type = await nc_client.webdav.get_note_attachment(
|
||||
note_id=note_id, filename=attachment_filename, category=note_category
|
||||
)
|
||||
assert retrieved_img_content == image_content
|
||||
assert mime_type.startswith("image/png")
|
||||
logger.info(
|
||||
"Successfully retrieved and verified image attachment content and mime type."
|
||||
)
|
||||
|
||||
# 5. Manually trigger deletion to verify cleanup (instead of waiting for fixture teardown)
|
||||
logger.info(
|
||||
f"Manually deleting note ID: {note_id} to verify proper attachment cleanup"
|
||||
)
|
||||
await nc_client.notes.delete_note(note_id=note_id)
|
||||
logger.info(f"Note ID: {note_id} deleted successfully.")
|
||||
time.sleep(1)
|
||||
|
||||
# 6. 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).")
|
||||
|
||||
# 7. Verify attachment directory is deleted via WebDAV PROPFIND
|
||||
logger.info("Directly verifying attachment directory doesn't exist via PROPFIND")
|
||||
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.
|
||||
@@ -0,0 +1,260 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import uuid # Keep uuid if needed for generating unique data within tests
|
||||
|
||||
import pytest
|
||||
from httpx import HTTPStatusError
|
||||
|
||||
from nextcloud_mcp_server.client import NextcloudClient
|
||||
|
||||
# Note: nc_client fixture is now session-scoped in conftest.py
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Mark all tests in this module as integration tests
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
|
||||
async def test_notes_api_create_and_read(
|
||||
nc_client: NextcloudClient, temporary_note: dict
|
||||
):
|
||||
"""
|
||||
Tests creating a note via the API (using fixture) and then reading it back.
|
||||
"""
|
||||
created_note_data = temporary_note # Get data from fixture
|
||||
note_id = created_note_data["id"]
|
||||
|
||||
logger.info(f"Reading note created by fixture, ID: {note_id}")
|
||||
read_note = await nc_client.notes.get_note(note_id=note_id)
|
||||
|
||||
assert read_note["id"] == note_id
|
||||
assert read_note["title"] == created_note_data["title"]
|
||||
assert read_note["content"] == created_note_data["content"]
|
||||
assert read_note["category"] == created_note_data["category"]
|
||||
logger.info(f"Successfully read and verified note ID: {note_id}")
|
||||
|
||||
|
||||
async def test_notes_api_update(nc_client: NextcloudClient, temporary_note: dict):
|
||||
"""
|
||||
Tests updating a note created by the fixture.
|
||||
"""
|
||||
created_note_data = temporary_note
|
||||
note_id = created_note_data["id"]
|
||||
original_etag = created_note_data["etag"]
|
||||
original_category = created_note_data["category"]
|
||||
|
||||
update_title = f"Updated Title {uuid.uuid4().hex[:8]}"
|
||||
update_content = f"Updated Content {uuid.uuid4().hex[:8]}"
|
||||
|
||||
logger.info(f"Attempting to update note ID: {note_id} with etag: {original_etag}")
|
||||
updated_note = await nc_client.notes.update(
|
||||
note_id=note_id,
|
||||
etag=original_etag,
|
||||
title=update_title,
|
||||
content=update_content,
|
||||
# category=original_category # Explicitly pass category if required by update
|
||||
)
|
||||
logger.info(f"Note updated: {updated_note}")
|
||||
|
||||
assert updated_note["id"] == note_id
|
||||
assert updated_note["title"] == update_title
|
||||
assert updated_note["content"] == update_content
|
||||
assert (
|
||||
updated_note["category"] == original_category
|
||||
) # Verify category didn't change
|
||||
assert "etag" in updated_note
|
||||
assert updated_note["etag"] != original_etag # Etag must change
|
||||
|
||||
# Optional: Verify update by reading again
|
||||
await asyncio.sleep(1) # Allow potential propagation delay
|
||||
read_updated_note = await nc_client.notes.get_note(note_id=note_id)
|
||||
assert read_updated_note["title"] == update_title
|
||||
assert read_updated_note["content"] == update_content
|
||||
logger.info(f"Successfully updated and verified note ID: {note_id}")
|
||||
|
||||
|
||||
async def test_notes_api_update_conflict(
|
||||
nc_client: NextcloudClient, temporary_note: dict
|
||||
):
|
||||
"""
|
||||
Tests that attempting to update with an old etag fails with 412.
|
||||
"""
|
||||
created_note_data = temporary_note
|
||||
note_id = created_note_data["id"]
|
||||
original_etag = created_note_data["etag"]
|
||||
|
||||
# Perform a first update to change the etag
|
||||
first_update_title = f"First Update {uuid.uuid4().hex[:8]}"
|
||||
logger.info(f"Performing first update on note ID: {note_id} to change etag.")
|
||||
first_updated_note = await nc_client.notes.update(
|
||||
note_id=note_id,
|
||||
etag=original_etag,
|
||||
title=first_update_title,
|
||||
content="First update content",
|
||||
# category=created_note_data["category"] # Pass category if required
|
||||
)
|
||||
new_etag = first_updated_note["etag"]
|
||||
assert new_etag != original_etag
|
||||
logger.info(f"Note ID: {note_id} updated, new etag: {new_etag}")
|
||||
await asyncio.sleep(1)
|
||||
|
||||
# Now attempt update with the *original* etag
|
||||
logger.info(
|
||||
f"Attempting second update on note ID: {note_id} with OLD etag: {original_etag}"
|
||||
)
|
||||
with pytest.raises(HTTPStatusError) as excinfo:
|
||||
await nc_client.notes.update(
|
||||
note_id=note_id,
|
||||
etag=original_etag, # Use the stale etag
|
||||
title="This update should fail due to conflict",
|
||||
# category=created_note_data["category"] # Pass category if required
|
||||
)
|
||||
assert excinfo.value.response.status_code == 412 # Precondition Failed
|
||||
logger.info("Update with old etag correctly failed with 412 Precondition Failed.")
|
||||
|
||||
|
||||
async def test_notes_api_delete_nonexistent(nc_client: NextcloudClient):
|
||||
"""
|
||||
Tests deleting a note that doesn't exist fails with 404.
|
||||
"""
|
||||
non_existent_id = 999999999 # Use an ID highly unlikely to exist
|
||||
logger.info(f"\nAttempting to delete non-existent note ID: {non_existent_id}")
|
||||
with pytest.raises(HTTPStatusError) as excinfo:
|
||||
await nc_client.notes.delete_note(note_id=non_existent_id)
|
||||
assert excinfo.value.response.status_code == 404
|
||||
logger.info(
|
||||
f"Deleting non-existent note ID: {non_existent_id} correctly failed with 404."
|
||||
)
|
||||
|
||||
|
||||
async def test_notes_api_append_content_to_existing_note(
|
||||
nc_client: NextcloudClient, temporary_note: dict
|
||||
):
|
||||
"""
|
||||
Tests appending content to an existing note using the new append functionality.
|
||||
"""
|
||||
created_note_data = temporary_note
|
||||
note_id = created_note_data["id"]
|
||||
original_content = created_note_data["content"]
|
||||
|
||||
append_text = f"Appended content {uuid.uuid4().hex[:8]}"
|
||||
|
||||
logger.info(f"Appending content to note ID: {note_id}")
|
||||
updated_note = await nc_client.notes.append_content(
|
||||
note_id=note_id, content=append_text
|
||||
)
|
||||
logger.info(f"Note after append: {updated_note}")
|
||||
|
||||
# Verify the note was updated
|
||||
assert updated_note["id"] == note_id
|
||||
assert "etag" in updated_note
|
||||
assert updated_note["etag"] != created_note_data["etag"] # Etag must change
|
||||
|
||||
# Verify content has the separator and appended text
|
||||
expected_content = original_content + "\n---\n" + append_text
|
||||
assert updated_note["content"] == expected_content
|
||||
|
||||
# Verify by reading the note again
|
||||
await asyncio.sleep(1) # Allow potential propagation delay
|
||||
read_note = await nc_client.notes.get_note(note_id=note_id)
|
||||
assert read_note["content"] == expected_content
|
||||
logger.info(f"Successfully appended content to note ID: {note_id}")
|
||||
|
||||
|
||||
async def test_notes_api_append_content_to_empty_note(nc_client: NextcloudClient):
|
||||
"""
|
||||
Tests appending content to an empty note (no separator should be added).
|
||||
"""
|
||||
# Create an empty note
|
||||
test_title = f"Empty Note {uuid.uuid4().hex[:8]}"
|
||||
test_category = "Test"
|
||||
|
||||
logger.info("Creating empty note for append test")
|
||||
empty_note = await nc_client.notes.create_note(
|
||||
title=test_title,
|
||||
content="",
|
||||
category=test_category, # Empty content
|
||||
)
|
||||
note_id = empty_note["id"]
|
||||
|
||||
try:
|
||||
append_text = f"First content {uuid.uuid4().hex[:8]}"
|
||||
|
||||
logger.info(f"Appending content to empty note ID: {note_id}")
|
||||
updated_note = await nc_client.notes.append_content(
|
||||
note_id=note_id, content=append_text
|
||||
)
|
||||
|
||||
# For empty notes, content should just be the appended text (no separator)
|
||||
assert updated_note["content"] == append_text
|
||||
|
||||
# Verify by reading the note again
|
||||
await asyncio.sleep(1)
|
||||
read_note = await nc_client.notes.get_note(note_id=note_id)
|
||||
assert read_note["content"] == append_text
|
||||
logger.info(f"Successfully appended content to empty note ID: {note_id}")
|
||||
|
||||
finally:
|
||||
# Clean up the test note
|
||||
try:
|
||||
await nc_client.notes.delete_note(note_id=note_id)
|
||||
logger.info(f"Cleaned up test note ID: {note_id}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to clean up test note ID: {note_id}: {e}")
|
||||
|
||||
|
||||
async def test_notes_api_append_content_multiple_times(
|
||||
nc_client: NextcloudClient, temporary_note: dict
|
||||
):
|
||||
"""
|
||||
Tests appending content multiple times to verify separator behavior.
|
||||
"""
|
||||
created_note_data = temporary_note
|
||||
note_id = created_note_data["id"]
|
||||
original_content = created_note_data["content"]
|
||||
|
||||
first_append = f"First append {uuid.uuid4().hex[:8]}"
|
||||
second_append = f"Second append {uuid.uuid4().hex[:8]}"
|
||||
|
||||
logger.info(f"Performing multiple appends to note ID: {note_id}")
|
||||
|
||||
# First append
|
||||
updated_note = await nc_client.notes.append_content(
|
||||
note_id=note_id, content=first_append
|
||||
)
|
||||
|
||||
expected_content_after_first = original_content + "\n---\n" + first_append
|
||||
assert updated_note["content"] == expected_content_after_first
|
||||
|
||||
# Second append
|
||||
updated_note = await nc_client.notes.append_content(
|
||||
note_id=note_id, content=second_append
|
||||
)
|
||||
|
||||
expected_content_after_second = (
|
||||
expected_content_after_first + "\n---\n" + second_append
|
||||
)
|
||||
assert updated_note["content"] == expected_content_after_second
|
||||
|
||||
# Verify by reading the note again
|
||||
await asyncio.sleep(1)
|
||||
read_note = await nc_client.notes.get_note(note_id=note_id)
|
||||
assert read_note["content"] == expected_content_after_second
|
||||
logger.info(f"Successfully performed multiple appends to note ID: {note_id}")
|
||||
|
||||
|
||||
async def test_notes_api_append_content_nonexistent_note(nc_client: NextcloudClient):
|
||||
"""
|
||||
Tests that appending to a non-existent note fails with 404.
|
||||
"""
|
||||
non_existent_id = 999999999
|
||||
|
||||
logger.info(f"Attempting to append to non-existent note ID: {non_existent_id}")
|
||||
with pytest.raises(HTTPStatusError) as excinfo:
|
||||
await nc_client.notes.append_content(
|
||||
note_id=non_existent_id, content="This should fail"
|
||||
)
|
||||
assert excinfo.value.response.status_code == 404
|
||||
logger.info(
|
||||
f"Appending to non-existent note ID: {non_existent_id} correctly failed with 404."
|
||||
)
|
||||
@@ -0,0 +1,535 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import uuid
|
||||
from typing import Any, Dict
|
||||
|
||||
import pytest
|
||||
from httpx import HTTPStatusError
|
||||
|
||||
from nextcloud_mcp_server.client import NextcloudClient
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Mark all tests in this module as integration tests
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
async def sample_table_info(nc_client: NextcloudClient) -> Dict[str, Any]:
|
||||
"""
|
||||
Fixture to get information about the sample table that comes with Nextcloud Tables.
|
||||
This assumes that the sample table exists in the Nextcloud instance.
|
||||
"""
|
||||
logger.info("Looking for sample table in Nextcloud Tables app")
|
||||
|
||||
# Get all tables
|
||||
tables = await nc_client.tables.list_tables()
|
||||
|
||||
# Look for a sample table (usually created by default)
|
||||
sample_table = None
|
||||
for table in tables:
|
||||
# Common names for sample tables
|
||||
if any(
|
||||
keyword in table.get("title", "").lower()
|
||||
for keyword in ["sample", "demo", "example", "test"]
|
||||
):
|
||||
sample_table = table
|
||||
break
|
||||
|
||||
if not sample_table and tables:
|
||||
# If no sample table found, use the first available table
|
||||
sample_table = tables[0]
|
||||
logger.info(
|
||||
f"No sample table found, using first available table: {sample_table.get('title')}"
|
||||
)
|
||||
|
||||
if not sample_table:
|
||||
pytest.skip(
|
||||
"No tables found in Nextcloud Tables app. Please ensure Tables app is installed and has at least one table."
|
||||
)
|
||||
|
||||
# Get the schema for the sample table
|
||||
table_id = sample_table["id"]
|
||||
schema = await nc_client.tables.get_table_schema(table_id)
|
||||
|
||||
logger.info(f"Using sample table: {sample_table.get('title')} (ID: {table_id})")
|
||||
|
||||
return {
|
||||
"table": sample_table,
|
||||
"schema": schema,
|
||||
"table_id": table_id,
|
||||
"columns": schema.get("columns", []),
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def temporary_table_row(
|
||||
nc_client: NextcloudClient, sample_table_info: Dict[str, Any]
|
||||
):
|
||||
"""
|
||||
Fixture to create a temporary row in the sample table for testing.
|
||||
Yields the created row data and cleans up afterward.
|
||||
"""
|
||||
table_id = sample_table_info["table_id"]
|
||||
columns = sample_table_info["columns"]
|
||||
|
||||
# Create test data based on the table schema
|
||||
test_data = {}
|
||||
unique_suffix = uuid.uuid4().hex[:8]
|
||||
|
||||
for column in columns:
|
||||
column_id = column["id"]
|
||||
column_type = column.get("type", "text")
|
||||
column_title = column.get("title", f"column_{column_id}")
|
||||
|
||||
# Generate test data based on column type
|
||||
if column_type == "text":
|
||||
test_data[column_id] = f"Test {column_title} {unique_suffix}"
|
||||
elif column_type == "number":
|
||||
test_data[column_id] = 42
|
||||
elif column_type == "datetime":
|
||||
test_data[column_id] = "2024-01-01T12:00:00Z"
|
||||
elif column_type == "select":
|
||||
# For select columns, use the first option if available
|
||||
options = column.get("selectOptions", [])
|
||||
if options:
|
||||
test_data[column_id] = options[0].get("label", "Option 1")
|
||||
else:
|
||||
test_data[column_id] = "Test Option"
|
||||
else:
|
||||
# Default to text for unknown types
|
||||
test_data[column_id] = f"Test {column_title} {unique_suffix}"
|
||||
|
||||
logger.info(f"Creating temporary row in table {table_id} with data: {test_data}")
|
||||
|
||||
created_row = None
|
||||
try:
|
||||
created_row = await nc_client.tables.create_row(table_id, test_data)
|
||||
row_id = created_row.get("id")
|
||||
|
||||
if not row_id:
|
||||
pytest.fail("Failed to get ID from created temporary row.")
|
||||
|
||||
logger.info(f"Temporary row created with ID: {row_id}")
|
||||
yield created_row
|
||||
|
||||
finally:
|
||||
if created_row and created_row.get("id"):
|
||||
row_id = created_row["id"]
|
||||
logger.info(f"Cleaning up temporary row ID: {row_id}")
|
||||
try:
|
||||
await nc_client.tables.delete_row(row_id)
|
||||
logger.info(f"Successfully deleted temporary row ID: {row_id}")
|
||||
except HTTPStatusError as e:
|
||||
# Ignore 404 if row was already deleted by the test itself
|
||||
if e.response.status_code != 404:
|
||||
logger.error(f"HTTP error deleting temporary row {row_id}: {e}")
|
||||
else:
|
||||
logger.warning(f"Temporary row {row_id} already deleted (404).")
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error deleting temporary row {row_id}: {e}")
|
||||
|
||||
|
||||
async def test_tables_list_tables(nc_client: NextcloudClient):
|
||||
"""
|
||||
Test listing all tables available to the user.
|
||||
"""
|
||||
logger.info("Testing list_tables functionality")
|
||||
|
||||
tables = await nc_client.tables.list_tables()
|
||||
|
||||
assert isinstance(tables, list)
|
||||
assert len(tables) > 0, "Expected at least one table to be available"
|
||||
|
||||
# Check that each table has required fields
|
||||
for table in tables:
|
||||
assert "id" in table
|
||||
assert "title" in table
|
||||
assert isinstance(table["id"], int)
|
||||
assert isinstance(table["title"], str)
|
||||
|
||||
logger.info(f"Successfully listed {len(tables)} tables")
|
||||
|
||||
|
||||
async def test_tables_get_schema(
|
||||
nc_client: NextcloudClient, sample_table_info: Dict[str, Any]
|
||||
):
|
||||
"""
|
||||
Test getting the schema/structure of a specific table.
|
||||
"""
|
||||
table_id = sample_table_info["table_id"]
|
||||
|
||||
logger.info(f"Testing get_table_schema for table ID: {table_id}")
|
||||
|
||||
schema = await nc_client.tables.get_table_schema(table_id)
|
||||
|
||||
assert isinstance(schema, dict)
|
||||
assert "columns" in schema
|
||||
assert isinstance(schema["columns"], list)
|
||||
assert len(schema["columns"]) > 0, "Expected at least one column in the table"
|
||||
|
||||
# Check that each column has required fields
|
||||
for column in schema["columns"]:
|
||||
assert "id" in column
|
||||
assert "title" in column
|
||||
assert "type" in column
|
||||
assert isinstance(column["id"], int)
|
||||
assert isinstance(column["title"], str)
|
||||
assert isinstance(column["type"], str)
|
||||
|
||||
logger.info(f"Successfully retrieved schema with {len(schema['columns'])} columns")
|
||||
|
||||
|
||||
async def test_tables_read_table(
|
||||
nc_client: NextcloudClient, sample_table_info: Dict[str, Any]
|
||||
):
|
||||
"""
|
||||
Test reading rows from a table.
|
||||
"""
|
||||
table_id = sample_table_info["table_id"]
|
||||
|
||||
logger.info(f"Testing get_table_rows for table ID: {table_id}")
|
||||
|
||||
# Test without pagination
|
||||
rows = await nc_client.tables.get_table_rows(table_id)
|
||||
|
||||
assert isinstance(rows, list)
|
||||
# Note: The table might be empty, so we don't assert len > 0
|
||||
|
||||
# Test with pagination
|
||||
rows_limited = await nc_client.tables.get_table_rows(table_id, limit=5, offset=0)
|
||||
|
||||
assert isinstance(rows_limited, list)
|
||||
assert len(rows_limited) <= 5
|
||||
|
||||
# If there are rows, check their structure
|
||||
if rows:
|
||||
row = rows[0]
|
||||
assert "id" in row
|
||||
assert "tableId" in row
|
||||
assert "data" in row
|
||||
assert isinstance(row["id"], int)
|
||||
assert isinstance(row["tableId"], int)
|
||||
assert isinstance(row["data"], list)
|
||||
|
||||
logger.info(f"Successfully read {len(rows)} rows from table")
|
||||
|
||||
|
||||
async def test_tables_create_row(
|
||||
nc_client: NextcloudClient, sample_table_info: Dict[str, Any]
|
||||
):
|
||||
"""
|
||||
Test creating a new row in a table.
|
||||
"""
|
||||
table_id = sample_table_info["table_id"]
|
||||
columns = sample_table_info["columns"]
|
||||
|
||||
# Create test data based on the table schema
|
||||
test_data = {}
|
||||
unique_suffix = uuid.uuid4().hex[:8]
|
||||
|
||||
for column in columns:
|
||||
column_id = column["id"]
|
||||
column_type = column.get("type", "text")
|
||||
column_title = column.get("title", f"column_{column_id}")
|
||||
|
||||
# Generate test data based on column type
|
||||
if column_type == "text":
|
||||
test_data[column_id] = f"Test Create {column_title} {unique_suffix}"
|
||||
elif column_type == "number":
|
||||
test_data[column_id] = 123
|
||||
elif column_type == "datetime":
|
||||
test_data[column_id] = "2024-01-01T12:00:00Z"
|
||||
elif column_type == "select":
|
||||
# For select columns, use the first option if available
|
||||
options = column.get("selectOptions", [])
|
||||
if options:
|
||||
test_data[column_id] = options[0].get("label", "Option 1")
|
||||
else:
|
||||
test_data[column_id] = "Test Option"
|
||||
else:
|
||||
# Default to text for unknown types
|
||||
test_data[column_id] = f"Test Create {column_title} {unique_suffix}"
|
||||
|
||||
logger.info(f"Testing create_row for table ID: {table_id} with data: {test_data}")
|
||||
|
||||
created_row = None
|
||||
try:
|
||||
created_row = await nc_client.tables.create_row(table_id, test_data)
|
||||
|
||||
assert isinstance(created_row, dict)
|
||||
assert "id" in created_row
|
||||
assert "tableId" in created_row
|
||||
assert isinstance(created_row["id"], int)
|
||||
assert created_row["tableId"] == table_id
|
||||
|
||||
# Verify the row was created by reading it back
|
||||
await asyncio.sleep(1) # Allow potential propagation delay
|
||||
rows = await nc_client.tables.get_table_rows(table_id)
|
||||
created_row_id = created_row["id"]
|
||||
|
||||
# Find the created row in the results
|
||||
found_row = None
|
||||
for row in rows:
|
||||
if row["id"] == created_row_id:
|
||||
found_row = row
|
||||
break
|
||||
|
||||
assert found_row is not None, (
|
||||
f"Created row with ID {created_row_id} not found in table"
|
||||
)
|
||||
|
||||
logger.info(f"Successfully created row with ID: {created_row_id}")
|
||||
|
||||
finally:
|
||||
# Clean up the created row
|
||||
if created_row and created_row.get("id"):
|
||||
try:
|
||||
await nc_client.tables.delete_row(created_row["id"])
|
||||
logger.info(f"Cleaned up created row ID: {created_row['id']}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to clean up created row: {e}")
|
||||
|
||||
|
||||
async def test_tables_update_row(
|
||||
nc_client: NextcloudClient,
|
||||
temporary_table_row: Dict[str, Any],
|
||||
sample_table_info: Dict[str, Any],
|
||||
):
|
||||
"""
|
||||
Test updating an existing row in a table.
|
||||
"""
|
||||
row_id = temporary_table_row["id"]
|
||||
columns = sample_table_info["columns"]
|
||||
|
||||
# Create updated data
|
||||
update_data = {}
|
||||
unique_suffix = uuid.uuid4().hex[:8]
|
||||
|
||||
for column in columns:
|
||||
column_id = column["id"]
|
||||
column_type = column.get("type", "text")
|
||||
column_title = column.get("title", f"column_{column_id}")
|
||||
|
||||
# Generate updated test data based on column type
|
||||
if column_type == "text":
|
||||
update_data[column_id] = f"Updated {column_title} {unique_suffix}"
|
||||
elif column_type == "number":
|
||||
update_data[column_id] = 456
|
||||
elif column_type == "datetime":
|
||||
update_data[column_id] = "2024-12-31T23:59:59Z"
|
||||
elif column_type == "select":
|
||||
# For select columns, use the first option if available
|
||||
options = column.get("selectOptions", [])
|
||||
if options:
|
||||
update_data[column_id] = options[0].get("label", "Option 1")
|
||||
else:
|
||||
update_data[column_id] = "Updated Option"
|
||||
else:
|
||||
# Default to text for unknown types
|
||||
update_data[column_id] = f"Updated {column_title} {unique_suffix}"
|
||||
|
||||
logger.info(f"Testing update_row for row ID: {row_id} with data: {update_data}")
|
||||
|
||||
updated_row = await nc_client.tables.update_row(row_id, update_data)
|
||||
|
||||
assert isinstance(updated_row, dict)
|
||||
assert "id" in updated_row
|
||||
assert updated_row["id"] == row_id
|
||||
|
||||
# Verify the row was updated by reading it back
|
||||
await asyncio.sleep(1) # Allow potential propagation delay
|
||||
table_id = sample_table_info["table_id"]
|
||||
rows = await nc_client.tables.get_table_rows(table_id)
|
||||
|
||||
# Find the updated row in the results
|
||||
found_row = None
|
||||
for row in rows:
|
||||
if row["id"] == row_id:
|
||||
found_row = row
|
||||
break
|
||||
|
||||
assert found_row is not None, f"Updated row with ID {row_id} not found in table"
|
||||
|
||||
logger.info(f"Successfully updated row with ID: {row_id}")
|
||||
|
||||
|
||||
async def test_tables_delete_row(
|
||||
nc_client: NextcloudClient, sample_table_info: Dict[str, Any]
|
||||
):
|
||||
"""
|
||||
Test deleting a row from a table.
|
||||
"""
|
||||
table_id = sample_table_info["table_id"]
|
||||
columns = sample_table_info["columns"]
|
||||
|
||||
# First create a row to delete
|
||||
test_data = {}
|
||||
unique_suffix = uuid.uuid4().hex[:8]
|
||||
|
||||
for column in columns:
|
||||
column_id = column["id"]
|
||||
column_type = column.get("type", "text")
|
||||
column_title = column.get("title", f"column_{column_id}")
|
||||
|
||||
if column_type == "text":
|
||||
test_data[column_id] = f"Test Delete {column_title} {unique_suffix}"
|
||||
elif column_type == "number":
|
||||
test_data[column_id] = 789
|
||||
elif column_type == "datetime":
|
||||
test_data[column_id] = "2024-06-15T10:30:00Z"
|
||||
elif column_type == "select":
|
||||
options = column.get("selectOptions", [])
|
||||
if options:
|
||||
test_data[column_id] = options[0].get("label", "Option 1")
|
||||
else:
|
||||
test_data[column_id] = "Delete Option"
|
||||
else:
|
||||
test_data[column_id] = f"Test Delete {column_title} {unique_suffix}"
|
||||
|
||||
logger.info(f"Creating row for delete test in table ID: {table_id}")
|
||||
|
||||
created_row = await nc_client.tables.create_row(table_id, test_data)
|
||||
row_id = created_row["id"]
|
||||
|
||||
logger.info(f"Testing delete_row for row ID: {row_id}")
|
||||
|
||||
# Delete the row
|
||||
delete_result = await nc_client.tables.delete_row(row_id)
|
||||
|
||||
assert isinstance(delete_result, dict)
|
||||
# The delete response might vary, but it should be successful
|
||||
|
||||
# Verify the row was deleted by trying to find it
|
||||
await asyncio.sleep(1) # Allow potential propagation delay
|
||||
rows = await nc_client.tables.get_table_rows(table_id)
|
||||
|
||||
# Ensure the deleted row is not in the results
|
||||
found_row = None
|
||||
for row in rows:
|
||||
if row["id"] == row_id:
|
||||
found_row = row
|
||||
break
|
||||
|
||||
assert found_row is None, f"Deleted row with ID {row_id} still found in table"
|
||||
|
||||
logger.info(f"Successfully deleted row with ID: {row_id}")
|
||||
|
||||
|
||||
async def test_tables_delete_nonexistent_row(nc_client: NextcloudClient):
|
||||
"""
|
||||
Test that deleting a non-existent row fails appropriately.
|
||||
"""
|
||||
non_existent_id = 999999999 # Use an ID highly unlikely to exist
|
||||
|
||||
logger.info(f"Testing delete_row for non-existent row ID: {non_existent_id}")
|
||||
|
||||
with pytest.raises(HTTPStatusError) as excinfo:
|
||||
await nc_client.tables.delete_row(non_existent_id)
|
||||
|
||||
# Accept both 404 and 500 as valid error responses for non-existent rows
|
||||
# The API behavior may vary between Nextcloud versions
|
||||
assert excinfo.value.response.status_code in [404, 500]
|
||||
logger.info(
|
||||
f"Deleting non-existent row ID: {non_existent_id} correctly failed with {excinfo.value.response.status_code}."
|
||||
)
|
||||
|
||||
|
||||
async def test_tables_transform_row_data(
|
||||
nc_client: NextcloudClient, sample_table_info: Dict[str, Any]
|
||||
):
|
||||
"""
|
||||
Test the transform_row_data utility method.
|
||||
"""
|
||||
table_id = sample_table_info["table_id"]
|
||||
columns = sample_table_info["columns"]
|
||||
|
||||
logger.info(f"Testing transform_row_data for table ID: {table_id}")
|
||||
|
||||
# Get some rows to transform
|
||||
rows = await nc_client.tables.get_table_rows(table_id, limit=5)
|
||||
|
||||
if not rows:
|
||||
logger.info("No rows to transform, skipping transform_row_data test")
|
||||
return
|
||||
|
||||
# Transform the rows
|
||||
transformed_rows = nc_client.tables.transform_row_data(rows, columns)
|
||||
|
||||
assert isinstance(transformed_rows, list)
|
||||
assert len(transformed_rows) == len(rows)
|
||||
|
||||
# Check the structure of transformed rows
|
||||
for i, transformed_row in enumerate(transformed_rows):
|
||||
original_row = rows[i]
|
||||
|
||||
assert "id" in transformed_row
|
||||
assert "tableId" in transformed_row
|
||||
assert "data" in transformed_row
|
||||
assert transformed_row["id"] == original_row["id"]
|
||||
assert transformed_row["tableId"] == original_row["tableId"]
|
||||
assert isinstance(transformed_row["data"], dict)
|
||||
|
||||
# Check that column IDs were transformed to column names
|
||||
for column in columns:
|
||||
column_title = column["title"]
|
||||
# The transformed data should have column names as keys
|
||||
# (though the column might not have data in this row)
|
||||
if any(item["columnId"] == column["id"] for item in original_row["data"]):
|
||||
assert column_title in transformed_row["data"]
|
||||
|
||||
logger.info(f"Successfully transformed {len(transformed_rows)} rows")
|
||||
|
||||
|
||||
async def test_tables_get_nonexistent_table_schema(nc_client: NextcloudClient):
|
||||
"""
|
||||
Test that getting schema for a non-existent table fails appropriately.
|
||||
"""
|
||||
non_existent_id = 999999999 # Use an ID highly unlikely to exist
|
||||
|
||||
logger.info(
|
||||
f"Testing get_table_schema for non-existent table ID: {non_existent_id}"
|
||||
)
|
||||
|
||||
with pytest.raises(HTTPStatusError) as excinfo:
|
||||
await nc_client.tables.get_table_schema(non_existent_id)
|
||||
|
||||
assert excinfo.value.response.status_code == 404
|
||||
logger.info(
|
||||
f"Getting schema for non-existent table ID: {non_existent_id} correctly failed with 404."
|
||||
)
|
||||
|
||||
|
||||
async def test_tables_read_nonexistent_table(nc_client: NextcloudClient):
|
||||
"""
|
||||
Test that reading from a non-existent table fails appropriately.
|
||||
"""
|
||||
non_existent_id = 999999999 # Use an ID highly unlikely to exist
|
||||
|
||||
logger.info(f"Testing get_table_rows for non-existent table ID: {non_existent_id}")
|
||||
|
||||
with pytest.raises(HTTPStatusError) as excinfo:
|
||||
await nc_client.tables.get_table_rows(non_existent_id)
|
||||
|
||||
assert excinfo.value.response.status_code == 404
|
||||
logger.info(
|
||||
f"Reading from non-existent table ID: {non_existent_id} correctly failed with 404."
|
||||
)
|
||||
|
||||
|
||||
async def test_tables_create_row_invalid_table(nc_client: NextcloudClient):
|
||||
"""
|
||||
Test that creating a row in a non-existent table fails appropriately.
|
||||
"""
|
||||
non_existent_id = 999999999 # Use an ID highly unlikely to exist
|
||||
test_data = {1: "test value"}
|
||||
|
||||
logger.info(f"Testing create_row for non-existent table ID: {non_existent_id}")
|
||||
|
||||
with pytest.raises(HTTPStatusError) as excinfo:
|
||||
await nc_client.tables.create_row(non_existent_id, test_data)
|
||||
|
||||
assert excinfo.value.response.status_code == 404
|
||||
logger.info(
|
||||
f"Creating row in non-existent table ID: {non_existent_id} correctly failed with 404."
|
||||
)
|
||||
@@ -0,0 +1,137 @@
|
||||
"""Integration tests for OAuth authentication."""
|
||||
|
||||
import logging
|
||||
import os
|
||||
|
||||
import pytest
|
||||
from httpx import HTTPStatusError
|
||||
|
||||
from nextcloud_mcp_server.auth import BearerAuth
|
||||
from nextcloud_mcp_server.client import NextcloudClient
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
pytestmark = [pytest.mark.integration, pytest.mark.oauth]
|
||||
|
||||
|
||||
# OAuth Client Tests
|
||||
|
||||
|
||||
async def test_oauth_client_capabilities(nc_oauth_client: NextcloudClient):
|
||||
"""Test that OAuth client can fetch capabilities."""
|
||||
capabilities = await nc_oauth_client.capabilities()
|
||||
|
||||
assert capabilities is not None
|
||||
assert "ocs" in capabilities
|
||||
logger.info(
|
||||
f"OAuth client successfully fetched capabilities: {capabilities.get('ocs').get('meta')}"
|
||||
)
|
||||
|
||||
|
||||
async def test_oauth_client_notes_list(nc_oauth_client: NextcloudClient):
|
||||
"""Test that OAuth client can list notes."""
|
||||
notes = await nc_oauth_client.notes.get_all_notes()
|
||||
|
||||
assert isinstance(notes, list)
|
||||
logger.info(f"OAuth client successfully listed {len(notes)} notes")
|
||||
|
||||
|
||||
async def test_oauth_client_create_note(nc_oauth_client: NextcloudClient):
|
||||
"""Test that OAuth client can create and delete a note."""
|
||||
# Create note
|
||||
note_title = "OAuth Test Note"
|
||||
note_content = "This note was created with OAuth authentication"
|
||||
|
||||
created_note = await nc_oauth_client.notes.create_note(
|
||||
title=note_title, content=note_content
|
||||
)
|
||||
|
||||
assert created_note is not None
|
||||
assert created_note.get("title") == note_title
|
||||
note_id = created_note.get("id")
|
||||
assert note_id is not None
|
||||
|
||||
logger.info(f"OAuth client successfully created note with ID: {note_id}")
|
||||
|
||||
# Clean up - delete the note
|
||||
try:
|
||||
await nc_oauth_client.notes.delete_note(note_id=note_id)
|
||||
logger.info(f"OAuth client successfully deleted note {note_id}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to clean up test note {note_id}: {e}")
|
||||
raise
|
||||
|
||||
|
||||
# OAuth Token Validation Tests
|
||||
|
||||
|
||||
async def test_token_in_request_headers(
|
||||
nc_oauth_client: NextcloudClient, playwright_oauth_token: str
|
||||
):
|
||||
"""Verify that bearer token is being used in requests."""
|
||||
# The client should be using BearerAuth
|
||||
assert nc_oauth_client._client.auth is not None
|
||||
|
||||
# Make a request and verify it works
|
||||
capabilities = await nc_oauth_client.capabilities()
|
||||
assert capabilities is not None
|
||||
|
||||
logger.info("OAuth bearer token is correctly included in requests")
|
||||
|
||||
|
||||
async def test_invalid_token_fails():
|
||||
"""Test that an invalid token results in authentication failure."""
|
||||
nextcloud_host = os.getenv("NEXTCLOUD_HOST")
|
||||
if not nextcloud_host:
|
||||
pytest.skip("NEXTCLOUD_HOST not set")
|
||||
|
||||
# Create client with invalid token using BearerAuth
|
||||
invalid_client = NextcloudClient(
|
||||
base_url=nextcloud_host,
|
||||
username="testuser",
|
||||
auth=BearerAuth("invalid_token_12345"),
|
||||
)
|
||||
|
||||
# Attempt to use a protected endpoint - should fail with 401
|
||||
# Note: capabilities endpoint is public and doesn't require auth
|
||||
with pytest.raises(HTTPStatusError) as exc_info:
|
||||
await invalid_client.notes.get_all_notes()
|
||||
|
||||
assert exc_info.value.response.status_code == 401
|
||||
|
||||
await invalid_client.close()
|
||||
logger.info("Invalid OAuth token correctly rejected")
|
||||
|
||||
|
||||
# OAuth MCP Integration Tests
|
||||
|
||||
|
||||
async def test_mcp_oauth_server_connection(nc_mcp_oauth_client):
|
||||
"""Test connection to OAuth-enabled MCP server."""
|
||||
result = await nc_mcp_oauth_client.list_tools()
|
||||
assert result is not None
|
||||
assert len(result.tools) > 0
|
||||
|
||||
logger.info(f"OAuth MCP server has {len(result.tools)} tools available")
|
||||
|
||||
|
||||
async def test_mcp_oauth_tool_execution(nc_mcp_oauth_client):
|
||||
"""Test executing a tool on the OAuth-enabled MCP server."""
|
||||
import json
|
||||
|
||||
# Example: Execute the 'nc_notes_search_notes' tool
|
||||
result = await nc_mcp_oauth_client.call_tool(
|
||||
"nc_notes_search_notes", arguments={"query": ""}
|
||||
)
|
||||
|
||||
assert result.isError is False, f"Tool execution failed: {result.content}"
|
||||
assert result.content is not None
|
||||
response_data = json.loads(result.content[0].text)
|
||||
|
||||
# The search response should have a 'results' field containing the list
|
||||
assert "results" in response_data
|
||||
assert isinstance(response_data["results"], list)
|
||||
|
||||
logger.info(
|
||||
f"Successfully executed 'nc_notes_search_notes' tool on OAuth MCP server and got {len(response_data['results'])} notes."
|
||||
)
|
||||
@@ -0,0 +1,41 @@
|
||||
"""Interactive integration tests for OAuth authentication."""
|
||||
|
||||
import logging
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
pytestmark = [pytest.mark.integration, pytest.mark.oauth]
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
"GITHUB_ACTIONS" in os.environ,
|
||||
reason="Unable to access interactive browser in GitHub Actions",
|
||||
)
|
||||
async def test_oauth_client_with_interactive_flow(nc_oauth_client_interactive):
|
||||
"""Test that OAuth client created via interactive flow can access Nextcloud APIs."""
|
||||
# Test 1: Check capabilities
|
||||
capabilities = await nc_oauth_client_interactive.capabilities()
|
||||
assert capabilities is not None
|
||||
logger.info("OAuth client (interactive) successfully fetched capabilities")
|
||||
|
||||
# Test 2: List notes
|
||||
notes = await nc_oauth_client_interactive.notes.get_all_notes()
|
||||
assert isinstance(notes, list)
|
||||
logger.info(f"OAuth client (interactive) successfully listed {len(notes)} notes")
|
||||
|
||||
# Test 3: Create and delete a note
|
||||
test_note = await nc_oauth_client_interactive.notes.create_note(
|
||||
title="OAuth Interactive Test Note",
|
||||
content="This note was created during OAuth interactive testing",
|
||||
)
|
||||
assert test_note is not None
|
||||
assert test_note.get("id") is not None
|
||||
note_id = test_note["id"]
|
||||
logger.info(f"OAuth client (interactive) successfully created note {note_id}")
|
||||
|
||||
# Clean up
|
||||
await nc_oauth_client_interactive.notes.delete_note(note_id=note_id)
|
||||
logger.info(f"OAuth client (interactive) successfully deleted note {note_id}")
|
||||
@@ -0,0 +1,54 @@
|
||||
"""Integration tests for Playwright-based OAuth authentication."""
|
||||
|
||||
import logging
|
||||
|
||||
import pytest
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
pytestmark = [pytest.mark.integration, pytest.mark.oauth]
|
||||
|
||||
|
||||
async def test_playwright_oauth_token_acquisition(playwright_oauth_token: str):
|
||||
"""Test that Playwright can acquire an OAuth token automatically."""
|
||||
assert playwright_oauth_token is not None
|
||||
assert isinstance(playwright_oauth_token, str)
|
||||
assert len(playwright_oauth_token) > 0
|
||||
logger.info(
|
||||
f"Successfully acquired OAuth token via Playwright: {playwright_oauth_token[:20]}..."
|
||||
)
|
||||
|
||||
|
||||
async def test_oauth_client_with_playwright_flow(nc_oauth_client_playwright):
|
||||
"""Test that OAuth client created via Playwright flow can access Nextcloud APIs."""
|
||||
# Test 1: Check capabilities
|
||||
capabilities = await nc_oauth_client_playwright.capabilities()
|
||||
assert capabilities is not None
|
||||
logger.info("OAuth client (Playwright) successfully fetched capabilities")
|
||||
|
||||
# Test 2: List notes
|
||||
notes = await nc_oauth_client_playwright.notes.get_all_notes()
|
||||
assert isinstance(notes, list)
|
||||
logger.info(f"OAuth client (Playwright) successfully listed {len(notes)} notes")
|
||||
|
||||
|
||||
async def test_mcp_oauth_client_with_playwright(nc_mcp_oauth_client_playwright):
|
||||
"""Test that MCP OAuth client via Playwright can execute tools."""
|
||||
import json
|
||||
|
||||
# Test: Execute the 'nc_notes_search_notes' tool
|
||||
result = await nc_mcp_oauth_client_playwright.call_tool(
|
||||
"nc_notes_search_notes", arguments={"query": ""}
|
||||
)
|
||||
|
||||
assert result.isError is False, f"Tool execution failed: {result.content}"
|
||||
assert result.content is not None
|
||||
response_data = json.loads(result.content[0].text)
|
||||
|
||||
# The search response should have a 'results' field containing the list
|
||||
assert "results" in response_data
|
||||
assert isinstance(response_data["results"], list)
|
||||
|
||||
logger.info(
|
||||
f"Successfully executed 'nc_notes_search_notes' tool on Playwright OAuth MCP server and got {len(response_data['results'])} notes."
|
||||
)
|
||||
@@ -0,0 +1,264 @@
|
||||
import logging
|
||||
import time
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
from httpx import HTTPStatusError
|
||||
|
||||
from nextcloud_mcp_server.client import NextcloudClient
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Mark all tests in this module as integration tests
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
|
||||
async def test_category_change_cleans_up_old_attachments_directory(
|
||||
nc_client: NextcloudClient,
|
||||
):
|
||||
"""
|
||||
Tests that when a note's category is changed, the old attachment directory is properly cleaned up.
|
||||
"""
|
||||
note_id = None
|
||||
initial_category = "CategoryTest1"
|
||||
new_category = "CategoryTest2"
|
||||
unique_suffix = uuid.uuid4().hex[:8]
|
||||
note_title = f"Category Cleanup Test {unique_suffix}"
|
||||
attachment_filename = f"cleanup_test_{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
|
||||
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. Construct and check the WebDAV path for the initial category's attachment directory
|
||||
initial_webdav_path = f"Notes/{initial_category}/.attachments.{note_id}"
|
||||
logger.info(f"Initial WebDAV path for attachments: {initial_webdav_path}")
|
||||
# Here we would check if the directory exists, but the WebDAV client doesn't directly
|
||||
# expose directory listing functionality, so we'll infer from attachment retrieval success
|
||||
|
||||
# 5. Update note category
|
||||
logger.info(
|
||||
f"Updating note {note_id} category from '{initial_category}' to '{new_category}'"
|
||||
)
|
||||
current_note_data = await nc_client.notes.get_note(note_id=note_id)
|
||||
current_etag = current_note_data["etag"]
|
||||
updated_note = await nc_client.notes.update(
|
||||
note_id=note_id,
|
||||
etag=current_etag,
|
||||
category=new_category,
|
||||
title=note_title,
|
||||
content="Updated content",
|
||||
)
|
||||
etag3 = updated_note["etag"]
|
||||
assert updated_note["category"] == new_category
|
||||
logger.info(f"Note category updated successfully. New Etag: {etag3}")
|
||||
time.sleep(1)
|
||||
|
||||
# 6. Verify attachment retrieval from 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.")
|
||||
|
||||
# 7. Try to retrieve from old category - this should fail
|
||||
logger.info(
|
||||
f"Trying to retrieve attachment from old category '{initial_category}' - should fail"
|
||||
)
|
||||
try:
|
||||
await nc_client.webdav.get_note_attachment(
|
||||
note_id=note_id, filename=attachment_filename, category=initial_category
|
||||
)
|
||||
# If we get here, it means the old directory still exists (a problem)
|
||||
logger.error(
|
||||
"ISSUE DETECTED: Was able to retrieve attachment from old category path!"
|
||||
)
|
||||
assert False, (
|
||||
"Old category attachment directory still exists and accessible!"
|
||||
)
|
||||
except HTTPStatusError as e:
|
||||
# This is the expected outcome - old directory should be gone
|
||||
logger.info(
|
||||
f"Correctly got error accessing old category path: {e.response.status_code}"
|
||||
)
|
||||
assert e.response.status_code == 404, (
|
||||
f"Expected 404, got {e.response.status_code}"
|
||||
)
|
||||
logger.info(
|
||||
"Verified old category attachment directory is not accessible (good!)"
|
||||
)
|
||||
|
||||
# 7.1 Directly check old attachment directory existence using 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,
|
||||
]: # Success codes indicate the directory exists (a problem)
|
||||
logger.error(
|
||||
f"Old attachment directory still exists! PROPFIND returned {status}"
|
||||
)
|
||||
assert False, (
|
||||
f"Expected old attachment directory to be gone, but it still exists (PROPFIND returned {status})!"
|
||||
)
|
||||
# If we got another status code (like 404), it's also good - the directory doesn't exist
|
||||
logger.info(
|
||||
f"Verified old attachment directory does not exist (PROPFIND returned {status})"
|
||||
)
|
||||
except HTTPStatusError as e:
|
||||
# 404 is expected - directory should not exist
|
||||
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)"
|
||||
)
|
||||
|
||||
finally:
|
||||
# 8. Cleanup: Delete the note
|
||||
if note_id:
|
||||
logger.info(f"Cleaning up note ID: {note_id}")
|
||||
try:
|
||||
await nc_client.notes.delete_note(note_id=note_id)
|
||||
logger.info(f"Note {note_id} deleted.")
|
||||
time.sleep(1)
|
||||
|
||||
# 9. Verify both old and new attachment paths are gone
|
||||
logger.info("Verifying all attachment paths are gone")
|
||||
with pytest.raises(HTTPStatusError) as excinfo_new:
|
||||
await nc_client.webdav.get_note_attachment(
|
||||
note_id=note_id,
|
||||
filename=attachment_filename,
|
||||
category=new_category,
|
||||
)
|
||||
assert excinfo_new.value.response.status_code == 404
|
||||
|
||||
with pytest.raises(HTTPStatusError) as excinfo_old:
|
||||
await nc_client.webdav.get_note_attachment(
|
||||
note_id=note_id,
|
||||
filename=attachment_filename,
|
||||
category=initial_category,
|
||||
)
|
||||
assert excinfo_old.value.response.status_code == 404
|
||||
|
||||
# 9.1 Directly verify directories don't exist using 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:
|
||||
propfind_resp = await nc_client._client.request(
|
||||
"PROPFIND", new_attachment_dir_path, headers=propfind_headers
|
||||
)
|
||||
status = propfind_resp.status_code
|
||||
if status in [
|
||||
200,
|
||||
207,
|
||||
]: # Success codes indicate the directory exists (a problem)
|
||||
logger.error(
|
||||
f"New category attachment directory still exists! PROPFIND returned {status}"
|
||||
)
|
||||
assert False, (
|
||||
f"Expected new category attachment directory to be gone, but it still exists (PROPFIND returned {status})!"
|
||||
)
|
||||
# If we got another status code (like 404), it's also good - the directory doesn't exist
|
||||
logger.info(
|
||||
f"Verified new category attachment directory does not exist (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 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:
|
||||
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,
|
||||
]: # Success codes indicate the directory exists (a problem)
|
||||
logger.error(
|
||||
f"Old category attachment directory still exists! PROPFIND returned {status}"
|
||||
)
|
||||
assert False, (
|
||||
f"Expected old category attachment directory to be gone, but it still exists (PROPFIND returned {status})!"
|
||||
)
|
||||
# If we got another status code (like 404), it's also good - the directory doesn't exist
|
||||
logger.info(
|
||||
f"Verified old category attachment directory does not exist (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 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}")
|
||||
@@ -0,0 +1,273 @@
|
||||
"""Integration tests for WebDAV operations."""
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
from httpx import HTTPStatusError
|
||||
|
||||
from nextcloud_mcp_server.client import NextcloudClient
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Mark all tests in this module as integration tests
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def test_base_path(nc_client: NextcloudClient):
|
||||
"""Base path for test files/directories."""
|
||||
test_dir = f"mcp_test_{uuid.uuid4().hex[:8]}"
|
||||
await nc_client.webdav.create_directory(test_dir)
|
||||
yield test_dir
|
||||
await nc_client.webdav.delete_resource(test_dir)
|
||||
|
||||
|
||||
async def test_create_and_delete_directory(
|
||||
nc_client: NextcloudClient, test_base_path: str
|
||||
):
|
||||
"""Test creating and deleting directories."""
|
||||
test_dir = f"{test_base_path}/test_directory"
|
||||
|
||||
try:
|
||||
# Create directory
|
||||
result = await nc_client.webdav.create_directory(test_dir)
|
||||
assert result["status_code"] == 201 # Created
|
||||
logger.info(f"Created directory: {test_dir}")
|
||||
|
||||
# Verify directory exists by listing parent
|
||||
parent_listing = await nc_client.webdav.list_directory(test_base_path)
|
||||
dir_names = [item["name"] for item in parent_listing]
|
||||
assert "test_directory" in dir_names
|
||||
|
||||
# Delete directory
|
||||
delete_result = await nc_client.webdav.delete_resource(test_dir)
|
||||
assert delete_result["status_code"] in [204, 404] # No Content or Not Found
|
||||
logger.info(f"Deleted directory: {test_dir}")
|
||||
|
||||
finally:
|
||||
# Cleanup: ensure directory is deleted
|
||||
try:
|
||||
await nc_client.webdav.delete_resource(test_dir)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
async def test_write_read_delete_file(nc_client: NextcloudClient, test_base_path: str):
|
||||
"""Test writing, reading, and deleting files."""
|
||||
test_file = f"{test_base_path}/test_file.txt"
|
||||
test_content = f"Test content {uuid.uuid4().hex}"
|
||||
|
||||
try:
|
||||
# Create base directory first
|
||||
await nc_client.webdav.create_directory(test_base_path)
|
||||
|
||||
# Write file
|
||||
write_result = await nc_client.webdav.write_file(
|
||||
test_file, test_content.encode("utf-8"), content_type="text/plain"
|
||||
)
|
||||
assert write_result["status_code"] in [200, 201, 204] # Success codes
|
||||
logger.info(f"Wrote file: {test_file}")
|
||||
|
||||
# Read file back
|
||||
content, content_type = await nc_client.webdav.read_file(test_file)
|
||||
assert content.decode("utf-8") == test_content
|
||||
assert "text/plain" in content_type
|
||||
logger.info(f"Read file: {test_file}")
|
||||
|
||||
# Verify file appears in directory listing
|
||||
listing = await nc_client.webdav.list_directory(test_base_path)
|
||||
file_names = [item["name"] for item in listing]
|
||||
assert "test_file.txt" in file_names
|
||||
|
||||
# Delete file
|
||||
delete_result = await nc_client.webdav.delete_resource(test_file)
|
||||
assert delete_result["status_code"] in [204, 404] # No Content or Not Found
|
||||
logger.info(f"Deleted file: {test_file}")
|
||||
|
||||
finally:
|
||||
# Cleanup
|
||||
try:
|
||||
await nc_client.webdav.delete_resource(test_file)
|
||||
await nc_client.webdav.delete_resource(test_base_path)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
async def test_list_directory_empty_and_populated(
|
||||
nc_client: NextcloudClient, test_base_path: str
|
||||
):
|
||||
"""Test listing empty and populated directories."""
|
||||
try:
|
||||
# Create base directory
|
||||
await nc_client.webdav.create_directory(test_base_path)
|
||||
|
||||
# List empty directory
|
||||
empty_listing = await nc_client.webdav.list_directory(test_base_path)
|
||||
assert isinstance(empty_listing, list)
|
||||
assert len(empty_listing) == 0
|
||||
logger.info(f"Empty directory listing: {len(empty_listing)} items")
|
||||
|
||||
# Add some files and directories
|
||||
await nc_client.webdav.create_directory(f"{test_base_path}/subdir1")
|
||||
await nc_client.webdav.create_directory(f"{test_base_path}/subdir2")
|
||||
await nc_client.webdav.write_file(
|
||||
f"{test_base_path}/file1.txt", b"content1", content_type="text/plain"
|
||||
)
|
||||
await nc_client.webdav.write_file(
|
||||
f"{test_base_path}/file2.md",
|
||||
b"# Markdown content",
|
||||
content_type="text/markdown",
|
||||
)
|
||||
|
||||
# List populated directory
|
||||
populated_listing = await nc_client.webdav.list_directory(test_base_path)
|
||||
assert len(populated_listing) == 4 # 2 dirs + 2 files
|
||||
|
||||
# Check that we have both files and directories
|
||||
names = [item["name"] for item in populated_listing]
|
||||
assert "subdir1" in names
|
||||
assert "subdir2" in names
|
||||
assert "file1.txt" in names
|
||||
assert "file2.md" in names
|
||||
|
||||
# Check metadata is present
|
||||
for item in populated_listing:
|
||||
assert "name" in item
|
||||
assert "path" in item
|
||||
assert "is_directory" in item
|
||||
assert "size" in item
|
||||
assert "content_type" in item
|
||||
assert "last_modified" in item
|
||||
|
||||
logger.info(f"Populated directory listing: {len(populated_listing)} items")
|
||||
|
||||
finally:
|
||||
# Cleanup
|
||||
try:
|
||||
await nc_client.webdav.delete_resource(f"{test_base_path}/file1.txt")
|
||||
await nc_client.webdav.delete_resource(f"{test_base_path}/file2.md")
|
||||
await nc_client.webdav.delete_resource(f"{test_base_path}/subdir1")
|
||||
await nc_client.webdav.delete_resource(f"{test_base_path}/subdir2")
|
||||
await nc_client.webdav.delete_resource(test_base_path)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
async def test_read_nonexistent_file(nc_client: NextcloudClient):
|
||||
"""Test reading a file that doesn't exist."""
|
||||
nonexistent_file = f"nonexistent_{uuid.uuid4().hex}.txt"
|
||||
|
||||
with pytest.raises(HTTPStatusError) as exc_info:
|
||||
await nc_client.webdav.read_file(nonexistent_file)
|
||||
|
||||
assert exc_info.value.response.status_code == 404
|
||||
logger.info(f"Correctly got 404 for nonexistent file: {nonexistent_file}")
|
||||
|
||||
|
||||
async def test_delete_nonexistent_resource(nc_client: NextcloudClient):
|
||||
"""Test deleting a resource that doesn't exist."""
|
||||
nonexistent_resource = f"nonexistent_{uuid.uuid4().hex}"
|
||||
|
||||
result = await nc_client.webdav.delete_resource(nonexistent_resource)
|
||||
assert result["status_code"] == 404
|
||||
logger.info(f"Correctly got 404 for nonexistent resource: {nonexistent_resource}")
|
||||
|
||||
|
||||
async def test_create_nested_directories(
|
||||
nc_client: NextcloudClient, test_base_path: str
|
||||
):
|
||||
"""Test creating nested directory structures."""
|
||||
nested_path = f"{test_base_path}/level1/level2/level3"
|
||||
|
||||
try:
|
||||
# Create nested directories (should create parent directories automatically)
|
||||
result = await nc_client.webdav.create_directory(nested_path, True)
|
||||
assert result["status_code"] == 201
|
||||
|
||||
# Verify the structure was created
|
||||
level1_listing = await nc_client.webdav.list_directory(
|
||||
f"{test_base_path}/level1"
|
||||
)
|
||||
assert len(level1_listing) == 1
|
||||
assert level1_listing[0]["name"] == "level2"
|
||||
assert level1_listing[0]["is_directory"] is True
|
||||
|
||||
level2_listing = await nc_client.webdav.list_directory(
|
||||
f"{test_base_path}/level1/level2"
|
||||
)
|
||||
assert len(level2_listing) == 1
|
||||
assert level2_listing[0]["name"] == "level3"
|
||||
assert level2_listing[0]["is_directory"] is True
|
||||
|
||||
logger.info(f"Created nested directory structure: {nested_path}")
|
||||
|
||||
finally:
|
||||
# Cleanup - delete from deepest to shallowest
|
||||
try:
|
||||
await nc_client.webdav.delete_resource(nested_path)
|
||||
await nc_client.webdav.delete_resource(f"{test_base_path}/level1/level2")
|
||||
await nc_client.webdav.delete_resource(f"{test_base_path}/level1")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
async def test_overwrite_existing_file(nc_client: NextcloudClient, test_base_path: str):
|
||||
"""Test overwriting an existing file."""
|
||||
test_file = f"{test_base_path}/overwrite_test.txt"
|
||||
original_content = "Original content"
|
||||
new_content = "New content after overwrite"
|
||||
|
||||
try:
|
||||
# Create base directory
|
||||
await nc_client.webdav.create_directory(test_base_path)
|
||||
|
||||
# Write original file
|
||||
await nc_client.webdav.write_file(
|
||||
test_file, original_content.encode("utf-8"), content_type="text/plain"
|
||||
)
|
||||
|
||||
# Verify original content
|
||||
content, _ = await nc_client.webdav.read_file(test_file)
|
||||
assert content.decode("utf-8") == original_content
|
||||
|
||||
# Overwrite with new content
|
||||
overwrite_result = await nc_client.webdav.write_file(
|
||||
test_file, new_content.encode("utf-8"), content_type="text/plain"
|
||||
)
|
||||
assert overwrite_result["status_code"] in [200, 204] # OK or No Content
|
||||
|
||||
# Verify new content
|
||||
content, _ = await nc_client.webdav.read_file(test_file)
|
||||
assert content.decode("utf-8") == new_content
|
||||
|
||||
logger.info(f"Successfully overwrote file: {test_file}")
|
||||
|
||||
finally:
|
||||
# Cleanup
|
||||
try:
|
||||
await nc_client.webdav.delete_resource(test_file)
|
||||
await nc_client.webdav.delete_resource(test_base_path)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
async def test_list_root_directory(nc_client: NextcloudClient):
|
||||
"""Test listing the root directory."""
|
||||
root_listing = await nc_client.webdav.list_directory("")
|
||||
|
||||
# Root directory should exist and be listable
|
||||
assert isinstance(root_listing, list)
|
||||
# Should have at least some default folders/files
|
||||
assert len(root_listing) >= 0
|
||||
|
||||
# Check structure of items
|
||||
for item in root_listing:
|
||||
assert "name" in item
|
||||
assert "path" in item
|
||||
assert "is_directory" in item
|
||||
assert "size" in item
|
||||
assert "content_type" in item
|
||||
assert "last_modified" in item
|
||||
|
||||
logger.info(f"Root directory contains {len(root_listing)} items")
|
||||
Reference in New Issue
Block a user