From ece737826c89bd48072e789b9ceeb2aa3c9cf887 Mon Sep 17 00:00:00 2001 From: Marc Durepos Date: Thu, 2 Oct 2025 10:06:02 -0400 Subject: [PATCH] caldav_sync: fixed a bug in events created from appointments, added test module test_caldav_sync_appointments --- caldav_sync/models/calendar_event.py | 12 +- test_caldav_sync_appointments/__init__.py | 2 + test_caldav_sync_appointments/__manifest__.py | 28 +++ .../tests/__init__.py | 1 + .../test_appointment_caldav_integration.py | 183 ++++++++++++++++++ 5 files changed, 223 insertions(+), 3 deletions(-) create mode 100644 test_caldav_sync_appointments/__init__.py create mode 100644 test_caldav_sync_appointments/__manifest__.py create mode 100644 test_caldav_sync_appointments/tests/__init__.py create mode 100644 test_caldav_sync_appointments/tests/test_appointment_caldav_integration.py diff --git a/caldav_sync/models/calendar_event.py b/caldav_sync/models/calendar_event.py index 950a182..736b06a 100644 --- a/caldav_sync/models/calendar_event.py +++ b/caldav_sync/models/calendar_event.py @@ -223,7 +223,9 @@ class CalendarEvent(models.Model): {"caldav_uid": caldav_uid} ) except Exception as e: - _logger.error(f"Failed to sync event to CalDAV server: {e}") + _logger.error( + f"Failed to sync event to CalDAV server: {e}", exc_info=True + ) def write(self, vals): res = super(CalendarEvent, self.with_context(caldav_no_sync=True)).write(vals) @@ -347,7 +349,6 @@ class CalendarEvent(models.Model): calendar = client.calendar(url=user.caldav_calendar_url) try: caldav_event = calendar.event_by_uid(self.caldav_uid) - assert isinstance(caldav_event, caldav.Event) if not delete_all and self.recurrence_id and not self.is_base_event: index = self._get_subcomponent_index_for_recurrence( caldav_event @@ -488,7 +489,12 @@ class CalendarEvent(models.Model): def _add_event_dates(self, event_data: Dict) -> None: """Add pertinent dates to event data, based on self.""" - tz = self.event_tz or self.env.user.tz + # Determine timezone: prefer event_tz, then user tz, finally UTC + # Note: All datetimes in Odoo are stored in UTC, so defaulting to UTC is correct. + # UTC times are sent in from the appointments app when installed, without + # timezone information. This was breaking the sync process due to a call to + # upper() on boolean value False. + tz = self.event_tz or self.env.user.tz or "UTC" event_tz = timezone(tz) event_data["last-modified"] = vDatetime( utc.localize(self.write_date).astimezone(event_tz) diff --git a/test_caldav_sync_appointments/__init__.py b/test_caldav_sync_appointments/__init__.py new file mode 100644 index 0000000..b729aa3 --- /dev/null +++ b/test_caldav_sync_appointments/__init__.py @@ -0,0 +1,2 @@ +# -*- coding: utf-8 -*- +# Test module for CalDAV sync appointments integration diff --git a/test_caldav_sync_appointments/__manifest__.py b/test_caldav_sync_appointments/__manifest__.py new file mode 100644 index 0000000..f6abb5b --- /dev/null +++ b/test_caldav_sync_appointments/__manifest__.py @@ -0,0 +1,28 @@ +# -*- coding: utf-8 -*- +{ + 'name': 'Test CalDAV Sync Appointments Integration', + 'version': '18.0.1.0.0', + 'category': 'Hidden/Tests', + 'summary': 'Test module for CalDAV sync integration with appointments', + 'description': """ +Test CalDAV Sync Appointments Integration +========================================= + +This module contains tests for the integration between CalDAV sync and the +Appointments app. It verifies that appointment events are properly handled +during CalDAV synchronization operations. + +This is a test-only module and should not be installed in production. + """, + 'author': 'Bemade Inc.', + 'website': 'https://www.bemade.org', + 'depends': [ + 'caldav_sync', + 'appointment', + ], + 'installable': True, + 'auto_install': False, + 'application': False, + # Mark as test module + 'post_init_hook': None, +} diff --git a/test_caldav_sync_appointments/tests/__init__.py b/test_caldav_sync_appointments/tests/__init__.py new file mode 100644 index 0000000..8e3a3ef --- /dev/null +++ b/test_caldav_sync_appointments/tests/__init__.py @@ -0,0 +1 @@ +from . import test_appointment_caldav_integration diff --git a/test_caldav_sync_appointments/tests/test_appointment_caldav_integration.py b/test_caldav_sync_appointments/tests/test_appointment_caldav_integration.py new file mode 100644 index 0000000..6a54fb2 --- /dev/null +++ b/test_caldav_sync_appointments/tests/test_appointment_caldav_integration.py @@ -0,0 +1,183 @@ +"""Test CalDAV sync integration with appointment events.""" + +import logging +from unittest.mock import patch, MagicMock +from datetime import datetime, timedelta +from contextlib import contextmanager + +from odoo.tests.common import TransactionCase, tagged +from odoo import Command +from odoo.addons.caldav_sync.tests.common import CaldavTestCommon +from odoo.tools import mute_logger + +_logger = logging.getLogger(__name__) + + +@contextmanager +def _patch_caldav_for_appointments(user): + """Patch CalDAV operations for appointment testing, capturing real iCalendar events.""" + with mute_logger("odoo.addons.caldav_sync.models.res_users"), patch( + "caldav.DAVClient" + ) as MockDAVClient: + mock_client = MockDAVClient.return_value + mock_calendar = MagicMock() + mock_client.calendar.return_value = mock_calendar + + # Storage for created CalDAV events and global delete tracking + created_events = {} # uid -> caldav_event + global_delete_calls = [] # Track all delete calls + + def save_event_side_effect(**ical_data): + """Let the real CalDAV event creation happen, then capture and store it.""" + # Create a mock CalDAV event that we can track delete() calls on + mock_caldav_event = MagicMock() + + # Set up the vobject_instance structure that CalDAV sync expects + uid = ical_data.get("uid", f"test-uid-{len(created_events)}") + mock_caldav_event.vobject_instance.vevent.uid.value = uid + + # Ensure delete method is properly trackable and records calls globally + def delete_side_effect(): + global_delete_calls.append(uid) + + mock_caldav_event.delete = MagicMock(side_effect=delete_side_effect) + + # Set up icalendar_component structure using the real iCalendar data + mock_ical_component = MagicMock() + + def ical_component_get(key): + if key == "dtstart": + mock_dtstart = MagicMock() + dtstart_value = ical_data.get("dtstart") + + # Handle vDatetime objects from iCalendar library + if hasattr(dtstart_value, "dt"): + # It's already a vDatetime, extract the actual datetime + actual_dt = dtstart_value.dt + else: + # It's a regular datetime + actual_dt = dtstart_value + + # Create a mock that has tzinfo like a regular datetime + mock_dt = MagicMock() + mock_dt.tzinfo = getattr(actual_dt, "tzinfo", None) + mock_dt.__eq__ = lambda self, other: actual_dt == other + + mock_dtstart.dt = mock_dt + return mock_dtstart + return MagicMock() + + mock_ical_component.get.side_effect = ical_component_get + mock_caldav_event.icalendar_component = mock_ical_component + + # Store the event by UID for later retrieval + created_events[uid] = mock_caldav_event + + return mock_caldav_event + + def event_by_uid_side_effect(uid): + """Return the previously created CalDAV event by UID.""" + if uid in created_events: + # Always return the same mock object for the same UID + return created_events[uid] + # Raise NotFoundError if event doesn't exist (like real CalDAV) + from caldav.lib.error import NotFoundError + + raise NotFoundError("Event not found") + + # Set up the mock methods + mock_calendar.events.return_value = [] + mock_calendar.save_event.side_effect = save_event_side_effect + mock_calendar.event_by_uid.side_effect = event_by_uid_side_effect + + # Enable CalDAV for the user + user.write({"is_caldav_enabled": True}) + + # Return both the mock calendar and the global delete tracking + mock_calendar.global_delete_calls = global_delete_calls + yield mock_calendar + + +@tagged("post_install", "-at_install") +class TestAppointmentCalDAVIntegration(TransactionCase, CaldavTestCommon): + """Test CalDAV sync behavior with appointment events.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + + # Create test user with CalDAV enabled using the common method + cls.test_user = cls._generate_user( + "test_caldav_user", + caldav_username="testuser", + caldav_password="testpass", + caldav_url="https://test.caldav.server/calendar/testuser", + ) + + # Create test partner + cls.test_partner = cls.env["res.partner"].create( + { + "name": "Test Partner", + "email": "partner@example.com", + } + ) + + # Create appointment type + cls.appointment_type = cls.env["appointment.type"].create( + { + "category": "custom", + "appointment_duration": 1.0, + "staff_user_ids": [Command.link(cls.test_user.id)], + } + ) + + def test_appointment_booking_syncs_to_caldav(self): + """Test that appointments created through the booking interface sync to CalDAV. + + BUG: When a public user books an appointment through the booking interface, + the event is created in Odoo but NOT synced to the CalDAV server. This causes + the event to be deleted on the next polling round since CalDAV sync thinks it + was deleted from the server. + """ + + with _patch_caldav_for_appointments(self.test_user) as mock_calendar: + # Simulate the appointment booking flow - appointments are created with + # the staff user but initiated by a public/portal user + # The key is that we need to simulate how the appointment controller creates events + + # Create appointment event as it would be created during booking + appointment_event = ( + self.env["calendar.event"] + .with_user(self.test_user) + .create( + { + "name": f"{self.appointment_type.name} with {self.test_user.name}", + "start": datetime.now() + timedelta(days=1), + "stop": datetime.now() + timedelta(days=1, hours=1), + "user_id": self.test_user.id, + "partner_ids": [(4, self.test_partner.id)], + "appointment_type_id": self.appointment_type.id, + "appointment_status": "booked", + } + ) + ) + + # Debug output + print(f"\nDEBUG Appointment Booking:") + print(f" save_event called: {mock_calendar.save_event.called}") + print(f" save_event call_count: {mock_calendar.save_event.call_count}") + print(f" caldav_uid: {appointment_event.caldav_uid}") + print(f" user_id: {appointment_event.user_id.name}") + print(f" is_caldav_enabled: {appointment_event._is_caldav_enabled()}") + + # THE BUG: Appointment events should be synced to CalDAV on creation + # This will FAIL if the bug exists + self.assertTrue( + mock_calendar.save_event.called, + "BUG: Appointment event was NOT synced to CalDAV server on creation! " + "This will cause it to be deleted on next polling round.", + ) + self.assertTrue( + appointment_event.caldav_uid, + "BUG: Appointment event has no caldav_uid! Event will be deleted on next sync.", + )