import json import sqlite3 from urllib import error from urllib.parse import parse_qs, urlparse import pytest from fastapi.testclient import TestClient from app.auth_db import reset_auth_db_caches from app.config import Settings, get_settings from app.integrations.ticktick import ( AUTH_SCOPE, TICKTICK_AUTH_URL, TickTickClient, TickTickTask, default_auth_state_store, ) from app.main import create_app class _FakeJsonResponse: def __init__(self, status_code: int, payload): self.status_code = status_code self.payload = payload def getcode(self) -> int: return self.status_code def read(self) -> bytes: return json.dumps(self.payload).encode("utf-8") def __enter__(self): return self def __exit__(self, exc_type, exc, tb) -> None: return None def _configured_settings(**overrides) -> Settings: payload = { "app_env": "development", "app_hostname": "localhost:8000", "ticktick_client_id": "ticktick-client-id", "ticktick_client_secret": "ticktick-client-secret", "ticktick_token": "ticktick-access-token", "home_assistant_action_task_project_id": "project-123", } payload.update(overrides) return Settings(_env_file=None, **payload) def _extract_csrf_token(html: str) -> str: import re match = re.search(r'name="csrf_token" value="([^"]+)"', html) assert match is not None return match.group(1) def test_build_authorization_url_contains_expected_query(monkeypatch: pytest.MonkeyPatch) -> None: client = TickTickClient(settings=_configured_settings()) monkeypatch.setattr("app.integrations.ticktick.secrets.token_hex", lambda _: "state-123") authorization_url = client.build_authorization_url() parsed = urlparse(authorization_url) query = parse_qs(parsed.query) assert f"{parsed.scheme}://{parsed.netloc}{parsed.path}" == TICKTICK_AUTH_URL assert query["client_id"] == ["ticktick-client-id"] assert query["response_type"] == ["code"] assert query["redirect_uri"] == ["http://localhost:8000/ticktick/auth/code"] assert query["state"] == ["state-123"] assert query["scope"] == [AUTH_SCOPE] def test_exchange_authorization_code_posts_expected_request(monkeypatch: pytest.MonkeyPatch) -> None: captured = {} client = TickTickClient(settings=_configured_settings()) default_auth_state_store.pending_state = "expected-state" def fake_urlopen(req, timeout): captured["url"] = req.full_url captured["timeout"] = timeout captured["authorization"] = req.headers["Authorization"] captured["content_type"] = req.headers["Content-type"] captured["body"] = req.data.decode("utf-8") return _FakeJsonResponse(200, {"access_token": "new-token"}) monkeypatch.setattr("app.integrations.ticktick.request.urlopen", fake_urlopen) token = client.exchange_authorization_code(code="oauth-code", state="expected-state") assert token == "new-token" assert captured["url"] == "https://ticktick.com/oauth/token" assert captured["timeout"] == pytest.approx(10.0) assert captured["content_type"] == "application/x-www-form-urlencoded" assert captured["authorization"].startswith("Basic ") assert "code=oauth-code" in captured["body"] assert "grant_type=authorization_code" in captured["body"] assert "scope=tasks%3Aread+tasks%3Awrite" in captured["body"] assert "client_id=" not in captured["body"] assert "client_secret=" not in captured["body"] def test_exchange_authorization_code_trims_ticktick_config_values(monkeypatch: pytest.MonkeyPatch) -> None: captured = {} client = TickTickClient( settings=_configured_settings( app_hostname=" localhost:8000 ", ticktick_client_id=" ticktick-client-id ", ticktick_client_secret=" ticktick-client-secret ", ) ) default_auth_state_store.pending_state = "trimmed-state" def fake_urlopen(req, timeout): captured["authorization"] = req.headers["Authorization"] captured["body"] = req.data.decode("utf-8") return _FakeJsonResponse(200, {"access_token": "trimmed-token"}) monkeypatch.setattr("app.integrations.ticktick.request.urlopen", fake_urlopen) token = client.exchange_authorization_code(code="oauth-code", state="trimmed-state") assert token == "trimmed-token" assert captured["authorization"].startswith("Basic ") assert "redirect_uri=http%3A%2F%2Flocalhost%3A8000%2Fticktick%2Fauth%2Fcode" in captured["body"] def test_create_task_skips_duplicate_titles(monkeypatch: pytest.MonkeyPatch) -> None: client = TickTickClient(settings=_configured_settings()) def fake_urlopen(req, timeout): assert req.full_url.endswith("/project/project-123/data") return _FakeJsonResponse( 200, { "tasks": [ { "id": "task-1", "projectId": "project-123", "title": "wash dishes", "columnId": "column-7", } ] }, ) monkeypatch.setattr("app.integrations.ticktick.request.urlopen", fake_urlopen) client.create_task(TickTickTask(projectId="project-123", title="wash dishes")) def test_get_projects_ignores_unknown_fields(monkeypatch: pytest.MonkeyPatch) -> None: client = TickTickClient(settings=_configured_settings()) def fake_urlopen(req, timeout): assert req.full_url.endswith("/project/") return _FakeJsonResponse( 200, [ { "id": "project-123", "name": "Inbox", "etag": "project-etag", } ], ) monkeypatch.setattr("app.integrations.ticktick.request.urlopen", fake_urlopen) projects = client.get_projects() assert len(projects) == 1 assert projects[0].id == "project-123" assert projects[0].name == "Inbox" def test_create_task_posts_expected_payload(monkeypatch: pytest.MonkeyPatch) -> None: captured = {"calls": []} client = TickTickClient(settings=_configured_settings()) def fake_urlopen(req, timeout): captured["calls"].append(req.full_url) if req.full_url.endswith("/project/project-123/data"): return _FakeJsonResponse(200, {"tasks": []}) captured["authorization"] = req.headers["Authorization"] captured["content_type"] = req.headers["Content-type"] captured["body"] = json.loads(req.data.decode("utf-8")) return _FakeJsonResponse(200, {"id": "task-99"}) monkeypatch.setattr("app.integrations.ticktick.request.urlopen", fake_urlopen) client.create_task( TickTickTask(projectId="project-123", title="wash dishes", dueDate="2026-04-21T00:00:00+0000") ) assert captured["calls"] == [ "https://api.ticktick.com/open/v1/project/project-123/data", "https://api.ticktick.com/open/v1/task", ] assert captured["authorization"] == "Bearer ticktick-access-token" assert captured["content_type"] == "application/json" assert captured["body"] == { "projectId": "project-123", "title": "wash dishes", "dueDate": "2026-04-21T00:00:00+0000", } def test_homeassistant_publish_creates_ticktick_action_task( test_database_urls, ready_location_database, ready_poo_database, auth_database, monkeypatch: pytest.MonkeyPatch, ) -> None: monkeypatch.setenv("APP_HOSTNAME", "localhost:8000") monkeypatch.setenv("TICKTICK_CLIENT_ID", "ticktick-client-id") monkeypatch.setenv("TICKTICK_CLIENT_SECRET", "ticktick-client-secret") monkeypatch.setenv("TICKTICK_TOKEN", "ticktick-access-token") monkeypatch.setenv("HOME_ASSISTANT_ACTION_TASK_PROJECT_ID", "project-123") get_settings.cache_clear() reset_auth_db_caches() captured = {"calls": []} def fake_urlopen(req, timeout): captured["calls"].append(req.full_url) if req.full_url.endswith("/project/project-123/data"): return _FakeJsonResponse(200, {"tasks": []}) captured["body"] = json.loads(req.data.decode("utf-8")) return _FakeJsonResponse(200, {"id": "task-1"}) monkeypatch.setattr("app.integrations.ticktick.request.urlopen", fake_urlopen) with TestClient(create_app()) as client: response = client.post( "/homeassistant/publish", json={ "target": "ticktick", "action": "create_action_task", "content": "{'title': 'ignored', 'action': 'take out trash', 'due_hour': 6}", }, ) assert response.status_code == 200 assert captured["calls"] == [ "https://api.ticktick.com/open/v1/project/project-123/data", "https://api.ticktick.com/open/v1/task", ] assert captured["body"]["projectId"] == "project-123" assert captured["body"]["title"] == "take out trash" assert captured["body"]["dueDate"].endswith("+0000") def test_ticktick_auth_start_redirects_authenticated_user( test_database_urls, ready_location_database, ready_poo_database, auth_database, monkeypatch: pytest.MonkeyPatch, ) -> None: monkeypatch.setenv("APP_HOSTNAME", "localhost:8000") monkeypatch.setenv("TICKTICK_CLIENT_ID", "ticktick-client-id") monkeypatch.setenv("TICKTICK_CLIENT_SECRET", "ticktick-client-secret") get_settings.cache_clear() reset_auth_db_caches() monkeypatch.setattr("app.integrations.ticktick.secrets.token_hex", lambda _: "state-redirect") with TestClient(create_app()) as client: login_page = client.get("/login") csrf_token = _extract_csrf_token(login_page.text) client.post( "/login", data={ "username": "admin", "password": "test-password", "csrf_token": csrf_token, }, follow_redirects=False, ) response = client.get("/ticktick/auth/start", follow_redirects=False) assert response.status_code == 303 parsed = urlparse(response.headers["location"]) query = parse_qs(parsed.query) assert f"{parsed.scheme}://{parsed.netloc}{parsed.path}" == TICKTICK_AUTH_URL assert query["state"] == ["state-redirect"] def test_ticktick_auth_callback_persists_token( test_database_urls, ready_location_database, ready_poo_database, auth_database, monkeypatch: pytest.MonkeyPatch, ) -> None: monkeypatch.setenv("APP_HOSTNAME", "localhost:8000") monkeypatch.setenv("TICKTICK_CLIENT_ID", "ticktick-client-id") monkeypatch.setenv("TICKTICK_CLIENT_SECRET", "ticktick-client-secret") get_settings.cache_clear() reset_auth_db_caches() default_auth_state_store.pending_state = "callback-state" def fake_urlopen(req, timeout): return _FakeJsonResponse(200, {"access_token": "persisted-token"}) monkeypatch.setattr("app.integrations.ticktick.request.urlopen", fake_urlopen) with TestClient(create_app()) as client: response = client.get( "/ticktick/auth/code?state=callback-state&code=oauth-code", follow_redirects=False, ) assert response.status_code == 303 assert response.headers["location"] == "/config?ticktick_oauth=success" conn = sqlite3.connect(test_database_urls["app_path"]) try: row = conn.execute( "SELECT value FROM app_config WHERE key = ?", ("TICKTICK_TOKEN",), ).fetchone() finally: conn.close() assert row is not None assert row[0] == "persisted-token" def test_ticktick_auth_callback_redirects_on_invalid_state( test_database_urls, ready_location_database, ready_poo_database, auth_database, monkeypatch: pytest.MonkeyPatch, ) -> None: monkeypatch.setenv("APP_HOSTNAME", "localhost:8000") monkeypatch.setenv("TICKTICK_CLIENT_ID", "ticktick-client-id") monkeypatch.setenv("TICKTICK_CLIENT_SECRET", "ticktick-client-secret") get_settings.cache_clear() reset_auth_db_caches() default_auth_state_store.pending_state = "expected-state" with TestClient(create_app()) as client: response = client.get( "/ticktick/auth/code?state=wrong-state&code=oauth-code", follow_redirects=False, ) assert response.status_code == 303 assert response.headers["location"] == "/config?ticktick_oauth=invalid-state" def test_ticktick_auth_callback_redirects_when_token_exchange_fails( test_database_urls, ready_location_database, ready_poo_database, auth_database, monkeypatch: pytest.MonkeyPatch, ) -> None: monkeypatch.setenv("APP_HOSTNAME", "localhost:8000") monkeypatch.setenv("TICKTICK_CLIENT_ID", "ticktick-client-id") monkeypatch.setenv("TICKTICK_CLIENT_SECRET", "ticktick-client-secret") get_settings.cache_clear() reset_auth_db_caches() default_auth_state_store.pending_state = "callback-state" def fake_urlopen(req, timeout): raise error.HTTPError(req.full_url, 401, "Unauthorized", hdrs=None, fp=None) monkeypatch.setattr("app.integrations.ticktick.request.urlopen", fake_urlopen) with TestClient(create_app()) as client: response = client.get( "/ticktick/auth/code?state=callback-state&code=oauth-code", follow_redirects=False, ) assert response.status_code == 303 assert response.headers["location"] == "/config?ticktick_oauth=failed"