Files

255 lines
9.1 KiB
Python
Raw Permalink Normal View History

2026-06-24 12:44:34 -07:00
"""Regression tests for the google_mail viewer HTTP app.
These tests exercise the viewer routes against a real ``MailboxService`` loaded
from fixture data. The viewer reads from the shared ``google_mail.state``
mailbox registry so it uses the same state surface as the MCP server.
"""
from __future__ import annotations
import json
import tempfile
from pathlib import Path
import pytest
from starlette.testclient import TestClient
import google_mail.state as state_mod
from google_mail.services.mailbox import MailboxService
from google_mail.viewer import create_mail_viewer_app
SAMPLE_DATA = {
"mailbox": {"email": "alice@example.com", "name": "Alice"},
"contacts": [
{"email": "bob@example.com", "name": "Bob"},
],
"groups": [
{
"email": "team@example.com",
"name": "Team",
"members": ["alice@example.com", "bob@example.com"],
},
],
"folders": [],
"emails": [
{
"email_id": "1",
"folder": "INBOX",
"subject": "Hello Alice",
"from_addr": "bob@example.com",
"to_addr": "alice@example.com",
"cc_addr": "carol@example.com",
"date": "2024-01-15T10:00:00Z",
"message_id": "<msg1@example.com>",
"body_text": "Hi Alice.",
"is_read": False,
"is_important": True,
"attachments": [
{
"filename": "doc.pdf",
"content_type": "application/pdf",
"content_base64": "SGVsbG8=",
}
],
},
{
"email_id": "2",
"folder": "INBOX",
"subject": "Re: meeting",
"from_addr": "carol@example.com",
"to_addr": "alice@example.com",
"date": "2024-01-14T09:00:00Z",
"message_id": "<msg2@example.com>",
"body_text": "See you Monday.",
"is_read": True,
"is_important": False,
},
],
"next_email_id": 3,
}
@pytest.fixture
def populated_mailbox(monkeypatch):
"""Install a ``default`` MailboxService into the state registry."""
monkeypatch.delenv("MCP_PROXY_TOKEN", raising=False)
with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f:
json.dump(SAMPLE_DATA, f)
path = Path(f.name)
service = MailboxService(path)
service.load()
original = dict(state_mod.get_mailboxes())
state_mod.set_mailboxes({"default": service})
try:
yield service
finally:
state_mod.set_mailboxes(original)
path.unlink(missing_ok=True)
@pytest.fixture
def client(populated_mailbox) -> TestClient:
return TestClient(create_mail_viewer_app(), raise_server_exceptions=False)
class TestViewerRoutes:
def test_root_serves_html(self, client: TestClient) -> None:
resp = client.get("/")
assert resp.status_code == 200
assert "<title>Mail</title>" in resp.text
def test_folders_returns_counts(self, client: TestClient) -> None:
resp = client.get("/api/folders")
assert resp.status_code == 200
folders = {f["name"]: f for f in resp.json()["folders"]}
assert "INBOX" in folders
assert folders["INBOX"]["total"] == 2
assert folders["INBOX"]["unread"] == 1
def test_emails_list_returns_summary(self, client: TestClient) -> None:
resp = client.get("/api/emails?folder=INBOX")
assert resp.status_code == 200
body = resp.json()
assert body["total"] == 2
ids = {e["email_id"] for e in body["emails"]}
assert ids == {"1", "2"}
first = next(e for e in body["emails"] if e["email_id"] == "1")
assert first["has_attachments"] is True
assert first["is_important"] is True
def test_emails_search_filters(self, client: TestClient) -> None:
resp = client.get("/api/emails?search=meeting")
assert resp.status_code == 200
body = resp.json()
assert body["total"] == 1
assert body["emails"][0]["email_id"] == "2"
def test_email_detail_returns_full(self, client: TestClient) -> None:
resp = client.get("/api/emails/1")
assert resp.status_code == 200
email = resp.json()["email"]
assert email["body_text"] == "Hi Alice."
assert email["cc_addr"] == "carol@example.com"
assert email["attachments"][0]["filename"] == "doc.pdf"
def test_email_detail_marks_read(self, client: TestClient, populated_mailbox) -> None:
# The unread email becomes read after viewing.
client.get("/api/emails/1")
email = populated_mailbox.get_email("1")
assert email.is_read is True
def test_email_detail_missing_returns_404(self, client: TestClient) -> None:
resp = client.get("/api/emails/does-not-exist")
assert resp.status_code == 404
assert resp.json()["error"]
def test_contacts_route(self, client: TestClient) -> None:
resp = client.get("/api/contacts")
assert resp.status_code == 200
contacts = resp.json()["contacts"]
assert {"email": "bob@example.com", "name": "Bob"} in contacts
groups = resp.json()["groups"]
team = next(g for g in groups if g["email"] == "team@example.com")
assert team["members"] == ["alice@example.com", "bob@example.com"]
def test_stats_route(self, client: TestClient) -> None:
resp = client.get("/api/stats")
assert resp.status_code == 200
stats = resp.json()
assert stats["total_emails"] == 2
assert stats["total_unread"] == 1
assert stats["mailbox"]["email"] == "alice@example.com"
class TestAuth:
def test_proxy_token_required_when_set(self, populated_mailbox, monkeypatch) -> None:
monkeypatch.setenv("MCP_PROXY_TOKEN", "secret")
client = TestClient(create_mail_viewer_app(), raise_server_exceptions=False)
unauth = client.get("/api/folders")
assert unauth.status_code == 403
ok = client.get("/api/folders", headers={"x-proxy-token": "secret"})
assert ok.status_code == 200
class TestMultiMailbox:
@pytest.fixture
def multi_mailbox(self, monkeypatch):
monkeypatch.delenv("MCP_PROXY_TOKEN", raising=False)
def _load(email: str) -> MailboxService:
data = {
"mailbox": {"email": email, "name": email},
"contacts": [],
"folders": [],
"emails": [],
"next_email_id": 1,
}
with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f:
json.dump(data, f)
p = Path(f.name)
svc = MailboxService(p)
svc.load()
return svc
original = dict(state_mod.get_mailboxes())
state_mod.set_mailboxes(
{
"alice": _load("alice@example.com"),
"bob": _load("bob@example.com"),
}
)
try:
yield
finally:
state_mod.set_mailboxes(original)
def test_defaults_to_first_mailbox_when_no_default(self, multi_mailbox) -> None:
client = TestClient(create_mail_viewer_app(), raise_server_exceptions=False)
resp = client.get("/api/stats")
assert resp.status_code == 200
# Sorted ids → "alice" comes first.
assert resp.json()["mailbox"]["email"] == "alice@example.com"
def test_mailbox_id_query_param_selects(self, multi_mailbox) -> None:
client = TestClient(create_mail_viewer_app(), raise_server_exceptions=False)
resp = client.get("/api/stats?mailbox_id=bob")
assert resp.status_code == 200
assert resp.json()["mailbox"]["email"] == "bob@example.com"
def test_unknown_mailbox_id_returns_404(self, multi_mailbox) -> None:
client = TestClient(create_mail_viewer_app(), raise_server_exceptions=False)
resp = client.get("/api/folders?mailbox_id=nope")
assert resp.status_code == 404
class TestLazyInit:
"""Regression: when the registry is empty, hitting the viewer must not 500.
The viewer used to reference a non-existent server mailbox attribute.
With the registry empty, ``init_state()`` should be invoked and a default
mailbox materialized.
"""
def test_empty_registry_initializes_default(self, monkeypatch) -> None:
monkeypatch.delenv("MCP_PROXY_TOKEN", raising=False)
monkeypatch.delenv("BUNDLEDIR", raising=False)
monkeypatch.delenv("INPUTDIR", raising=False)
monkeypatch.delenv("OUTPUTDIR", raising=False)
monkeypatch.delenv("BUNDLE_OUTPUT_DIR", raising=False)
original = dict(state_mod.get_mailboxes())
state_mod.set_mailboxes({})
try:
client = TestClient(create_mail_viewer_app(), raise_server_exceptions=False)
for path in ("/api/folders", "/api/emails", "/api/contacts", "/api/stats"):
resp = client.get(path)
assert resp.status_code == 200, f"{path}{resp.status_code}: {resp.text}"
assert "default" in state_mod.get_mailboxes()
finally:
state_mod.set_mailboxes(original)