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.

299 lines
11 KiB
Python

# backend/tests/test_phase16_mail.py — phase-16 실계정 메일(Gmail/Outlook OAuth + 멀티계정 + 발송)
import time
import pytest
from sqlmodel import select
from app.config import get_settings
from app.connectors import oauth
from app.connectors.base import RawRecord
from app.connectors.mail.normalize import mail_account_id_for, normalize_email
from app.connectors.mail.outbound import send_outbound
from app.connectors.mail.real_outlook import OutlookConnector
from app.connectors.registry import ConnectorRegistry, _impl
from app.crypto import encrypt_token
from app.models import (
ConnectorAccount,
ConnectorDomain,
ConnectorMode,
ConnState,
MailAccount,
OAuthState,
OutboundMail,
Sent,
)
class _FakeResp:
def __init__(self, status, payload=None):
self.status_code = status
self._payload = payload or {}
def json(self):
return self._payload
def raise_for_status(self):
if self.status_code >= 400:
raise RuntimeError(f"http {self.status_code}")
@pytest.fixture()
def oauth_creds(monkeypatch):
monkeypatch.setenv("GOOGLE_CLIENT_ID", "g-cid")
monkeypatch.setenv("GOOGLE_CLIENT_SECRET", "g-sec")
monkeypatch.setenv("MICROSOFT_CLIENT_ID", "m-cid")
monkeypatch.setenv("MICROSOFT_CLIENT_SECRET", "m-sec")
get_settings.cache_clear()
yield
get_settings.cache_clear()
def _mock_token_and_identity(monkeypatch, *, email, token=None):
tok = token or {"access_token": "AT", "refresh_token": "RT", "expires_in": 3600, "scope": "s"}
monkeypatch.setattr(oauth.httpx, "post", lambda *a, **k: _FakeResp(200, tok))
# google: gmail/v1/users/me/profile → emailAddress ; microsoft: graph /me → mail
monkeypatch.setattr(
oauth.httpx,
"get",
lambda *a, **k: _FakeResp(200, {"emailAddress": email, "mail": email}),
)
# ── OAuth: Outlook provider + tenant URL ──
def test_outlook_authorize_url_uses_microsoft_tenant(session, oauth_creds):
s, _ = session
url = oauth.start_oauth(s, "mail", "outlook", redirect_after="/settings?tab=mail")
assert url.startswith("https://login.microsoftonline.com/common/oauth2/v2.0/authorize")
assert "code_challenge=" in url and "state=" in url # PKCE 보존
assert "Mail.Read" in url and "Mail.Send" in url and "offline_access" in url
# ── 멀티계정: 이메일별 고유 계정 id + MailAccount upsert ──
def test_finish_oauth_creates_unique_account_per_email(session, oauth_creds, monkeypatch):
s, _ = session
def connect(provider, email):
url = oauth.start_oauth(s, "mail", provider)
state = url.split("state=")[1].split("&")[0]
_mock_token_and_identity(monkeypatch, email=email)
return oauth.finish_oauth(s, code="C", state=state)
a1 = connect("gmail", "alice@gmail.com")
a2 = connect("gmail", "bob@gmail.com")
a3 = connect("outlook", "carol@outlook.com")
# 같은 provider 여도 이메일이 다르면 계정 id 가 갈린다(덮어쓰기 없음)
assert a1.id != a2.id
assert a1.external_account_id == "alice@gmail.com"
assert a3.provider == "outlook" and a3.mode == ConnectorMode.real
# 메일 페이지 노출용 MailAccount 도 함께 생성
assert s.get(MailAccount, mail_account_id_for("alice@gmail.com")) is not None
assert s.get(MailAccount, mail_account_id_for("carol@outlook.com")) is not None
# state row 소거
assert s.get(OAuthState, "x") is None
# ── normalize: Graph(Outlook) 메시지 → 내부 Email 필드 ──
def test_normalize_outlook_golden(session):
acct = ConnectorAccount(
id="ca-mail-outlook-xyz",
domain=ConnectorDomain.mail,
provider="outlook",
external_account_id="me@outlook.com",
)
payload = {
"id": "AAMk-1",
"subject": "분기 리포트 검토 부탁",
"bodyPreview": "안녕하세요, 리포트 초안입니다.",
"body": {"contentType": "text", "content": "안녕하세요,\n리포트 초안입니다.\n확인 부탁드려요."},
"from": {"emailAddress": {"name": "현우", "address": "hyunwoo@corp.com"}},
"toRecipients": [{"emailAddress": {"address": "me@outlook.com"}}],
"isRead": False,
"flag": {"flagStatus": "flagged"},
"hasAttachments": True,
"categories": ["업무"],
"receivedDateTime": "2026-06-15T09:00:00Z",
}
raw = RawRecord(external_id="AAMk-1", payload=payload, etag="W/1")
norm = normalize_email(acct, raw, provider="outlook")
f = norm.fields
assert f["account"] == mail_account_id_for("me@outlook.com")
# 발신자 표시 이름 보존('이름 <메일>') — 목록은 이름만, 리더는 이름+주소 노출.
assert f["from_key"] == "현우 <hyunwoo@corp.com>"
assert f["subject"] == "분기 리포트 검토 부탁"
assert f["read"] is False and f["starred"] is True and f["has_attach"] is True
assert "리포트 초안입니다." in " ".join(f["body"])
assert f["labels"] == ["업무"]
def test_normalize_outlook_html_body_stripped(session):
acct = ConnectorAccount(id="ca-o2", domain=ConnectorDomain.mail, provider="outlook")
payload = {
"id": "h1",
"subject": "공지",
"bodyPreview": "프리뷰",
"body": {"contentType": "html", "content": "<div><p>안녕하세요</p><p>좋은 하루</p></div>"},
"from": {"emailAddress": {"address": "x@y.com"}},
"isRead": True,
}
norm = normalize_email(acct, RawRecord(external_id="h1", payload=payload), provider="outlook")
# 태그 제거 + 블록(<p>) 경계마다 줄바꿈 → 읽기 좋은 다중 줄.
assert norm.fields["body"] == ["안녕하세요", "좋은 하루"]
# ── registry: provider 로 Gmail/Outlook 분기 ──
def test_connector_dispatch_by_provider(session):
s, _ = session
from app.connectors.mail.real_gmail import GmailConnector
assert _impl("mail", "real", "outlook").__name__ == "OutlookConnector"
assert _impl("mail", "real", "gmail").__name__ == "GmailConnector"
out_acct = ConnectorAccount(
id="ca-o", domain=ConnectorDomain.mail, provider="outlook", mode=ConnectorMode.real
)
gm_acct = ConnectorAccount(
id="ca-g", domain=ConnectorDomain.mail, provider="gmail", mode=ConnectorMode.real
)
assert isinstance(ConnectorRegistry.get(s, out_acct), OutlookConnector)
assert isinstance(ConnectorRegistry.get(s, gm_acct), GmailConnector)
# ── 발송: real 연결 계정 → connector.send_mail 호출 ──
def test_send_outbound_real_calls_connector(session, monkeypatch):
s, _ = session
acct = ConnectorAccount(
id="ca-mail-gmail-abc",
domain=ConnectorDomain.mail,
provider="gmail",
mode=ConnectorMode.real,
state=ConnState.connected,
external_account_id="me@gmail.com",
token_enc=encrypt_token({"access_token": "AT", "expires_at": int(time.time()) + 9999}),
)
s.add(acct)
ob = OutboundMail(
id="ob-1",
approval_id="ap-1",
connector_account_id=acct.id,
mail_account=mail_account_id_for("me@gmail.com"),
to="친구 <pal@x.com>",
subject="안녕",
body="잘 지내?",
status="pending",
)
s.add(ob)
s.commit()
calls = {}
from app.connectors.mail.real_gmail import GmailConnector
def fake_send(self, session, *, to, subject, body, cc="", bcc="", attachments=None):
calls.update(to=to, subject=subject, body=body)
return "sent"
monkeypatch.setattr(GmailConnector, "send_mail", fake_send)
out = send_outbound(s, ob)
assert out.status == "sent" and out.sent_at is not None
assert calls == {"to": "친구 <pal@x.com>", "subject": "안녕", "body": "잘 지내?"}
# ── 발송: 미연결 → failed(가짜 발송 없음, phase-16+) ──
def test_send_outbound_unconnected_fails(session):
s, _ = session
ob = OutboundMail(
id="ob-2",
approval_id="ap-2",
connector_account_id=None,
mail_account="work",
to="대표님",
subject="보고",
body="첨부 확인 부탁드립니다.",
status="pending",
)
s.add(ob)
s.commit()
out = send_outbound(s, ob)
assert out.status == "failed" # 가짜 Sent 행 생성 없음
assert s.exec(select(Sent)).all() == []
# ── 발송 실패는 failed 로 격리(서버 안 죽음) ──
def test_send_outbound_failure_isolated(session, monkeypatch):
s, _ = session
acct = ConnectorAccount(
id="ca-fail",
domain=ConnectorDomain.mail,
provider="gmail",
mode=ConnectorMode.real,
state=ConnState.connected,
external_account_id="me@gmail.com",
token_enc=encrypt_token({"access_token": "AT", "expires_at": int(time.time()) + 9999}),
)
s.add(acct)
ob = OutboundMail(
id="ob-3", approval_id="ap-3", connector_account_id=acct.id, mail_account="x",
to="a@b.com", subject="s", body="b", status="pending",
)
s.add(ob)
s.commit()
from app.connectors.mail.real_gmail import GmailConnector
def boom(self, session, **k):
raise RuntimeError("network down")
monkeypatch.setattr(GmailConnector, "send_mail", boom)
out = send_outbound(s, ob)
assert out.status == "failed" and "network down" in out.error_detail
# ── API: /connectors/providers 구성 여부 ──
def test_providers_endpoint_unconfigured(client):
rows = client.get("/api/connectors/providers?domain=mail").json()
by = {r["provider"]: r for r in rows}
assert set(by) == {"gmail", "outlook"}
assert by["gmail"]["configured"] is False and by["outlook"]["configured"] is False
def test_providers_endpoint_configured(client, oauth_creds):
rows = client.get("/api/connectors/providers").json()
by = {r["provider"]: r for r in rows}
assert by["gmail"]["configured"] is True and by["outlook"]["configured"] is True
# ── API: /mail/send (연결된 계정) → 결재 + OutboundMail(pending) 생성 ──
def test_mail_send_creates_pending_outbound(client, session):
s, _ = session
acct = ConnectorAccount(
id="ca-mail-gmail-x", domain=ConnectorDomain.mail, provider="gmail",
mode=ConnectorMode.real, state=ConnState.connected, external_account_id="me@gmail.com",
)
s.add(acct)
s.commit()
slug = mail_account_id_for("me@gmail.com")
r = client.post(
"/api/mail/send",
json={"from_account": slug, "to": "대표님", "subject": "보고", "body": "확인 부탁"},
)
assert r.status_code == 200
aid = r.json()["approval_id"]
s.expire_all()
ob = s.exec(select(OutboundMail).where(OutboundMail.approval_id == aid)).first()
assert ob is not None and ob.status == "pending"
assert ob.connector_account_id == "ca-mail-gmail-x" # 연결된 real 계정에 묶임
assert ob.subject == "보고"
def test_mail_send_unconnected_rejected(client, session):
# 연결된 계정 없으면 400(가짜 발송 없음)
r = client.post(
"/api/mail/send",
json={"from_account": "work", "to": "대표님", "subject": "보고", "body": "x"},
)
assert r.status_code == 400
# ── 시작 검증: 미구성 outlook 은 400 ──
def test_oauth_start_outlook_requires_microsoft_client_id(client):
r = client.get("/api/connectors/oauth/start?domain=mail&provider=outlook")
assert r.status_code == 400