diff --git a/tests/tasks_tests/test_todoist.py b/tests/tasks_tests/test_todoist.py new file mode 100644 index 0000000..7b24299 --- /dev/null +++ b/tests/tasks_tests/test_todoist.py @@ -0,0 +1,127 @@ +from unittest.mock import MagicMock, patch + +import pytest +from django.contrib.auth import get_user_model + +User = get_user_model() + + +@pytest.fixture +def user_profile(db): + user = User.objects.create_user(username="testuser", password="testpass") + return user.profile + + +@pytest.mark.django_db +class TestGenerateTodoistOauthUrl: + def test_generates_url_with_state(self, user_profile): + from tasks.todoist import generate_todoist_oauth_url + + url = generate_todoist_oauth_url(user_profile.user_id) + + user_profile.refresh_from_db() + assert user_profile.todoist_state is not None + assert len(user_profile.todoist_state) == 32 + assert url.startswith("https://todoist.com/oauth/authorize") + assert user_profile.todoist_state in url + + def test_updates_existing_state(self, user_profile): + from tasks.todoist import generate_todoist_oauth_url + + old_state = "oldstate12345678901234567890123" + user_profile.todoist_state = old_state + user_profile.save() + + url = generate_todoist_oauth_url(user_profile.user_id) + + user_profile.refresh_from_db() + assert user_profile.todoist_state != old_state + + +@pytest.mark.django_db +class TestGetTodoistAccessToken: + def test_raises_when_profile_not_found(self): + from tasks.todoist import get_todoist_access_token + + with pytest.raises(Exception, match="Could not find profile"): + get_todoist_access_token(user_id=999, state="anystate", code="anycode") + + def test_raises_when_state_mismatch(self, user_profile): + from tasks.todoist import get_todoist_access_token + + user_profile.todoist_state = "correctstate1234567890123" + user_profile.save() + + with pytest.raises(Exception, match="state mismatch"): + get_todoist_access_token( + user_id=user_profile.user_id, state="wrongstate", code="anycode" + ) + + @patch("tasks.todoist.requests.post") + def test_exchanges_code_for_token(self, mock_post, user_profile): + from tasks.todoist import get_todoist_access_token + + user_profile.todoist_state = "correctstate1234567890123" + user_profile.save() + + mock_token_response = MagicMock() + mock_token_response.status_code = 200 + mock_token_response.json.return_value = {"access_token": "test_access_token"} + mock_post.return_value = mock_token_response + + get_todoist_access_token( + user_id=user_profile.user_id, + state="correctstate1234567890123", + code="testcode", + ) + + user_profile.refresh_from_db() + assert user_profile.todoist_auth_key == "test_access_token" + assert user_profile.todoist_state is None + + @patch("tasks.todoist.requests.post") + def test_fetches_todoist_user_id(self, mock_post, user_profile): + from tasks.todoist import get_todoist_access_token + + user_profile.todoist_state = "correctstate1234567890123" + user_profile.save() + + mock_token_response = MagicMock() + mock_token_response.status_code = 200 + mock_token_response.json.return_value = {"access_token": "test_access_token"} + + mock_sync_response = MagicMock() + mock_sync_response.status_code = 200 + mock_sync_response.json.return_value = {"user": {"id": "12345"}} + + mock_post.side_effect = [mock_token_response, mock_sync_response] + + get_todoist_access_token( + user_id=user_profile.user_id, + state="correctstate1234567890123", + code="testcode", + ) + + user_profile.refresh_from_db() + assert user_profile.todoist_user_id == "12345" + + @patch("tasks.todoist.requests.post") + def test_handles_token_exchange_failure(self, mock_post, user_profile): + from tasks.todoist import get_todoist_access_token + + user_profile.todoist_state = "correctstate1234567890123" + user_profile.save() + + mock_response = MagicMock() + mock_response.status_code = 400 + mock_post.return_value = mock_response + + get_todoist_access_token( + user_id=user_profile.user_id, + state="correctstate1234567890123", + code="badcode", + ) + + user_profile.refresh_from_db() + assert user_profile.todoist_auth_key is None + assert user_profile.todoist_state == "correctstate1234567890123" diff --git a/vrobbler/apps/tasks/todoist.py b/vrobbler/apps/tasks/todoist.py index 262db80..278cf2d 100644 --- a/vrobbler/apps/tasks/todoist.py +++ b/vrobbler/apps/tasks/todoist.py @@ -1,5 +1,4 @@ import logging - import secrets import requests @@ -16,6 +15,7 @@ TODOIST_OAUTH_START_URL = "https://todoist.com/oauth/authorize?client_id={id}&sc id=TODOIST_CLIENT_ID ) TODOIST_OAUTH_TOKEN_URL = "https://todoist.com/oauth/access_token" +TODOIST_API_URL = "https://api.todoist.com/api/v1/sync" def generate_todoist_oauth_url(user_id: int) -> str: @@ -28,12 +28,29 @@ def generate_todoist_oauth_url(user_id: int) -> str: def get_todoist_access_token(user_id: int, state: str, code: str): logger.info( "[get_todoist_access_token] called", - extra={"state": state, "code": code}, + extra={ + "user_id": user_id, + "state": state, + "state_repr": repr(state), + "code": code, + }, ) - user_profile = UserProfile.objects.filter(todoist_state=state).first() - if not user_profile: - raise Exception("Could not find profile") + user_profile = UserProfile.objects.filter(user_id=user_id).first() + + logger.info( + "[get_todoist_access_token] found profile", + extra={ + "user_id": user_id, + "profile_state": user_profile.todoist_state if user_profile else None, + "passed_state": state, + "match": user_profile.todoist_state == state if user_profile else False, + }, + ) + + if not user_profile or user_profile.todoist_state != state: + logger.error("[get_todoist_access_token] profile not found or state mismatch") + raise Exception("Could not find profile or state mismatch") post_data = { "client_id": settings.TODOIST_CLIENT_ID, @@ -44,11 +61,27 @@ def get_todoist_access_token(user_id: int, state: str, code: str): response = requests.post(TODOIST_OAUTH_TOKEN_URL, data=post_data) if response.status_code == 200: - user_profile.todoist_auth_key = response.json().get("access_token") + access_token = response.json().get("access_token") + user_profile.todoist_auth_key = access_token user_profile.todoist_state = None - user_profile.save() + user_profile.save(update_fields=["todoist_auth_key", "todoist_state"]) + + sync_response = requests.post( + TODOIST_API_URL, + data={"sync_token": "*", "resource_types": '["user"]'}, + headers={"Authorization": f"Bearer {access_token}"}, + ) + if sync_response.status_code == 200: + todoist_user_id = sync_response.json().get("user", {}).get("id") + if todoist_user_id: + user_profile.todoist_user_id = todoist_user_id + user_profile.save(update_fields=["todoist_user_id"]) + logger.info( + "[get_todoist_access_token] set todoist_user_id", + extra={"todoist_user_id": todoist_user_id}, + ) logger.info( "[get_todoist_access_token] finished", - extra={"user_id": user_profile.user.id}, + extra={"user_id": user_id}, )