Add Handbook.md benchmark tasks
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""Tests for google_mail."""
|
||||
@@ -0,0 +1,365 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_mail_state():
|
||||
import google_mail.state as state
|
||||
|
||||
state.set_mailboxes({})
|
||||
state.set_snapshot_paths(final_path=None, bundle_state_path=None)
|
||||
yield
|
||||
state.set_mailboxes({})
|
||||
state.set_snapshot_paths(final_path=None, bundle_state_path=None)
|
||||
|
||||
|
||||
def _write_mailbox(path):
|
||||
path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"mailbox": {"email": "t@t.com", "name": "T"},
|
||||
"contacts": [],
|
||||
"folders": [],
|
||||
"emails": [],
|
||||
"next_email_id": 1,
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def test_inputdir_loads_first_json(monkeypatch, tmp_path):
|
||||
"""When INPUTDIR is set, the server loads the first .json in that dir."""
|
||||
data_file = tmp_path / "google_mail.json"
|
||||
_write_mailbox(data_file)
|
||||
monkeypatch.setenv("INPUTDIR", str(tmp_path))
|
||||
|
||||
json_files = sorted(Path(str(tmp_path)).glob("*.json"))
|
||||
assert json_files[0] == data_file
|
||||
|
||||
|
||||
def test_inputdir_loads_only_first_file_not_merged(monkeypatch, tmp_path):
|
||||
"""Legacy contract: INPUTDIR loads ONLY the first *.json (alphabetically),
|
||||
not a merge of all of them — matching scripts/validate_external_services.py.
|
||||
Folder coalescing is a bundle-only behavior."""
|
||||
import google_mail.server as srv
|
||||
import google_mail.state as state
|
||||
|
||||
inputdir = tmp_path / "in"
|
||||
inputdir.mkdir()
|
||||
(inputdir / "a.json").write_text(
|
||||
json.dumps({"mailbox": {"email": "first@t.com", "name": "First"}, "emails": [], "next_email_id": 1})
|
||||
)
|
||||
(inputdir / "b.json").write_text(
|
||||
json.dumps({"mailbox": {"email": "second@t.com", "name": "Second"}, "emails": [], "next_email_id": 1})
|
||||
)
|
||||
|
||||
monkeypatch.delenv("BUNDLEDIR", raising=False)
|
||||
monkeypatch.setenv("INPUTDIR", str(inputdir))
|
||||
monkeypatch.delenv("OUTPUTDIR", raising=False)
|
||||
monkeypatch.delenv("BUNDLE_OUTPUT_DIR", raising=False)
|
||||
state.set_mailboxes({})
|
||||
|
||||
srv.init_state()
|
||||
|
||||
mailboxes = state.get_mailboxes()
|
||||
assert set(mailboxes) == {"default"}
|
||||
assert mailboxes["default"].data.mailbox.email == "first@t.com"
|
||||
|
||||
|
||||
def test_inputdir_empty_uses_default(monkeypatch, tmp_path):
|
||||
"""When INPUTDIR is set but empty, server starts with an empty default mailbox."""
|
||||
monkeypatch.setenv("INPUTDIR", str(tmp_path))
|
||||
|
||||
json_files = sorted(Path(str(tmp_path)).glob("*.json"))
|
||||
assert json_files == []
|
||||
|
||||
|
||||
def test_inputdir_not_set_uses_default(monkeypatch):
|
||||
"""When INPUTDIR is not set, server starts with an empty default mailbox (no error)."""
|
||||
monkeypatch.delenv("INPUTDIR", raising=False)
|
||||
import os
|
||||
|
||||
assert os.environ.get("INPUTDIR") is None
|
||||
# Server should start cleanly — no error raised
|
||||
|
||||
|
||||
def test_bundle_state_json_preferred_over_inputdir(monkeypatch, tmp_path):
|
||||
"""When BUNDLEDIR is set and $BUNDLEDIR/services/google_mail/state.json
|
||||
exists, init_state loads it instead of any legacy *.json files
|
||||
in INPUTDIR."""
|
||||
import google_mail.server as srv
|
||||
import google_mail.state as state
|
||||
|
||||
bundle_root = tmp_path / "bundle"
|
||||
service_dir = bundle_root / "services" / "google_mail"
|
||||
service_dir.mkdir(parents=True)
|
||||
(service_dir / "state.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"mailbox": {"email": "bundle@t.com", "name": "Bundle"},
|
||||
"contacts": [],
|
||||
"folders": [],
|
||||
"emails": [],
|
||||
"next_email_id": 1,
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
inputdir = tmp_path / "legacy"
|
||||
inputdir.mkdir()
|
||||
_write_mailbox(inputdir / "legacy.json")
|
||||
|
||||
monkeypatch.setenv("BUNDLEDIR", str(bundle_root))
|
||||
monkeypatch.setenv("INPUTDIR", str(inputdir))
|
||||
monkeypatch.delenv("OUTPUTDIR", raising=False)
|
||||
monkeypatch.delenv("BUNDLE_OUTPUT_DIR", raising=False)
|
||||
|
||||
srv.init_state()
|
||||
|
||||
assert state.get_mailboxes()["default"].data.mailbox.email == "bundle@t.com"
|
||||
|
||||
|
||||
def test_bundle_glob_when_state_json_absent(monkeypatch, tmp_path):
|
||||
"""When BUNDLEDIR carries a per-service subdir with a named JSON (e.g.
|
||||
the preserved entities-zip layout) but no state.json, the loader reads
|
||||
it from that subdir."""
|
||||
import google_mail.server as srv
|
||||
import google_mail.state as state
|
||||
|
||||
bundle_root = tmp_path / "bundle"
|
||||
service_dir = bundle_root / "services" / "google_mail"
|
||||
service_dir.mkdir(parents=True)
|
||||
(service_dir / "inbox.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"mailbox": {"email": "preserved@t.com", "name": "Preserved"},
|
||||
"contacts": [],
|
||||
"folders": [],
|
||||
"emails": [],
|
||||
"next_email_id": 1,
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
monkeypatch.setenv("BUNDLEDIR", str(bundle_root))
|
||||
monkeypatch.delenv("INPUTDIR", raising=False)
|
||||
monkeypatch.delenv("OUTPUTDIR", raising=False)
|
||||
monkeypatch.delenv("BUNDLE_OUTPUT_DIR", raising=False)
|
||||
|
||||
srv.init_state()
|
||||
|
||||
assert state.get_mailboxes()["default"].data.mailbox.email == "preserved@t.com"
|
||||
|
||||
|
||||
def test_falls_back_to_inputdir_when_bundle_service_dir_missing(monkeypatch, tmp_path):
|
||||
"""When BUNDLEDIR is set but doesn't carry this service's slice,
|
||||
init_state falls back to the legacy INPUTDIR/*.json glob — a partial
|
||||
bundle doesn't strand a service."""
|
||||
import google_mail.server as srv
|
||||
import google_mail.state as state
|
||||
|
||||
# BUNDLEDIR points at a real dir, but no google_mail/ subdir under it.
|
||||
bundle_root = tmp_path / "bundle"
|
||||
(bundle_root / "services").mkdir(parents=True)
|
||||
|
||||
inputdir = tmp_path / "legacy"
|
||||
inputdir.mkdir()
|
||||
(inputdir / "legacy.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"mailbox": {"email": "legacy@t.com", "name": "Legacy"},
|
||||
"contacts": [],
|
||||
"folders": [],
|
||||
"emails": [],
|
||||
"next_email_id": 1,
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
monkeypatch.setenv("BUNDLEDIR", str(bundle_root))
|
||||
monkeypatch.setenv("INPUTDIR", str(inputdir))
|
||||
monkeypatch.delenv("OUTPUTDIR", raising=False)
|
||||
monkeypatch.delenv("BUNDLE_OUTPUT_DIR", raising=False)
|
||||
|
||||
srv.init_state()
|
||||
|
||||
assert state.get_mailboxes()["default"].data.mailbox.email == "legacy@t.com"
|
||||
|
||||
|
||||
def test_falls_back_to_inputdir_when_bundledir_unset(monkeypatch, tmp_path):
|
||||
"""When BUNDLEDIR is unset entirely, init_state uses INPUTDIR (the
|
||||
common local-dev path). The bundle code path is skipped."""
|
||||
import google_mail.server as srv
|
||||
import google_mail.state as state
|
||||
|
||||
inputdir = tmp_path / "legacy"
|
||||
inputdir.mkdir()
|
||||
(inputdir / "legacy.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"mailbox": {"email": "legacy@t.com", "name": "Legacy"},
|
||||
"contacts": [],
|
||||
"folders": [],
|
||||
"emails": [],
|
||||
"next_email_id": 1,
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
monkeypatch.delenv("BUNDLEDIR", raising=False)
|
||||
monkeypatch.setenv("INPUTDIR", str(inputdir))
|
||||
monkeypatch.delenv("OUTPUTDIR", raising=False)
|
||||
monkeypatch.delenv("BUNDLE_OUTPUT_DIR", raising=False)
|
||||
|
||||
srv.init_state()
|
||||
|
||||
assert state.get_mailboxes()["default"].data.mailbox.email == "legacy@t.com"
|
||||
|
||||
|
||||
def test_init_state_configures_snapshot_paths_when_registry_preloaded(monkeypatch, tmp_path):
|
||||
"""Preloaded state should still pick up env-configured snapshot paths."""
|
||||
import google_mail.server as srv
|
||||
import google_mail.state as state
|
||||
from google_mail.services.mailbox import MailboxService
|
||||
|
||||
data_file = tmp_path / "mailbox.json"
|
||||
_write_mailbox(data_file)
|
||||
svc = MailboxService(data_file)
|
||||
svc.load()
|
||||
|
||||
outputdir = tmp_path / "output"
|
||||
bundle_output_dir = tmp_path / "services" / "google_mail"
|
||||
state.set_mailboxes({"default": svc})
|
||||
state.set_snapshot_paths(final_path=None, bundle_state_path=None)
|
||||
monkeypatch.setenv("OUTPUTDIR", str(outputdir))
|
||||
monkeypatch.setenv("BUNDLE_OUTPUT_DIR", str(bundle_output_dir))
|
||||
|
||||
srv.init_state()
|
||||
|
||||
assert state.get_final_path() == outputdir / "final.json"
|
||||
assert state.get_bundle_state_path() == bundle_output_dir / "state.json"
|
||||
|
||||
|
||||
def _write_mailbox_email(path, email):
|
||||
path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"mailbox": {"email": email, "name": email.split("@")[0]},
|
||||
"contacts": [],
|
||||
"folders": [],
|
||||
"emails": [],
|
||||
"next_email_id": 1,
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _email_record(email_id, subject):
|
||||
return {
|
||||
"email_id": email_id,
|
||||
"folder": "INBOX",
|
||||
"subject": subject,
|
||||
"from_addr": "sender@t.com",
|
||||
"to_addr": "agent@t.com",
|
||||
"date": "2026-01-01T00:00:00+00:00",
|
||||
"message_id": f"<{email_id}@t.com>",
|
||||
"body_text": subject,
|
||||
"is_read": False,
|
||||
}
|
||||
|
||||
|
||||
def test_bundle_multifile_flat_files_merge_into_one_mailbox(monkeypatch, tmp_path):
|
||||
"""the raw entities layout splits ONE mailbox across per-entity files
|
||||
(e.g. inbox.json + sent.json). Flat files without a {mailboxes} wrapper
|
||||
coalesce into a single default mailbox — entities combined, not fragmented
|
||||
into separate mailboxes."""
|
||||
import google_mail.server as srv
|
||||
import google_mail.state as state
|
||||
|
||||
service_dir = tmp_path / "bundle" / "services" / "google_mail"
|
||||
service_dir.mkdir(parents=True)
|
||||
# Two halves of the SAME mailbox: identity + first email; second email.
|
||||
(service_dir / "a.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"mailbox": {"email": "agent@t.com", "name": "Agent"},
|
||||
"contacts": [],
|
||||
"folders": [],
|
||||
"emails": [_email_record("1", "first")],
|
||||
"next_email_id": 2,
|
||||
}
|
||||
)
|
||||
)
|
||||
(service_dir / "b.json").write_text(json.dumps({"emails": [_email_record("2", "second")], "next_email_id": 3}))
|
||||
|
||||
monkeypatch.setenv("BUNDLEDIR", str(tmp_path / "bundle"))
|
||||
monkeypatch.delenv("INPUTDIR", raising=False)
|
||||
monkeypatch.delenv("OUTPUTDIR", raising=False)
|
||||
monkeypatch.delenv("BUNDLE_OUTPUT_DIR", raising=False)
|
||||
|
||||
state.set_mailboxes({})
|
||||
srv.init_state()
|
||||
|
||||
mailboxes = state.get_mailboxes()
|
||||
assert set(mailboxes) == {"default"}, "flat per-entity files must merge into ONE mailbox"
|
||||
data = mailboxes["default"].data
|
||||
assert data.mailbox.email == "agent@t.com"
|
||||
subjects = {e.subject for e in data.emails}
|
||||
assert subjects == {"first", "second"}, "both per-entity files' emails must be present"
|
||||
assert data.next_email_id == 3
|
||||
|
||||
|
||||
def test_bundle_mailboxes_wrapper_yields_named_mailboxes(monkeypatch, tmp_path):
|
||||
"""An explicit {mailboxes: {...}} wrapper still produces multiple named
|
||||
mailboxes — multi-tenant worlds opt in via the wrapper, not via separate
|
||||
flat files."""
|
||||
import google_mail.server as srv
|
||||
import google_mail.state as state
|
||||
|
||||
service_dir = tmp_path / "bundle" / "services" / "google_mail"
|
||||
service_dir.mkdir(parents=True)
|
||||
(service_dir / "state.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"mailboxes": {
|
||||
"alice": {"mailbox": {"email": "a@t.com", "name": "A"}, "emails": [], "next_email_id": 1},
|
||||
"bob": {"mailbox": {"email": "b@t.com", "name": "B"}, "emails": [], "next_email_id": 1},
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
monkeypatch.setenv("BUNDLEDIR", str(tmp_path / "bundle"))
|
||||
monkeypatch.delenv("INPUTDIR", raising=False)
|
||||
monkeypatch.delenv("OUTPUTDIR", raising=False)
|
||||
monkeypatch.delenv("BUNDLE_OUTPUT_DIR", raising=False)
|
||||
|
||||
state.set_mailboxes({})
|
||||
srv.init_state()
|
||||
|
||||
mailboxes = state.get_mailboxes()
|
||||
assert set(mailboxes) == {"alice", "bob"}
|
||||
assert {svc.data.mailbox.email for svc in mailboxes.values()} == {"a@t.com", "b@t.com"}
|
||||
|
||||
|
||||
def test_resolve_bundle_state_paths_returns_whole_folder(monkeypatch, tmp_path):
|
||||
"""The plural resolver returns ALL sorted *.json when there's no state.json,
|
||||
and exactly [state.json] when one is present."""
|
||||
import google_mail.state as state
|
||||
|
||||
service_dir = tmp_path / "services" / "google_mail"
|
||||
service_dir.mkdir(parents=True)
|
||||
a_json = service_dir / "a.json"
|
||||
b_json = service_dir / "b.json"
|
||||
_write_mailbox_email(a_json, "a@t.com")
|
||||
_write_mailbox_email(b_json, "b@t.com")
|
||||
|
||||
monkeypatch.setenv("BUNDLEDIR", str(tmp_path))
|
||||
|
||||
assert state.resolve_bundle_state_paths() == [a_json, b_json]
|
||||
|
||||
state_json = service_dir / "state.json"
|
||||
_write_mailbox_email(state_json, "state@t.com")
|
||||
assert state.resolve_bundle_state_paths() == [state_json]
|
||||
@@ -0,0 +1,350 @@
|
||||
"""Tests for the _snapshot_on_write decorator — final.json is written after every write tool call."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from google_mail.services.mailbox import MailboxService
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mailbox_service(tmp_path):
|
||||
"""Create a MailboxService seeded with minimal data."""
|
||||
data_path = tmp_path / "mailbox.json"
|
||||
data_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"mailbox": {"email": "test@test.com", "name": "Test User"},
|
||||
"contacts": [{"email": "bob@test.com", "name": "Bob"}],
|
||||
"folders": [],
|
||||
"emails": [],
|
||||
"next_email_id": 1,
|
||||
}
|
||||
)
|
||||
)
|
||||
svc = MailboxService(data_path)
|
||||
svc.load()
|
||||
return svc
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def outputdir(tmp_path):
|
||||
"""Return a temp directory for OUTPUTDIR, with a pre-configured final.json path."""
|
||||
out = tmp_path / "output" / "google_mail"
|
||||
out.mkdir(parents=True)
|
||||
return out
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _patch_server_globals(mailbox_service, outputdir):
|
||||
"""Wire up state globals so tools and the decorator can run."""
|
||||
import google_mail.state as state
|
||||
|
||||
state.set_mailboxes({"default": mailbox_service})
|
||||
state.set_snapshot_paths(final_path=outputdir / "final.json")
|
||||
yield
|
||||
state.set_mailboxes({})
|
||||
state.set_snapshot_paths(final_path=None, bundle_state_path=None)
|
||||
|
||||
|
||||
def test_send_email_writes_final_json(outputdir):
|
||||
"""Calling mail_send_email produces a final.json snapshot immediately."""
|
||||
from google_mail.server import mail_send_email
|
||||
|
||||
final = outputdir / "final.json"
|
||||
assert not final.exists(), "final.json should not exist before any write"
|
||||
|
||||
result = asyncio.run(
|
||||
mail_send_email(
|
||||
to="bob@test.com",
|
||||
subject="snapshot test",
|
||||
body="hello",
|
||||
)
|
||||
)
|
||||
data = json.loads(result)
|
||||
assert data.get("status") == "sent"
|
||||
assert final.exists(), "final.json must be written after mail_send_email"
|
||||
|
||||
snapshot = json.loads(final.read_text())
|
||||
subjects = [e["subject"] for e in snapshot.get("emails", [])]
|
||||
assert "snapshot test" in subjects
|
||||
|
||||
|
||||
def test_send_email_without_recipients_returns_error_without_consuming_id(mailbox_service):
|
||||
"""Empty recipient sends return a structured error and leave counters untouched."""
|
||||
from google_mail.server import mail_send_email
|
||||
|
||||
next_email_id = mailbox_service.data.next_email_id
|
||||
|
||||
result = asyncio.run(mail_send_email(to="", subject="empty recipient", body="hello"))
|
||||
data = json.loads(result)
|
||||
|
||||
assert data.get("status") == "bounced"
|
||||
assert data.get("error")
|
||||
assert mailbox_service.data.next_email_id == next_email_id
|
||||
|
||||
|
||||
def test_save_draft_invalid_recipient_returns_error_without_consuming_id(mailbox_service):
|
||||
"""Invalid draft recipients return structured errors instead of raising."""
|
||||
from google_mail.server import mail_save_draft
|
||||
|
||||
next_email_id = mailbox_service.data.next_email_id
|
||||
|
||||
result = asyncio.run(mail_save_draft(to="not an email", subject="bad draft", body="hello"))
|
||||
data = json.loads(result)
|
||||
|
||||
assert data.get("status") == "failed"
|
||||
assert data.get("error")
|
||||
assert mailbox_service.data.next_email_id == next_email_id
|
||||
|
||||
|
||||
def test_update_draft_invalid_recipient_returns_error_without_corrupting_draft(mailbox_service):
|
||||
"""Invalid draft updates return structured errors and preserve the existing draft."""
|
||||
from google_mail.server import mail_save_draft, mail_update_draft
|
||||
|
||||
created = json.loads(asyncio.run(mail_save_draft(to="bob@test.com", subject="draft", body="hello")))
|
||||
draft_id = created["draft"]["draft_id"]
|
||||
original = mailbox_service.get_draft(draft_id).model_dump(mode="json")
|
||||
|
||||
result = asyncio.run(mail_update_draft(draft_id=draft_id, to="not an email", subject="bad update"))
|
||||
data = json.loads(result)
|
||||
|
||||
assert data.get("status") == "failed"
|
||||
assert data.get("error")
|
||||
assert mailbox_service.get_draft(draft_id).model_dump(mode="json") == original
|
||||
|
||||
|
||||
def test_delete_email_writes_final_json(outputdir):
|
||||
"""Calling mail_delete_emails produces a final.json snapshot."""
|
||||
from google_mail.server import mail_delete_emails, mail_send_email
|
||||
|
||||
# First, create an email to delete
|
||||
result = asyncio.run(mail_send_email(to="bob@test.com", subject="to-delete", body="bye"))
|
||||
sent = json.loads(result)
|
||||
email_id = sent["email"]["email_id"]
|
||||
|
||||
# Remove existing final.json to isolate the delete's snapshot
|
||||
final = outputdir / "final.json"
|
||||
final.unlink()
|
||||
|
||||
asyncio.run(mail_delete_emails([email_id]))
|
||||
|
||||
assert final.exists(), "final.json must be written after mail_delete_emails"
|
||||
|
||||
|
||||
def test_delete_emails_reports_partial_batch_status():
|
||||
from google_mail.server import mail_delete_emails, mail_send_email
|
||||
|
||||
result = asyncio.run(mail_send_email(to="bob@test.com", subject="to-delete", body="bye"))
|
||||
email_id = json.loads(result)["email"]["email_id"]
|
||||
|
||||
data = json.loads(asyncio.run(mail_delete_emails([email_id, "missing"])))
|
||||
|
||||
assert data["status"] == "partial_success"
|
||||
assert data["summary"] == "1 of 2 attempts succeeded"
|
||||
assert data["requestedCount"] == 2
|
||||
assert data["succeededCount"] == 1
|
||||
assert data["failedCount"] == 1
|
||||
assert data["deletedIds"] == [email_id]
|
||||
assert data["errors"][0]["email_id"] == "missing"
|
||||
|
||||
|
||||
def test_delete_emails_reports_all_failed_batch_status():
|
||||
from google_mail.server import mail_delete_emails
|
||||
|
||||
data = json.loads(asyncio.run(mail_delete_emails(["missing-1", "missing-2"])))
|
||||
|
||||
assert data["status"] == "all_failed"
|
||||
assert data["summary"] == "All 2 attempts failed"
|
||||
assert data["requestedCount"] == 2
|
||||
assert data["succeededCount"] == 0
|
||||
assert data["failedCount"] == 2
|
||||
|
||||
|
||||
def test_mark_emails_requires_an_action_flag():
|
||||
from google_mail.server import mail_mark_emails, mail_send_email
|
||||
|
||||
result = asyncio.run(mail_send_email(to="bob@test.com", subject="to-mark", body="hi"))
|
||||
email_id = json.loads(result)["email"]["email_id"]
|
||||
|
||||
data = json.loads(asyncio.run(mail_mark_emails([email_id])))
|
||||
|
||||
assert data["status"] == "failed"
|
||||
assert "At least one" in data["error"]
|
||||
|
||||
|
||||
def test_mark_emails_reports_partial_batch_status():
|
||||
from google_mail.server import mail_mark_emails, mail_send_email
|
||||
|
||||
result = asyncio.run(mail_send_email(to="bob@test.com", subject="to-mark", body="hi"))
|
||||
email_id = json.loads(result)["email"]["email_id"]
|
||||
|
||||
data = json.loads(asyncio.run(mail_mark_emails([email_id, "missing"], is_read=False)))
|
||||
|
||||
assert data["status"] == "partial_success"
|
||||
assert data["summary"] == "1 of 2 attempts succeeded"
|
||||
assert data["markedCount"] == 1
|
||||
assert data["markedIds"] == [email_id]
|
||||
assert data["errors"][0]["email_id"] == "missing"
|
||||
|
||||
|
||||
def test_create_folder_writes_final_json(outputdir):
|
||||
"""Calling mail_create_folder produces a final.json snapshot."""
|
||||
from google_mail.server import mail_create_folder
|
||||
|
||||
final = outputdir / "final.json"
|
||||
asyncio.run(mail_create_folder("TestFolder"))
|
||||
|
||||
assert final.exists(), "final.json must be written after mail_create_folder"
|
||||
snapshot = json.loads(final.read_text())
|
||||
folder_names = [f["name"] for f in snapshot.get("folders", [])]
|
||||
assert "TestFolder" in folder_names
|
||||
|
||||
|
||||
def test_read_only_tool_does_not_write_final_json(outputdir):
|
||||
"""Calling a read-only tool (mail_get_emails) does NOT write final.json."""
|
||||
from google_mail.server import mail_get_emails
|
||||
|
||||
final = outputdir / "final.json"
|
||||
asyncio.run(mail_get_emails(folder="INBOX"))
|
||||
|
||||
assert not final.exists(), "final.json must NOT be written after a read-only tool"
|
||||
|
||||
|
||||
def test_final_json_updates_incrementally(outputdir):
|
||||
"""Each write tool call overwrites final.json with the latest state."""
|
||||
from google_mail.server import mail_send_email
|
||||
|
||||
final = outputdir / "final.json"
|
||||
|
||||
asyncio.run(mail_send_email(to="bob@test.com", subject="first", body="1"))
|
||||
snap1 = json.loads(final.read_text())
|
||||
count1 = len(snap1.get("emails", []))
|
||||
|
||||
asyncio.run(mail_send_email(to="bob@test.com", subject="second", body="2"))
|
||||
snap2 = json.loads(final.read_text())
|
||||
count2 = len(snap2.get("emails", []))
|
||||
|
||||
assert count2 > count1, "final.json should reflect each incremental mutation"
|
||||
|
||||
|
||||
def test_add_contact_writes_final_json(outputdir):
|
||||
"""Calling mail_add_contact produces a final.json snapshot."""
|
||||
from google_mail.server import mail_add_contact
|
||||
|
||||
final = outputdir / "final.json"
|
||||
assert not final.exists()
|
||||
|
||||
result = asyncio.run(mail_add_contact(email="alice@test.com", name="Alice"))
|
||||
data = json.loads(result)
|
||||
assert data.get("status") == "created"
|
||||
assert final.exists(), "final.json must be written after mail_add_contact"
|
||||
|
||||
|
||||
def test_schedule_email_writes_final_json(outputdir):
|
||||
"""Calling mail_schedule_email produces a final.json snapshot."""
|
||||
from google_mail.server import mail_schedule_email
|
||||
|
||||
final = outputdir / "final.json"
|
||||
assert not final.exists()
|
||||
|
||||
result = asyncio.run(
|
||||
mail_schedule_email(
|
||||
to="bob@test.com",
|
||||
subject="scheduled test",
|
||||
body="hello",
|
||||
scheduled_time="2025-06-01T09:00:00Z",
|
||||
)
|
||||
)
|
||||
data = json.loads(result)
|
||||
assert data.get("status") == "scheduled"
|
||||
assert final.exists(), "final.json must be written after mail_schedule_email"
|
||||
|
||||
|
||||
def test_schedule_email_without_recipients_returns_error_without_consuming_id(mailbox_service):
|
||||
"""Empty scheduled sends return a structured error and leave counters untouched."""
|
||||
from google_mail.server import mail_schedule_email
|
||||
|
||||
next_email_id = mailbox_service.data.next_email_id
|
||||
|
||||
result = asyncio.run(
|
||||
mail_schedule_email(
|
||||
to="",
|
||||
subject="empty recipient",
|
||||
body="hello",
|
||||
scheduled_time="2025-06-01T09:00:00Z",
|
||||
)
|
||||
)
|
||||
data = json.loads(result)
|
||||
|
||||
assert data.get("status") == "failed"
|
||||
assert data.get("error")
|
||||
assert mailbox_service.data.next_email_id == next_email_id
|
||||
|
||||
|
||||
def test_get_contacts_does_not_write_final_json(outputdir):
|
||||
"""Calling a read-only contact tool does NOT write final.json."""
|
||||
from google_mail.server import mail_get_contacts
|
||||
|
||||
final = outputdir / "final.json"
|
||||
asyncio.run(mail_get_contacts())
|
||||
assert not final.exists(), "final.json must NOT be written after a read-only tool"
|
||||
|
||||
|
||||
def test_no_final_path_skips_snapshot():
|
||||
"""When _final_path is None (no OUTPUTDIR), write tools still work without error."""
|
||||
import google_mail.state as state
|
||||
from google_mail.server import mail_send_email
|
||||
|
||||
state.set_snapshot_paths(final_path=None, bundle_state_path=None)
|
||||
|
||||
result = asyncio.run(mail_send_email(to="bob@test.com", subject="no-outputdir", body="ok"))
|
||||
data = json.loads(result)
|
||||
assert data.get("status") == "sent"
|
||||
|
||||
|
||||
def test_dual_writes_bundle_path_and_final_json(outputdir, tmp_path):
|
||||
"""Bundle migration: writable tools dual-write the bundle path (nested
|
||||
services/<name>/state.json layout) AND legacy final.json."""
|
||||
import google_mail.state as state
|
||||
from google_mail.server import mail_send_email
|
||||
|
||||
bundle_output_dir = tmp_path / "services" / "google_mail"
|
||||
bundle_output_dir.mkdir(parents=True)
|
||||
bundle_path = bundle_output_dir / "state.json"
|
||||
final_path = outputdir / "final.json"
|
||||
state.set_snapshot_paths(final_path=final_path, bundle_state_path=bundle_path)
|
||||
|
||||
asyncio.run(mail_send_email(to="bob@test.com", subject="dual-write", body="hi"))
|
||||
|
||||
assert bundle_path.exists(), "<BUNDLE_OUTPUT_DIR>/state.json must be written"
|
||||
assert final_path.exists(), "legacy final.json must still be written"
|
||||
assert bundle_path.read_text() == final_path.read_text()
|
||||
|
||||
|
||||
def test_set_snapshot_paths_partial_update_preserves_existing_path(tmp_path):
|
||||
import google_mail.state as state
|
||||
|
||||
final_path = tmp_path / "final.json"
|
||||
bundle_path = tmp_path / "bundle.json"
|
||||
updated_final_path = tmp_path / "updated-final.json"
|
||||
|
||||
state.set_snapshot_paths(final_path=final_path, bundle_state_path=bundle_path)
|
||||
state.set_snapshot_paths(final_path=updated_final_path)
|
||||
|
||||
assert state.get_final_path() == updated_final_path
|
||||
assert state.get_bundle_state_path() == bundle_path
|
||||
|
||||
|
||||
def test_set_snapshot_paths_can_clear_individual_path(tmp_path):
|
||||
import google_mail.state as state
|
||||
|
||||
final_path = tmp_path / "final.json"
|
||||
bundle_path = tmp_path / "bundle.json"
|
||||
|
||||
state.set_snapshot_paths(final_path=final_path, bundle_state_path=bundle_path)
|
||||
state.set_snapshot_paths(bundle_state_path=None)
|
||||
|
||||
assert state.get_final_path() == final_path
|
||||
assert state.get_bundle_state_path() is None
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,267 @@
|
||||
"""Tests for multi-mailbox support."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from google_mail.models import MultiMailboxData
|
||||
from google_mail.services.mailbox import MailboxService
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def multi_mailbox_services(tmp_path):
|
||||
"""Create two mailbox services with different data."""
|
||||
work_path = tmp_path / "work.json"
|
||||
work_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"mailbox": {"email": "alex@acmecorp.com", "name": "Alex Morgan"},
|
||||
"contacts": [{"email": "pam@acmecorp.com", "name": "Pam Chen"}],
|
||||
"folders": [],
|
||||
"emails": [
|
||||
{
|
||||
"email_id": "1",
|
||||
"folder": "INBOX",
|
||||
"subject": "Work email",
|
||||
"from_addr": "pam@acmecorp.com",
|
||||
"to_addr": "alex@acmecorp.com",
|
||||
"date": "2026-04-14T09:00:00Z",
|
||||
"message_id": "<work1@acmecorp.com>",
|
||||
"body_text": "This is a work email.",
|
||||
"is_read": False,
|
||||
"is_important": False,
|
||||
}
|
||||
],
|
||||
"next_email_id": 10,
|
||||
}
|
||||
)
|
||||
)
|
||||
personal_path = tmp_path / "personal.json"
|
||||
personal_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"mailbox": {"email": "alex.m@gmail.com", "name": "Alex"},
|
||||
"contacts": [{"email": "friend@gmail.com", "name": "Best Friend"}],
|
||||
"folders": [],
|
||||
"emails": [
|
||||
{
|
||||
"email_id": "1",
|
||||
"folder": "INBOX",
|
||||
"subject": "Personal email",
|
||||
"from_addr": "friend@gmail.com",
|
||||
"to_addr": "alex.m@gmail.com",
|
||||
"date": "2026-04-14T10:00:00Z",
|
||||
"message_id": "<personal1@gmail.com>",
|
||||
"body_text": "Hey Alex! Want to grab lunch?",
|
||||
"is_read": False,
|
||||
"is_important": False,
|
||||
}
|
||||
],
|
||||
"next_email_id": 10,
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
work_svc = MailboxService(work_path)
|
||||
work_svc.load()
|
||||
personal_svc = MailboxService(personal_path)
|
||||
personal_svc.load()
|
||||
|
||||
return {"work": work_svc, "personal": personal_svc}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def outputdir(tmp_path):
|
||||
out = tmp_path / "output" / "google_mail"
|
||||
out.mkdir(parents=True)
|
||||
return out
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _patch_server_globals(multi_mailbox_services, outputdir):
|
||||
import google_mail.state as state
|
||||
|
||||
state.set_mailboxes(multi_mailbox_services)
|
||||
state.set_snapshot_paths(final_path=outputdir / "final.json")
|
||||
yield
|
||||
state.set_mailboxes({})
|
||||
state.set_snapshot_paths(final_path=None, bundle_state_path=None)
|
||||
|
||||
|
||||
class TestListMailboxes:
|
||||
def test_lists_all_mailboxes(self):
|
||||
from google_mail.server import mail_list_mailboxes
|
||||
|
||||
result = json.loads(asyncio.run(mail_list_mailboxes()))
|
||||
assert result["total"] == 2
|
||||
ids = [m["mailbox_id"] for m in result["mailboxes"]]
|
||||
assert "work" in ids
|
||||
assert "personal" in ids
|
||||
|
||||
def test_mailbox_info(self):
|
||||
from google_mail.server import mail_list_mailboxes
|
||||
|
||||
result = json.loads(asyncio.run(mail_list_mailboxes()))
|
||||
work = next(m for m in result["mailboxes"] if m["mailbox_id"] == "work")
|
||||
assert work["email"] == "alex@acmecorp.com"
|
||||
assert work["email_count"] == 1
|
||||
assert work["contact_count"] == 1
|
||||
|
||||
|
||||
class TestMailboxIsolation:
|
||||
def test_get_emails_from_work(self):
|
||||
from google_mail.server import mail_get_emails
|
||||
|
||||
result = json.loads(asyncio.run(mail_get_emails(mailbox_id="work")))
|
||||
assert result["total"] == 1
|
||||
assert result["emails"][0]["subject"] == "Work email"
|
||||
|
||||
def test_get_emails_from_personal(self):
|
||||
from google_mail.server import mail_get_emails
|
||||
|
||||
result = json.loads(asyncio.run(mail_get_emails(mailbox_id="personal")))
|
||||
assert result["total"] == 1
|
||||
assert result["emails"][0]["subject"] == "Personal email"
|
||||
|
||||
def test_contacts_are_isolated(self):
|
||||
from google_mail.server import mail_get_contacts
|
||||
|
||||
work_result = json.loads(asyncio.run(mail_get_contacts(mailbox_id="work")))
|
||||
assert len(work_result["contacts"]) == 1
|
||||
assert work_result["contacts"][0]["name"] == "Pam Chen"
|
||||
|
||||
personal_result = json.loads(asyncio.run(mail_get_contacts(mailbox_id="personal")))
|
||||
assert len(personal_result["contacts"]) == 1
|
||||
assert personal_result["contacts"][0]["name"] == "Best Friend"
|
||||
|
||||
def test_send_email_in_correct_mailbox(self):
|
||||
from google_mail.server import mail_send_email
|
||||
|
||||
result = json.loads(
|
||||
asyncio.run(
|
||||
mail_send_email(
|
||||
to="pam@acmecorp.com",
|
||||
subject="Test from work",
|
||||
body="Sent from work mailbox",
|
||||
mailbox_id="work",
|
||||
)
|
||||
)
|
||||
)
|
||||
assert result["status"] == "sent"
|
||||
assert result["email"]["from"] == "alex@acmecorp.com"
|
||||
|
||||
def test_send_email_wrong_mailbox_contact(self):
|
||||
from google_mail.server import mail_send_email
|
||||
|
||||
# pam@acmecorp.com is a work contact, not personal
|
||||
result = json.loads(
|
||||
asyncio.run(
|
||||
mail_send_email(
|
||||
to="pam@acmecorp.com",
|
||||
subject="Should fail",
|
||||
body="Wrong mailbox",
|
||||
mailbox_id="personal",
|
||||
)
|
||||
)
|
||||
)
|
||||
assert "error" in result # recipient not found in personal contacts
|
||||
|
||||
def test_invalid_mailbox_id(self):
|
||||
from google_mail.server import mail_get_emails
|
||||
|
||||
with pytest.raises(ValueError, match="not found"):
|
||||
asyncio.run(mail_get_emails(mailbox_id="nonexistent"))
|
||||
|
||||
def test_write_to_one_doesnt_affect_other(self):
|
||||
from google_mail.server import mail_create_folder, mail_get_folders
|
||||
|
||||
# Create folder in work
|
||||
asyncio.run(mail_create_folder(folder_name="Projects", mailbox_id="work"))
|
||||
|
||||
# Verify it's in work
|
||||
work_folders = json.loads(asyncio.run(mail_get_folders(mailbox_id="work")))
|
||||
folder_names = [f["name"] for f in work_folders["folders"]]
|
||||
assert "Projects" in folder_names
|
||||
|
||||
# Verify it's NOT in personal
|
||||
personal_folders = json.loads(asyncio.run(mail_get_folders(mailbox_id="personal")))
|
||||
personal_names = [f["name"] for f in personal_folders["folders"]]
|
||||
assert "Projects" not in personal_names
|
||||
|
||||
|
||||
class TestMultiMailboxSnapshot:
|
||||
def test_snapshot_includes_all_mailboxes(self, outputdir):
|
||||
from google_mail.server import mail_send_email
|
||||
|
||||
final = outputdir / "final.json"
|
||||
|
||||
asyncio.run(
|
||||
mail_send_email(
|
||||
to="pam@acmecorp.com",
|
||||
subject="Trigger snapshot",
|
||||
body="test",
|
||||
mailbox_id="work",
|
||||
)
|
||||
)
|
||||
|
||||
assert final.exists()
|
||||
snapshot = json.loads(final.read_text())
|
||||
# Multi-mailbox format
|
||||
assert "mailboxes" in snapshot
|
||||
assert "work" in snapshot["mailboxes"]
|
||||
assert "personal" in snapshot["mailboxes"]
|
||||
|
||||
|
||||
class TestMultiMailboxStateTools:
|
||||
def test_export_state_returns_typed_multi_mailbox_state(self):
|
||||
from google_mail.server import export_state
|
||||
|
||||
state = asyncio.run(export_state())
|
||||
|
||||
assert isinstance(state, MultiMailboxData)
|
||||
assert set(state.mailboxes) == {"work", "personal"}
|
||||
|
||||
def test_import_state_accepts_multi_mailbox_dict(self):
|
||||
import google_mail.state as mail_state
|
||||
from google_mail.server import import_state, mail_list_mailboxes
|
||||
|
||||
state = {
|
||||
"mailboxes": {
|
||||
"replacement": {
|
||||
"mailbox": {"email": "replacement@example.com", "name": "Replacement"},
|
||||
"contacts": [],
|
||||
"folders": [],
|
||||
"emails": [],
|
||||
"next_email_id": 1,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result = asyncio.run(import_state(state))
|
||||
|
||||
assert result == {"ok": True}
|
||||
assert set(mail_state.get_mailboxes()) == {"replacement"}
|
||||
listed = json.loads(asyncio.run(mail_list_mailboxes()))
|
||||
assert listed["mailboxes"][0]["mailbox_id"] == "replacement"
|
||||
|
||||
def test_import_state_flat_payload_replaces_multi_mailbox_registry(self):
|
||||
import google_mail.state as mail_state
|
||||
from google_mail.server import import_state, mail_list_mailboxes
|
||||
|
||||
state = {
|
||||
"mailbox": {"email": "replacement@example.com", "name": "Replacement"},
|
||||
"contacts": [],
|
||||
"folders": [],
|
||||
"emails": [],
|
||||
"next_email_id": 1,
|
||||
}
|
||||
|
||||
result = asyncio.run(import_state(state))
|
||||
|
||||
assert result == {"ok": True}
|
||||
assert set(mail_state.get_mailboxes()) == {"default"}
|
||||
listed = json.loads(asyncio.run(mail_list_mailboxes()))
|
||||
assert listed["total"] == 1
|
||||
assert listed["mailboxes"][0]["mailbox_id"] == "default"
|
||||
assert listed["mailboxes"][0]["email"] == "replacement@example.com"
|
||||
@@ -0,0 +1,573 @@
|
||||
"""Tests for schema validators on MailboxData / Email."""
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from google_mail.models import Email, MailboxData, MultiMailboxData
|
||||
|
||||
|
||||
def _sample_email(**overrides: object) -> dict[str, object]:
|
||||
base: dict[str, object] = {
|
||||
"email_id": "1",
|
||||
"folder": "INBOX",
|
||||
"subject": "s",
|
||||
"from_addr": "bob@example.com",
|
||||
"to_addr": "alice@example.com",
|
||||
"date": "2024-01-15T10:00:00Z",
|
||||
"message_id": "<m@x>",
|
||||
"body_text": "hi",
|
||||
}
|
||||
base.update(overrides)
|
||||
return base
|
||||
|
||||
|
||||
def test_email_accepts_list_for_addr_fields():
|
||||
email = Email.model_validate(
|
||||
_sample_email(
|
||||
to_addr=["alice@example.com", "bob@example.com"],
|
||||
cc_addr=["carol@example.com"],
|
||||
bcc_addr=["dan@example.com", "eve@example.com"],
|
||||
)
|
||||
)
|
||||
assert email.to_addr == "alice@example.com, bob@example.com"
|
||||
assert email.cc_addr == "carol@example.com"
|
||||
assert email.bcc_addr == "dan@example.com, eve@example.com"
|
||||
|
||||
|
||||
def test_email_list_validator_trims_and_drops_empties():
|
||||
email = Email.model_validate(_sample_email(to_addr=[" alice@example.com ", "", "bob@example.com"]))
|
||||
assert email.to_addr == "alice@example.com, bob@example.com"
|
||||
|
||||
|
||||
def test_email_addr_string_passthrough_unchanged():
|
||||
email = Email.model_validate(_sample_email(to_addr="alice@example.com, bob@example.com"))
|
||||
assert email.to_addr == "alice@example.com, bob@example.com"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("alias", ["labelIds", "label_ids"])
|
||||
def test_email_accepts_label_aliases(alias):
|
||||
email = Email.model_validate(_sample_email(**{alias: ["INBOX", "Client"]}))
|
||||
assert email.labels == ["INBOX", "Client"]
|
||||
assert "labelIds" not in email.model_dump(mode="json")
|
||||
assert "label_ids" not in email.model_dump(mode="json")
|
||||
|
||||
|
||||
def test_email_normalizes_labels():
|
||||
email = Email.model_validate(_sample_email(labels=[" client ", "", "client", "INBOX", " "]))
|
||||
assert email.labels == ["client", "INBOX"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("field", ["email_id", "folder", "message_id"])
|
||||
def test_email_rejects_empty_identity_fields(field):
|
||||
with pytest.raises(ValidationError):
|
||||
Email.model_validate(_sample_email(**{field: ""}))
|
||||
|
||||
|
||||
def test_email_allows_empty_subject_and_body_for_sparse_messages():
|
||||
email = Email.model_validate(_sample_email(subject="", body_text="", body_html=""))
|
||||
assert email.subject == ""
|
||||
assert email.body_text == ""
|
||||
assert email.body_html == ""
|
||||
|
||||
|
||||
def test_email_accepts_empty_to_addr_for_drafts():
|
||||
email = Email.model_validate(_sample_email(folder="Drafts", to_addr=""))
|
||||
assert email.to_addr == ""
|
||||
|
||||
|
||||
def test_email_accepts_empty_to_addr_for_trash():
|
||||
email = Email.model_validate(_sample_email(folder="Trash", to_addr=""))
|
||||
assert email.to_addr == ""
|
||||
|
||||
|
||||
def test_email_rejects_empty_to_addr_outside_drafts():
|
||||
with pytest.raises(ValidationError):
|
||||
Email.model_validate(_sample_email(to_addr=""))
|
||||
|
||||
|
||||
def test_email_allows_active_message_with_only_cc_recipient():
|
||||
email = Email.model_validate(_sample_email(to_addr="", cc_addr="bob@example.com"))
|
||||
assert email.to_addr == ""
|
||||
assert email.cc_addr == "bob@example.com"
|
||||
|
||||
|
||||
def test_email_normalizes_empty_optional_recipient_fields_to_none():
|
||||
email = Email.model_validate(_sample_email(cc_addr="", bcc_addr=" "))
|
||||
assert email.cc_addr is None
|
||||
assert email.bcc_addr is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"field,value",
|
||||
[
|
||||
("from_addr", "not-an-email"),
|
||||
("to_addr", "not-an-email"),
|
||||
("cc_addr", "alice@example.com, not-an-email"),
|
||||
("bcc_addr", ["dan@example.com", "not-an-email"]),
|
||||
],
|
||||
)
|
||||
def test_email_rejects_invalid_addr_fields(field, value):
|
||||
with pytest.raises(ValidationError):
|
||||
Email.model_validate(_sample_email(**{field: value}))
|
||||
|
||||
|
||||
def test_mailbox_data_import_coerces_email_addr_lists():
|
||||
"""import_state feeds JSON through MailboxData; list-form addrs should round-trip to strings."""
|
||||
data = MailboxData.model_validate(
|
||||
{
|
||||
"mailbox": {"email": "alice@example.com", "name": "Alice"},
|
||||
"contacts": [],
|
||||
"folders": [],
|
||||
"emails": [
|
||||
_sample_email(to_addr=["alice@example.com", "bob@example.com"]),
|
||||
],
|
||||
"next_email_id": 2,
|
||||
}
|
||||
)
|
||||
assert data.emails[0].to_addr == "alice@example.com, bob@example.com"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"payload",
|
||||
[
|
||||
{"mailbox": {"email": "not-an-email", "name": "Alice"}},
|
||||
{
|
||||
"mailbox": {"email": "alice@example.com", "name": "Alice"},
|
||||
"contacts": [{"email": "not-an-email", "name": "Bob"}],
|
||||
},
|
||||
{
|
||||
"mailbox": {"email": "alice@example.com", "name": "Alice"},
|
||||
"groups": [{"email": "not-an-email", "name": "Team", "members": ["alice@example.com"]}],
|
||||
},
|
||||
{
|
||||
"mailbox": {"email": "alice@example.com", "name": "Alice"},
|
||||
"groups": [{"email": "team@example.com", "name": "Team", "members": ["not-an-email"]}],
|
||||
},
|
||||
],
|
||||
)
|
||||
def test_mailbox_data_rejects_invalid_email_addresses(payload):
|
||||
with pytest.raises(ValidationError):
|
||||
MailboxData.model_validate(payload)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"payload",
|
||||
[
|
||||
{
|
||||
"mailbox": {"email": "alice@example.com", "name": "Alice"},
|
||||
"unexpected": True,
|
||||
},
|
||||
{
|
||||
"mailbox": {"email": "alice@example.com", "name": "Alice", "unexpected": True},
|
||||
},
|
||||
{
|
||||
"mailbox": {"email": "alice@example.com", "name": "Alice"},
|
||||
"contacts": [{"email": "bob@example.com", "name": "Bob", "unexpected": True}],
|
||||
},
|
||||
{
|
||||
"mailbox": {"email": "alice@example.com", "name": "Alice"},
|
||||
"folders": [{"name": "Projects", "unexpected": True}],
|
||||
},
|
||||
{
|
||||
"mailbox": {"email": "alice@example.com", "name": "Alice"},
|
||||
"emails": [_sample_email(unexpected=True)],
|
||||
},
|
||||
{
|
||||
"mailbox": {"email": "alice@example.com", "name": "Alice"},
|
||||
"emails": [
|
||||
_sample_email(
|
||||
attachments=[
|
||||
{
|
||||
"filename": "notes.txt",
|
||||
"content_type": "text/plain",
|
||||
"content_base64": "aGk=",
|
||||
"unexpected": True,
|
||||
}
|
||||
]
|
||||
)
|
||||
],
|
||||
},
|
||||
],
|
||||
)
|
||||
def test_mailbox_data_rejects_unknown_state_fields(payload):
|
||||
with pytest.raises(ValidationError):
|
||||
MailboxData.model_validate(payload)
|
||||
|
||||
|
||||
def test_mailbox_data_still_parses_json_datetime_strings():
|
||||
data = MailboxData.model_validate(
|
||||
{
|
||||
"mailbox": {"email": "alice@example.com", "name": "Alice"},
|
||||
"emails": [_sample_email(date="2024-01-15T10:00:00Z")],
|
||||
"next_email_id": 2,
|
||||
}
|
||||
)
|
||||
assert data.emails[0].date.isoformat() == "2024-01-15T10:00:00+00:00"
|
||||
|
||||
|
||||
def test_mailbox_data_rejects_non_positive_next_email_id():
|
||||
with pytest.raises(ValidationError):
|
||||
MailboxData.model_validate(
|
||||
{
|
||||
"mailbox": {"email": "alice@example.com", "name": "Alice"},
|
||||
"next_email_id": 0,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_mailbox_data_rejects_duplicate_folder_names():
|
||||
with pytest.raises(ValidationError):
|
||||
MailboxData.model_validate(
|
||||
{
|
||||
"mailbox": {"email": "alice@example.com", "name": "Alice"},
|
||||
"folders": [{"name": "Projects"}, {"name": "Projects"}],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_mailbox_data_rejects_empty_folder_name():
|
||||
with pytest.raises(ValidationError):
|
||||
MailboxData.model_validate(
|
||||
{
|
||||
"mailbox": {"email": "alice@example.com", "name": "Alice"},
|
||||
"folders": [{"name": ""}],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("folder_name", ["INBOX", "Sent", "Drafts", "Trash", "Scheduled"])
|
||||
def test_mailbox_data_rejects_system_folders_as_custom_folders(folder_name):
|
||||
with pytest.raises(ValidationError):
|
||||
MailboxData.model_validate(
|
||||
{
|
||||
"mailbox": {"email": "alice@example.com", "name": "Alice"},
|
||||
"folders": [{"name": folder_name}],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_mailbox_data_rejects_case_insensitive_duplicate_contact_emails():
|
||||
with pytest.raises(ValidationError):
|
||||
MailboxData.model_validate(
|
||||
{
|
||||
"mailbox": {"email": "alice@example.com", "name": "Alice"},
|
||||
"contacts": [
|
||||
{"email": "bob@example.com", "name": "Bob"},
|
||||
{"email": "BOB@example.com", "name": "Other Bob"},
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_mailbox_data_rejects_duplicate_email_ids():
|
||||
with pytest.raises(ValidationError):
|
||||
MailboxData.model_validate(
|
||||
{
|
||||
"mailbox": {"email": "alice@example.com", "name": "Alice"},
|
||||
"emails": [
|
||||
_sample_email(email_id="1", subject="First"),
|
||||
_sample_email(email_id="1", subject="Second"),
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_mailbox_data_rejects_next_email_id_that_collides_with_numeric_email_id():
|
||||
with pytest.raises(ValidationError):
|
||||
MailboxData.model_validate(
|
||||
{
|
||||
"mailbox": {"email": "alice@example.com", "name": "Alice"},
|
||||
"emails": [_sample_email(email_id="5")],
|
||||
"next_email_id": 5,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_mailbox_data_rejects_next_email_id_below_existing_numeric_email_id():
|
||||
with pytest.raises(ValidationError):
|
||||
MailboxData.model_validate(
|
||||
{
|
||||
"mailbox": {"email": "alice@example.com", "name": "Alice"},
|
||||
"emails": [_sample_email(email_id="7")],
|
||||
"next_email_id": 6,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_mailbox_data_allows_next_email_id_with_non_numeric_email_ids():
|
||||
data = MailboxData.model_validate(
|
||||
{
|
||||
"mailbox": {"email": "alice@example.com", "name": "Alice"},
|
||||
"emails": [_sample_email(email_id="draft-1")],
|
||||
"next_email_id": 1,
|
||||
}
|
||||
)
|
||||
assert data.next_email_id == 1
|
||||
|
||||
|
||||
def test_mailbox_data_rejects_email_with_unknown_folder():
|
||||
with pytest.raises(ValidationError):
|
||||
MailboxData.model_validate(
|
||||
{
|
||||
"mailbox": {"email": "alice@example.com", "name": "Alice"},
|
||||
"emails": [_sample_email(folder="Projects")],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_mailbox_data_accepts_empty_attachment_content():
|
||||
data = MailboxData.model_validate(
|
||||
{
|
||||
"mailbox": {"email": "alice@example.com", "name": "Alice"},
|
||||
"emails": [
|
||||
_sample_email(
|
||||
attachments=[
|
||||
{
|
||||
"filename": "empty.txt",
|
||||
"content_type": "text/plain",
|
||||
"content_base64": "",
|
||||
}
|
||||
]
|
||||
)
|
||||
],
|
||||
"next_email_id": 2,
|
||||
}
|
||||
)
|
||||
assert data.emails[0].attachments[0].size == 0
|
||||
|
||||
|
||||
def test_mailbox_data_rejects_invalid_attachment_base64():
|
||||
with pytest.raises(ValidationError):
|
||||
MailboxData.model_validate(
|
||||
{
|
||||
"mailbox": {"email": "alice@example.com", "name": "Alice"},
|
||||
"emails": [
|
||||
_sample_email(
|
||||
attachments=[
|
||||
{
|
||||
"filename": "broken.txt",
|
||||
"content_type": "text/plain",
|
||||
"content_base64": "not base64!",
|
||||
}
|
||||
]
|
||||
)
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_mailbox_data_rejects_empty_attachment_filename():
|
||||
with pytest.raises(ValidationError):
|
||||
MailboxData.model_validate(
|
||||
{
|
||||
"mailbox": {"email": "alice@example.com", "name": "Alice"},
|
||||
"emails": [
|
||||
_sample_email(
|
||||
attachments=[
|
||||
{
|
||||
"filename": "",
|
||||
"content_type": "text/plain",
|
||||
"content_base64": "aGk=",
|
||||
}
|
||||
]
|
||||
)
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_mailbox_data_rejects_duplicate_attachment_filenames_per_email():
|
||||
with pytest.raises(ValidationError):
|
||||
MailboxData.model_validate(
|
||||
{
|
||||
"mailbox": {"email": "alice@example.com", "name": "Alice"},
|
||||
"emails": [
|
||||
_sample_email(
|
||||
attachments=[
|
||||
{
|
||||
"filename": "notes.txt",
|
||||
"content_type": "text/plain",
|
||||
"content_base64": "aGk=",
|
||||
},
|
||||
{
|
||||
"filename": "notes.txt",
|
||||
"content_type": "text/plain",
|
||||
"content_base64": "Ynll",
|
||||
},
|
||||
]
|
||||
)
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_mailbox_data_accepts_scheduled_email_with_scheduled_time():
|
||||
data = MailboxData.model_validate(
|
||||
{
|
||||
"mailbox": {"email": "alice@example.com", "name": "Alice"},
|
||||
"emails": [
|
||||
_sample_email(
|
||||
folder="Scheduled",
|
||||
scheduled_time="2024-01-16T10:00:00Z",
|
||||
)
|
||||
],
|
||||
"next_email_id": 2,
|
||||
}
|
||||
)
|
||||
scheduled_time = data.emails[0].scheduled_time
|
||||
assert scheduled_time is not None
|
||||
assert scheduled_time.isoformat() == "2024-01-16T10:00:00+00:00"
|
||||
|
||||
|
||||
def test_mailbox_data_rejects_scheduled_folder_without_scheduled_time():
|
||||
with pytest.raises(ValidationError):
|
||||
MailboxData.model_validate(
|
||||
{
|
||||
"mailbox": {"email": "alice@example.com", "name": "Alice"},
|
||||
"emails": [_sample_email(folder="Scheduled")],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_mailbox_data_rejects_scheduled_time_outside_scheduled_folder():
|
||||
with pytest.raises(ValidationError):
|
||||
MailboxData.model_validate(
|
||||
{
|
||||
"mailbox": {"email": "alice@example.com", "name": "Alice"},
|
||||
"emails": [_sample_email(folder="INBOX", scheduled_time="2024-01-16T10:00:00Z")],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_mailbox_data_accepts_group_members_from_contacts_and_mailbox_owner():
|
||||
data = MailboxData.model_validate(
|
||||
{
|
||||
"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"]},
|
||||
],
|
||||
}
|
||||
)
|
||||
group = data.get_group_by_email("team@example.com")
|
||||
assert group is not None
|
||||
assert group.members == ["alice@example.com", "bob@example.com"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"members",
|
||||
[
|
||||
[],
|
||||
[""],
|
||||
["bob@example.com", "bob@example.com"],
|
||||
["team@example.com"],
|
||||
["unknown@example.com"],
|
||||
],
|
||||
)
|
||||
def test_mailbox_data_rejects_invalid_group_members(members):
|
||||
with pytest.raises(ValidationError):
|
||||
MailboxData.model_validate(
|
||||
{
|
||||
"mailbox": {"email": "alice@example.com", "name": "Alice"},
|
||||
"contacts": [
|
||||
{"email": "bob@example.com", "name": "Bob"},
|
||||
],
|
||||
"groups": [
|
||||
{"email": "team@example.com", "name": "Team", "members": members},
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_multi_mailbox_data_validates_nested_mailboxes():
|
||||
state = MultiMailboxData.model_validate(
|
||||
{
|
||||
"mailboxes": {
|
||||
"work": {
|
||||
"mailbox": {"email": "alice@example.com", "name": "Alice"},
|
||||
"emails": [_sample_email()],
|
||||
"next_email_id": 2,
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
assert state.mailboxes["work"].mailbox.email == "alice@example.com"
|
||||
|
||||
|
||||
def test_multi_mailbox_data_rejects_unknown_wrapper_fields():
|
||||
with pytest.raises(ValidationError):
|
||||
MultiMailboxData.model_validate(
|
||||
{
|
||||
"mailboxes": {
|
||||
"work": {
|
||||
"mailbox": {"email": "alice@example.com", "name": "Alice"},
|
||||
}
|
||||
},
|
||||
"unexpected": True,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_multi_mailbox_data_rejects_empty_mailbox_map():
|
||||
with pytest.raises(ValidationError):
|
||||
MultiMailboxData.model_validate({"mailboxes": {}})
|
||||
|
||||
|
||||
def test_multi_mailbox_data_rejects_empty_mailbox_id():
|
||||
with pytest.raises(ValidationError):
|
||||
MultiMailboxData.model_validate(
|
||||
{
|
||||
"mailboxes": {
|
||||
"": {
|
||||
"mailbox": {"email": "alice@example.com", "name": "Alice"},
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_addr_fields_advertise_array_variant_in_json_schema():
|
||||
"""MCP clients validate payloads against inputSchema; the list form must be advertised."""
|
||||
email_schema = MailboxData.model_json_schema()["$defs"]["Email"]["properties"]
|
||||
array_variant = {"type": "array", "items": {"type": "string", "format": "email"}}
|
||||
|
||||
to_variants = email_schema["to_addr"]["anyOf"]
|
||||
assert {"type": "string"} in to_variants
|
||||
assert array_variant in to_variants
|
||||
|
||||
cc_variants = email_schema["cc_addr"]["anyOf"]
|
||||
assert {"type": "string"} in cc_variants
|
||||
assert {"type": "null"} in cc_variants
|
||||
assert array_variant in cc_variants
|
||||
|
||||
|
||||
def test_address_book_fields_advertise_email_format_in_json_schema():
|
||||
schema = MailboxData.model_json_schema()
|
||||
|
||||
assert schema["$defs"]["Mailbox"]["properties"]["email"]["format"] == "email"
|
||||
assert schema["$defs"]["Contact"]["properties"]["email"]["format"] == "email"
|
||||
assert schema["$defs"]["ContactGroup"]["properties"]["email"]["format"] == "email"
|
||||
assert schema["$defs"]["ContactGroup"]["properties"]["members"]["items"]["format"] == "email"
|
||||
assert schema["$defs"]["Email"]["properties"]["from_addr"]["format"] == "email"
|
||||
|
||||
|
||||
def test_attachment_content_advertises_base64_encoding_in_json_schema():
|
||||
attachment_schema = MailboxData.model_json_schema()["$defs"]["Attachment"]["properties"]
|
||||
assert attachment_schema["content_base64"]["contentEncoding"] == "base64"
|
||||
|
||||
|
||||
def test_non_empty_state_fields_advertise_min_length_in_json_schema():
|
||||
schema = MailboxData.model_json_schema()["$defs"]
|
||||
assert schema["Folder"]["properties"]["name"]["minLength"] == 1
|
||||
assert schema["Attachment"]["properties"]["filename"]["minLength"] == 1
|
||||
assert schema["Email"]["properties"]["email_id"]["minLength"] == 1
|
||||
assert schema["Email"]["properties"]["folder"]["minLength"] == 1
|
||||
assert schema["Email"]["properties"]["message_id"]["minLength"] == 1
|
||||
|
||||
|
||||
def test_multi_mailbox_data_advertises_non_empty_mailboxes_in_json_schema():
|
||||
mailboxes_schema = MultiMailboxData.model_json_schema()["properties"]["mailboxes"]
|
||||
assert mailboxes_schema["minProperties"] == 1
|
||||
assert mailboxes_schema["propertyNames"]["minLength"] == 1
|
||||
@@ -0,0 +1,254 @@
|
||||
"""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)
|
||||
Reference in New Issue
Block a user