# backend/tests/test_phase16_calendar.py — phase-16 Outlook/M365 캘린더 OAuth + Graph sync import time import pytest from sqlmodel import select from app.config import get_settings from app.connectors import oauth from app.connectors.calendar import real_outlook from app.connectors.calendar.real_outlook import OutlookCalendarConnector from app.connectors.registry import ConnectorRegistry, _impl from app.crypto import encrypt_token from app.models import CalEvent, ConnectorAccount, ConnectorDomain, ConnectorMode, ConnState @pytest.fixture() def ms_creds(monkeypatch): monkeypatch.setenv("MICROSOFT_CLIENT_ID", "m-cid") monkeypatch.setenv("MICROSOFT_CLIENT_SECRET", "m-sec") get_settings.cache_clear() yield get_settings.cache_clear() class _Resp: def __init__(self, payload, status=200): self._p = payload self.status_code = status self.headers = {} def json(self): return self._p def raise_for_status(self): if self.status_code >= 400: raise RuntimeError(f"http {self.status_code}") class _Client: """httpx.Client 컨텍스트매니저 스텁 — 페이지를 순서대로 반환.""" def __init__(self, pages): self._pages = list(pages) self.calls = [] def __enter__(self): return self def __exit__(self, *a): return False def get(self, url, headers=None, params=None): self.calls.append((url, params)) return _Resp(self._pages.pop(0)) # ── OAuth: Outlook 캘린더 authorize URL (microsoft 테넌트 + Calendars.Read) ── def test_outlook_calendar_authorize_url(session, ms_creds): s, _ = session url = oauth.start_oauth(s, "calendar", "outlook_calendar", redirect_after="/settings") assert url.startswith("https://login.microsoftonline.com/common/oauth2/v2.0/authorize") assert "code_challenge=" in url and "state=" in url # PKCE assert "Calendars.Read" in url and "offline_access" in url assert "Mail.Read" not in url # 캘린더 스코프엔 메일 권한 없음 # ── registry: provider 로 Google/Outlook 캘린더 분기 ── def test_calendar_dispatch_by_provider(session): s, _ = session assert _impl("calendar", "real", "outlook_calendar").__name__ == "OutlookCalendarConnector" assert _impl("calendar", "real", "google_calendar").__name__ == "GoogleCalendarConnector" out_acct = ConnectorAccount( id="ca-cal-o", domain=ConnectorDomain.calendar, provider="outlook_calendar", mode=ConnectorMode.real, ) assert isinstance(ConnectorRegistry.get(s, out_acct), OutlookCalendarConnector) # ── fetch: calendarView 페이지네이션(nextLink) + isCancelled 스킵 + CalEvent upsert ── def test_outlook_calendar_fetch_paginates_and_upserts(session, monkeypatch): s, _ = session acct = ConnectorAccount( id="ca-cal-outlook-1", domain=ConnectorDomain.calendar, provider="outlook_calendar", mode=ConnectorMode.real, state=ConnState.connected, external_account_id="me@corp.com", token_enc=encrypt_token({"access_token": "AT", "expires_at": int(time.time()) + 9999}), ) s.add(acct) s.commit() ev = lambda i, **k: { # noqa: E731 "id": f"ev-{i}", "subject": f"미팅 {i}", "start": {"dateTime": "2026-06-12T09:00:00.0000000"}, "end": {"dateTime": "2026-06-12T09:30:00.0000000"}, "location": {"displayName": "룸"}, **k, } pages = [ {"value": [ev(1), ev(2, isCancelled=True)], "@odata.nextLink": "https://graph/next?p=2"}, {"value": [ev(3)]}, ] client = _Client(pages) monkeypatch.setattr(real_outlook.httpx, "Client", lambda *a, **k: client) conn = OutlookCalendarConnector(acct) res = conn.sync(s) # 취소 이벤트 제외하고 2건만 적재(ev-1, ev-3) assert res.upserted == 2 titles = {e.title for e in s.exec(select(CalEvent)).all()} assert titles == {"미팅 1", "미팅 3"} # 2번째 호출은 nextLink URL + params=None (쿼리는 nextLink 에 포함) assert client.calls[0][0].endswith("/me/calendarView") assert client.calls[1] == ("https://graph/next?p=2", None) # ── API: /connectors/providers?domain=calendar → google + outlook 캘린더 ── def test_calendar_providers_endpoint(client, ms_creds, monkeypatch): monkeypatch.setenv("GOOGLE_CLIENT_ID", "g-cid") get_settings.cache_clear() rows = client.get("/api/connectors/providers?domain=calendar").json() by = {r["provider"]: r for r in rows} assert set(by) == {"google_calendar", "outlook_calendar"} assert by["outlook_calendar"]["configured"] is True # ms_creds 설정됨 assert by["outlook_calendar"]["label"] == "Outlook 캘린더" # ── 시작 검증: 미구성 outlook_calendar 는 400 ── def test_oauth_start_outlook_calendar_requires_microsoft_client_id(client): r = client.get("/api/connectors/oauth/start?domain=calendar&provider=outlook_calendar") assert r.status_code == 400