import json import requests from unittest.mock import patch, call, MagicMock from odoo import fields from odoo.addons.microsoft_calendar.utils.microsoft_calendar import MicrosoftCalendarService from odoo.addons.microsoft_calendar.utils.microsoft_event import MicrosoftEvent from odoo.addons.microsoft_account.models.microsoft_service import MicrosoftService from odoo.tests import TransactionCase DEFAULT_TIMEOUT = 20 class TestMicrosoftService(TransactionCase): def _do_request_result(self, data): """ _do_request returns a tuple (status, data, time) but only the data part is used """ return (None, data, None) def setUp(self): super(TestMicrosoftService, self).setUp() self.service = MicrosoftCalendarService(self.env["microsoft.service"]) self.fake_token = "MY_TOKEN" self.fake_sync_token = "MY_SYNC_TOKEN" self.fake_next_sync_token = "MY_NEXT_SYNC_TOKEN" self.fake_next_sync_token_url = f"https://graph.microsoft.com/v1.0/me/calendarView/delta?$deltatoken={self.fake_next_sync_token}" self.header_prefer = 'outlook.body-content-type="html", odata.maxpagesize=50' self.header = {'Content-type': 'application/json', 'Authorization': 'Bearer %s' % self.fake_token} self.call_with_sync_token = call( "/v1.0/me/calendarView/delta", {"$deltatoken": self.fake_sync_token}, {**self.header, 'Prefer': self.header_prefer}, method="GET", timeout=DEFAULT_TIMEOUT, ) self.call_without_sync_token = call( "/v1.0/me/calendarView/delta", { 'startDateTime': fields.Datetime.subtract(fields.Datetime.now(), years=1).strftime("%Y-%m-%dT00:00:00Z"), 'endDateTime': fields.Datetime.add(fields.Datetime.now(), years=2).strftime("%Y-%m-%dT00:00:00Z"), }, {**self.header, 'Prefer': self.header_prefer}, method="GET", timeout=DEFAULT_TIMEOUT, ) def test_get_events_delta_without_token(self): """ if no token is provided, an exception is raised """ with self.assertRaises(AttributeError): self.service._get_events_delta() @patch.object(MicrosoftService, "_do_request") def test_get_events_unexpected_exception(self, mock_do_request): """ When an unexpected exception is raised, just propagate it. """ mock_do_request.side_effect = Exception() with self.assertRaises(Exception): self.service._get_events_delta(token=self.fake_token, timeout=DEFAULT_TIMEOUT) @patch.object(MicrosoftCalendarService, "_check_full_sync_required") @patch.object(MicrosoftService, "_do_request") def test_get_events_delta_token_error(self, mock_do_request, mock_check_full_sync_required): """ When the provided sync token is invalid, an exception should be raised and then a full sync should be done. """ mock_do_request.side_effect = [ requests.HTTPError(response=MagicMock(status_code=410, content="fullSyncRequired")), self._do_request_result({"value": []}), ] mock_check_full_sync_required.return_value = (True) events, next_token = self.service._get_events_delta( token=self.fake_token, sync_token=self.fake_sync_token, timeout=DEFAULT_TIMEOUT ) self.assertEqual(next_token, None) self.assertFalse(events) mock_do_request.assert_has_calls([self.call_with_sync_token, self.call_without_sync_token]) @patch.object(MicrosoftService, "_do_request") def test_get_events_delta_without_sync_token(self, mock_do_request): """ when no sync token is provided, a full sync should be done """ # returns empty data without any next sync token mock_do_request.return_value = self._do_request_result({"value": []}) events, next_token = self.service._get_events_delta(token=self.fake_token, timeout=DEFAULT_TIMEOUT) self.assertEqual(next_token, None) self.assertFalse(events) mock_do_request.assert_has_calls([self.call_without_sync_token]) @patch.object(MicrosoftService, "_do_request") def test_get_events_delta_with_sync_token(self, mock_do_request): """ when a sync token is provided, we should retrieve the sync token to use for the next sync. """ # returns empty data with a next sync token mock_do_request.return_value = self._do_request_result({ "value": [], "@odata.deltaLink": self.fake_next_sync_token_url }) events, next_token = self.service._get_events_delta( token=self.fake_token, sync_token=self.fake_sync_token, timeout=DEFAULT_TIMEOUT ) self.assertEqual(next_token, "MY_NEXT_SYNC_TOKEN") self.assertFalse(events) mock_do_request.assert_has_calls([self.call_with_sync_token]) @patch.object(MicrosoftService, "_do_request") def test_get_events_one_page(self, mock_do_request): """ When all events are on one page, just get them. """ mock_do_request.return_value = self._do_request_result({ "value": [ {"id": 1, "type": "singleInstance", "subject": "ev1"}, {"id": 2, "type": "singleInstance", "subject": "ev2"}, {"id": 3, "type": "singleInstance", "subject": "ev3"}, ], }) events, _ = self.service._get_events_delta(token=self.fake_token, timeout=DEFAULT_TIMEOUT) self.assertEqual(events, MicrosoftEvent([ {"id": 1, "type": "singleInstance", "subject": "ev1"}, {"id": 2, "type": "singleInstance", "subject": "ev2"}, {"id": 3, "type": "singleInstance", "subject": "ev3"}, ])) mock_do_request.assert_has_calls([self.call_without_sync_token]) @patch.object(MicrosoftService, "_do_request") def test_get_events_loop_over_pages(self, mock_do_request): """ Loop over pages to retrieve all the events. """ mock_do_request.side_effect = [ self._do_request_result({ "value": [{"id": 1, "type": "singleInstance", "subject": "ev1"}], "@odata.nextLink": "link_1" }), self._do_request_result({ "value": [{"id": 2, "type": "singleInstance", "subject": "ev2"}], "@odata.nextLink": "link_2" }), self._do_request_result({ "value": [{"id": 3, "type": "singleInstance", "subject": "ev3"}], }), ] events, _ = self.service._get_events_delta(token=self.fake_token, timeout=DEFAULT_TIMEOUT) self.assertEqual(events, MicrosoftEvent([ {"id": 1, "type": "singleInstance", "subject": "ev1"}, {"id": 2, "type": "singleInstance", "subject": "ev2"}, {"id": 3, "type": "singleInstance", "subject": "ev3"}, ])) mock_do_request.assert_has_calls([ self.call_without_sync_token, call( "link_1", {}, {**self.header, 'Prefer': self.header_prefer}, preuri='', method="GET", timeout=DEFAULT_TIMEOUT ), call( "link_2", {}, {**self.header, 'Prefer': self.header_prefer}, preuri='', method="GET", timeout=DEFAULT_TIMEOUT ), ]) @patch.object(MicrosoftService, "_do_request") def test_get_events_filter_out_occurrences(self, mock_do_request): """ When all events are on one page, just get them. """ mock_do_request.return_value = self._do_request_result({ "value": [ {"id": 1, "type": "singleInstance", "subject": "ev1"}, {"id": 2, "type": "occurrence", "subject": "ev2"}, {"id": 3, "type": "seriesMaster", "subject": "ev3"}, ], }) events, _ = self.service._get_events_delta(token=self.fake_token, timeout=DEFAULT_TIMEOUT) self.assertEqual(events, MicrosoftEvent([ {"id": 1, "type": "singleInstance", "subject": "ev1"}, {"id": 3, "type": "seriesMaster", "subject": "ev3"}, ])) mock_do_request.assert_has_calls([self.call_without_sync_token]) def test_get_occurrence_details_token_error(self): """ if no token is provided, an exception is raised """ with self.assertRaises(AttributeError): self.service._get_occurrence_details(1) @patch.object(MicrosoftService, "_do_request") def test_get_occurrence_details(self, mock_do_request): mock_do_request.return_value = self._do_request_result({ "value": [ {"id": 1, "type": "singleInstance", "subject": "ev1"}, {"id": 2, "type": "occurrence", "subject": "ev2"}, {"id": 3, "type": "seriesMaster", "subject": "ev3"}, ], }) events = self.service._get_occurrence_details(123, token=self.fake_token, timeout=DEFAULT_TIMEOUT) self.assertEqual(events, MicrosoftEvent([ {"id": 1, "type": "singleInstance", "subject": "ev1"}, {"id": 2, "type": "occurrence", "subject": "ev2"}, {"id": 3, "type": "seriesMaster", "subject": "ev3"}, ])) mock_do_request.assert_called_with( "/v1.0/me/events/123/instances", { 'startDateTime': fields.Datetime.subtract(fields.Datetime.now(), years=1).strftime("%Y-%m-%dT00:00:00Z"), 'endDateTime': fields.Datetime.add(fields.Datetime.now(), years=2).strftime("%Y-%m-%dT00:00:00Z"), }, {**self.header, 'Prefer': self.header_prefer}, method='GET', timeout=DEFAULT_TIMEOUT, ) def test_get_events_token_error(self): """ if no token is provided, an exception is raised """ with self.assertRaises(AttributeError): self.service.get_events() @patch.object(MicrosoftService, "_do_request") def test_get_events_no_serie_master(self, mock_do_request): """ When there is no serie master, just retrieve the list of events. """ mock_do_request.return_value = self._do_request_result({ "value": [ {"id": 1, "type": "singleInstance", "subject": "ev1"}, {"id": 2, "type": "singleInstance", "subject": "ev2"}, {"id": 3, "type": "singleInstance", "subject": "ev3"}, ], }) events, _ = self.service.get_events(token=self.fake_token, timeout=DEFAULT_TIMEOUT) self.assertEqual(events, MicrosoftEvent([ {"id": 1, "type": "singleInstance", "subject": "ev1"}, {"id": 2, "type": "singleInstance", "subject": "ev2"}, {"id": 3, "type": "singleInstance", "subject": "ev3"}, ])) @patch.object(MicrosoftService, "_do_request") def test_get_events_with_one_serie_master(self, mock_do_request): """ When there is a serie master, retrieve the list of events and event occurrences linked to the serie master """ mock_do_request.side_effect = [ self._do_request_result({ "value": [ {"id": 1, "type": "singleInstance", "subject": "ev1"}, {"id": 2, "type": "seriesMaster", "subject": "ev2"}, ], }), self._do_request_result({ "value": [ {"id": 3, "type": "occurrence", "subject": "ev3"}, ], }), ] events, _ = self.service.get_events(token=self.fake_token, timeout=DEFAULT_TIMEOUT) self.assertEqual(events, MicrosoftEvent([ {"id": 1, "type": "singleInstance", "subject": "ev1"}, {"id": 2, "type": "seriesMaster", "subject": "ev2"}, {"id": 3, "type": "occurrence", "subject": "ev3"}, ])) def test_insert_token_error(self): """ if no token is provided, an exception is raised """ with self.assertRaises(AttributeError): self.service.insert({}) @patch.object(MicrosoftService, "_do_request") def test_insert(self, mock_do_request): mock_do_request.return_value = self._do_request_result({'id': 1, 'iCalUId': 2}) instance_id, event_id = self.service.insert({"subject": "ev1"}, token=self.fake_token, timeout=DEFAULT_TIMEOUT) self.assertEqual(instance_id, 1) self.assertEqual(event_id, 2) mock_do_request.assert_called_with( "/v1.0/me/calendar/events", json.dumps({"subject": "ev1"}), self.header, method="POST", timeout=DEFAULT_TIMEOUT ) def test_patch_token_error(self): """ if no token is provided, an exception is raised """ with self.assertRaises(AttributeError): self.service.patch(123, {}) @patch.object(MicrosoftService, "_do_request") def test_patch_returns_false_if_event_does_not_exist(self, mock_do_request): event_id = 123 values = {"subject": "ev2"} mock_do_request.return_value = (404, "", None) res = self.service.patch(event_id, values, token=self.fake_token, timeout=DEFAULT_TIMEOUT) self.assertFalse(res) mock_do_request.assert_called_with( f"/v1.0/me/calendar/events/{event_id}", json.dumps(values), self.header, method="PATCH", timeout=DEFAULT_TIMEOUT ) @patch.object(MicrosoftService, "_do_request") def test_patch_an_existing_event(self, mock_do_request): event_id = 123 values = {"subject": "ev2"} mock_do_request.return_value = (200, "", None) res = self.service.patch(event_id, values, token=self.fake_token, timeout=DEFAULT_TIMEOUT) self.assertTrue(res) mock_do_request.assert_called_with( f"/v1.0/me/calendar/events/{event_id}", json.dumps(values), self.header, method="PATCH", timeout=DEFAULT_TIMEOUT ) def test_delete_token_error(self): """ if no token is provided, an exception is raised """ with self.assertRaises(AttributeError): self.service.delete(123) @patch.object(MicrosoftService, "_do_request") def test_delete_returns_false_if_event_does_not_exist(self, mock_do_request): event_id = 123 mock_do_request.return_value = (404, "", None) res = self.service.delete(event_id, token=self.fake_token, timeout=DEFAULT_TIMEOUT) self.assertFalse(res) mock_do_request.assert_called_with( f"/v1.0/me/calendar/events/{event_id}", {}, headers={'Authorization': 'Bearer %s' % self.fake_token}, method="DELETE", timeout=DEFAULT_TIMEOUT ) @patch.object(MicrosoftService, "_do_request") def test_delete_an_already_cancelled_event(self, mock_do_request): """ When an event has already been cancelled, Outlook may return a status code equals to 403 or 410. In this case, the delete method should return True. """ event_id = 123 for status in (403, 410): mock_do_request.return_value = (status, "", None) res = self.service.delete(event_id, token=self.fake_token, timeout=DEFAULT_TIMEOUT) self.assertTrue(res) mock_do_request.assert_called_with( f"/v1.0/me/calendar/events/{event_id}", {}, headers={'Authorization': 'Bearer %s' % self.fake_token}, method="DELETE", timeout=DEFAULT_TIMEOUT ) @patch.object(MicrosoftService, "_do_request") def test_delete_an_existing_event(self, mock_do_request): event_id = 123 mock_do_request.return_value = (200, "", None) res = self.service.delete(event_id, token=self.fake_token, timeout=DEFAULT_TIMEOUT) self.assertTrue(res) mock_do_request.assert_called_with( f"/v1.0/me/calendar/events/{event_id}", {}, headers={'Authorization': 'Bearer %s' % self.fake_token}, method="DELETE", timeout=DEFAULT_TIMEOUT ) def test_answer_token_error(self): """ if no token is provided, an exception is raised """ with self.assertRaises(AttributeError): self.service.answer(123, 'ok', {}) @patch.object(MicrosoftService, "_do_request") def test_answer_returns_false_if_event_does_not_exist(self, mock_do_request): event_id = 123 answer = "accept" values = {"a": 1, "b": 2} mock_do_request.return_value = (404, "", None) res = self.service.answer(event_id, answer, values, token=self.fake_token, timeout=DEFAULT_TIMEOUT) self.assertFalse(res) mock_do_request.assert_called_with( f"/v1.0/me/calendar/events/{event_id}/{answer}", json.dumps(values), self.header, method="POST", timeout=DEFAULT_TIMEOUT ) @patch.object(MicrosoftService, "_do_request") def test_answer_to_an_existing_event(self, mock_do_request): event_id = 123 answer = "decline" values = {"a": 1, "b": 2} mock_do_request.return_value = (200, "", None) res = self.service.answer(event_id, answer, values, token=self.fake_token, timeout=DEFAULT_TIMEOUT) self.assertTrue(res) mock_do_request.assert_called_with( f"/v1.0/me/calendar/events/{event_id}/{answer}", json.dumps(values), self.header, method="POST", timeout=DEFAULT_TIMEOUT ) @patch.object(MicrosoftCalendarService, "_check_full_sync_required") @patch.object(MicrosoftService, "_do_request") def test_get_events_delta_with_outdated_sync_token(self, mock_do_request, mock_check_full_sync_required): """ When an outdated sync token is provided, we must fetch all events again for updating the old token. """ # Throw a 'HTTPError' when the token is outdated, thus triggering the fetching of all events. # Simulate a scenario which the full sync is required, such as when getting the 'SyncStateNotFound' error code. mock_do_request.side_effect = [ requests.HTTPError(response=MagicMock(status_code=410, error={'code': "SyncStateNotFound"})), self._do_request_result({"value": []}), ] mock_check_full_sync_required.return_value = (True) # Call the regular 'delta' get events with an outdated token for triggering the all events fetching. self.env.user.microsoft_calendar_sync_token = self.fake_sync_token self.service._get_events_delta(token=self.fake_token, sync_token=self.fake_sync_token, timeout=DEFAULT_TIMEOUT) # Two calls must have been made: one call with the outdated sync token and another one with no sync token. mock_do_request.assert_has_calls([ self.call_with_sync_token, self.call_without_sync_token ])