Files
handbook/docker/packages/google_mail/tests/test_google_mail_snapshot.py
T

351 lines
12 KiB
Python
Raw Normal View History

2026-06-24 12:44:34 -07:00
"""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