fix(caldav): Check that calendar exists after creation to avoid race condition

Verify that field preservation tests still operate
This commit is contained in:
Chris Coutinho
2025-10-19 23:44:39 +02:00
parent 1dc2ddfdb7
commit a143123acc
9 changed files with 524 additions and 136 deletions
@@ -1,4 +1,9 @@
"""Integration tests for Calendar CalDAV operations."""
"""Integration tests for Calendar CalDAV operations.
Note: These tests use the shared temporary_calendar fixture from conftest.py
which reuses a session-scoped calendar to avoid Nextcloud rate limiting issues.
Each test cleans up its own events/todos but shares the same calendar.
"""
import logging
import uuid
@@ -15,50 +20,13 @@ logger = logging.getLogger(__name__)
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."""
"""Create a temporary event for testing and clean up afterward.
Uses the shared temporary_calendar fixture from conftest.py which reuses
a session-scoped calendar to avoid Nextcloud rate limiting.
"""
event_uid = None
calendar_name = temporary_calendar
@@ -351,11 +319,11 @@ async def test_get_nonexistent_event(
calendar_name = temporary_calendar
fake_uid = f"nonexistent-{uuid.uuid4()}"
with pytest.raises(HTTPStatusError) as exc_info:
# caldav library raises generic Exception for missing events, not HTTPStatusError
with pytest.raises(Exception, match="not found"):
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}")
logger.info(f"Correctly raised exception for nonexistent event: {fake_uid}")
async def test_delete_nonexistent_event(
@@ -420,7 +388,11 @@ async def test_calendar_operations_error_handling(
# 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)
# caldav library returns empty list for non-existent calendars, doesn't raise
# Testing that it doesn't crash and returns empty results
events = await nc_client.calendar.get_calendar_events(fake_calendar)
assert isinstance(events, list)
# Empty list is expected for non-existent calendar
assert len(events) == 0
logger.info("Error handling tests completed successfully")
@@ -15,7 +15,7 @@ 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."""
"""Test that custom iCal fields are preserved during round-trip update operations."""
calendar_name = "personal"
# Create an event with standard fields
@@ -32,7 +32,12 @@ async def test_calendar_event_custom_fields_preservation(nc_client):
event_uid = result["uid"]
try:
# Now manually inject a custom iCal property by creating a new version with raw iCal
# Get the calendar object from the caldav library
calendar = nc_client.calendar._get_calendar(calendar_name)
event = await calendar.event_by_uid(event_uid)
await event.load()
# Now manually inject custom iCal properties into the raw data
# This simulates what would happen if the event was created by another CalDAV client
# with extended properties
custom_ical = f"""BEGIN:VCALENDAR
@@ -57,22 +62,15 @@ 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"},
)
# Update the event's raw data and save
event.data = custom_ical
await event.save()
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
# Reload the event to confirm custom fields are present
await event.load()
raw_ical_before = event.data
logger.info("Raw iCal before update:")
logger.info(raw_ical_before)
@@ -93,31 +91,24 @@ END:VCALENDAR"""
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
# Reload the event to see if custom fields survived
await event.load()
raw_ical_after = event.data
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
# THIS IS THE CRITICAL TEST - custom fields should be preserved
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 during update")
finally:
# Cleanup
@@ -299,7 +290,7 @@ END:VCARD"""
@pytest.mark.integration
async def test_calendar_event_roundtrip_data_loss_demonstration(nc_client):
"""Demonstrates specific data loss scenarios in calendar events."""
"""Test that extended iCal properties are preserved during round-trip update operations."""
calendar_name = "personal"
event_data = {
@@ -313,6 +304,11 @@ async def test_calendar_event_roundtrip_data_loss_demonstration(nc_client):
event_uid = result["uid"]
try:
# Get the calendar object and event
calendar = nc_client.calendar._get_calendar(calendar_name)
event = await calendar.event_by_uid(event_uid)
await event.load()
# Inject additional iCal properties that are valid but not supported by our parser
extended_ical = f"""BEGIN:VCALENDAR
VERSION:2.0
@@ -342,20 +338,13 @@ 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"},
)
# Update the event's raw data and save
event.data = extended_ical
await event.save()
# Verify extended properties are present
response = await nc_client.calendar._make_request(
"GET", event_path, headers={"Accept": "text/calendar"}
)
original_ical = response.text
# Reload to verify extended properties are present
await event.load()
original_ical = event.data
# Confirm extended properties exist
extended_properties = [
@@ -392,11 +381,9 @@ END:VCALENDAR"""
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
# Reload the event to check what survived the round-trip
await event.load()
updated_ical = event.data
logger.info("Checking which properties survived the update...")
@@ -423,13 +410,16 @@ END:VCALENDAR"""
lost.append(prop)
logger.info(f"Properties that SURVIVED: {survived}")
logger.error(f"Properties that were LOST: {lost}")
if lost:
logger.error(f"Properties that were LOST: {lost}")
# This test should fail - we expect data loss
# Assert that all extended properties were preserved
assert len(lost) == 0, (
f"Round-trip update lost {len(lost)} extended properties: {lost}"
)
logger.info("✓ All extended properties preserved during update")
finally:
try:
await nc_client.calendar.delete_event(calendar_name, event_uid)