diff --git a/backend/.env.example b/backend/.env.example index 204695090..a6423959d 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -10,6 +10,8 @@ APP_ENV=dev APP_ADMIN_ALLOW_LIST= APP_SETUP APP_ALLOW_FIRST_TIME_REGISTER= +# Enable Google Calendar native invites (True/False). Defaults based on APP_ENV if unset. +GOOGLE_INVITE_ENABLED= # -- BACKEND -- BACKEND_URL=http://localhost:5000 @@ -95,6 +97,7 @@ GOOGLE_AUTH_CLIENT_ID= GOOGLE_AUTH_SECRET= GOOGLE_AUTH_PROJECT_ID= GOOGLE_AUTH_CALLBACK=http://localhost:5000/google/callback +GOOGLE_CHANNEL_TTL_IN_SECONDS=604800 # 7 days # -- Zoom API -- ZOOM_API_ENABLED=False diff --git a/backend/.env.test b/backend/.env.test index dbd9223ba..70348c8bd 100644 --- a/backend/.env.test +++ b/backend/.env.test @@ -53,6 +53,7 @@ GOOGLE_AUTH_CLIENT_ID= GOOGLE_AUTH_SECRET= GOOGLE_AUTH_PROJECT_ID= GOOGLE_AUTH_CALLBACK=http://localhost:5000/google/callback +GOOGLE_CHANNEL_TTL_IN_SECONDS=604800 # 7 days # -- TB ACCOUNTS -- TB_ACCOUNTS_CALDAV_URL=https://stage-thundermail.com @@ -70,6 +71,7 @@ SIGNED_SECRET=test-secret-pls-ignore SENTRY_DSN= # Possible values: prod, dev, test APP_ENV=test +GOOGLE_INVITE_ENABLED=True # Possible values: password, fxa AUTH_SCHEME=password diff --git a/backend/src/appointment/celery_app.py b/backend/src/appointment/celery_app.py index 6444da263..917bf8e31 100644 --- a/backend/src/appointment/celery_app.py +++ b/backend/src/appointment/celery_app.py @@ -40,6 +40,9 @@ def create_celery_app() -> Celery: sentry_sdk.set_extra('CELERY_RESULT_EXPIRES', result_expires) sentry_sdk.set_extra('CELERY_TASK_ALWAYS_EAGER', task_always_eager) + google_channel_ttl = float(os.getenv('GOOGLE_CHANNEL_TTL_IN_SECONDS', 604800)) + google_channel_renew_interval = google_channel_ttl - 86400 # 1 day buffer + app = Celery('appointment') app.config_from_object({ @@ -59,6 +62,10 @@ def create_celery_app() -> Celery: 'task': 'appointment.tasks.health.heartbeat', 'schedule': 60.0, }, + 'renew-google-channels': { + 'task': 'appointment.tasks.google.renew_google_channels', + 'schedule': google_channel_renew_interval, + }, }, 'beat_schedule_filename': 'celerybeat-appointment-schedule', }) diff --git a/backend/src/appointment/commands/backfill_google_channels.py b/backend/src/appointment/commands/backfill_google_channels.py new file mode 100644 index 000000000..42904d4f5 --- /dev/null +++ b/backend/src/appointment/commands/backfill_google_channels.py @@ -0,0 +1,101 @@ +"""One-off command to set up Google Calendar watch channels for existing connected calendars.""" + +import json +import logging +import uuid +from datetime import datetime, timezone + +from google.oauth2.credentials import Credentials + +from ..controller.google_watch import get_webhook_url +from ..database import repo, models +from ..dependencies.database import get_engine_and_session +from ..dependencies.google import get_google_client +from ..main import _common_setup + + +def run(): + _common_setup() + google_client = get_google_client() + + _, SessionLocal = get_engine_and_session() + db = SessionLocal() + + webhook_url = get_webhook_url() + if not webhook_url: + print('BACKEND_URL not set, aborting.') + db.close() + return + + # Find connected Google calendars that are the default in a schedule + # and don't yet have a watch channel + schedule_calendar_ids = [ + s.calendar_id for s in + db.query(models.Schedule.calendar_id).filter(models.Schedule.calendar_id != None).all() # noqa: E711 + ] + + all_calendars = db.query(models.Calendar).filter( + models.Calendar.provider == models.CalendarProvider.google, + models.Calendar.connected == True, # noqa: E712 + models.Calendar.id.in_(schedule_calendar_ids), + ).all() + + candidates = [] + for cal in all_calendars: + existing = repo.google_calendar_channel.get_by_calendar_id(db, cal.id) + if not existing: + candidates.append(cal) + + print(f'Found {len(candidates)} connected Google calendar(s) without a watch channel.') + + created = 0 + skipped = 0 + failed = 0 + + for calendar in candidates: + ext_conn = calendar.external_connection + if not ext_conn or not ext_conn.token: + print(f' Calendar {calendar.id}: no external connection or token, skipping.') + skipped += 1 + continue + + try: + token = Credentials.from_authorized_user_info( + json.loads(ext_conn.token), google_client.SCOPES + ) + except Exception as e: + print(f' Calendar {calendar.id}: failed to parse token ({e}), skipping.') + skipped += 1 + continue + + try: + state = str(uuid.uuid4()) + response = google_client.watch_events(calendar.user, webhook_url, token, state=state) + if not response: + print(f' Calendar {calendar.id}: watch_events returned no response.') + failed += 1 + continue + + expiration_ms = int(response.get('expiration', 0)) + expiration_dt = datetime.fromtimestamp(expiration_ms / 1000, tz=timezone.utc) + + sync_token = google_client.get_initial_sync_token(calendar.user, token) + + repo.google_calendar_channel.create( + db, + calendar_id=calendar.id, + channel_id=response['id'], + resource_id=response['resourceId'], + expiration=expiration_dt, + state=state, + sync_token=sync_token, + ) + created += 1 + print(f' Calendar {calendar.id}: channel created (expires {expiration_dt}).') + except Exception as e: + print(f' Calendar {calendar.id}: failed ({e}).') + logging.error(f'[backfill_google_channels] Error for calendar {calendar.id}: {e}') + failed += 1 + + db.close() + print(f'\nBackfill complete: {created} created, {skipped} skipped, {failed} failed.') diff --git a/backend/src/appointment/commands/renew_google_channels.py b/backend/src/appointment/commands/renew_google_channels.py new file mode 100644 index 000000000..eaf736deb --- /dev/null +++ b/backend/src/appointment/commands/renew_google_channels.py @@ -0,0 +1,87 @@ +"""Renew expiring Google Calendar push notification channels. + +Run periodically (e.g., daily) to ensure channels don't expire. +Google channels typically last ~7 days, so daily renewal keeps a buffer. +""" + +import json +import logging +import uuid +from datetime import datetime, timedelta, timezone + +from google.oauth2.credentials import Credentials + +from ..controller.google_watch import get_webhook_url +from ..database import repo +from ..dependencies.database import get_engine_and_session +from ..dependencies.google import get_google_client +from ..main import _common_setup + + +def run(): + _common_setup() + google_client = get_google_client() + + _, SessionLocal = get_engine_and_session() + db = SessionLocal() + + webhook_url = get_webhook_url() + if not webhook_url: + logging.error('[renew_google_channels] BACKEND_URL not set, aborting') + db.close() + return + + # Renew channels that expire within the next 24 hours + threshold = datetime.now(tz=timezone.utc) + timedelta(hours=24) + channels = repo.google_calendar_channel.get_expiring(db, before=threshold) + + renewed = 0 + failed = 0 + + for channel in channels: + calendar = channel.calendar + if not calendar or not calendar.connected: + repo.google_calendar_channel.delete(db, channel) + continue + + external_connection = calendar.external_connection + if not external_connection or not external_connection.token: + repo.google_calendar_channel.delete(db, channel) + continue + + token = Credentials.from_authorized_user_info( + json.loads(external_connection.token), google_client.SCOPES + ) + + # Stop the old channel + try: + google_client.stop_channel(channel.channel_id, channel.resource_id, token) + except Exception as e: + logging.warning(f'[renew_google_channels] Failed to stop old channel {channel.channel_id}: {e}') + + # Create a new channel + try: + new_state = str(uuid.uuid4()) + response = google_client.watch_events(calendar.user, webhook_url, token, state=new_state) + if response: + expiration_ms = int(response.get('expiration', 0)) + expiration_dt = datetime.fromtimestamp(expiration_ms / 1000, tz=timezone.utc) + + repo.google_calendar_channel.update_expiration( + db, + channel, + new_channel_id=response['id'], + new_resource_id=response['resourceId'], + new_expiration=expiration_dt, + new_state=new_state, + ) + renewed += 1 + else: + repo.google_calendar_channel.delete(db, channel) + failed += 1 + except Exception as e: + logging.error(f'[renew_google_channels] Failed to renew channel for calendar {calendar.id}: {e}') + failed += 1 + + db.close() + print(f'Channel renewal complete: {renewed} renewed, {failed} failed, {len(channels)} total processed') diff --git a/backend/src/appointment/controller/apis/google_client.py b/backend/src/appointment/controller/apis/google_client.py index f839873d6..1240d091f 100644 --- a/backend/src/appointment/controller/apis/google_client.py +++ b/backend/src/appointment/controller/apis/google_client.py @@ -1,5 +1,6 @@ import logging import os +import uuid from datetime import datetime import sentry_sdk @@ -12,7 +13,12 @@ from ...database.models import CalendarProvider from ...database.schemas import CalendarConnection from ...defines import DATETIMEFMT -from ...exceptions.calendar import EventNotCreatedException, EventNotDeletedException, FreeBusyTimeException +from ...exceptions.calendar import ( + EventNotCreatedException, + EventNotDeletedException, + EventNotPatchedException, + FreeBusyTimeException +) from ...exceptions.google_api import GoogleScopeChanged, GoogleInvalidCredentials @@ -235,6 +241,16 @@ def list_events(self, calendar_id, time_min, time_max, token): return items + def get_event(self, calendar_id, event_id, token): + """Retrieve a single event by ID. + Ref: https://developers.google.com/calendar/api/v3/reference/events/get""" + with build('calendar', 'v3', credentials=token, cache_discovery=False) as service: + try: + return service.events().get(calendarId=calendar_id, eventId=event_id).execute() + except HttpError as e: + logging.warning(f'[google_client.get_event] Request Error: {e.status_code}/{e.error_details}') + return None + def save_event(self, calendar_id, body, token): response = None with build('calendar', 'v3', credentials=token, cache_discovery=False) as service: @@ -246,17 +262,150 @@ def save_event(self, calendar_id, body, token): return response - def delete_event(self, calendar_id, event_id, token): + def insert_event(self, calendar_id, body, token, send_updates='all'): + response = None + with build('calendar', 'v3', credentials=token, cache_discovery=False) as service: + try: + response = service.events().insert( + calendarId=calendar_id, body=body, sendUpdates=send_updates + ).execute() + except HttpError as e: + logging.warning(f'[google_client.insert_event] Request Error: {e.status_code}/{e.error_details}') + raise EventNotCreatedException() + + return response + + def patch_event(self, calendar_id, event_id, body, token, send_updates='all'): response = None with build('calendar', 'v3', credentials=token, cache_discovery=False) as service: try: - response = service.events().delete(calendarId=calendar_id, eventId=event_id).execute() + response = ( + service.events() + .patch(calendarId=calendar_id, eventId=event_id, body=body, sendUpdates=send_updates) + .execute() + ) + except HttpError as e: + logging.warning(f'[google_client.patch_event] Request Error: {e.status_code}/{e.error_details}') + raise EventNotPatchedException() + + return response + + def delete_event(self, calendar_id, event_id, token, send_updates='none'): + response = None + with build('calendar', 'v3', credentials=token, cache_discovery=False) as service: + try: + response = ( + service.events() + .delete(calendarId=calendar_id, eventId=event_id, sendUpdates=send_updates) + .execute() + ) except HttpError as e: logging.warning(f'[google_client.delete_event] Request Error: {e.status_code}/{e.error_details}') raise EventNotDeletedException() return response + def watch_events(self, calendar_id, webhook_url, token, state: str): + """Register a push notification channel for calendar event changes. + Ref: https://developers.google.com/calendar/api/v3/reference/events/watch""" + channel_id = str(uuid.uuid4()) + ttl = os.getenv('GOOGLE_CHANNEL_TTL_IN_SECONDS', 604800) # 7 days + + body = { + 'id': channel_id, + 'type': 'web_hook', + 'address': webhook_url, + 'token': state, + 'params': { + 'ttl': str(ttl) + }, + } + + with build('calendar', 'v3', credentials=token, cache_discovery=False) as service: + try: + response = service.events().watch( + calendarId=calendar_id, + body=body, + ).execute() + except HttpError as e: + logging.error(f'[google_client.watch_events] Request Error: {e.status_code}/{e.error_details}') + return None + except Exception as e: + logging.error(f'[google_client.watch_events] Error: {e}') + return None + + return response + + def stop_channel(self, channel_id, resource_id, token): + """Stop a push notification channel. + Ref: https://developers.google.com/calendar/api/v3/reference/channels/stop""" + with build('calendar', 'v3', credentials=token, cache_discovery=False) as service: + try: + service.channels().stop( + body={ + 'id': channel_id, + 'resourceId': resource_id, + }, + ).execute() + except HttpError as e: + logging.warning(f'[google_client.stop_channel] Request Error: {e.status_code}/{e.error_details}') + + def list_events_sync(self, calendar_id, sync_token, token): + """Fetch events that changed since the last sync token. + Ref: https://developers.google.com/calendar/api/v3/reference/events/list (incremental sync)""" + items = [] + next_sync_token = None + + with build('calendar', 'v3', credentials=token, cache_discovery=False) as service: + page_token = None + while True: + try: + params = { + 'calendarId': calendar_id, + 'syncToken': sync_token, + } + if page_token: + params['pageToken'] = page_token + + response = service.events().list(**params).execute() + items += response.get('items', []) + page_token = response.get('nextPageToken') + if not page_token: + next_sync_token = response.get('nextSyncToken') + break + except HttpError as e: + if e.status_code == 410: + logging.info('[google_client.list_events_sync] Sync token expired, full sync needed') + return None, None + logging.warning( + f'[google_client.list_events_sync] Request Error: {e.status_code}/{e.error_details}' + ) + break + + return items, next_sync_token + + def get_initial_sync_token(self, calendar_id, token): + """Perform an initial list to obtain a sync token without fetching all events.""" + with build('calendar', 'v3', credentials=token, cache_discovery=False) as service: + try: + page_token = None + while True: + params = { + 'calendarId': calendar_id, + 'maxResults': 250, + } + if page_token: + params['pageToken'] = page_token + response = service.events().list(**params).execute() + page_token = response.get('nextPageToken') + if not page_token: + return response.get('nextSyncToken') + except HttpError as e: + logging.error( + f'[google_client.get_initial_sync_token] Request Error: {e.status_code}/{e.error_details}' + ) + return None + def sync_calendars(self, db, subscriber_id: int, token, external_connection_id: int | None = None): # Grab all the Google calendars calendars = self.list_calendars(token) diff --git a/backend/src/appointment/controller/calendar.py b/backend/src/appointment/controller/calendar.py index 2f3592683..a89af27c6 100644 --- a/backend/src/appointment/controller/calendar.py +++ b/backend/src/appointment/controller/calendar.py @@ -268,6 +268,8 @@ def save_event( attendee: schemas.AttendeeBase, organizer: schemas.Subscriber, organizer_email: str, + send_google_notification: bool = False, + booking_confirmation: bool = False, ) -> schemas.Event: """add a new event to the connected calendar""" @@ -281,26 +283,52 @@ def save_event( if event.location.phone: description.append(l10n('join-phone', {'phone': event.location.phone}, lang=organizer_language)) - body = { - 'iCalUID': event.uuid.hex, - 'summary': event.title, - 'location': event.location.url if event.location.url else None, - 'description': '\n'.join(description), - 'start': {'dateTime': event.start.isoformat()}, - 'end': {'dateTime': event.end.isoformat()}, - 'attendees': [ - {'displayName': organizer.name, 'email': organizer_email, 'responseStatus': 'accepted'}, - {'displayName': attendee.name, 'email': attendee.email, 'responseStatus': 'accepted'}, - ], - 'organizer': { - 'displayName': organizer.name, - 'email': self.remote_calendar_id, - }, - } - - new_event = self.google_client.save_event( - calendar_id=self.remote_calendar_id, body=body, token=self.google_token - ) + if send_google_notification: + if booking_confirmation: + attendees = [ + {'displayName': organizer.name, 'email': organizer_email, 'responseStatus': 'needsAction'}, + {'displayName': attendee.name, 'email': attendee.email, 'responseStatus': 'needsAction'}, + ] + else: + attendees = [ + {'displayName': organizer.name, 'email': organizer_email, 'responseStatus': 'accepted'}, + {'displayName': attendee.name, 'email': attendee.email, 'responseStatus': 'needsAction'}, + ] + + body = { + 'summary': event.title, + 'location': event.location.url if event.location.url else None, + 'description': '\n'.join(description), + 'start': {'dateTime': event.start.isoformat()}, + 'end': {'dateTime': event.end.isoformat()}, + 'status': 'tentative' if booking_confirmation else 'confirmed', + 'attendees': attendees, + } + + new_event = self.google_client.insert_event( + calendar_id=self.remote_calendar_id, body=body, token=self.google_token + ) + else: + body = { + 'iCalUID': event.uuid.hex, + 'summary': event.title, + 'location': event.location.url if event.location.url else None, + 'description': '\n'.join(description), + 'start': {'dateTime': event.start.isoformat()}, + 'end': {'dateTime': event.end.isoformat()}, + 'attendees': [ + {'displayName': organizer.name, 'email': organizer_email, 'responseStatus': 'accepted'}, + {'displayName': attendee.name, 'email': attendee.email, 'responseStatus': 'accepted'}, + ], + 'organizer': { + 'displayName': organizer.name, + 'email': self.remote_calendar_id, + }, + } + + new_event = self.google_client.save_event( + calendar_id=self.remote_calendar_id, body=body, token=self.google_token + ) # Fill in the external_id so we can delete events later! event.external_id = new_event.get('id') @@ -309,9 +337,54 @@ def save_event( return event - def delete_event(self, uid: str): + def confirm_event( + self, event_id: str, event: schemas.Event = None, organizer_language: str = None, + ): + """Patch a tentative event to confirmed status, notifying attendees. + + When *event* is provided the patch also updates summary, location, + and description so that a newly-created meeting link is visible on + the Google Calendar event. + """ + body = {'status': 'confirmed'} + + if event: + body['summary'] = event.title + if event.location and event.location.url: + body['location'] = event.location.url + + lang = organizer_language or FALLBACK_LOCALE + description = [event.description] if event.description else [] + if event.location and event.location.url: + description.append(l10n('join-online', {'url': event.location.url}, lang=lang)) + if description: + body['description'] = '\n'.join(description) + + remote_event = self.google_client.get_event( + calendar_id=self.remote_calendar_id, + event_id=event_id, + token=self.google_token, + ) + if remote_event and remote_event.get('attendees'): + attendees = remote_event['attendees'] + for att in attendees: + if att.get('self'): + att['responseStatus'] = 'accepted' + body['attendees'] = attendees + + self.google_client.patch_event( + calendar_id=self.remote_calendar_id, + event_id=event_id, + body=body, + token=self.google_token, + ) + self.bust_cached_events() + + def delete_event(self, uid: str, send_updates: str = 'none'): """Delete remote event of given external_id""" - self.google_client.delete_event(calendar_id=self.remote_calendar_id, event_id=uid, token=self.google_token) + self.google_client.delete_event( + calendar_id=self.remote_calendar_id, event_id=uid, token=self.google_token, send_updates=send_updates + ) self.bust_cached_events() def delete_events(self, start): @@ -549,7 +622,13 @@ def list_events(self, start, end): return events def save_event( - self, event: schemas.Event, attendee: schemas.AttendeeBase, organizer: schemas.Subscriber, organizer_email: str + self, + event: schemas.Event, + attendee: schemas.AttendeeBase, + organizer: schemas.Subscriber, + organizer_email: str, + send_google_notification: bool = False, + booking_confirmation: bool = False, ): """add a new event to the connected calendar""" calendar = self.client.calendar(url=self.url) @@ -571,7 +650,7 @@ def save_event( return event - def delete_event(self, uid: str): + def delete_event(self, uid: str, send_updates: str = 'none'): """Delete remote event of given uid""" event = self.client.calendar(url=self.url).event_by_uid(uid) event.delete() @@ -611,12 +690,15 @@ def create_vevent( cal = Calendar() cal.add('prodid', '-//thunderbird.net/Thunderbird Appointment//EN') cal.add('version', '2.0') + cal.add('calscale', 'GREGORIAN') cal.add('method', 'CANCEL' if event_status == RemoteEventState.CANCELLED.value else 'REQUEST') org = vCalAddress('MAILTO:' + organizer.preferred_email) org.params['cn'] = vText(organizer.preferred_email) org.params['role'] = vText('CHAIR') + now = datetime.now(UTC) + event = Event() event.add('uid', appointment.uuid.hex) event.add('summary', appointment.title) @@ -625,8 +707,12 @@ def create_vevent( 'dtend', slot.start.replace(tzinfo=timezone.utc) + timedelta(minutes=slot.duration), ) - event.add('dtstamp', datetime.now(UTC)) + event.add('dtstamp', now) + event.add('created', now) + event.add('last-modified', now) + event.add('sequence', 0) event.add('status', event_status) + event.add('transp', 'OPAQUE') event['description'] = appointment.details event['organizer'] = org @@ -638,9 +724,9 @@ def create_vevent( else: attendee.params['cn'] = vText(slot.attendee.email) - # Set the attendee status to accepted by default - # since they are the ones who are submitting the request - attendee.params['partstat'] = vText(RemoteEventState.ACCEPTED.value) + attendee.params['cutype'] = vText('INDIVIDUAL') + attendee.params['partstat'] = vText('NEEDS-ACTION') + attendee.params['rsvp'] = vText('TRUE') attendee.params['role'] = vText('REQ-PARTICIPANT') event.add('attendee', attendee) diff --git a/backend/src/appointment/controller/google_watch.py b/backend/src/appointment/controller/google_watch.py new file mode 100644 index 000000000..1d588d0f9 --- /dev/null +++ b/backend/src/appointment/controller/google_watch.py @@ -0,0 +1,143 @@ +"""Shared helpers for managing Google Calendar push notification (watch) channels.""" + +import json +import logging +import os +import uuid +from datetime import datetime, timezone + +from google.oauth2.credentials import Credentials +from sqlalchemy.orm import Session + +from .apis.google_client import GoogleClient +from ..database import repo, models + + +def get_webhook_url() -> str | None: + """Build the Google Calendar webhook URL from the backend URL, requires https.""" + backend_url = os.getenv('BACKEND_URL') + if not backend_url: + return None + return f'{backend_url}/webhooks/google-calendar' + + +def get_google_token(google_client: GoogleClient, external_connection: models.ExternalConnections): + """Build Google Credentials from an external connection's stored token.""" + if not external_connection.token: + return None + + return Credentials.from_authorized_user_info( + json.loads(external_connection.token), google_client.SCOPES + ) + + +def setup_watch_channel(db: Session, google_client: GoogleClient, calendar: models.Calendar) -> bool: + """Register a push notification channel for a single Google calendar. + Returns True if a channel exists (or was created), False on failure.""" + if not google_client or calendar.provider != models.CalendarProvider.google: + return False + + webhook_url = get_webhook_url() + if not webhook_url: + logging.warning('[google_watch] BACKEND_URL not set, skipping watch channel setup') + return False + + existing = repo.google_calendar_channel.get_by_calendar_id(db, calendar.id) + if existing: + return True + + external_connection = calendar.external_connection + if not external_connection or not external_connection.token: + return False + + try: + token = get_google_token(google_client, external_connection) + except (json.JSONDecodeError, Exception) as e: + logging.error(f'[google_watch] Could not parse token for calendar {calendar.id}: {e}') + return False + + if not token: + logging.error(f'[google_watch] Missing token for calendar {calendar.id}') + return False + + try: + state = str(uuid.uuid4()) + response = google_client.watch_events(calendar.user, webhook_url, token, state=state) + if response: + expiration_ms = int(response.get('expiration', 0)) + expiration_dt = datetime.fromtimestamp(expiration_ms / 1000, tz=timezone.utc) + + sync_token = google_client.get_initial_sync_token(calendar.user, token) + + repo.google_calendar_channel.create( + db, + calendar_id=calendar.id, + channel_id=response['id'], + resource_id=response['resourceId'], + expiration=expiration_dt, + state=state, + sync_token=sync_token, + ) + except Exception as e: + logging.warning(f'[google_watch] Failed to set up watch channel for calendar {calendar.id}: {e}') + return False + + return True + + +def teardown_watch_channel(db: Session, google_client: GoogleClient | None, calendar: models.Calendar) -> bool: + """Stop and delete the watch channel for a single Google calendar. + Returns True if the channel was deleted, False if there was nothing to remove.""" + channel = repo.google_calendar_channel.get_by_calendar_id(db, calendar.id) + if not channel: + return False + + if google_client and calendar.external_connection and calendar.external_connection.token: + try: + token = get_google_token(google_client, calendar.external_connection) + + if not token: + logging.error(f'[google_watch] Missing token for channel {channel.channel_id}') + else: + google_client.stop_channel(channel.channel_id, channel.resource_id, token) + except Exception as e: + logging.warning(f'[google_watch] Failed to stop channel {channel.channel_id}: {e}') + + repo.google_calendar_channel.delete(db, channel) + return True + + +def teardown_watch_channels_for_connection( + db: Session, + google_client: GoogleClient | None, + google_connection: models.ExternalConnections, +): + """Stop and remove all watch channels for calendars under a Google connection.""" + if not google_connection or not google_connection.token: + return + + token = None + if google_client: + try: + token = get_google_token(google_client, google_connection) + except (json.JSONDecodeError, Exception) as e: + logging.error(f'[google_watch] Could not parse token for channel teardown: {e}') + + calendars = ( + db.query(models.Calendar) + .filter(models.Calendar.external_connection_id == google_connection.id) + .all() + ) + + for calendar in calendars: + channel = repo.google_calendar_channel.get_by_calendar_id(db, calendar.id) + if not channel: + continue + + if google_client and token: + try: + google_client.stop_channel(channel.channel_id, channel.resource_id, token) + except Exception as e: + logging.warning(f'[google_watch] Failed to stop channel {channel.channel_id}: {e}') + + repo.google_calendar_channel.delete(db, channel) diff --git a/backend/src/appointment/controller/zoom.py b/backend/src/appointment/controller/zoom.py index 8a3fe6257..4b0a939ec 100644 --- a/backend/src/appointment/controller/zoom.py +++ b/backend/src/appointment/controller/zoom.py @@ -1,7 +1,11 @@ +import logging + +import sentry_sdk from sqlalchemy.orm import Session -from appointment.database import repo, models -from appointment.database.models import ExternalConnectionType +from ..database import repo, models +from ..database.models import ExternalConnectionType +from ..dependencies.zoom import get_zoom_client def update_schedules_meeting_link_provider(db: Session, subscriber_id: int) -> bool: @@ -14,6 +18,33 @@ def update_schedules_meeting_link_provider(db: Session, subscriber_id: int) -> b return True +def create_meeting_link( + db: Session, + slot: models.Slot, + subscriber: models.Subscriber, + title: str, +) -> str | None: + """Create a Zoom meeting and persist the link on the slot. + + Returns the join URL on success, or ``None`` on any failure. + """ + try: + zoom_client = get_zoom_client(subscriber) + response = zoom_client.create_meeting(title, slot.start.isoformat(), slot.duration, subscriber.timezone) + if 'id' in response: + join_url = zoom_client.get_meeting(response['id'])['join_url'] + slot.meeting_link_id = response['id'] + slot.meeting_link_url = join_url + db.add(slot) + db.commit() + return join_url + except Exception as err: + logging.error(f'[zoom] Zoom meeting creation error: {err}') + if sentry_sdk.is_initialized(): + sentry_sdk.capture_exception(err) + return None + + def disconnect(db: Session, subscriber_id: int, type_id: str) -> bool: """Disconnects a zoom external connection from a given subscriber id and zoom type id""" repo.external_connection.delete_by_type(db, subscriber_id, ExternalConnectionType.zoom, type_id) diff --git a/backend/src/appointment/database/models.py b/backend/src/appointment/database/models.py index c77f2629a..56afdcfcb 100644 --- a/backend/src/appointment/database/models.py +++ b/backend/src/appointment/database/models.py @@ -235,6 +235,9 @@ class Calendar(Base): 'Appointment', cascade='all,delete', back_populates='calendar' ) schedules: Mapped[list['Schedule']] = relationship('Schedule', cascade='all,delete', back_populates='calendar') + google_channel: Mapped['GoogleCalendarChannel'] = relationship( + 'GoogleCalendarChannel', cascade='all,delete', back_populates='calendar', uselist=False + ) def __str__(self): return f'Calendar: {self.id}' @@ -454,3 +457,22 @@ class ExternalConnections(Base): def __str__(self): return f'External Connection: {self.id}' + + +class GoogleCalendarChannel(Base): + """Tracks Google Calendar push notification channels for real-time RSVP sync.""" + + __tablename__ = 'google_calendar_channels' + + id = Column(Integer, primary_key=True, index=True) + calendar_id = Column(Integer, ForeignKey('calendars.id'), unique=True) + channel_id = Column(String, index=True) + resource_id = Column(String) + expiration = Column(DateTime) + sync_token = Column(String, nullable=True) + state = Column(encrypted_type(String, length=36), nullable=True) + + calendar: Mapped[Calendar] = relationship('Calendar', back_populates='google_channel') + + def __str__(self): + return f'GoogleCalendarChannel: {self.channel_id}' diff --git a/backend/src/appointment/database/repo/__init__.py b/backend/src/appointment/database/repo/__init__.py index 4f3cf4667..f305d0344 100644 --- a/backend/src/appointment/database/repo/__init__.py +++ b/backend/src/appointment/database/repo/__init__.py @@ -1 +1,13 @@ -from . import appointment, attendee, availability, calendar, external_connection, schedule, slot, subscriber # noqa: F401 +# ruff: noqa + +from . import ( + appointment, + attendee, + availability, + calendar, + external_connection, + google_calendar_channel, + schedule, + slot, + subscriber, +) diff --git a/backend/src/appointment/database/repo/google_calendar_channel.py b/backend/src/appointment/database/repo/google_calendar_channel.py new file mode 100644 index 000000000..f919a3f66 --- /dev/null +++ b/backend/src/appointment/database/repo/google_calendar_channel.py @@ -0,0 +1,85 @@ +"""Module: repo.google_calendar_channel + +Repository providing CRUD functions for GoogleCalendarChannel database models. +""" + +from datetime import datetime + +from sqlalchemy.orm import Session +from .. import models + + +def get_by_calendar_id(db: Session, calendar_id: int) -> models.GoogleCalendarChannel | None: + return ( + db.query(models.GoogleCalendarChannel) + .filter(models.GoogleCalendarChannel.calendar_id == calendar_id) + .first() + ) + + +def get_by_channel_id(db: Session, channel_id: str) -> models.GoogleCalendarChannel | None: + return ( + db.query(models.GoogleCalendarChannel) + .filter(models.GoogleCalendarChannel.channel_id == channel_id) + .first() + ) + + +def get_expiring(db: Session, before: datetime) -> list[models.GoogleCalendarChannel]: + return ( + db.query(models.GoogleCalendarChannel) + .filter(models.GoogleCalendarChannel.expiration < before) + .all() + ) + + +def create( + db: Session, + calendar_id: int, + channel_id: str, + resource_id: str, + expiration: datetime, + state: str, + sync_token: str | None = None, +) -> models.GoogleCalendarChannel: + channel = models.GoogleCalendarChannel( + calendar_id=calendar_id, + channel_id=channel_id, + resource_id=resource_id, + expiration=expiration, + sync_token=sync_token, + state=state, + ) + db.add(channel) + db.commit() + db.refresh(channel) + return channel + + +def update_sync_token(db: Session, channel: models.GoogleCalendarChannel, sync_token: str): + channel.sync_token = sync_token + db.commit() + db.refresh(channel) + return channel + + +def update_expiration( + db: Session, + channel: models.GoogleCalendarChannel, + new_channel_id: str, + new_resource_id: str, + new_expiration: datetime, + new_state: str, +): + channel.channel_id = new_channel_id + channel.resource_id = new_resource_id + channel.expiration = new_expiration + channel.state = new_state + db.commit() + db.refresh(channel) + return channel + + +def delete(db: Session, channel: models.GoogleCalendarChannel): + db.delete(channel) + db.commit() diff --git a/backend/src/appointment/exceptions/calendar.py b/backend/src/appointment/exceptions/calendar.py index da4688ae9..5fe49c9f4 100644 --- a/backend/src/appointment/exceptions/calendar.py +++ b/backend/src/appointment/exceptions/calendar.py @@ -4,6 +4,11 @@ class EventNotCreatedException(Exception): pass +class EventNotPatchedException(Exception): + """Raise if an event cannot be patched on a remote calendar""" + + pass + class EventNotDeletedException(Exception): """Raise if an event cannot be deleted on a remote calendar""" diff --git a/backend/src/appointment/migrations/versions/2026_02_23_1200-a1b2c3d4e5f6_add_google_calendar_channels_.py b/backend/src/appointment/migrations/versions/2026_02_23_1200-a1b2c3d4e5f6_add_google_calendar_channels_.py new file mode 100644 index 000000000..b022b53f7 --- /dev/null +++ b/backend/src/appointment/migrations/versions/2026_02_23_1200-a1b2c3d4e5f6_add_google_calendar_channels_.py @@ -0,0 +1,33 @@ +"""add google_calendar_channels table + +Revision ID: a1b2c3d4e5f6 +Revises: 17792ef315c1 +Create Date: 2026-02-23 12:00:00.000000 + +""" + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'a1b2c3d4e5f6' +down_revision = '17792ef315c1' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + 'google_calendar_channels', + sa.Column('id', sa.Integer, primary_key=True, index=True), + sa.Column('calendar_id', sa.Integer, sa.ForeignKey('calendars.id'), unique=True), + sa.Column('channel_id', sa.String, index=True), + sa.Column('resource_id', sa.String), + sa.Column('expiration', sa.DateTime), + sa.Column('sync_token', sa.String, nullable=True), + ) + + +def downgrade() -> None: + op.drop_table('google_calendar_channels') diff --git a/backend/src/appointment/migrations/versions/2026_04_01_1200-b3c4d5e6f7a8_add_state_to_google_calendar_.py b/backend/src/appointment/migrations/versions/2026_04_01_1200-b3c4d5e6f7a8_add_state_to_google_calendar_.py new file mode 100644 index 000000000..02b61b529 --- /dev/null +++ b/backend/src/appointment/migrations/versions/2026_04_01_1200-b3c4d5e6f7a8_add_state_to_google_calendar_.py @@ -0,0 +1,29 @@ +"""add state to google_calendar_channels + +Revision ID: b3c4d5e6f7a8 +Revises: d9c5594694c5 +Create Date: 2026-04-01 12:00:00.000000 + +""" + +from alembic import op +import sqlalchemy as sa + +from appointment.database.models import encrypted_type + +# revision identifiers, used by Alembic. +revision = 'b3c4d5e6f7a8' +down_revision = 'd9c5594694c5' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column( + 'google_calendar_channels', + sa.Column('state', encrypted_type(sa.String, length=36), nullable=True), + ) + + +def downgrade() -> None: + op.drop_column('google_calendar_channels', 'state') diff --git a/backend/src/appointment/routes/commands.py b/backend/src/appointment/routes/commands.py index fcb7aa04e..0f3d376ba 100644 --- a/backend/src/appointment/routes/commands.py +++ b/backend/src/appointment/routes/commands.py @@ -4,7 +4,14 @@ import os import typer -from ..commands import update_db, download_legal, setup, generate_documentation_pages +from ..commands import ( + update_db, + download_legal, + setup, + generate_documentation_pages, + renew_google_channels, + backfill_google_channels, +) router = typer.Typer() @@ -44,3 +51,21 @@ def generate_docs(): @router.command('setup') def setup_app(): setup.run() + + +@router.command('renew-google-channels') +def renew_channels(): + try: + with cron_lock('renew_google_channels'): + renew_google_channels.run() + except FileExistsError: + print('renew-google-channels is already running, skipping.') + + +@router.command('backfill-google-channels') +def backfill_channels(): + try: + with cron_lock('backfill_google_channels'): + backfill_google_channels.run() + except FileExistsError: + print('backfill-google-channels is already running, skipping.') diff --git a/backend/src/appointment/routes/google.py b/backend/src/appointment/routes/google.py index 0826f5956..179674d69 100644 --- a/backend/src/appointment/routes/google.py +++ b/backend/src/appointment/routes/google.py @@ -4,6 +4,7 @@ from fastapi.responses import RedirectResponse from ..controller.apis.google_client import GoogleClient +from ..controller.google_watch import teardown_watch_channels_for_connection from ..database import repo, schemas, models from sqlalchemy.orm import Session @@ -151,6 +152,7 @@ def disconnect_account( request_body: schemas.DisconnectGoogleAccountRequest, db: Session = Depends(get_db), subscriber: Subscriber = Depends(get_subscriber), + google_client: GoogleClient = Depends(get_google_client), ): """Disconnects a google account. Removes associated data from our services and deletes the connection details.""" google_connection = subscriber.get_external_connection(ExternalConnectionType.google, request_body.type_id) @@ -162,6 +164,9 @@ def disconnect_account( if schedule.calendar and schedule.calendar.external_connection_id == google_connection.id: raise ConnectionContainsDefaultCalendarException() + # Tear down watch channels before deleting calendars + teardown_watch_channels_for_connection(db, google_client, google_connection) + # Remove all of the google calendars on their given google connection repo.calendar.delete_by_subscriber_and_provider( db, subscriber.id, provider=models.CalendarProvider.google, external_connection_id=google_connection.id diff --git a/backend/src/appointment/routes/schedule.py b/backend/src/appointment/routes/schedule.py index d8ea440ef..3107a6aab 100644 --- a/backend/src/appointment/routes/schedule.py +++ b/backend/src/appointment/routes/schedule.py @@ -3,14 +3,13 @@ import os import zoneinfo -from oauthlib.oauth2 import OAuth2Error -from requests import HTTPError from sentry_sdk import capture_exception -from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.orm import Session +from ..controller import zoom from ..controller.calendar import CalDavConnector, Tools, GoogleConnector from ..controller.apis.google_client import GoogleClient +from ..controller.google_watch import setup_watch_channel, teardown_watch_channel from ..controller.auth import signed_url_by_subscriber from ..database import repo, schemas, models from ..database.models import ( @@ -29,7 +28,6 @@ from zoneinfo import ZoneInfo from ..defines import FALLBACK_LOCALE -from ..dependencies.zoom import get_zoom_client from ..exceptions import validation from ..exceptions.calendar import EventNotCreatedException, EventNotDeletedException from ..exceptions.misc import UnexpectedBehaviourWarning @@ -81,10 +79,7 @@ def is_this_a_valid_booking_time(schedule: models.Schedule, booking_slot: schema booking_slot_end = booking_slot_start + timedelta(minutes=schedule.slot_duration) if schedule.use_custom_availabilities: - custom_availabilities = [ - a for a in schedule.availabilities - if a.day_of_week.value == iso_weekday - ] + custom_availabilities = [a for a in schedule.availabilities if a.day_of_week.value == iso_weekday] if not custom_availabilities: return False @@ -95,8 +90,7 @@ def is_this_a_valid_booking_time(schedule: models.Schedule, booking_slot: schema add_day = 1 if availability.end_time <= availability.start_time else 0 start_datetime = ( - datetime.combine(today, availability.start_time, tzinfo=schedule_tzinfo) - + schedule.timezone_offset + datetime.combine(today, availability.start_time, tzinfo=schedule_tzinfo) + schedule.timezone_offset ) end_datetime = ( datetime.combine(today, availability.end_time, tzinfo=schedule_tzinfo) @@ -137,6 +131,7 @@ def create_calendar_schedule( schedule: schemas.ScheduleValidationIn, db: Session = Depends(get_db), subscriber: Subscriber = Depends(get_subscriber), + google_client: GoogleClient = Depends(get_google_client), ): """endpoint to add a new schedule for a given calendar""" if not repo.calendar.exists(db, calendar_id=schedule.calendar_id): @@ -157,6 +152,9 @@ def create_calendar_schedule( repo.schedule.hard_delete(db, db_schedule.id) raise validation.ScheduleCreationException() + if os.getenv('GOOGLE_INVITE_ENABLED') == 'True': + _sync_watch_channels(db, google_client, subscriber, schedule.calendar_id) + return db_schedule @@ -188,6 +186,7 @@ def update_schedule( schedule: schemas.ScheduleValidationIn, db: Session = Depends(get_db), subscriber: Subscriber = Depends(get_subscriber), + google_client: GoogleClient = Depends(get_google_client), ): """endpoint to update an existing schedule for authenticated subscriber""" if not repo.schedule.exists(db, schedule_id=id): @@ -210,7 +209,25 @@ def update_schedule( if schedule.use_custom_availabilities and not repo.schedule.all_availability_is_valid(schedule): raise validation.InvalidAvailabilityException() - return repo.schedule.update(db=db, schedule=schedule, schedule_id=id) + result = repo.schedule.update(db=db, schedule=schedule, schedule_id=id) + + if os.getenv('GOOGLE_INVITE_ENABLED') == 'True': + _sync_watch_channels(db, google_client, subscriber, schedule.calendar_id) + + return result + + +def _sync_watch_channels(db: Session, google_client: GoogleClient, subscriber: Subscriber, default_calendar_id: int): + """Ensure a watch channel exists only for the schedule's default Google calendar. + Tears down channels on any other Google calendars for this subscriber.""" + calendars = repo.calendar.get_by_subscriber(db, subscriber.id) + for cal in calendars: + if cal.provider != CalendarProvider.google: + continue + if cal.id == default_calendar_id: + setup_watch_channel(db, google_client, cal) + else: + teardown_watch_channel(db, google_client, cal) @router.post('/public/availability', response_model=schemas.AppointmentOut, tags=['no-cache']) @@ -398,7 +415,14 @@ def request_schedule_availability_slot( # Create a pending appointment owner_language = subscriber.language if subscriber.language is not None else FALLBACK_LOCALE - prefix = f'{l10n("event-hold-prefix", lang=owner_language)} ' if schedule.booking_confirmation else '' + use_google_invite = ( + calendar.provider == CalendarProvider.google and os.getenv('GOOGLE_INVITE_ENABLED') == 'True' + ) + + # Google invites skip the HOLD step — the event is created directly and + # Google sends the invite to the subscriber for accept/decline. + use_hold = schedule.booking_confirmation and not use_google_invite + prefix = f'{l10n("event-hold-prefix", lang=owner_language)} ' if use_hold else '' title = Tools.default_event_title(slot, subscriber, prefix) status = models.AppointmentStatus.opened if schedule.booking_confirmation else models.AppointmentStatus.closed @@ -432,7 +456,6 @@ def request_schedule_availability_slot( # If bookings are configured to be confirmed by the owner for this schedule, # Create HOLD event in owners calender and send emails to owner for confirmation and attendee for information if schedule.booking_confirmation: - # Sending confirmation email to owner background_tasks.add_task( send_confirmation_email, url=url, @@ -445,7 +468,6 @@ def request_schedule_availability_slot( lang=subscriber.language, ) - # Create remote HOLD event event = schemas.Event( title=title, start=slot.start.replace(tzinfo=timezone.utc), @@ -459,14 +481,23 @@ def request_schedule_availability_slot( uuid=slot.appointment.uuid if slot.appointment else None, ) - # create HOLD event in owners calender - event = save_remote_event(event, calendar, subscriber, slot, db, redis, google_client) + event = save_remote_event( + event, + calendar, + subscriber, + slot, + db, + redis, + google_client, + send_google_notification=use_google_invite, + booking_confirmation=schedule.booking_confirmation, + ) # Add the external id if available if appointment and event.external_id: repo.appointment.update_external_id(db, appointment, event.external_id) - # Sending confirmation pending information email to attendee with HOLD event attached - Tools().send_hold_vevent(background_tasks, slot.appointment, slot, subscriber, slot.attendee) + if not use_google_invite: + Tools().send_hold_vevent(background_tasks, slot.appointment, slot, subscriber, slot.attendee) # If no confirmation is needed, directly confirm the booking and send invitation mail else: @@ -576,11 +607,15 @@ def handle_schedule_availability_decision( db.add(appointment) appointment_calendar = appointment.calendar + use_google_invite = calendar.provider == CalendarProvider.google and os.getenv('GOOGLE_INVITE_ENABLED') == 'True' + # TODO: Check booking expiration date # check if request was denied if confirmed is False: - # send rejection information to bookee - Tools().send_reject_vevent(background_tasks, appointment, slot, subscriber, slot.attendee) + # For non-Google calendars, send branded rejection email to the bookee. + # For Google, the delete with sendUpdates handles the cancellation notification. + if not use_google_invite: + Tools().send_reject_vevent(background_tasks, appointment, slot, subscriber, slot.attendee) # mark the slot as BookingStatus.declined slot_update = schemas.SlotUpdate(booking_status=models.BookingStatus.declined) @@ -589,7 +624,10 @@ def handle_schedule_availability_decision( # Delete remote HOLD event if existing if appointment: uuid = slot.appointment.external_id if slot.appointment.external_id else str(slot.appointment.uuid) - delete_remote_event(uuid, appointment_calendar, subscriber, db, redis, google_client) + send_updates = 'all' if use_google_invite else 'none' + delete_remote_event( + uuid, appointment_calendar, subscriber, db, redis, google_client, send_updates=send_updates + ) return True @@ -605,46 +643,16 @@ def handle_schedule_availability_decision( # If needed: Create a zoom meeting link for this booking if schedule.meeting_link_provider == MeetingLinkProviderType.zoom: - try: - zoom_client = get_zoom_client(subscriber) - response = zoom_client.create_meeting(title, slot.start.isoformat(), slot.duration, subscriber.timezone) - if 'id' in response: - location_url = zoom_client.get_meeting(response['id'])['join_url'] - slot.meeting_link_id = response['id'] - slot.meeting_link_url = location_url - - db.add(slot) - db.commit() - except HTTPError as err: # Not fatal, just a bummer - logging.error('Zoom meeting creation error: ', err) - - # Ensure sentry captures the error too! - if os.getenv('SENTRY_DSN') != '': - capture_exception(err) - - # Notify the organizer that the meeting link could not be created! - background_tasks.add_task( - send_zoom_meeting_failed_email, - to=subscriber.preferred_email, - appointment_title=schedule.name, - lang=subscriber.language, - ) - except OAuth2Error as err: - logging.error('OAuth flow error during zoom meeting creation: ', err) - if os.getenv('SENTRY_DSN') != '': - capture_exception(err) - - # Notify the organizer that the meeting link could not be created! + zoom_url = zoom.create_meeting_link(db, slot, subscriber, title) + if zoom_url: + location_url = zoom_url + else: background_tasks.add_task( send_zoom_meeting_failed_email, to=subscriber.preferred_email, appointment_title=schedule.name, lang=subscriber.language, ) - except SQLAlchemyError as err: # Not fatal, but could make things tricky - logging.error('Failed to save the zoom meeting link to the appointment: ', err) - if os.getenv('SENTRY_DSN') != '': - capture_exception(err) event = schemas.Event( title=title, @@ -661,14 +669,39 @@ def handle_schedule_availability_decision( # Update HOLD event appointment = repo.appointment.update_title(db, slot.appointment_id, title) - event = save_remote_event(event, appointment_calendar, subscriber, slot, db, redis, google_client) - if appointment and event.external_id: - repo.appointment.update_external_id(db, appointment, event.external_id) + + if use_google_invite: + if appointment and appointment.external_id: + # Patch the tentative hold event to confirmed; Google notifies the bookee. + con, _ = get_remote_connection(appointment_calendar, subscriber, db, redis, google_client) + owner_language = subscriber.language if subscriber.language is not None else FALLBACK_LOCALE + con.confirm_event(appointment.external_id, event=event, organizer_language=owner_language) + else: + # No hold event exists (booking_confirmation was false); create a confirmed event directly. + event = save_remote_event( + event, appointment_calendar, subscriber, slot, db, redis, google_client, + send_google_notification=True, booking_confirmation=False, + ) + if appointment and event.external_id: + repo.appointment.update_external_id(db, appointment, event.external_id) + else: + event = save_remote_event( + event, + appointment_calendar, + subscriber, + slot, + db, + redis, + google_client, + ) + if appointment and event.external_id: + repo.appointment.update_external_id(db, appointment, event.external_id) # Book the slot at the end slot = repo.slot.book(db, slot.id) - Tools().send_invitation_vevent(background_tasks, appointment, slot, subscriber, slot.attendee) + if not use_google_invite: + Tools().send_invitation_vevent(background_tasks, appointment, slot, subscriber, slot.attendee) return True @@ -711,25 +744,38 @@ def get_remote_connection(calendar, subscriber, db, redis, google_client): return (con, organizer_email) -def save_remote_event(event, calendar, subscriber, slot, db, redis, google_client): +def save_remote_event( + event, + calendar, + subscriber, + slot, + db, + redis, + google_client, + send_google_notification=False, + booking_confirmation=False, +): """Create or update a remote event""" con, organizer_email = get_remote_connection(calendar, subscriber, db, redis, google_client) try: return con.save_event( - event=event, attendee=slot.attendee, organizer=subscriber, organizer_email=organizer_email + event=event, + attendee=slot.attendee, + organizer=subscriber, + organizer_email=organizer_email, + send_google_notification=send_google_notification, + booking_confirmation=booking_confirmation, ) except EventNotCreatedException: raise EventCouldNotBeAccepted -def delete_remote_event(uid: str, calendar, subscriber, db, redis, google_client): - """Delete a remote event - if is_hold: remove an event from remote calendar - """ +def delete_remote_event(uid: str, calendar, subscriber, db, redis, google_client, send_updates: str = 'none'): + """Delete a remote event from the connected calendar.""" con, _ = get_remote_connection(calendar, subscriber, db, redis, google_client) try: - con.delete_event(uid=uid) + con.delete_event(uid=uid, send_updates=send_updates) except EventNotDeletedException: raise EventCouldNotBeDeleted diff --git a/backend/src/appointment/routes/webhooks.py b/backend/src/appointment/routes/webhooks.py index dc1c8766a..e7840723e 100644 --- a/backend/src/appointment/routes/webhooks.py +++ b/backend/src/appointment/routes/webhooks.py @@ -1,18 +1,27 @@ +import json import logging import requests import sentry_sdk -from fastapi import APIRouter, Depends, Request +from fastapi import APIRouter, Depends, Request, Response, BackgroundTasks +from google.oauth2.credentials import Credentials from sqlalchemy.orm import Session from ..controller import auth, data, zoom from ..controller.apis.fxa_client import FxaClient +from ..controller.apis.google_client import GoogleClient +from ..controller.calendar import Tools +from ..controller.google_watch import teardown_watch_channel from ..database import repo, models, schemas -from ..dependencies.database import get_db +from ..database.models import MeetingLinkProviderType +from ..defines import FALLBACK_LOCALE +from ..dependencies.database import get_db, get_redis from ..dependencies.fxa import get_webhook_auth as get_webhook_auth_fxa, get_fxa_client +from ..dependencies.google import get_google_client from ..dependencies.zoom import get_webhook_auth as get_webhook_auth_zoom from ..exceptions.account_api import AccountDeletionSubscriberFail from ..exceptions.fxa_api import MissingRefreshTokenException +from ..l10n import l10n router = APIRouter() @@ -110,3 +119,293 @@ def zoom_deauthorization( except Exception as ex: sentry_sdk.capture_exception(ex) logging.error(f'Error disconnecting zoom connection: {ex}') + + +@router.post('/google-calendar') +def google_calendar_notification( + request: Request, + background_tasks: BackgroundTasks, + db: Session = Depends(get_db), + redis=Depends(get_redis), + google_client: GoogleClient = Depends(get_google_client), +): + """Webhook endpoint for Google Calendar push notifications. + Google sends a POST here whenever events change on a watched calendar.""" + channel_id = request.headers.get('X-Goog-Channel-Id') + resource_state = request.headers.get('X-Goog-Resource-State') + + success_response = Response(status_code=200) + + # Google sends a 'sync' notification when the channel is first created; just acknowledge it + if not channel_id or resource_state == 'sync': + return success_response + + channel = repo.google_calendar_channel.get_by_channel_id(db, channel_id) + if not channel: + logging.warning(f'[webhooks.google_calendar] Unknown channel_id: {channel_id}') + return success_response + + incoming_state = request.headers.get('X-Goog-Channel-Token') + if incoming_state != channel.state: + logging.warning(f'[webhooks.google_calendar] State mismatch for channel {channel_id}') + return success_response + + calendar = channel.calendar + if not calendar: + repo.google_calendar_channel.delete(db, channel) + return success_response + if not calendar.connected: + teardown_watch_channel(db, google_client, calendar) + return success_response + + external_connection = calendar.external_connection + if not external_connection or not external_connection.token: + teardown_watch_channel(db, google_client, calendar) + return success_response + + token = Credentials.from_authorized_user_info( + json.loads(external_connection.token), google_client.SCOPES + ) + + if not channel.sync_token: + sync_token = google_client.get_initial_sync_token(calendar.user, token) + if sync_token: + repo.google_calendar_channel.update_sync_token(db, channel, sync_token) + + changed_events, new_sync_token = google_client.list_events_sync( + calendar.user, channel.sync_token, token + ) + + if changed_events is None: + # Sync token expired -- do a full re-sync to get a fresh one + fresh_token = google_client.get_initial_sync_token(calendar.user, token) + if fresh_token: + repo.google_calendar_channel.update_sync_token(db, channel, fresh_token) + return success_response + + if new_sync_token: + repo.google_calendar_channel.update_sync_token(db, channel, new_sync_token) + + background_tasks.add_task( + _process_google_event_changes, + calendar_id=calendar.id, + changed_events=changed_events, + google_client=google_client, + google_token=token, + remote_calendar_id=calendar.user, + ) + + return success_response + + + +def _process_google_event_changes( + calendar_id: int, + changed_events: list[dict], + google_client: GoogleClient, + google_token, + remote_calendar_id: str, +): + """Process changed events from a Google Calendar push notification. + Runs as a background task to avoid blocking the webhook response.""" + from ..dependencies.database import get_engine_and_session + + _, SessionLocal = get_engine_and_session() + db = SessionLocal() + + try: + for event in changed_events: + google_event_id = event.get('id') + if not google_event_id: + continue + + appointment = _find_appointment_by_external_id(db, calendar_id, google_event_id) + if not appointment: + continue + + slot = appointment.slots[0] if appointment.slots else None + if not slot or not slot.attendee: + continue + + # Subscriber deleted/cancelled the event from Google Calendar + if event.get('status') == 'cancelled': + _handle_event_cancelled(db, appointment, slot) + continue + + attendees = event.get('attendees', []) + if not attendees: + continue + + # Check if the subscriber (calendar owner) responded via Google Calendar + self_attendee = next((a for a in attendees if a.get('self')), None) + if self_attendee: + _handle_subscriber_rsvp( + db, appointment, slot, self_attendee.get('responseStatus'), + google_client, google_token, remote_calendar_id, + ) + + # Check if the bookee responded via Google Calendar + attendee_email = slot.attendee.email.lower() + google_attendee = next( + (a for a in attendees if a.get('email', '').lower() == attendee_email), + None, + ) + if not google_attendee: + continue + + response_status = google_attendee.get('responseStatus') + _handle_bookee_rsvp( + db, appointment, slot, response_status, google_client, google_token, remote_calendar_id + ) + except Exception as e: + logging.error(f'[webhooks.google_calendar] Error processing event changes: {e}') + if sentry_sdk.is_initialized(): + sentry_sdk.capture_exception(e) + finally: + db.close() + + +def _find_appointment_by_external_id( + db: Session, calendar_id: int, external_id: str +) -> models.Appointment | None: + """Find an appointment on the given calendar matching the Google event ID.""" + return ( + db.query(models.Appointment) + .filter( + models.Appointment.calendar_id == calendar_id, + models.Appointment.external_id == external_id, + ) + .first() + ) + + +def _handle_event_cancelled( + db: Session, + appointment: models.Appointment, + slot: models.Slot, +): + """Handle a Google Calendar event that was deleted/cancelled by the subscriber.""" + if slot.booking_status not in (models.BookingStatus.requested, models.BookingStatus.booked): + return + + slot_update = schemas.SlotUpdate(booking_status=models.BookingStatus.cancelled) + repo.slot.update(db, slot.id, slot_update) + + logging.info( + f'[webhooks.google_calendar] Event cancelled for appointment {appointment.id}, ' + f'slot {slot.id} marked as cancelled' + ) + + + +def _handle_subscriber_rsvp( + db: Session, + appointment: models.Appointment, + slot: models.Slot, + response_status: str, + google_client: GoogleClient, + google_token, + remote_calendar_id: str, +): + """React to the subscriber (calendar owner) accepting/declining via Google Calendar.""" + if response_status == 'accepted': + if ( + appointment.status != models.AppointmentStatus.opened + or slot.booking_status != models.BookingStatus.requested + ): + return + + repo.appointment.update_status(db, appointment.id, models.AppointmentStatus.closed) + repo.slot.book(db, slot.id) + + if appointment.external_id: + subscriber = appointment.calendar.owner + title = Tools.default_event_title(slot, subscriber) + repo.appointment.update_title(db, appointment.id, title) + + location_url = appointment.location_url + if appointment.meeting_link_provider == MeetingLinkProviderType.zoom: + location_url = zoom.create_meeting_link(db, slot, subscriber, title) or location_url + + owner_lang = subscriber.language if subscriber.language else FALLBACK_LOCALE + body = {'status': 'confirmed', 'summary': title} + + if location_url: + body['location'] = location_url + + description = [appointment.details or ''] + if location_url: + description.append(l10n('join-online', {'url': location_url}, lang=owner_lang)) + + body['description'] = '\n'.join(description) + + try: + remote_event = google_client.get_event(remote_calendar_id, appointment.external_id, google_token) + if remote_event and remote_event.get('attendees'): + for att in remote_event['attendees']: + if att.get('self'): + att['responseStatus'] = 'accepted' + body['attendees'] = remote_event['attendees'] + + google_client.patch_event( + remote_calendar_id, appointment.external_id, body, google_token, + ) + except Exception: + logging.warning('[webhooks.google_calendar] Failed to confirm event in Google') + + logging.info( + f'[webhooks.google_calendar] Subscriber confirmed appointment {appointment.id} ' + f'via Google Calendar, slot {slot.id} booked' + ) + + elif response_status == 'declined': + if slot.booking_status in (models.BookingStatus.requested, models.BookingStatus.booked): + slot_update = schemas.SlotUpdate(booking_status=models.BookingStatus.declined) + repo.slot.update(db, slot.id, slot_update) + + if appointment.external_id: + try: + google_client.delete_event( + remote_calendar_id, appointment.external_id, google_token, + send_updates='all', + ) + except Exception: + logging.warning('[webhooks.google_calendar] Failed to delete declined event from Google') + + logging.info( + f'[webhooks.google_calendar] Subscriber declined appointment {appointment.id} ' + f'via Google Calendar, slot {slot.id} marked as declined' + ) + + +def _handle_bookee_rsvp( + db: Session, + appointment: models.Appointment, + slot: models.Slot, + response_status: str, + google_client: GoogleClient, + google_token, + remote_calendar_id: str, +): + """React to the bookee's RSVP status change from Google Calendar.""" + if response_status == 'declined': + if slot.booking_status in (models.BookingStatus.requested, models.BookingStatus.booked): + slot_update = schemas.SlotUpdate(booking_status=models.BookingStatus.declined) + repo.slot.update(db, slot.id, slot_update) + + if appointment.external_id: + try: + google_client.delete_event(remote_calendar_id, appointment.external_id, google_token) + except Exception: + logging.warning('[webhooks.google_calendar] Failed to delete declined event from Google') + + logging.info( + f'[webhooks.google_calendar] Bookee declined appointment {appointment.id}, ' + f'slot {slot.id} marked as declined' + ) + + elif response_status == 'accepted': + logging.info( + f'[webhooks.google_calendar] Bookee accepted appointment {appointment.id}, ' + f'slot {slot.id}' + ) diff --git a/backend/src/appointment/tasks/__init__.py b/backend/src/appointment/tasks/__init__.py index af4ae4da0..aeb62618f 100644 --- a/backend/src/appointment/tasks/__init__.py +++ b/backend/src/appointment/tasks/__init__.py @@ -1 +1,2 @@ from appointment.tasks.health import * # noqa: F401,F403 +from appointment.tasks.google import * # noqa: F401,F403 diff --git a/backend/src/appointment/tasks/google.py b/backend/src/appointment/tasks/google.py new file mode 100644 index 000000000..501040ffa --- /dev/null +++ b/backend/src/appointment/tasks/google.py @@ -0,0 +1,14 @@ +import logging + +from appointment.celery_app import celery + +log = logging.getLogger(__name__) + + +@celery.task +def renew_google_channels(): + from appointment.commands.renew_google_channels import run + + log.info('Starting Google Calendar channel renewal') + run() + log.info('Google Calendar channel renewal complete') diff --git a/backend/test/conftest.py b/backend/test/conftest.py index 71632facb..9d34cf3f9 100644 --- a/backend/test/conftest.py +++ b/backend/test/conftest.py @@ -50,11 +50,19 @@ def list_calendars(self): return [schemas.CalendarConnectionOut(url=TEST_CALDAV_URL, user=TEST_CALDAV_USER)] @staticmethod - def save_event(self, event, attendee, organizer, organizer_email): + def save_event( + self, + event, + attendee, + organizer, + organizer_email, + send_google_notification=False, + booking_confirmation=False, + ): return event @staticmethod - def delete_event(self, uid): + def delete_event(self, uid, send_updates='none'): return True @staticmethod diff --git a/backend/test/integration/test_google_calendar_webhook.py b/backend/test/integration/test_google_calendar_webhook.py new file mode 100644 index 000000000..fcea880cf --- /dev/null +++ b/backend/test/integration/test_google_calendar_webhook.py @@ -0,0 +1,477 @@ +"""Integration tests for the Google Calendar webhook endpoint and watch channel lifecycle.""" + +import json +from datetime import datetime, timedelta, timezone +from unittest.mock import MagicMock + +import pytest + +from appointment.controller.apis.google_client import GoogleClient +from appointment.database import models, repo, schemas +from appointment.dependencies import google as google_dep +from appointment.routes.webhooks import _handle_subscriber_rsvp, _handle_bookee_rsvp, _handle_event_cancelled + +from defines import auth_headers, TEST_USER_ID + + +class TestGoogleCalendarWebhook: + def test_sync_notification_returns_200(self, with_client): + """Google sends a sync notification when a channel is first created.""" + response = with_client.post( + '/webhooks/google-calendar', + headers={ + 'X-Goog-Channel-Id': 'some-channel-id', + 'X-Goog-Resource-State': 'sync', + }, + ) + assert response.status_code == 200 + + def test_missing_channel_id_returns_200(self, with_client): + response = with_client.post( + '/webhooks/google-calendar', + headers={ + 'X-Goog-Resource-State': 'exists', + }, + ) + assert response.status_code == 200 + + def test_unknown_channel_id_returns_200(self, with_client): + response = with_client.post( + '/webhooks/google-calendar', + headers={ + 'X-Goog-Channel-Id': 'unknown-channel-id', + 'X-Goog-Resource-State': 'exists', + }, + ) + assert response.status_code == 200 + + def test_state_mismatch_is_rejected( + self, with_db, with_client, make_pro_subscriber, make_google_calendar, make_external_connections + ): + """A notification with a mismatched state token should be silently ignored.""" + subscriber = make_pro_subscriber() + google_creds = json.dumps({ + 'token': 'fake-token', + 'refresh_token': 'fake-refresh', + 'client_id': 'fake-client-id', + 'client_secret': 'fake-secret', + }) + ext_conn = make_external_connections( + subscriber.id, + type=models.ExternalConnectionType.google, + token=google_creds, + ) + calendar = make_google_calendar( + subscriber_id=subscriber.id, + connected=True, + external_connection_id=ext_conn.id, + ) + + with with_db() as db: + repo.google_calendar_channel.create( + db, + calendar_id=calendar.id, + channel_id='state-channel', + resource_id='res-state', + expiration=datetime.now(tz=timezone.utc) + timedelta(days=7), + state='correct-state-token', + sync_token='some-sync-token', + ) + + response = with_client.post( + '/webhooks/google-calendar', + headers={ + 'X-Goog-Channel-Id': 'state-channel', + 'X-Goog-Resource-State': 'exists', + 'X-Goog-Channel-Token': 'wrong-state-token', + }, + ) + assert response.status_code == 200 + + with with_db() as db: + channel = repo.google_calendar_channel.get_by_channel_id(db, 'state-channel') + assert channel is not None + assert channel.sync_token == 'some-sync-token' + + def test_disconnected_calendar_triggers_teardown( + self, with_db, with_client, make_pro_subscriber, make_google_calendar, make_external_connections + ): + """If the calendar is no longer connected, the channel should be cleaned up.""" + subscriber = make_pro_subscriber() + google_creds = json.dumps({ + 'token': 'fake-token', + 'refresh_token': 'fake-refresh', + 'client_id': 'fake-client-id', + 'client_secret': 'fake-secret', + }) + ext_conn = make_external_connections( + subscriber.id, + type=models.ExternalConnectionType.google, + token=google_creds, + ) + calendar = make_google_calendar( + subscriber_id=subscriber.id, + connected=False, + external_connection_id=ext_conn.id, + ) + + channel_state = 'disconnected-state' + with with_db() as db: + repo.google_calendar_channel.create( + db, + calendar_id=calendar.id, + channel_id='disconnected-cal-channel', + resource_id='res-disconnected', + expiration=datetime.now(tz=timezone.utc) + timedelta(days=7), + state=channel_state, + sync_token='some-sync-token', + ) + + response = with_client.post( + '/webhooks/google-calendar', + headers={ + 'X-Goog-Channel-Id': 'disconnected-cal-channel', + 'X-Goog-Resource-State': 'exists', + 'X-Goog-Channel-Token': channel_state, + }, + ) + assert response.status_code == 200 + + with with_db() as db: + assert repo.google_calendar_channel.get_by_channel_id(db, 'disconnected-cal-channel') is None + + def test_valid_notification_returns_200( + self, with_db, with_client, make_pro_subscriber, make_google_calendar, make_external_connections + ): + """A valid notification for a connected calendar should return 200 and update the sync token.""" + mock_google_client = MagicMock(spec=GoogleClient) + mock_google_client.SCOPES = GoogleClient.SCOPES + mock_google_client.list_events_sync.return_value = ([], 'new-sync-token') + with_client.app.dependency_overrides[google_dep.get_google_client] = lambda: mock_google_client + + subscriber = make_pro_subscriber() + google_creds = json.dumps({ + 'token': 'fake-token', + 'refresh_token': 'fake-refresh', + 'client_id': 'fake-client-id', + 'client_secret': 'fake-secret', + }) + ext_conn = make_external_connections( + subscriber.id, + type=models.ExternalConnectionType.google, + token=google_creds, + ) + calendar = make_google_calendar( + subscriber_id=subscriber.id, + connected=True, + external_connection_id=ext_conn.id, + ) + + channel_state = 'test-state-token' + with with_db() as db: + repo.google_calendar_channel.create( + db, + calendar_id=calendar.id, + channel_id='valid-channel', + resource_id='res-valid', + expiration=datetime.now(tz=timezone.utc) + timedelta(days=7), + state=channel_state, + sync_token='initial-sync-token', + ) + + response = with_client.post( + '/webhooks/google-calendar', + headers={ + 'X-Goog-Channel-Id': 'valid-channel', + 'X-Goog-Resource-State': 'exists', + 'X-Goog-Channel-Token': channel_state, + }, + ) + assert response.status_code == 200 + + with with_db() as db: + channel = repo.google_calendar_channel.get_by_channel_id(db, 'valid-channel') + assert channel is not None + assert channel.sync_token == 'new-sync-token' + + +class TestCalendarConnectWatchChannel: + """Watch channels are managed at the schedule level, not on connect/disconnect. + These tests verify that connecting/disconnecting a calendar does NOT touch watch channels.""" + + def test_connect_google_calendar_does_not_create_channel( + self, with_db, with_client, make_google_calendar, make_external_connections + ): + """Connecting a Google calendar alone should not create a watch channel. + Channels are created when the calendar is set as default in a schedule.""" + google_creds = json.dumps({ + 'token': 'fake-token', + 'refresh_token': 'fake-refresh', + 'client_id': 'fake-client-id', + 'client_secret': 'fake-secret', + }) + ext_conn = make_external_connections( + TEST_USER_ID, type=models.ExternalConnectionType.google, token=google_creds, + ) + calendar = make_google_calendar( + subscriber_id=TEST_USER_ID, connected=False, external_connection_id=ext_conn.id, + ) + + response = with_client.post(f'/cal/{calendar.id}/connect', headers=auth_headers) + assert response.status_code == 200, response.text + assert response.json()['connected'] is True + + with with_db() as db: + assert repo.google_calendar_channel.get_by_calendar_id(db, calendar.id) is None + + def test_disconnect_google_calendar_does_not_remove_channel( + self, with_db, with_client, make_google_calendar, make_external_connections + ): + """Disconnecting a Google calendar should not tear down its watch channel. + Channels are managed when the schedule's default calendar changes.""" + google_creds = json.dumps({ + 'token': 'fake-token', + 'refresh_token': 'fake-refresh', + 'client_id': 'fake-client-id', + 'client_secret': 'fake-secret', + }) + ext_conn = make_external_connections( + TEST_USER_ID, type=models.ExternalConnectionType.google, token=google_creds, + ) + calendar = make_google_calendar( + subscriber_id=TEST_USER_ID, connected=True, external_connection_id=ext_conn.id, + ) + + with with_db() as db: + repo.google_calendar_channel.create( + db, + calendar_id=calendar.id, + channel_id='should-remain', + resource_id='res-remain', + expiration=datetime.now(tz=timezone.utc) + timedelta(days=7), + state='remain-state', + ) + + response = with_client.post(f'/cal/{calendar.id}/disconnect', headers=auth_headers) + assert response.status_code == 200, response.text + assert response.json()['connected'] is False + + with with_db() as db: + channel = repo.google_calendar_channel.get_by_calendar_id(db, calendar.id) + assert channel is not None + assert channel.channel_id == 'should-remain' + + def test_connect_caldav_calendar_no_channel(self, with_db, with_client, make_caldav_calendar): + """Connecting a CalDAV calendar should not create a watch channel.""" + calendar = make_caldav_calendar(connected=False) + + response = with_client.post(f'/cal/{calendar.id}/connect', headers=auth_headers) + assert response.status_code == 200, response.text + + with with_db() as db: + assert repo.google_calendar_channel.get_by_calendar_id(db, calendar.id) is None + + +class TestGoogleCalendarEventRsvp: + """Tests for subscriber and bookee RSVP handling via the Google Calendar webhook.""" + + BOOKEE_EMAIL = 'bookee@example.com' + GOOGLE_EVENT_ID = 'google-event-123' + REMOTE_CALENDAR_ID = 'cal@google.com' + + @pytest.fixture + def rsvp_setup( + self, with_db, + make_pro_subscriber, make_google_calendar, make_external_connections, + make_appointment, make_attendee, make_appointment_slot, + ): + """Create an opened appointment with a requested slot, linked to a Google calendar.""" + subscriber = make_pro_subscriber() + calendar = make_google_calendar(subscriber_id=subscriber.id, connected=True) + attendee = make_attendee(email=self.BOOKEE_EMAIL, name='Bookee') + appointment = make_appointment( + calendar_id=calendar.id, + status=models.AppointmentStatus.opened, + slots=None, + ) + make_appointment_slot( + appointment_id=appointment.id, + attendee_id=attendee.id, + booking_status=models.BookingStatus.requested, + booking_tkn='test-token', + ) + + with with_db() as db: + repo.appointment.update_external_id_by_id(db, appointment.id, self.GOOGLE_EVENT_ID) + + mock_google_client = MagicMock(spec=GoogleClient) + mock_google_token = MagicMock() + + return mock_google_client, mock_google_token, appointment + + def _reload(self, with_db, appointment_id): + """Reload appointment and its first slot from a fresh session.""" + with with_db() as db: + appt = repo.appointment.get(db, appointment_id) + slot = appt.slots[0] + return appt, slot + + def test_subscriber_accepts_confirms_booking(self, with_db, rsvp_setup): + """When the subscriber accepts a tentative event via Google Calendar, + the appointment should be closed, the slot booked, and the event + patched with status, summary, location, and description.""" + mock_google_client, mock_token, appointment = rsvp_setup + mock_google_client.get_event.return_value = { + 'attendees': [ + {'email': 'owner@example.com', 'self': True, 'responseStatus': 'needsAction'}, + {'email': self.BOOKEE_EMAIL, 'responseStatus': 'needsAction'}, + ] + } + + with with_db() as db: + appt = repo.appointment.get(db, appointment.id) + slot = appt.slots[0] + _handle_subscriber_rsvp( + db, appt, slot, 'accepted', + mock_google_client, mock_token, self.REMOTE_CALENDAR_ID, + ) + + appt, slot = self._reload(with_db, appointment.id) + assert appt.status == models.AppointmentStatus.closed + assert slot.booking_status == models.BookingStatus.booked + + mock_google_client.patch_event.assert_called_once() + call_args = mock_google_client.patch_event.call_args + assert call_args.args[0] == self.REMOTE_CALENDAR_ID + assert call_args.args[1] == self.GOOGLE_EVENT_ID + body = call_args.args[2] + assert body['status'] == 'confirmed' + assert 'summary' in body + assert 'description' in body + owner_att = next(a for a in body['attendees'] if a.get('self')) + assert owner_att['responseStatus'] == 'accepted' + + def test_subscriber_accepts_already_closed_is_noop(self, with_db, rsvp_setup): + """If the appointment is already closed, a subscriber accept should not change anything.""" + mock_google_client, mock_token, appointment = rsvp_setup + + with with_db() as db: + repo.appointment.update_status(db, appointment.id, models.AppointmentStatus.closed) + + with with_db() as db: + appt = repo.appointment.get(db, appointment.id) + slot = appt.slots[0] + _handle_subscriber_rsvp( + db, appt, slot, 'accepted', + mock_google_client, mock_token, self.REMOTE_CALENDAR_ID, + ) + + _, slot = self._reload(with_db, appointment.id) + assert slot.booking_status == models.BookingStatus.requested + mock_google_client.patch_event.assert_not_called() + + def test_bookee_accepts_does_not_confirm(self, with_db, rsvp_setup): + """When the bookee accepts, the appointment should remain opened and the slot requested.""" + mock_google_client, mock_token, appointment = rsvp_setup + + with with_db() as db: + appt = repo.appointment.get(db, appointment.id) + slot = appt.slots[0] + _handle_bookee_rsvp( + db, appt, slot, 'accepted', + mock_google_client, mock_token, self.REMOTE_CALENDAR_ID, + ) + + appt, slot = self._reload(with_db, appointment.id) + assert appt.status == models.AppointmentStatus.opened + assert slot.booking_status == models.BookingStatus.requested + mock_google_client.patch_event.assert_not_called() + + def test_subscriber_declines_marks_slot_declined(self, with_db, rsvp_setup): + """When the subscriber declines via Google Calendar, the slot should be + marked as declined and the event deleted with notifications.""" + mock_google_client, mock_token, appointment = rsvp_setup + + with with_db() as db: + appt = repo.appointment.get(db, appointment.id) + slot = appt.slots[0] + _handle_subscriber_rsvp( + db, appt, slot, 'declined', + mock_google_client, mock_token, self.REMOTE_CALENDAR_ID, + ) + + _, slot = self._reload(with_db, appointment.id) + assert slot.booking_status == models.BookingStatus.declined + + mock_google_client.delete_event.assert_called_once_with( + self.REMOTE_CALENDAR_ID, self.GOOGLE_EVENT_ID, mock_token, + send_updates='all', + ) + + def test_subscriber_declines_already_declined_is_noop(self, with_db, rsvp_setup): + """If the slot is already declined, a subscriber decline should not call delete again.""" + mock_google_client, mock_token, appointment = rsvp_setup + + with with_db() as db: + slot_update = models.BookingStatus.declined + repo.slot.update(db, repo.appointment.get(db, appointment.id).slots[0].id, + schemas.SlotUpdate(booking_status=slot_update)) + + with with_db() as db: + appt = repo.appointment.get(db, appointment.id) + slot = appt.slots[0] + _handle_subscriber_rsvp( + db, appt, slot, 'declined', + mock_google_client, mock_token, self.REMOTE_CALENDAR_ID, + ) + + mock_google_client.delete_event.assert_not_called() + + def test_bookee_declines_marks_slot_declined(self, with_db, rsvp_setup): + """When the bookee declines, the slot should be marked as declined and the event deleted.""" + mock_google_client, mock_token, appointment = rsvp_setup + + with with_db() as db: + appt = repo.appointment.get(db, appointment.id) + slot = appt.slots[0] + _handle_bookee_rsvp( + db, appt, slot, 'declined', + mock_google_client, mock_token, self.REMOTE_CALENDAR_ID, + ) + + _, slot = self._reload(with_db, appointment.id) + assert slot.booking_status == models.BookingStatus.declined + mock_google_client.delete_event.assert_called_once() + + def test_event_cancelled_marks_slot_declined(self, with_db, rsvp_setup): + """When the subscriber deletes the event from Google Calendar, + the slot should be marked as cancelled.""" + _, _, appointment = rsvp_setup + + with with_db() as db: + appt = repo.appointment.get(db, appointment.id) + slot = appt.slots[0] + _handle_event_cancelled(db, appt, slot) + + _, slot = self._reload(with_db, appointment.id) + assert slot.booking_status == models.BookingStatus.cancelled + + def test_event_cancelled_already_cancelled_is_noop(self, with_db, rsvp_setup): + """If the slot is already cancelled, a cancelled event should not change anything.""" + _, _, appointment = rsvp_setup + + with with_db() as db: + appt = repo.appointment.get(db, appointment.id) + repo.slot.update( + db, appt.slots[0].id, + schemas.SlotUpdate(booking_status=models.BookingStatus.cancelled), + ) + + with with_db() as db: + appt = repo.appointment.get(db, appointment.id) + slot = appt.slots[0] + assert slot.booking_status == models.BookingStatus.cancelled + _handle_event_cancelled(db, appt, slot) + + _, slot = self._reload(with_db, appointment.id) + assert slot.booking_status == models.BookingStatus.cancelled diff --git a/backend/test/integration/test_schedule.py b/backend/test/integration/test_schedule.py index 8bdd238b7..ac7c24f05 100644 --- a/backend/test/integration/test_schedule.py +++ b/backend/test/integration/test_schedule.py @@ -569,11 +569,10 @@ def get_busy_time(self, remote_calendar_ids, start, end): slots = data['slots'] # Based off the earliest_booking our earliest slot is tomorrow at 9:00am - # Note: this should be in PDT (Pacific Daylight Time) + # 2024-03-04 is before spring-forward (Mar 10), so offset is PST (-08:00) assert slots[0]['start'] == '2024-03-04T09:00:00-08:00' - # Based off the farthest_booking our latest slot is 4:30pm - # Note: This should be in PDT (Pacific Daylight Time) - # Note2: The schedule ends at 0 UTC (or 16-07:00) so the last slot is 30 mins before that. + # 2024-03-15 is after spring-forward, so offset is PDT (-07:00) + # The schedule ends at 01:00 UTC (= 17:00 local) so the last slot starts at 16:30 assert slots[-1]['start'] == '2024-03-15T16:30:00-07:00' # Check availability over a year from now @@ -820,7 +819,7 @@ def bust_cached_events(self, all_calendars=False): def mock_get_zoom_client(*args, **kwargs): return mock_zoom_client - monkeypatch.setattr('appointment.routes.schedule.get_zoom_client', mock_get_zoom_client) + monkeypatch.setattr('appointment.controller.zoom.get_zoom_client', mock_get_zoom_client) response = with_client.put( '/schedule/public/availability/request', diff --git a/backend/test/unit/test_calendar_tools.py b/backend/test/unit/test_calendar_tools.py index 1ed2e35a3..8dcc958d9 100644 --- a/backend/test/unit/test_calendar_tools.py +++ b/backend/test/unit/test_calendar_tools.py @@ -227,7 +227,9 @@ def test_attendee_with_name(self, make_google_calendar, make_appointment, make_p assert 'MAILTO:attendee@example.com' in ics_str assert 'CN="John Doe"' in ics_str assert 'ROLE=REQ-PARTICIPANT' in ics_str - assert 'PARTSTAT=ACCEPTED' in ics_str + assert 'PARTSTAT=NEEDS-ACTION' in ics_str + assert 'RSVP=TRUE' in ics_str + assert 'CUTYPE=INDIVIDUAL' in ics_str def test_attendee_without_name_uses_email(self, make_google_calendar, make_appointment, make_pro_subscriber): """Verify attendee is added with email as name when slot has an attendee without a name""" @@ -247,7 +249,9 @@ def test_attendee_without_name_uses_email(self, make_google_calendar, make_appoi assert 'MAILTO:attendee@example.com' in ics_str assert 'CN=attendee@example.com' in ics_str assert 'ROLE=REQ-PARTICIPANT' in ics_str - assert 'PARTSTAT=ACCEPTED' in ics_str + assert 'PARTSTAT=NEEDS-ACTION' in ics_str + assert 'RSVP=TRUE' in ics_str + assert 'CUTYPE=INDIVIDUAL' in ics_str def test_no_attendee(self, make_google_calendar, make_appointment, make_pro_subscriber): """Verify no attendee is added when slot has no attendee""" @@ -441,20 +445,20 @@ def test_respects_weekday_filter(self): assert len(slots_tuesday) == 0 -class TestGoogleConnectorSaveEventLanguage: - """Verify that GoogleConnector.save_event uses the organizer's language - for the event description, not the language from the request context.""" +class TestGoogleConnectorSaveEvent: + """Tests for GoogleConnector.save_event with import_() and insert() paths.""" - def test_description_uses_organizer_language_not_context(self): - """When the request context is German but the organizer speaks English, - the 'join-online' and 'join-phone' strings in the event description - must be in English.""" - - # Set up starlette context with German (simulating a German-speaking bookee) - l10n_plugin = L10n() - l10n_fn = l10n_plugin.get_fluent_with_header('de') + def _make_connector(self, mock_google_client): + connector = GoogleConnector.__new__(GoogleConnector) + connector.google_client = mock_google_client + connector.remote_calendar_id = 'cal@example.com' + connector.google_token = None + connector.subscriber_id = 1 + connector.calendar_id = 1 + connector.redis_instance = None + return connector - # English-speaking organizer + def _make_event_and_organizer(self): organizer = Mock(spec=['name', 'email', 'language']) organizer.name = 'Owner' organizer.email = 'owner@example.com' @@ -471,17 +475,21 @@ def test_description_uses_organizer_language_not_context(self): uuid=uuid.uuid4(), ) - # Mock the GoogleConnector so we can capture the body passed to google_client.save_event + return event, attendee, organizer + + def test_description_uses_organizer_language_not_context(self): + """When the request context is German but the organizer speaks English, + the 'join-online' and 'join-phone' strings in the event description + must be in English.""" + + l10n_plugin = L10n() + l10n_fn = l10n_plugin.get_fluent_with_header('de') + + event, attendee, organizer = self._make_event_and_organizer() + mock_google_client = Mock() mock_google_client.save_event.return_value = {'id': 'mock_event_id'} - - connector = GoogleConnector.__new__(GoogleConnector) - connector.google_client = mock_google_client - connector.remote_calendar_id = 'cal@example.com' - connector.google_token = None - connector.subscriber_id = 1 - connector.calendar_id = 1 - connector.redis_instance = None + connector = self._make_connector(mock_google_client) with request_cycle_context({'l10n': l10n_fn}): connector.save_event( @@ -491,17 +499,140 @@ def test_description_uses_organizer_language_not_context(self): organizer_email='owner@example.com', ) - # Extract the description from the body passed to google_client.save_event call_kwargs = mock_google_client.save_event.call_args body = call_kwargs.kwargs.get('body') or call_kwargs[1].get('body') description = body['description'] - # Must be in English (organizer's language), not German assert 'Join online at:' in description assert 'Join by phone:' in description assert 'Online teilnehmen unter:' not in description assert 'Per Telefon teilnehmen:' not in description + def test_default_uses_import(self): + """By default (send_google_notification=False), save_event uses import_().""" + event, attendee, organizer = self._make_event_and_organizer() + + mock_google_client = Mock() + mock_google_client.save_event.return_value = {'id': 'import_event_id'} + connector = self._make_connector(mock_google_client) + + with request_cycle_context({}): + result = connector.save_event( + event=event, + attendee=attendee, + organizer=organizer, + organizer_email='owner@example.com', + ) + + mock_google_client.save_event.assert_called_once() + mock_google_client.insert_event.assert_not_called() + + body = mock_google_client.save_event.call_args.kwargs.get('body') + assert 'iCalUID' in body + assert result.external_id == 'import_event_id' + + def test_google_notification_uses_insert(self): + """When send_google_notification=True, save_event uses insert_event().""" + event, attendee, organizer = self._make_event_and_organizer() + + mock_google_client = Mock() + mock_google_client.insert_event.return_value = {'id': 'insert_event_id'} + connector = self._make_connector(mock_google_client) + + with request_cycle_context({}): + result = connector.save_event( + event=event, + attendee=attendee, + organizer=organizer, + organizer_email='owner@example.com', + send_google_notification=True, + ) + + mock_google_client.insert_event.assert_called_once() + mock_google_client.save_event.assert_not_called() + + body = mock_google_client.insert_event.call_args.kwargs.get('body') + assert 'iCalUID' not in body + assert result.external_id == 'insert_event_id' + + def test_insert_body_has_no_organizer_field(self): + """insert() body should not include the custom organizer field.""" + event, attendee, organizer = self._make_event_and_organizer() + + mock_google_client = Mock() + mock_google_client.insert_event.return_value = {'id': 'insert_event_id'} + connector = self._make_connector(mock_google_client) + + with request_cycle_context({}): + connector.save_event( + event=event, + attendee=attendee, + organizer=organizer, + organizer_email='owner@example.com', + send_google_notification=True, + ) + + body = mock_google_client.insert_event.call_args.kwargs.get('body') + assert 'organizer' not in body + + def test_booking_confirmation_true_creates_tentative_with_both_needsaction(self): + """With booking_confirmation=True, event is tentative and both + organizer and bookee are listed as needsAction.""" + event, attendee, organizer = self._make_event_and_organizer() + + mock_google_client = Mock() + mock_google_client.insert_event.return_value = {'id': 'tentative_id'} + connector = self._make_connector(mock_google_client) + + with request_cycle_context({}): + connector.save_event( + event=event, + attendee=attendee, + organizer=organizer, + organizer_email='owner@example.com', + send_google_notification=True, + booking_confirmation=True, + ) + + body = mock_google_client.insert_event.call_args.kwargs.get('body') + assert body['status'] == 'tentative' + + attendees = body['attendees'] + assert len(attendees) == 2 + assert attendees[0]['email'] == 'owner@example.com' + assert attendees[0]['responseStatus'] == 'needsAction' + assert attendees[1]['email'] == 'bookee@example.org' + assert attendees[1]['responseStatus'] == 'needsAction' + + def test_booking_confirmation_false_creates_confirmed_with_organizer_accepted(self): + """With booking_confirmation=False, event is confirmed and the + organizer is accepted while the bookee is needsAction.""" + event, attendee, organizer = self._make_event_and_organizer() + + mock_google_client = Mock() + mock_google_client.insert_event.return_value = {'id': 'confirmed_id'} + connector = self._make_connector(mock_google_client) + + with request_cycle_context({}): + connector.save_event( + event=event, + attendee=attendee, + organizer=organizer, + organizer_email='owner@example.com', + send_google_notification=True, + booking_confirmation=False, + ) + + body = mock_google_client.insert_event.call_args.kwargs.get('body') + assert body['status'] == 'confirmed' + + attendees = body['attendees'] + assert len(attendees) == 2 + assert attendees[0]['email'] == 'owner@example.com' + assert attendees[0]['responseStatus'] == 'accepted' + assert attendees[1]['email'] == 'bookee@example.org' + assert attendees[1]['responseStatus'] == 'needsAction' + class TestVEventTimezoneFallback: """Tests that send_*_vevent methods fall back to UTC for invalid timezones.""" @@ -595,3 +726,4 @@ def test_none_timezone_defaults_to_utc(self, method_name): call_kwargs = bg.add_task.call_args date_arg = call_kwargs.kwargs.get('date') or call_kwargs[1].get('date') assert date_arg.tzinfo is not None + diff --git a/backend/test/unit/test_commands.py b/backend/test/unit/test_commands.py index 713c891c6..8254e8f2e 100644 --- a/backend/test/unit/test_commands.py +++ b/backend/test/unit/test_commands.py @@ -1,7 +1,11 @@ +import json import os - import pytest +from datetime import datetime, timedelta, timezone +from unittest.mock import Mock, patch + +from appointment.database import models, repo from appointment.routes.commands import cron_lock @@ -29,3 +33,367 @@ def test_cron_lock(): # Remove the lock file we manually created os.remove(test_lock_file_name) + + +def _make_google_token(): + return json.dumps( + { + 'token': 'fake-access-token', + 'refresh_token': 'fake-refresh-token', + 'token_uri': 'https://oauth2.googleapis.com/token', + 'client_id': 'fake-client-id', + 'client_secret': 'fake-client-secret', + } + ) + + +class TestBackfillGoogleChannels: + """Tests that the backfill command only creates watch channels + for connected Google calendars that are the default in a schedule.""" + + def test_skips_google_calendar_without_schedule(self, with_db, make_google_calendar, make_external_connections): + """A connected Google calendar not used by any schedule should be skipped.""" + ext = make_external_connections( + subscriber_id=1, + type=models.ExternalConnectionType.google, + token=_make_google_token(), + ) + make_google_calendar(connected=True, external_connection_id=ext.id) + + mock_google_client = Mock() + mock_google_client.SCOPES = ['https://www.googleapis.com/auth/calendar'] + + with patch('appointment.commands.backfill_google_channels._common_setup'): + with patch( + 'appointment.commands.backfill_google_channels.get_google_client', return_value=mock_google_client + ): + with patch( + 'appointment.commands.backfill_google_channels.get_webhook_url', + return_value='https://example.com/webhook', + ): + with patch( + 'appointment.commands.backfill_google_channels.get_engine_and_session', + return_value=(None, with_db), + ): + from appointment.commands.backfill_google_channels import run + + run() + + mock_google_client.watch_events.assert_not_called() + + def test_creates_channel_for_schedule_calendar( + self, with_db, make_google_calendar, make_schedule, make_external_connections + ): + """A connected Google calendar used by a schedule should get a watch channel.""" + ext = make_external_connections( + subscriber_id=1, + type=models.ExternalConnectionType.google, + token=_make_google_token(), + ) + cal = make_google_calendar(connected=True, external_connection_id=ext.id) + make_schedule(calendar_id=cal.id, active=True) + + mock_google_client = Mock() + mock_google_client.SCOPES = ['https://www.googleapis.com/auth/calendar'] + mock_google_client.watch_events.return_value = { + 'id': 'channel-123', + 'resourceId': 'resource-456', + 'expiration': str(int(datetime(2030, 1, 1, tzinfo=timezone.utc).timestamp() * 1000)), + } + mock_google_client.get_initial_sync_token.return_value = 'sync-token-abc' + + with patch('appointment.commands.backfill_google_channels._common_setup'): + with patch( + 'appointment.commands.backfill_google_channels.get_google_client', return_value=mock_google_client + ): + with patch( + 'appointment.commands.backfill_google_channels.get_webhook_url', + return_value='https://example.com/webhook', + ): + with patch( + 'appointment.commands.backfill_google_channels.get_engine_and_session', + return_value=(None, with_db), + ): + from appointment.commands.backfill_google_channels import run + + run() + + mock_google_client.watch_events.assert_called_once() + + with with_db() as db: + channel = repo.google_calendar_channel.get_by_calendar_id(db, cal.id) + assert channel is not None + assert channel.channel_id == 'channel-123' + assert channel.resource_id == 'resource-456' + assert channel.sync_token == 'sync-token-abc' + assert channel.state is not None + + def test_skips_calendar_with_existing_channel( + self, with_db, make_google_calendar, make_schedule, make_external_connections + ): + """A schedule calendar that already has a watch channel should be skipped.""" + ext = make_external_connections( + subscriber_id=1, + type=models.ExternalConnectionType.google, + token=_make_google_token(), + ) + cal = make_google_calendar(connected=True, external_connection_id=ext.id) + make_schedule(calendar_id=cal.id, active=True) + + with with_db() as db: + repo.google_calendar_channel.create( + db, + calendar_id=cal.id, + channel_id='existing-channel', + resource_id='existing-resource', + expiration=datetime(2030, 1, 1, tzinfo=timezone.utc), + state='existing-state', + sync_token='existing-sync', + ) + + mock_google_client = Mock() + mock_google_client.SCOPES = ['https://www.googleapis.com/auth/calendar'] + + with patch('appointment.commands.backfill_google_channels._common_setup'): + with patch( + 'appointment.commands.backfill_google_channels.get_google_client', return_value=mock_google_client + ): + with patch( + 'appointment.commands.backfill_google_channels.get_webhook_url', + return_value='https://example.com/webhook', + ): + with patch( + 'appointment.commands.backfill_google_channels.get_engine_and_session', + return_value=(None, with_db), + ): + from appointment.commands.backfill_google_channels import run + + run() + + mock_google_client.watch_events.assert_not_called() + + def test_skips_disconnected_google_calendar( + self, with_db, make_google_calendar, make_schedule, make_external_connections + ): + """A disconnected Google calendar should be skipped even if it's in a schedule. + It shouldn't be possible to disconnect a default calendar from a schedule but just in case.""" + ext = make_external_connections( + subscriber_id=1, + type=models.ExternalConnectionType.google, + token=_make_google_token(), + ) + cal = make_google_calendar(connected=False, external_connection_id=ext.id) + make_schedule(calendar_id=cal.id, active=True) + + mock_google_client = Mock() + mock_google_client.SCOPES = ['https://www.googleapis.com/auth/calendar'] + + with patch('appointment.commands.backfill_google_channels._common_setup'): + with patch( + 'appointment.commands.backfill_google_channels.get_google_client', return_value=mock_google_client + ): + with patch( + 'appointment.commands.backfill_google_channels.get_webhook_url', + return_value='https://example.com/webhook', + ): + with patch( + 'appointment.commands.backfill_google_channels.get_engine_and_session', + return_value=(None, with_db), + ): + from appointment.commands.backfill_google_channels import run + + run() + + mock_google_client.watch_events.assert_not_called() + + +class TestRenewGoogleChannels: + """Tests that the renew command correctly refreshes expiring channels.""" + + MODULE = 'appointment.commands.renew_google_channels' + + def _run_renew(self, with_db, mock_google_client): + with patch(f'{self.MODULE}._common_setup'): + with patch(f'{self.MODULE}.get_google_client', return_value=mock_google_client): + with patch(f'{self.MODULE}.get_webhook_url', return_value='https://example.com/webhook'): + with patch(f'{self.MODULE}.get_engine_and_session', return_value=(None, with_db)): + from appointment.commands.renew_google_channels import run + + run() + + def _create_expiring_channel(self, with_db, calendar_id, hours_until_expiry=12): + """Create a channel that expires within the renewal threshold (24h).""" + with with_db() as db: + return repo.google_calendar_channel.create( + db, + calendar_id=calendar_id, + channel_id='old-channel-id', + resource_id='old-resource-id', + expiration=datetime.now(tz=timezone.utc) + timedelta(hours=hours_until_expiry), + state='old-state', + sync_token='old-sync-token', + ) + + def test_renews_expiring_channel( + self, with_db, make_google_calendar, make_external_connections + ): + """An expiring channel gets renewed with new ids and expiration.""" + ext = make_external_connections( + subscriber_id=1, + type=models.ExternalConnectionType.google, + token=_make_google_token(), + ) + cal = make_google_calendar(connected=True, external_connection_id=ext.id) + self._create_expiring_channel(with_db, cal.id) + + new_expiration_ms = int(datetime(2030, 6, 1, tzinfo=timezone.utc).timestamp() * 1000) + mock_google_client = Mock() + mock_google_client.SCOPES = ['https://www.googleapis.com/auth/calendar'] + mock_google_client.watch_events.return_value = { + 'id': 'new-channel-id', + 'resourceId': 'new-resource-id', + 'expiration': str(new_expiration_ms), + } + + self._run_renew(with_db, mock_google_client) + + mock_google_client.stop_channel.assert_called_once() + mock_google_client.watch_events.assert_called_once() + + with with_db() as db: + updated = repo.google_calendar_channel.get_by_calendar_id(db, cal.id) + assert updated is not None + assert updated.channel_id == 'new-channel-id' + assert updated.resource_id == 'new-resource-id' + expected = datetime.fromtimestamp(new_expiration_ms / 1000, tz=timezone.utc) + assert updated.expiration.replace(tzinfo=None) == expected.replace(tzinfo=None) + assert updated.state is not None + assert updated.state != 'old-state' + + def test_skips_channel_not_yet_expiring( + self, with_db, make_google_calendar, make_external_connections + ): + """A channel that expires beyond the 24-hour threshold is left alone.""" + ext = make_external_connections( + subscriber_id=1, + type=models.ExternalConnectionType.google, + token=_make_google_token(), + ) + cal = make_google_calendar(connected=True, external_connection_id=ext.id) + + with with_db() as db: + repo.google_calendar_channel.create( + db, + calendar_id=cal.id, + channel_id='healthy-channel', + resource_id='healthy-resource', + expiration=datetime.now(tz=timezone.utc) + timedelta(days=5), + state='healthy-state', + sync_token='healthy-sync', + ) + + mock_google_client = Mock() + mock_google_client.SCOPES = ['https://www.googleapis.com/auth/calendar'] + + self._run_renew(with_db, mock_google_client) + + mock_google_client.watch_events.assert_not_called() + mock_google_client.stop_channel.assert_not_called() + + with with_db() as db: + channel = repo.google_calendar_channel.get_by_calendar_id(db, cal.id) + assert channel is not None + assert channel.channel_id == 'healthy-channel' + + def test_deletes_channel_for_disconnected_calendar( + self, with_db, make_google_calendar, make_external_connections + ): + """If the calendar is disconnected, the channel record should be removed.""" + ext = make_external_connections( + subscriber_id=1, + type=models.ExternalConnectionType.google, + token=_make_google_token(), + ) + cal = make_google_calendar(connected=False, external_connection_id=ext.id) + self._create_expiring_channel(with_db, cal.id) + + mock_google_client = Mock() + mock_google_client.SCOPES = ['https://www.googleapis.com/auth/calendar'] + + self._run_renew(with_db, mock_google_client) + + mock_google_client.watch_events.assert_not_called() + + with with_db() as db: + assert repo.google_calendar_channel.get_by_calendar_id(db, cal.id) is None + + def test_deletes_channel_when_no_external_connection( + self, with_db, make_google_calendar + ): + """If the calendar has no external connection, the channel record should be removed.""" + cal = make_google_calendar(connected=True) + self._create_expiring_channel(with_db, cal.id) + + mock_google_client = Mock() + mock_google_client.SCOPES = ['https://www.googleapis.com/auth/calendar'] + + self._run_renew(with_db, mock_google_client) + + mock_google_client.watch_events.assert_not_called() + + with with_db() as db: + assert repo.google_calendar_channel.get_by_calendar_id(db, cal.id) is None + + def test_deletes_channel_when_watch_returns_none( + self, with_db, make_google_calendar, make_external_connections + ): + """If watch_events returns None, the channel record should be cleaned up.""" + ext = make_external_connections( + subscriber_id=1, + type=models.ExternalConnectionType.google, + token=_make_google_token(), + ) + cal = make_google_calendar(connected=True, external_connection_id=ext.id) + self._create_expiring_channel(with_db, cal.id) + + mock_google_client = Mock() + mock_google_client.SCOPES = ['https://www.googleapis.com/auth/calendar'] + mock_google_client.watch_events.return_value = None + + self._run_renew(with_db, mock_google_client) + + with with_db() as db: + assert repo.google_calendar_channel.get_by_calendar_id(db, cal.id) is None + + def test_still_renews_if_stop_channel_fails( + self, with_db, make_google_calendar, make_external_connections + ): + """Failure to stop the old channel should not prevent renewal.""" + ext = make_external_connections( + subscriber_id=1, + type=models.ExternalConnectionType.google, + token=_make_google_token(), + ) + cal = make_google_calendar(connected=True, external_connection_id=ext.id) + self._create_expiring_channel(with_db, cal.id) + + new_expiration_ms = int(datetime(2030, 6, 1, tzinfo=timezone.utc).timestamp() * 1000) + mock_google_client = Mock() + mock_google_client.SCOPES = ['https://www.googleapis.com/auth/calendar'] + mock_google_client.stop_channel.side_effect = Exception('Google API error') + mock_google_client.watch_events.return_value = { + 'id': 'new-channel-id', + 'resourceId': 'new-resource-id', + 'expiration': str(new_expiration_ms), + } + + self._run_renew(with_db, mock_google_client) + + mock_google_client.watch_events.assert_called_once() + + with with_db() as db: + updated = repo.google_calendar_channel.get_by_calendar_id(db, cal.id) + assert updated is not None + assert updated.channel_id == 'new-channel-id' + assert updated.state is not None + assert updated.state != 'old-state' diff --git a/backend/test/unit/test_google_webhook.py b/backend/test/unit/test_google_webhook.py new file mode 100644 index 000000000..fa4564a2c --- /dev/null +++ b/backend/test/unit/test_google_webhook.py @@ -0,0 +1,629 @@ +"""Unit tests for the Google Calendar webhook processing logic.""" + +import json +import os +from datetime import datetime, timedelta, timezone +from unittest.mock import Mock, patch + +from appointment.controller.google_watch import setup_watch_channel, teardown_watch_channel +from appointment.database import models, repo +from appointment.routes.webhooks import ( + _find_appointment_by_external_id, + _handle_bookee_rsvp, + _handle_subscriber_rsvp, +) + + +class TestFindAppointmentByExternalId: + def test_finds_matching_appointment(self, with_db, make_google_calendar, make_appointment): + calendar = make_google_calendar(connected=True) + appointment = make_appointment(calendar_id=calendar.id, slots=None) + + with with_db() as db: + db_appointment = repo.appointment.get(db, appointment.id) + db_appointment.external_id = 'google-event-123' + db.commit() + + result = _find_appointment_by_external_id(db, calendar.id, 'google-event-123') + assert result is not None + assert result.id == appointment.id + + def test_returns_none_for_no_match(self, with_db, make_google_calendar, make_appointment): + calendar = make_google_calendar(connected=True) + make_appointment(calendar_id=calendar.id, slots=None) + + with with_db() as db: + result = _find_appointment_by_external_id(db, calendar.id, 'nonexistent-event') + assert result is None + + def test_scoped_to_calendar(self, with_db, make_google_calendar, make_appointment, make_pro_subscriber): + """Appointments on other calendars should not be found.""" + sub = make_pro_subscriber() + cal1 = make_google_calendar(subscriber_id=sub.id, connected=True) + cal2 = make_google_calendar(subscriber_id=sub.id, connected=True) + appt = make_appointment(calendar_id=cal1.id, slots=None) + + with with_db() as db: + db_appt = repo.appointment.get(db, appt.id) + db_appt.external_id = 'event-on-cal1' + db.commit() + + assert _find_appointment_by_external_id(db, cal1.id, 'event-on-cal1') is not None + assert _find_appointment_by_external_id(db, cal2.id, 'event-on-cal1') is None + + +class TestHandleBookeeRsvp: + def _make_test_objects(self, with_db, make_google_calendar, make_appointment, make_attendee, make_appointment_slot): + calendar = make_google_calendar(connected=True) + attendee = make_attendee(email='bookee@example.com', name='Bookee') + appointment = make_appointment( + calendar_id=calendar.id, + status=models.AppointmentStatus.opened, + slots=None, + ) + make_appointment_slot( + appointment_id=appointment.id, + booking_status=models.BookingStatus.requested, + attendee_id=attendee.id, + ) + + with with_db() as db: + db_appt = repo.appointment.get(db, appointment.id) + db_appt.external_id = 'google-event-456' + db.commit() + + # Retrieve slot ID from DB to avoid detached instance issues + slot_id = db_appt.slots[0].id if db_appt.slots else None + + return calendar, appointment.id, slot_id, attendee + + def test_decline_marks_slot_declined( + self, with_db, make_google_calendar, make_appointment, make_attendee, make_appointment_slot + ): + calendar, appointment_id, slot_id, attendee = self._make_test_objects( + with_db, make_google_calendar, make_appointment, make_attendee, make_appointment_slot + ) + + mock_client = Mock() + mock_client.delete_event = Mock() + mock_token = Mock() + + with with_db() as db: + db_appointment = repo.appointment.get(db, appointment_id) + db_slot = repo.slot.get(db, slot_id) + + _handle_bookee_rsvp( + db, db_appointment, db_slot, 'declined', mock_client, mock_token, calendar.user + ) + + db.refresh(db_slot) + assert db_slot.booking_status == models.BookingStatus.declined + mock_client.delete_event.assert_called_once() + + def test_accept_does_not_auto_book( + self, with_db, make_google_calendar, make_appointment, make_attendee, make_appointment_slot + ): + """Bookee accepting the tentative invite should not auto-book; + the subscriber must still confirm via the branded email or app UI.""" + calendar, appointment_id, slot_id, attendee = self._make_test_objects( + with_db, make_google_calendar, make_appointment, make_attendee, make_appointment_slot + ) + + mock_client = Mock() + mock_token = Mock() + + with with_db() as db: + db_appointment = repo.appointment.get(db, appointment_id) + db_slot = repo.slot.get(db, slot_id) + + _handle_bookee_rsvp( + db, db_appointment, db_slot, 'accepted', mock_client, mock_token, calendar.user + ) + + db.refresh(db_slot) + assert db_slot.booking_status == models.BookingStatus.requested + + db.refresh(db_appointment) + assert db_appointment.status == models.AppointmentStatus.opened + + def test_accept_noop_for_already_booked( + self, with_db, make_google_calendar, make_appointment, make_attendee, make_appointment_slot + ): + calendar = make_google_calendar(connected=True) + attendee = make_attendee(email='bookee@example.com') + appointment = make_appointment( + calendar_id=calendar.id, + status=models.AppointmentStatus.closed, + slots=None, + ) + make_appointment_slot( + appointment_id=appointment.id, + booking_status=models.BookingStatus.booked, + attendee_id=attendee.id, + ) + + mock_client = Mock() + mock_token = Mock() + + with with_db() as db: + db_appointment = repo.appointment.get(db, appointment.id) + db_slot = db_appointment.slots[0] + + _handle_bookee_rsvp( + db, db_appointment, db_slot, 'accepted', mock_client, mock_token, calendar.user + ) + + db.refresh(db_slot) + assert db_slot.booking_status == models.BookingStatus.booked + mock_client.insert_event.assert_not_called() + + def test_needsaction_is_ignored( + self, with_db, make_google_calendar, make_appointment, make_attendee, make_appointment_slot + ): + calendar, appointment_id, slot_id, attendee = self._make_test_objects( + with_db, make_google_calendar, make_appointment, make_attendee, make_appointment_slot + ) + + mock_client = Mock() + mock_token = Mock() + + with with_db() as db: + db_appointment = repo.appointment.get(db, appointment_id) + db_slot = repo.slot.get(db, slot_id) + + _handle_bookee_rsvp( + db, db_appointment, db_slot, 'needsAction', mock_client, mock_token, calendar.user + ) + + db.refresh(db_slot) + assert db_slot.booking_status == models.BookingStatus.requested + + +class TestHandleSubscriberRsvp: + """Tests for _handle_subscriber_rsvp, focused on meeting-link and event-update behaviour.""" + + def _make_test_objects( + self, with_db, make_google_calendar, make_appointment, make_attendee, make_appointment_slot, + meeting_link_provider=models.MeetingLinkProviderType.none, + location_url='https://meet.example.com', + ): + calendar = make_google_calendar(connected=True) + attendee = make_attendee(email='bookee@example.com', name='Bookee') + appointment = make_appointment( + calendar_id=calendar.id, + status=models.AppointmentStatus.opened, + location_url=location_url, + meeting_link_provider=meeting_link_provider, + slots=None, + ) + make_appointment_slot( + appointment_id=appointment.id, + booking_status=models.BookingStatus.requested, + attendee_id=attendee.id, + ) + + with with_db() as db: + db_appt = repo.appointment.get(db, appointment.id) + db_appt.external_id = 'google-event-789' + db.commit() + slot_id = db_appt.slots[0].id + + return calendar, appointment.id, slot_id + + def test_accept_patches_event_with_location_and_title( + self, with_db, make_google_calendar, make_appointment, make_attendee, make_appointment_slot + ): + """Accepting via Google should patch the event with summary, location, and description.""" + calendar, appointment_id, slot_id = self._make_test_objects( + with_db, make_google_calendar, make_appointment, make_attendee, make_appointment_slot, + ) + + mock_client = Mock() + mock_client.get_event.return_value = { + 'attendees': [ + {'email': 'owner@example.com', 'self': True, 'responseStatus': 'needsAction'}, + {'email': 'bookee@example.com', 'responseStatus': 'needsAction'}, + ] + } + mock_token = Mock() + + with with_db() as db: + db_appointment = repo.appointment.get(db, appointment_id) + db_slot = repo.slot.get(db, slot_id) + + _handle_subscriber_rsvp( + db, db_appointment, db_slot, 'accepted', + mock_client, mock_token, calendar.user, + ) + + mock_client.patch_event.assert_called_once() + patch_body = mock_client.patch_event.call_args.args[2] + assert patch_body['status'] == 'confirmed' + assert 'summary' in patch_body + assert patch_body.get('location') == 'https://meet.example.com' + + desc_lines = patch_body.get('description', '').split('\n') + assert any(line.endswith('https://meet.example.com') for line in desc_lines) + + @patch('appointment.controller.zoom.create_meeting_link') + def test_accept_creates_zoom_link_when_configured( + self, mock_create_zoom, + with_db, make_google_calendar, make_appointment, make_attendee, make_appointment_slot, + ): + """When meeting_link_provider is zoom, a Zoom link should be created and used as location.""" + mock_create_zoom.return_value = 'https://zoom.us/j/123456' + + calendar, appointment_id, slot_id = self._make_test_objects( + with_db, make_google_calendar, make_appointment, make_attendee, make_appointment_slot, + meeting_link_provider=models.MeetingLinkProviderType.zoom, + ) + + mock_client = Mock() + mock_client.get_event.return_value = {'attendees': []} + mock_token = Mock() + + with with_db() as db: + db_appointment = repo.appointment.get(db, appointment_id) + db_slot = repo.slot.get(db, slot_id) + + _handle_subscriber_rsvp( + db, db_appointment, db_slot, 'accepted', + mock_client, mock_token, calendar.user, + ) + + mock_create_zoom.assert_called_once() + patch_body = mock_client.patch_event.call_args.args[2] + assert patch_body.get('location') == 'https://zoom.us/j/123456' + + def test_accept_sets_organizer_response_to_accepted( + self, with_db, make_google_calendar, make_appointment, make_attendee, make_appointment_slot + ): + """The organizer (self) attendee responseStatus should be set to accepted.""" + calendar, appointment_id, slot_id = self._make_test_objects( + with_db, make_google_calendar, make_appointment, make_attendee, make_appointment_slot, + ) + + mock_client = Mock() + mock_client.get_event.return_value = { + 'attendees': [ + {'email': 'owner@example.com', 'self': True, 'responseStatus': 'needsAction'}, + {'email': 'bookee@example.com', 'responseStatus': 'needsAction'}, + ] + } + mock_token = Mock() + + with with_db() as db: + db_appointment = repo.appointment.get(db, appointment_id) + db_slot = repo.slot.get(db, slot_id) + + _handle_subscriber_rsvp( + db, db_appointment, db_slot, 'accepted', + mock_client, mock_token, calendar.user, + ) + + patch_body = mock_client.patch_event.call_args.args[2] + owner_att = next(a for a in patch_body['attendees'] if a.get('self')) + assert owner_att['responseStatus'] == 'accepted' + + def test_accept_noop_when_already_closed( + self, with_db, make_google_calendar, make_appointment, make_attendee, make_appointment_slot + ): + """If the appointment is already closed, accepting again should be a no-op.""" + calendar = make_google_calendar(connected=True) + attendee = make_attendee(email='bookee@example.com', name='Bookee') + appointment = make_appointment( + calendar_id=calendar.id, + status=models.AppointmentStatus.closed, + slots=None, + ) + make_appointment_slot( + appointment_id=appointment.id, + booking_status=models.BookingStatus.booked, + attendee_id=attendee.id, + ) + + with with_db() as db: + db_appt = repo.appointment.get(db, appointment.id) + db_appt.external_id = 'already-confirmed-event' + db.commit() + + mock_client = Mock() + mock_token = Mock() + + with with_db() as db: + db_appointment = repo.appointment.get(db, appointment.id) + db_slot = db_appointment.slots[0] + + _handle_subscriber_rsvp( + db, db_appointment, db_slot, 'accepted', + mock_client, mock_token, calendar.user, + ) + + mock_client.patch_event.assert_not_called() + + +class TestGoogleCalendarChannelRepo: + def test_create_and_get_by_channel_id(self, with_db, make_google_calendar): + calendar = make_google_calendar(connected=True) + + with with_db() as db: + channel = repo.google_calendar_channel.create( + db, + calendar_id=calendar.id, + channel_id='test-channel-abc', + resource_id='test-resource-xyz', + expiration=datetime.now(tz=timezone.utc) + timedelta(days=7), + state='test-state-abc', + ) + assert channel.id is not None + + found = repo.google_calendar_channel.get_by_channel_id(db, 'test-channel-abc') + assert found is not None + assert found.id == channel.id + + def test_get_by_calendar_id(self, with_db, make_google_calendar): + calendar = make_google_calendar(connected=True) + + with with_db() as db: + repo.google_calendar_channel.create( + db, + calendar_id=calendar.id, + channel_id='channel-for-cal', + resource_id='resource-for-cal', + expiration=datetime.now(tz=timezone.utc) + timedelta(days=7), + state='state-for-cal', + ) + + found = repo.google_calendar_channel.get_by_calendar_id(db, calendar.id) + assert found is not None + assert found.channel_id == 'channel-for-cal' + + def test_update_sync_token(self, with_db, make_google_calendar): + calendar = make_google_calendar(connected=True) + + with with_db() as db: + channel = repo.google_calendar_channel.create( + db, + calendar_id=calendar.id, + channel_id='ch-sync-test', + resource_id='res-sync-test', + expiration=datetime.now(tz=timezone.utc) + timedelta(days=7), + state='state-sync-test', + ) + assert channel.sync_token is None + + updated = repo.google_calendar_channel.update_sync_token(db, channel, 'new-sync-token-123') + assert updated.sync_token == 'new-sync-token-123' + + def test_get_expiring(self, with_db, make_google_calendar, make_pro_subscriber): + sub = make_pro_subscriber() + cal1 = make_google_calendar(subscriber_id=sub.id, connected=True) + cal2 = make_google_calendar(subscriber_id=sub.id, connected=True) + + now = datetime.now(tz=timezone.utc) + with with_db() as db: + repo.google_calendar_channel.create( + db, + calendar_id=cal1.id, + channel_id='expiring-soon', + resource_id='res-1', + expiration=now + timedelta(hours=12), + state='state-expiring', + ) + repo.google_calendar_channel.create( + db, + calendar_id=cal2.id, + channel_id='not-expiring', + resource_id='res-2', + expiration=now + timedelta(days=5), + state='state-not-expiring', + ) + + threshold = now + timedelta(hours=24) + expiring = repo.google_calendar_channel.get_expiring(db, before=threshold) + assert len(expiring) == 1 + assert expiring[0].channel_id == 'expiring-soon' + + def test_delete(self, with_db, make_google_calendar): + calendar = make_google_calendar(connected=True) + + with with_db() as db: + channel = repo.google_calendar_channel.create( + db, + calendar_id=calendar.id, + channel_id='to-delete', + resource_id='res-del', + expiration=datetime.now(tz=timezone.utc) + timedelta(days=7), + state='state-del', + ) + + repo.google_calendar_channel.delete(db, channel) + + assert repo.google_calendar_channel.get_by_channel_id(db, 'to-delete') is None + + def test_cascade_delete_with_calendar(self, with_db, make_google_calendar): + """Channel should be deleted when its calendar is deleted.""" + calendar = make_google_calendar(connected=True) + + with with_db() as db: + repo.google_calendar_channel.create( + db, + calendar_id=calendar.id, + channel_id='cascade-test', + resource_id='res-cascade', + expiration=datetime.now(tz=timezone.utc) + timedelta(days=7), + state='state-cascade', + ) + + repo.calendar.delete(db, calendar.id) + + assert repo.google_calendar_channel.get_by_channel_id(db, 'cascade-test') is None + + +class TestSetupWatchChannel: + def test_creates_channel_for_google_calendar( + self, with_db, make_google_calendar, make_external_connections, make_pro_subscriber + ): + subscriber = make_pro_subscriber() + google_creds = json.dumps({ + 'token': 'fake-token', + 'refresh_token': 'fake-refresh', + 'client_id': 'fake-client-id', + 'client_secret': 'fake-secret', + }) + ext_conn = make_external_connections( + subscriber.id, type=models.ExternalConnectionType.google, token=google_creds, + ) + calendar = make_google_calendar( + subscriber_id=subscriber.id, connected=True, external_connection_id=ext_conn.id, + ) + + mock_client = Mock() + mock_client.SCOPES = ['https://www.googleapis.com/auth/calendar.events'] + mock_client.watch_events.return_value = { + 'id': 'new-channel-id', + 'resourceId': 'new-resource-id', + 'expiration': str(int((datetime.now(tz=timezone.utc) + timedelta(days=7)).timestamp() * 1000)), + } + mock_client.get_initial_sync_token.return_value = 'initial-sync-token' + + os.environ['BACKEND_URL'] = 'http://localhost:5000' + + with with_db() as db: + db_cal = repo.calendar.get(db, calendar.id) + result = setup_watch_channel(db, mock_client, db_cal) + + assert result is True + channel = repo.google_calendar_channel.get_by_calendar_id(db, calendar.id) + assert channel is not None + assert channel.channel_id == 'new-channel-id' + assert channel.sync_token == 'initial-sync-token' + assert channel.state is not None + + def test_noop_if_channel_already_exists( + self, with_db, make_google_calendar, make_external_connections, make_pro_subscriber + ): + subscriber = make_pro_subscriber() + google_creds = json.dumps({ + 'token': 'fake-token', + 'refresh_token': 'fake-refresh', + 'client_id': 'fake-client-id', + 'client_secret': 'fake-secret', + }) + ext_conn = make_external_connections( + subscriber.id, type=models.ExternalConnectionType.google, token=google_creds, + ) + calendar = make_google_calendar( + subscriber_id=subscriber.id, connected=True, external_connection_id=ext_conn.id, + ) + + with with_db() as db: + repo.google_calendar_channel.create( + db, + calendar_id=calendar.id, + channel_id='existing-channel', + resource_id='existing-resource', + expiration=datetime.now(tz=timezone.utc) + timedelta(days=7), + state='existing-state', + ) + + mock_client = Mock() + + with with_db() as db: + db_cal = repo.calendar.get(db, calendar.id) + result = setup_watch_channel(db, mock_client, db_cal) + + assert result is True + mock_client.watch_events.assert_not_called() + + def test_noop_for_caldav_calendar(self, with_db, make_caldav_calendar): + calendar = make_caldav_calendar(connected=True) + mock_client = Mock() + + with with_db() as db: + db_cal = repo.calendar.get(db, calendar.id) + result = setup_watch_channel(db, mock_client, db_cal) + + assert result is False + mock_client.watch_events.assert_not_called() + + def test_noop_if_no_google_client(self, with_db, make_google_calendar): + calendar = make_google_calendar(connected=True) + + with with_db() as db: + db_cal = repo.calendar.get(db, calendar.id) + result = setup_watch_channel(db, None, db_cal) + + assert result is False + assert repo.google_calendar_channel.get_by_calendar_id(db, calendar.id) is None + + +class TestTeardownWatchChannel: + def test_removes_existing_channel( + self, with_db, make_google_calendar, make_external_connections, make_pro_subscriber + ): + subscriber = make_pro_subscriber() + google_creds = json.dumps({ + 'token': 'fake-token', + 'refresh_token': 'fake-refresh', + 'client_id': 'fake-client-id', + 'client_secret': 'fake-secret', + }) + ext_conn = make_external_connections( + subscriber.id, type=models.ExternalConnectionType.google, token=google_creds, + ) + calendar = make_google_calendar( + subscriber_id=subscriber.id, connected=True, external_connection_id=ext_conn.id, + ) + + with with_db() as db: + repo.google_calendar_channel.create( + db, + calendar_id=calendar.id, + channel_id='teardown-channel', + resource_id='teardown-resource', + expiration=datetime.now(tz=timezone.utc) + timedelta(days=7), + state='teardown-state', + ) + + mock_client = Mock() + mock_client.SCOPES = ['https://www.googleapis.com/auth/calendar.events'] + + with with_db() as db: + db_cal = repo.calendar.get(db, calendar.id) + result = teardown_watch_channel(db, mock_client, db_cal) + + assert result is True + mock_client.stop_channel.assert_called_once() + assert repo.google_calendar_channel.get_by_calendar_id(db, calendar.id) is None + + def test_noop_if_no_channel(self, with_db, make_google_calendar): + calendar = make_google_calendar(connected=True) + mock_client = Mock() + + with with_db() as db: + db_cal = repo.calendar.get(db, calendar.id) + result = teardown_watch_channel(db, mock_client, db_cal) + + assert result is False + mock_client.stop_channel.assert_not_called() + + def test_deletes_record_even_without_google_client(self, with_db, make_google_calendar): + calendar = make_google_calendar(connected=True) + + with with_db() as db: + repo.google_calendar_channel.create( + db, + calendar_id=calendar.id, + channel_id='orphan-channel', + resource_id='orphan-resource', + expiration=datetime.now(tz=timezone.utc) + timedelta(days=7), + state='orphan-state', + ) + + with with_db() as db: + db_cal = repo.calendar.get(db, calendar.id) + result = teardown_watch_channel(db, None, db_cal) + + assert result is True + assert repo.google_calendar_channel.get_by_calendar_id(db, calendar.id) is None diff --git a/backend/test/unit/test_handle_availability_decision.py b/backend/test/unit/test_handle_availability_decision.py new file mode 100644 index 000000000..2ed2cb402 --- /dev/null +++ b/backend/test/unit/test_handle_availability_decision.py @@ -0,0 +1,236 @@ +"""Unit tests for handle_schedule_availability_decision Google calendar paths.""" + +from datetime import datetime, timedelta, timezone +from unittest.mock import Mock, MagicMock, patch + +from appointment.database import models, repo, schemas +from appointment.routes.schedule import handle_schedule_availability_decision + + +class TestHandleScheduleAvailabilityDecisionGoogle: + """Tests for the Google invite branches in handle_schedule_availability_decision.""" + + def _make_google_calendar(self): + cal = Mock() + cal.provider = models.CalendarProvider.google + cal.user = 'cal@google.com' + return cal + + def _make_schedule(self): + schedule = Mock() + schedule.location_url = 'https://meet.example.com' + schedule.meeting_link_provider = models.MeetingLinkProviderType.none + schedule.details = 'Test details' + schedule.name = 'Test Schedule' + return schedule + + def _make_subscriber(self): + subscriber = Mock() + subscriber.name = 'Subscriber' + subscriber.timezone = 'UTC' + subscriber.preferred_email = 'sub@example.com' + subscriber.language = 'en' + return subscriber + + def _setup( + self, with_db, make_google_calendar, make_appointment, + make_attendee, make_appointment_slot, has_external_id=True, + ): + calendar = make_google_calendar(connected=True) + attendee = make_attendee(email='bookee@example.com', name='Bookee') + appointment = make_appointment( + calendar_id=calendar.id, + status=models.AppointmentStatus.opened, + slots=None, + ) + make_appointment_slot( + appointment_id=appointment.id, + attendee_id=attendee.id, + booking_status=models.BookingStatus.requested, + booking_tkn='test-token', + ) + + if has_external_id: + with with_db() as db: + repo.appointment.update_external_id_by_id(db, appointment.id, 'google-event-123') + + return calendar, appointment + + @patch('appointment.routes.schedule.get_remote_connection') + def test_confirm_patches_existing_hold_event( + self, mock_get_remote_connection, + with_db, make_google_calendar, make_appointment, make_attendee, make_appointment_slot, + ): + """When a hold event exists (has external_id), confirm_event should be called.""" + calendar, appointment = self._setup( + with_db, make_google_calendar, make_appointment, + make_attendee, make_appointment_slot, has_external_id=True, + ) + + mock_connector = MagicMock() + mock_get_remote_connection.return_value = (mock_connector, 'sub@example.com') + + google_calendar = self._make_google_calendar() + schedule = self._make_schedule() + subscriber = self._make_subscriber() + background_tasks = MagicMock() + + with with_db() as db: + db_appointment = repo.appointment.get(db, appointment.id) + slot = db_appointment.slots[0] + db.add(slot) + db.add(slot.attendee) + + handle_schedule_availability_decision( + True, google_calendar, schedule, subscriber, slot, + db, None, None, background_tasks, + ) + + db.refresh(slot) + assert slot.booking_status == models.BookingStatus.booked + + mock_connector.confirm_event.assert_called_once() + call_args = mock_connector.confirm_event.call_args + assert call_args.args[0] == 'google-event-123' + event_arg = call_args.kwargs.get('event') + assert isinstance(event_arg, schemas.Event) + assert event_arg.location.url == 'https://meet.example.com' + assert call_args.kwargs.get('organizer_language') == 'en' + + @patch('appointment.routes.schedule.save_remote_event') + def test_confirm_creates_event_when_no_hold_exists( + self, mock_save_remote_event, + with_db, make_google_calendar, make_appointment, make_attendee, make_appointment_slot, + ): + """When no hold event exists (no external_id), save_remote_event should be called + with send_google_notification=True and booking_confirmation=False.""" + calendar, appointment = self._setup( + with_db, make_google_calendar, make_appointment, + make_attendee, make_appointment_slot, has_external_id=False, + ) + + mock_event = schemas.Event( + title='Test', + start=datetime.now(tz=timezone.utc), + end=datetime.now(tz=timezone.utc) + timedelta(minutes=30), + description='', + location=schemas.EventLocation(url=None), + external_id='new-google-event-456', + ) + mock_save_remote_event.return_value = mock_event + + google_calendar = self._make_google_calendar() + schedule = self._make_schedule() + subscriber = self._make_subscriber() + background_tasks = MagicMock() + + with with_db() as db: + db_appointment = repo.appointment.get(db, appointment.id) + slot = db_appointment.slots[0] + db.add(slot) + db.add(slot.attendee) + + handle_schedule_availability_decision( + True, google_calendar, schedule, subscriber, slot, + db, None, None, background_tasks, + ) + + db.refresh(slot) + assert slot.booking_status == models.BookingStatus.booked + + mock_save_remote_event.assert_called_once() + call_kwargs = mock_save_remote_event.call_args + assert call_kwargs.kwargs.get('send_google_notification') is True + assert call_kwargs.kwargs.get('booking_confirmation') is False + + with with_db() as db: + appt = repo.appointment.get(db, appointment.id) + assert appt.external_id == 'new-google-event-456' + + @patch('appointment.routes.schedule.save_remote_event') + def test_confirm_creates_event_does_not_send_branded_vevent( + self, mock_save_remote_event, + with_db, make_google_calendar, make_appointment, make_attendee, make_appointment_slot, + ): + """For Google invites, the branded vevent email should NOT be sent + (Google handles notifications via sendUpdates).""" + calendar, appointment = self._setup( + with_db, make_google_calendar, make_appointment, + make_attendee, make_appointment_slot, has_external_id=False, + ) + + mock_event = schemas.Event( + title='Test', + start=datetime.now(tz=timezone.utc), + end=datetime.now(tz=timezone.utc) + timedelta(minutes=30), + description='', + location=schemas.EventLocation(url=None), + ) + mock_save_remote_event.return_value = mock_event + + google_calendar = self._make_google_calendar() + schedule = self._make_schedule() + subscriber = self._make_subscriber() + background_tasks = MagicMock() + + with with_db() as db: + db_appointment = repo.appointment.get(db, appointment.id) + slot = db_appointment.slots[0] + db.add(slot) + db.add(slot.attendee) + + handle_schedule_availability_decision( + True, google_calendar, schedule, subscriber, slot, + db, None, None, background_tasks, + ) + + for call in background_tasks.add_task.call_args_list: + func = call[0][0] if call[0] else call.kwargs.get('func') + assert 'send_invitation' not in getattr(func, '__name__', '') + + @patch('appointment.routes.schedule.save_remote_event') + @patch.dict('os.environ', {'GOOGLE_INVITE_ENABLED': 'False'}) + def test_flag_disabled_uses_non_google_path( + self, mock_save_remote_event, + with_db, make_google_calendar, make_appointment, make_attendee, make_appointment_slot, + ): + """When GOOGLE_INVITE_ENABLED is False, a Google calendar should fall back + to the import/branded-email path (send_google_notification defaults to False).""" + calendar, appointment = self._setup( + with_db, make_google_calendar, make_appointment, + make_attendee, make_appointment_slot, has_external_id=False, + ) + + mock_event = schemas.Event( + title='Test', + start=datetime.now(tz=timezone.utc), + end=datetime.now(tz=timezone.utc) + timedelta(minutes=30), + description='', + location=schemas.EventLocation(url=None), + external_id='import-event-789', + ) + mock_save_remote_event.return_value = mock_event + + google_calendar = self._make_google_calendar() + schedule = self._make_schedule() + subscriber = self._make_subscriber() + background_tasks = MagicMock() + + with with_db() as db: + db_appointment = repo.appointment.get(db, appointment.id) + slot = db_appointment.slots[0] + db.add(slot) + db.add(slot.attendee) + + handle_schedule_availability_decision( + True, google_calendar, schedule, subscriber, slot, + db, None, None, background_tasks, + ) + + db.refresh(slot) + assert slot.booking_status == models.BookingStatus.booked + + mock_save_remote_event.assert_called_once() + call_kwargs = mock_save_remote_event.call_args + assert call_kwargs.kwargs.get('send_google_notification', False) is False + assert call_kwargs.kwargs.get('booking_confirmation', False) is False diff --git a/pulumi/config.prod.yaml b/pulumi/config.prod.yaml index b6e24d20d..1319e93c9 100644 --- a/pulumi/config.prod.yaml +++ b/pulumi/config.prod.yaml @@ -301,6 +301,8 @@ resources: value: https://thundermail.com - name: TB_ACCOUNTS_HOST value: https://accounts.tb.pro + - name: GOOGLE_INVITE_ENABLED + value: "False" tb:autoscale:EcsServiceAutoscaler: backend: diff --git a/pulumi/config.stage.yaml b/pulumi/config.stage.yaml index aaa8bf32e..9ace47eea 100644 --- a/pulumi/config.stage.yaml +++ b/pulumi/config.stage.yaml @@ -302,6 +302,8 @@ resources: value: https://stage-thundermail.com - name: TB_ACCOUNTS_HOST value: https://accounts-stage.tb.pro + - name: GOOGLE_INVITE_ENABLED + value: "True" tb:autoscale:EcsServiceAutoscaler: backend: