You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
138 lines
5.0 KiB
Python
138 lines
5.0 KiB
Python
# 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
|