diff --git a/packages/environments/mock-gmail/README.md b/packages/environments/mock-gmail/README.md index 00cdc1233..1d43ac0dd 100644 --- a/packages/environments/mock-gmail/README.md +++ b/packages/environments/mock-gmail/README.md @@ -14,7 +14,7 @@ Mock Gmail provides a safe, fully stateful Gmail environment where agents can be - **Full MIME/RFC 2822 support** — agents can send raw base64url-encoded emails exactly like the real API - **Stateful SQLite backend** — persistent CRUD, multi-user mailboxes, local delivery between users - **Snapshot/restore** — save and reset DB state for deterministic evaluation runs -- **35 golden fixtures** captured from the real Gmail API with conformance tests validating response shapes match Gmail behavior +- **47 golden fixtures** captured from the real Gmail API with conformance tests validating response shapes match Gmail behavior - **Task-aware seeding** for repo-level example tasks, with DB state diffs and action logs for verifiers - **MCP server** — expose all endpoints as MCP tools via `fastapi-mcp` - **Gymnasium environment** — `GmailEnv` for RL-style agent training @@ -93,6 +93,7 @@ uv run --extra dev pytest tests -q |-------|----------------| | `test_api.py` | Full CRUD for messages, threads, labels, drafts, admin, and task surfaces | | `test_conformance.py` | Response-shape validation against real Gmail fixtures | +| `test_threads.py` | Thread list/get filtering, ordering, pagination, formats, and orphan handling | | `test_settings.py` | Settings sub-resources: filters, sendAs, forwarding, delegates, vacation, IMAP, POP, language | | `test_mime.py` | RFC 2822 build/parse, base64url encoding, message-ID generation | | `test_snapshots.py` | Snapshot/reset behavior | diff --git a/packages/environments/mock-gmail/mock_gmail/api/threads.py b/packages/environments/mock-gmail/mock_gmail/api/threads.py index d2f86c44f..dee42714d 100644 --- a/packages/environments/mock-gmail/mock_gmail/api/threads.py +++ b/packages/environments/mock-gmail/mock_gmail/api/threads.py @@ -2,7 +2,10 @@ from __future__ import annotations +from typing import Literal + from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy import and_, case, func from sqlalchemy.orm import Session from mock_gmail.models import Thread, Message, MessageLabel @@ -34,7 +37,36 @@ def list_threads( db: Session = Depends(get_db), _user_id: str = Depends(resolve_user_id), ): - query = db.query(Thread).filter(Thread.user_id == _user_id) + latest_visible_message_date = func.max( + case( + ( + Message.is_trash.is_(False) & Message.is_spam.is_(False), + Message.internal_date, + ), + else_=None, + ) + ) + # Provider captures show that mixed Trash and Spam threads stay anchored to + # their latest visible message with includeSpamTrash either false or true. + # Fully hidden threads use their latest member as a deterministic fallback. + thread_sort_date = func.coalesce( + latest_visible_message_date, + func.max(Message.internal_date), + ) + latest_message = ( + db.query( + Message.thread_id.label("thread_id"), + thread_sort_date.label("internal_date"), + ) + .filter(Message.user_id == _user_id) + .group_by(Message.thread_id) + .subquery() + ) + query = ( + db.query(Thread) + .join(latest_message, latest_message.c.thread_id == Thread.id) + .filter(Thread.user_id == _user_id) + ) if labelIds: # Normalize: handle both repeated params and comma-separated @@ -44,21 +76,24 @@ def list_threads( lid = lid.strip() if lid: normalized.append(lid) + message_label_filters = [] for lid in normalized: - query = query.filter( - Thread.messages.any( - Message.labels.any(MessageLabel.label_id == lid) - if lid not in ("UNREAD", "STARRED", "TRASH", "SPAM", "DRAFT", "SENT") - else ( - Message.is_read == False if lid == "UNREAD" - else Message.is_starred == True if lid == "STARRED" - else Message.is_trash == True if lid == "TRASH" - else Message.is_spam == True if lid == "SPAM" - else Message.is_draft == True if lid == "DRAFT" - else Message.is_sent == True - ) + message_label_filters.append( + Message.labels.any(MessageLabel.label_id == lid) + if lid not in ("UNREAD", "STARRED", "TRASH", "SPAM", "DRAFT", "SENT") + else ( + Message.is_read.is_(False) if lid == "UNREAD" + else Message.is_starred.is_(True) if lid == "STARRED" + else Message.is_trash.is_(True) if lid == "TRASH" + else Message.is_spam.is_(True) if lid == "SPAM" + else Message.is_draft.is_(True) if lid == "DRAFT" + else Message.is_sent.is_(True) ) ) + if message_label_filters: + query = query.filter( + Thread.messages.any(and_(*message_label_filters)) + ) if not includeSpamTrash: query = query.filter( @@ -86,7 +121,15 @@ def list_threads( pass total = query.count() - threads = query.offset(offset).limit(maxResults).all() + threads = ( + query.order_by( + latest_message.c.internal_date.desc(), + Thread.id.asc(), + ) + .offset(offset) + .limit(maxResults) + .all() + ) next_token = None if offset + maxResults < total: @@ -108,7 +151,7 @@ def list_threads( def get_thread( userId: str, threadId: str, - format: str = Query("full"), + format: Literal["full", "metadata", "minimal"] = Query("full"), db: Session = Depends(get_db), _user_id: str = Depends(resolve_user_id), ): @@ -119,9 +162,12 @@ def get_thread( msgs = ( db.query(Message) .filter(Message.thread_id == threadId, Message.user_id == _user_id) - .order_by(Message.internal_date.asc()) + .order_by(Message.internal_date.asc(), Message.id.asc()) .all() ) + if not msgs: + # Empty Thread rows are orphaned mock state, not public Gmail resources. + raise HTTPException(404, f"Thread {threadId!r} not found") # Real Gmail threads.get returns {id, historyId, messages} — no snippet (Bug 2) return { diff --git a/packages/environments/mock-gmail/tests/fixtures/mock_coverage.json b/packages/environments/mock-gmail/tests/fixtures/mock_coverage.json index e7002f21b..044a848e3 100644 --- a/packages/environments/mock-gmail/tests/fixtures/mock_coverage.json +++ b/packages/environments/mock-gmail/tests/fixtures/mock_coverage.json @@ -168,7 +168,12 @@ "implemented": true, "fixture": "threads_list.json", "tests": [ - "tests/test_api.py::TestThreads::test_list_threads" + "tests/test_api.py::TestThreads::test_list_threads", + "tests/test_conformance.py::TestThreadsConformance::test_threads_list_structure", + "tests/test_conformance.py::TestThreadsConformance::test_threads_list_label_filters_share_one_message_fixture", + "tests/test_conformance.py::TestThreadsConformance::test_threads_list_mixed_trash_order_fixture", + "tests/test_conformance.py::TestThreadsConformance::test_threads_list_mixed_spam_order_fixture", + "tests/test_threads.py::TestThreadsListBehavior" ] }, { @@ -180,7 +185,10 @@ "fixture": "thread_get_full.json", "tests": [ "tests/test_api.py::TestThreads::test_get_thread", - "tests/test_conformance.py::TestThreadsConformance::test_thread_get_structure" + "tests/test_conformance.py::TestThreadsConformance::test_thread_get_structure", + "tests/test_conformance.py::TestThreadsConformance::test_thread_get_metadata_format", + "tests/test_conformance.py::TestThreadsConformance::test_thread_get_minimal_format", + "tests/test_threads.py::TestThreadsGetBehavior" ] }, { diff --git a/packages/environments/mock-gmail/tests/fixtures/real_gmail/thread_get_metadata_mixed_spam_c.json b/packages/environments/mock-gmail/tests/fixtures/real_gmail/thread_get_metadata_mixed_spam_c.json new file mode 100644 index 000000000..62dc2284a --- /dev/null +++ b/packages/environments/mock-gmail/tests/fixtures/real_gmail/thread_get_metadata_mixed_spam_c.json @@ -0,0 +1,49 @@ +{ + "id": "19f56a705ee6e94d", + "historyId": "11270", + "messages": [ + { + "id": "19f56a73603c0d83", + "threadId": "19f56a705ee6e94d", + "labelIds": [ + "SENT", + "INBOX" + ], + "snippet": "visible message C1", + "payload": { + "mimeType": "multipart/alternative", + "headers": [ + { + "name": "Subject", + "value": "[env0-fixture] mixed-spam-C" + } + ] + }, + "sizeEstimate": 594, + "historyId": "11175", + "internalDate": "1783865226000" + }, + { + "id": "19f56a7bafc0aceb", + "threadId": "19f56a705ee6e94d", + "labelIds": [ + "SENT", + "SPAM" + ], + "snippet": "hidden message C2 On Sun, Jul 12, 2026 at 10:07 PM Fixture User <fixture-user@example.com> wrote: visible message C1", + "payload": { + "mimeType": "multipart/alternative", + "headers": [ + { + "name": "Subject", + "value": "Re: [env0-fixture] mixed-spam-C" + } + ] + }, + "sizeEstimate": 1427, + "historyId": "11270", + "internalDate": "1783865260000" + } + ], + "_captured_at": "2026-07-12T14:16:54.508049+00:00" +} diff --git a/packages/environments/mock-gmail/tests/fixtures/real_gmail/thread_get_metadata_mixed_spam_d.json b/packages/environments/mock-gmail/tests/fixtures/real_gmail/thread_get_metadata_mixed_spam_d.json new file mode 100644 index 000000000..53b4280cb --- /dev/null +++ b/packages/environments/mock-gmail/tests/fixtures/real_gmail/thread_get_metadata_mixed_spam_d.json @@ -0,0 +1,30 @@ +{ + "id": "19f56a773c7f5c52", + "historyId": "10915", + "messages": [ + { + "id": "19f56a7a3046d75a", + "threadId": "19f56a773c7f5c52", + "labelIds": [ + "UNREAD", + "IMPORTANT", + "SENT", + "INBOX" + ], + "snippet": "visible message D1", + "payload": { + "mimeType": "multipart/alternative", + "headers": [ + { + "name": "Subject", + "value": "[env0-fixture] mixed-spam-D" + } + ] + }, + "sizeEstimate": 594, + "historyId": "10915", + "internalDate": "1783865254000" + } + ], + "_captured_at": "2026-07-12T14:16:54.240014+00:00" +} diff --git a/packages/environments/mock-gmail/tests/fixtures/real_gmail/thread_get_metadata_mixed_trash_a.json b/packages/environments/mock-gmail/tests/fixtures/real_gmail/thread_get_metadata_mixed_trash_a.json new file mode 100644 index 000000000..a99fe2adc --- /dev/null +++ b/packages/environments/mock-gmail/tests/fixtures/real_gmail/thread_get_metadata_mixed_trash_a.json @@ -0,0 +1,52 @@ +{ + "id": "19f56999f64f85dd", + "historyId": "10574", + "messages": [ + { + "id": "19f5699da6162829", + "threadId": "19f56999f64f85dd", + "labelIds": [ + "IMPORTANT", + "SENT", + "INBOX" + ], + "snippet": "visible message A1", + "historyId": "10574", + "internalDate": "1783864351000", + "sizeEstimate": 596, + "payload": { + "mimeType": "multipart/alternative", + "headers": [ + { + "name": "Subject", + "value": "[env0-fixture] mixed-hidden-A" + } + ] + } + }, + { + "id": "19f569a79794e0f1", + "threadId": "19f56999f64f85dd", + "labelIds": [ + "UNREAD", + "IMPORTANT", + "TRASH", + "SENT" + ], + "snippet": "hidden message A2 On Sun, Jul 12, 2026 at 9:52 PM Fixture User <fixture-user@example.com> wrote: visible message A1", + "historyId": "10571", + "internalDate": "1783864391000", + "sizeEstimate": 1427, + "payload": { + "mimeType": "multipart/alternative", + "headers": [ + { + "name": "Subject", + "value": "Re: [env0-fixture] mixed-hidden-A" + } + ] + } + } + ], + "_captured_at": "2026-07-12T13:54:49.469882+00:00" +} diff --git a/packages/environments/mock-gmail/tests/fixtures/real_gmail/thread_get_metadata_mixed_trash_b.json b/packages/environments/mock-gmail/tests/fixtures/real_gmail/thread_get_metadata_mixed_trash_b.json new file mode 100644 index 000000000..31f82efe6 --- /dev/null +++ b/packages/environments/mock-gmail/tests/fixtures/real_gmail/thread_get_metadata_mixed_trash_b.json @@ -0,0 +1,30 @@ +{ + "id": "19f569a1f4cfbf48", + "historyId": "10504", + "messages": [ + { + "id": "19f569a5d2a5e0a7", + "threadId": "19f569a1f4cfbf48", + "labelIds": [ + "UNREAD", + "IMPORTANT", + "SENT", + "INBOX" + ], + "snippet": "visible message B1", + "historyId": "10504", + "internalDate": "1783864384000", + "sizeEstimate": 596, + "payload": { + "mimeType": "multipart/alternative", + "headers": [ + { + "name": "Subject", + "value": "[env0-fixture] mixed-hidden-B" + } + ] + } + } + ], + "_captured_at": "2026-07-12T13:54:49.201377+00:00" +} diff --git a/packages/environments/mock-gmail/tests/fixtures/real_gmail/thread_get_minimal.json b/packages/environments/mock-gmail/tests/fixtures/real_gmail/thread_get_minimal.json new file mode 100644 index 000000000..c31a4d546 --- /dev/null +++ b/packages/environments/mock-gmail/tests/fixtures/real_gmail/thread_get_minimal.json @@ -0,0 +1,33 @@ +{ + "id": "19f568dea810b4ec", + "historyId": "10165", + "messages": [ + { + "id": "19f568e4248d0ed2", + "threadId": "19f568dea810b4ec", + "labelIds": [ + "IMPORTANT", + "SENT", + "INBOX" + ], + "snippet": "synthetic fixture message one", + "historyId": "10129", + "internalDate": "1783863591000", + "sizeEstimate": 627 + }, + { + "id": "19f568e69c16a0f5", + "threadId": "19f568dea810b4ec", + "labelIds": [ + "IMPORTANT", + "SENT", + "INBOX" + ], + "snippet": "synthetic fixture message two On Sun, Jul 12, 2026 at 9:39 PM Fixture User <fixture-user@example.com> wrote: synthetic fixture message one", + "historyId": "10165", + "internalDate": "1783863600000", + "sizeEstimate": 1482 + } + ], + "_captured_at": "2026-07-12T13:42:41.532585+00:00" +} diff --git a/packages/environments/mock-gmail/tests/fixtures/real_gmail/threads_list_labels_same_inbox_message.json b/packages/environments/mock-gmail/tests/fixtures/real_gmail/threads_list_labels_same_inbox_message.json new file mode 100644 index 000000000..727529bf6 --- /dev/null +++ b/packages/environments/mock-gmail/tests/fixtures/real_gmail/threads_list_labels_same_inbox_message.json @@ -0,0 +1,11 @@ +{ + "threads": [ + { + "id": "19f56999f64f85dd", + "snippet": "visible message A1", + "historyId": "10612" + } + ], + "resultSizeEstimate": 1, + "_captured_at": "2026-07-12T14:29:30.182981+00:00" +} diff --git a/packages/environments/mock-gmail/tests/fixtures/real_gmail/threads_list_labels_same_trash_message.json b/packages/environments/mock-gmail/tests/fixtures/real_gmail/threads_list_labels_same_trash_message.json new file mode 100644 index 000000000..476af25c2 --- /dev/null +++ b/packages/environments/mock-gmail/tests/fixtures/real_gmail/threads_list_labels_same_trash_message.json @@ -0,0 +1,11 @@ +{ + "threads": [ + { + "id": "19f56999f64f85dd", + "snippet": "hidden message A2 On Sun, Jul 12, 2026 at 9:52 PM Fixture User wrote: > visible message A1 > >", + "historyId": "10612" + } + ], + "resultSizeEstimate": 1, + "_captured_at": "2026-07-12T14:29:30.472382+00:00" +} diff --git a/packages/environments/mock-gmail/tests/fixtures/real_gmail/threads_list_labels_split_across_messages.json b/packages/environments/mock-gmail/tests/fixtures/real_gmail/threads_list_labels_split_across_messages.json new file mode 100644 index 000000000..5e26b11ea --- /dev/null +++ b/packages/environments/mock-gmail/tests/fixtures/real_gmail/threads_list_labels_split_across_messages.json @@ -0,0 +1,4 @@ +{ + "resultSizeEstimate": 0, + "_captured_at": "2026-07-12T14:29:29.881992+00:00" +} diff --git a/packages/environments/mock-gmail/tests/fixtures/real_gmail/threads_list_mixed_spam_default.json b/packages/environments/mock-gmail/tests/fixtures/real_gmail/threads_list_mixed_spam_default.json new file mode 100644 index 000000000..2252d78ce --- /dev/null +++ b/packages/environments/mock-gmail/tests/fixtures/real_gmail/threads_list_mixed_spam_default.json @@ -0,0 +1,16 @@ +{ + "threads": [ + { + "id": "19f56a773c7f5c52", + "snippet": "visible message D1", + "historyId": "10915" + }, + { + "id": "19f56a705ee6e94d", + "snippet": "visible message C1", + "historyId": "11270" + } + ], + "resultSizeEstimate": 2, + "_captured_at": "2026-07-12T14:16:53.700176+00:00" +} diff --git a/packages/environments/mock-gmail/tests/fixtures/real_gmail/threads_list_mixed_spam_included.json b/packages/environments/mock-gmail/tests/fixtures/real_gmail/threads_list_mixed_spam_included.json new file mode 100644 index 000000000..b001def4a --- /dev/null +++ b/packages/environments/mock-gmail/tests/fixtures/real_gmail/threads_list_mixed_spam_included.json @@ -0,0 +1,16 @@ +{ + "threads": [ + { + "id": "19f56a773c7f5c52", + "snippet": "visible message D1", + "historyId": "10915" + }, + { + "id": "19f56a705ee6e94d", + "snippet": "hidden message C2 On Sun, Jul 12, 2026 at 10:07 PM Fixture User wrote: > visible message C1 > >", + "historyId": "11270" + } + ], + "resultSizeEstimate": 2, + "_captured_at": "2026-07-12T14:16:53.981347+00:00" +} diff --git a/packages/environments/mock-gmail/tests/fixtures/real_gmail/threads_list_mixed_trash_default.json b/packages/environments/mock-gmail/tests/fixtures/real_gmail/threads_list_mixed_trash_default.json new file mode 100644 index 000000000..aeaf1f7c6 --- /dev/null +++ b/packages/environments/mock-gmail/tests/fixtures/real_gmail/threads_list_mixed_trash_default.json @@ -0,0 +1,16 @@ +{ + "threads": [ + { + "id": "19f569a1f4cfbf48", + "snippet": "visible message B1", + "historyId": "10504" + }, + { + "id": "19f56999f64f85dd", + "snippet": "visible message A1", + "historyId": "10574" + } + ], + "resultSizeEstimate": 2, + "_captured_at": "2026-07-12T13:54:48.618628+00:00" +} diff --git a/packages/environments/mock-gmail/tests/fixtures/real_gmail/threads_list_mixed_trash_included.json b/packages/environments/mock-gmail/tests/fixtures/real_gmail/threads_list_mixed_trash_included.json new file mode 100644 index 000000000..fcaed94f4 --- /dev/null +++ b/packages/environments/mock-gmail/tests/fixtures/real_gmail/threads_list_mixed_trash_included.json @@ -0,0 +1,16 @@ +{ + "threads": [ + { + "id": "19f569a1f4cfbf48", + "snippet": "visible message B1", + "historyId": "10504" + }, + { + "id": "19f56999f64f85dd", + "snippet": "hidden message A2 On Sun, Jul 12, 2026 at 9:52 PM Fixture User wrote: > visible message A1 > >", + "historyId": "10574" + } + ], + "resultSizeEstimate": 2, + "_captured_at": "2026-07-12T13:54:48.941152+00:00" +} diff --git a/packages/environments/mock-gmail/tests/test_api.py b/packages/environments/mock-gmail/tests/test_api.py index fe869ae98..86655ced8 100644 --- a/packages/environments/mock-gmail/tests/test_api.py +++ b/packages/environments/mock-gmail/tests/test_api.py @@ -417,7 +417,8 @@ def test_untrash_thread(self, client): assert "TRASH" not in msg["labelIds"] def test_delete_thread(self, client): - resp = client.get("/gmail/v1/users/me/threads") + # Pick a mailbox thread; deterministic date ordering puts the draft first. + resp = client.get("/gmail/v1/users/me/threads?labelIds=INBOX") thread_id = resp.json()["threads"][0]["id"] resp = client.delete(f"/gmail/v1/users/me/threads/{thread_id}") diff --git a/packages/environments/mock-gmail/tests/test_conformance.py b/packages/environments/mock-gmail/tests/test_conformance.py index 00c16bac1..23af909dc 100644 --- a/packages/environments/mock-gmail/tests/test_conformance.py +++ b/packages/environments/mock-gmail/tests/test_conformance.py @@ -71,6 +71,14 @@ def _assert_any_shape(real_items: list, mock_item, path: str, strict: bool) -> N pytest.fail(f"Mock item at {path} matches no fixture item shape: {'; '.join(errors)}") +def _assert_thread_message_core_types(message: dict) -> None: + for key in ("id", "threadId", "snippet", "historyId", "internalDate"): + assert isinstance(message[key], str) + assert isinstance(message["labelIds"], list) + assert all(isinstance(label_id, str) for label_id in message["labelIds"]) + assert isinstance(message["sizeEstimate"], int) + + class TestProfileConformance: def test_profile_keys(self, client): """Profile response has same keys as real Gmail.""" @@ -525,20 +533,30 @@ def test_thread_get_structure(self, client): pytest.skip("No threads") thread_id = threads[0]["id"] - resp = client.get(f"/gmail/v1/users/me/threads/{thread_id}") + resp = client.get(f"/gmail/v1/users/me/threads/{thread_id}?format=full") mock = resp.json() # Real threads.get returns {id, historyId, messages} — no snippet real = load_fixture("thread_get_full.json") assert set(mock.keys()) == set(real.keys()) + assert isinstance(mock["id"], str) + assert isinstance(mock["historyId"], str) assert "messages" in mock assert len(mock["messages"]) > 0 - # Each message in thread should have payload + real_message_keys = set(real["messages"][0]) + # Each message in thread should match the real full-format top-level shape. for msg in mock["messages"]: + assert set(msg) == real_message_keys + _assert_thread_message_core_types(msg) assert "payload" in msg + assert "raw" not in msg + assert isinstance(msg["payload"], dict) assert "id" in msg assert "threadId" in msg + for header in msg["payload"]["headers"]: + assert set(header) == {"name", "value"} + assert all(isinstance(value, str) for value in header.values()) def test_thread_get_metadata_format(self, client): """threads.get format=metadata messages have only {mimeType, headers} in payload.""" @@ -553,25 +571,138 @@ def test_thread_get_metadata_format(self, client): mock = resp.json() assert set(mock.keys()) == set(real.keys()) + assert isinstance(mock["id"], str) + assert isinstance(mock["historyId"], str) assert "messages" in mock - # Each message payload should match metadata format + # Every message and payload should match metadata format, not only the first. real_msg = real["messages"][0] - mock_msg = mock["messages"][0] - assert set(real_msg["payload"].keys()) == set(mock_msg["payload"].keys()) + for mock_msg in mock["messages"]: + assert set(mock_msg) == set(real_msg) + _assert_thread_message_core_types(mock_msg) + assert set(real_msg["payload"]) == set(mock_msg["payload"]) + assert "raw" not in mock_msg + for header in mock_msg["payload"]["headers"]: + assert set(header) == {"name", "value"} + assert all(isinstance(value, str) for value in header.values()) + + def test_thread_get_minimal_format(self, client): + """threads.get format=minimal omits payload and raw from every message.""" + real = load_fixture("thread_get_minimal.json") + resp = client.get("/gmail/v1/users/me/threads") + threads = resp.json()["threads"] + if not threads: + pytest.skip("No threads") + thread_id = threads[0]["id"] + + resp = client.get(f"/gmail/v1/users/me/threads/{thread_id}?format=minimal") + mock = resp.json() + + assert set(mock) == set(real) + assert isinstance(mock["id"], str) + assert isinstance(mock["historyId"], str) + assert "messages" in mock + + # The provider fixture contains two messages and records their API order. + assert len(real["messages"]) >= 2 + real_dates = [int(message["internalDate"]) for message in real["messages"]] + assert real_dates == sorted(real_dates) + + real_message_keys = set(real["messages"][0]) + for real_msg in real["messages"]: + assert set(real_msg) == real_message_keys + _assert_thread_message_core_types(real_msg) + assert "payload" not in real_msg + assert "raw" not in real_msg + for mock_msg in mock["messages"]: + assert set(mock_msg) == real_message_keys + _assert_thread_message_core_types(mock_msg) + assert "payload" not in mock_msg + assert "raw" not in mock_msg def test_threads_list_structure(self, client): """threads.list returns items with {id, snippet, historyId}.""" real = load_fixture("threads_list.json") - resp = client.get("/gmail/v1/users/me/threads") + # The real fixture was captured with maxResults=10. + resp = client.get("/gmail/v1/users/me/threads?maxResults=10") mock = resp.json() - assert "resultSizeEstimate" in mock - assert "threads" in mock + assert set(mock) == set(real) + assert isinstance(mock["resultSizeEstimate"], int) + assert isinstance(mock["nextPageToken"], str) + assert len(mock["threads"]) == len(real["threads"]) # Each real thread item has {id, snippet, historyId} for item in real["threads"]: assert set(item.keys()) == {"id", "snippet", "historyId"} for item in mock["threads"]: assert set(item.keys()) == {"id", "snippet", "historyId"} + assert all(isinstance(value, str) for value in item.values()) + + def test_threads_list_label_filters_share_one_message_fixture(self): + """Provider requires one member message to satisfy all label IDs.""" + same_inbox = load_fixture("threads_list_labels_same_inbox_message.json") + same_trash = load_fixture("threads_list_labels_same_trash_message.json") + split = load_fixture("threads_list_labels_split_across_messages.json") + thread = load_fixture("thread_get_metadata_mixed_trash_a.json") + + visible, trashed = thread["messages"] + assert {"INBOX", "SENT"} <= set(visible["labelIds"]) + assert {"TRASH", "SENT"} <= set(trashed["labelIds"]) + assert not any( + {"INBOX", "TRASH"} <= set(message["labelIds"]) + for message in thread["messages"] + ) + + assert [item["id"] for item in same_inbox["threads"]] == [thread["id"]] + assert [item["id"] for item in same_trash["threads"]] == [thread["id"]] + assert same_inbox["resultSizeEstimate"] == 1 + assert same_trash["resultSizeEstimate"] == 1 + assert split == {"resultSizeEstimate": 0} + + def test_threads_list_mixed_trash_order_fixture(self): + """Provider capture orders a mixed Trash thread by its visible message.""" + default = load_fixture("threads_list_mixed_trash_default.json") + included = load_fixture("threads_list_mixed_trash_included.json") + thread_a = load_fixture("thread_get_metadata_mixed_trash_a.json") + thread_b = load_fixture("thread_get_metadata_mixed_trash_b.json") + + assert len(thread_a["messages"]) == 2 + assert len(thread_b["messages"]) == 1 + a_visible, a_trashed = thread_a["messages"] + (b_visible,) = thread_b["messages"] + assert int(a_visible["internalDate"]) < int(b_visible["internalDate"]) + assert int(b_visible["internalDate"]) < int(a_trashed["internalDate"]) + assert "TRASH" not in a_visible["labelIds"] + assert "TRASH" not in b_visible["labelIds"] + assert "TRASH" in a_trashed["labelIds"] + + expected_order = [thread_b["id"], thread_a["id"]] + assert [thread["id"] for thread in default["threads"]] == expected_order + assert [thread["id"] for thread in included["threads"]] == expected_order + assert default["resultSizeEstimate"] == 2 + assert included["resultSizeEstimate"] == 2 + + def test_threads_list_mixed_spam_order_fixture(self): + """Provider capture orders a mixed Spam thread by its visible message.""" + default = load_fixture("threads_list_mixed_spam_default.json") + included = load_fixture("threads_list_mixed_spam_included.json") + thread_c = load_fixture("thread_get_metadata_mixed_spam_c.json") + thread_d = load_fixture("thread_get_metadata_mixed_spam_d.json") + + assert len(thread_c["messages"]) == 2 + assert len(thread_d["messages"]) == 1 + c_visible, c_spam = thread_c["messages"] + (d_visible,) = thread_d["messages"] + assert int(c_visible["internalDate"]) < int(d_visible["internalDate"]) + assert int(d_visible["internalDate"]) < int(c_spam["internalDate"]) + assert "SPAM" not in c_visible["labelIds"] + assert "SPAM" not in d_visible["labelIds"] + assert "SPAM" in c_spam["labelIds"] + + expected_order = [thread_d["id"], thread_c["id"]] + assert [thread["id"] for thread in default["threads"]] == expected_order + assert [thread["id"] for thread in included["threads"]] == expected_order + assert default["resultSizeEstimate"] == 2 + assert included["resultSizeEstimate"] == 2 class TestSettingsConformance: diff --git a/packages/environments/mock-gmail/tests/test_threads.py b/packages/environments/mock-gmail/tests/test_threads.py new file mode 100644 index 000000000..1249054c6 --- /dev/null +++ b/packages/environments/mock-gmail/tests/test_threads.py @@ -0,0 +1,471 @@ +"""Focused behavior tests for the Gmail Threads read API.""" + +from __future__ import annotations + +from datetime import datetime + +import pytest + +from mock_gmail.models import ( + Message, + MessageLabel, + Thread, + User, + get_session_factory, +) + + +@pytest.fixture +def db_session(client): + """Open a session against the database initialized by the API client.""" + db = get_session_factory()() + try: + yield db + finally: + db.close() + + +def _user_id(db_session) -> str: + return ( + db_session.query(User.id) + .filter(User.email_address == "alex@nexusai.com") + .scalar() + ) + + +def _add_thread( + db_session, + user_id: str, + thread_id: str, + messages: list[dict], + *, + subject: str = "Threads API regression", +) -> None: + db_session.add( + Thread( + id=thread_id, + user_id=user_id, + snippet=messages[-1].get("body", "")[:200] if messages else "", + history_id=9001, + ) + ) + for spec in messages: + labels = set(spec.get("labels", ["INBOX"])) + message = Message( + id=spec["id"], + thread_id=thread_id, + user_id=user_id, + sender="threads-regression@example.com", + to="alex@nexusai.com", + subject=spec.get("subject", subject), + snippet=spec.get("body", "")[:200], + body_plain=spec.get("body", ""), + internal_date=spec["date"], + is_read="UNREAD" not in labels, + is_starred="STARRED" in labels, + is_trash="TRASH" in labels, + is_spam="SPAM" in labels, + is_draft="DRAFT" in labels, + is_sent="SENT" in labels, + ) + db_session.add(message) + for label_id in labels - { + "UNREAD", + "STARRED", + "TRASH", + "SPAM", + "DRAFT", + "SENT", + }: + db_session.add(MessageLabel(message_id=message.id, label_id=label_id)) + db_session.commit() + + +def _listed_thread_ids(response) -> list[str]: + assert response.status_code == 200 + return [thread["id"] for thread in response.json().get("threads", [])] + + +def _at(month: int, day: int, hour: int = 8, minute: int = 0) -> datetime: + return datetime(2099, month, day, hour, minute) + + +def _message_spec( + message_id: str, + internal_date: datetime, + body: str, + labels: list[str] | None = None, +) -> dict: + spec = {"id": message_id, "date": internal_date, "body": body} + if labels is not None: + spec["labels"] = labels + return spec + + +class TestThreadsListBehavior: + def test_q_matches_any_older_message_and_returns_thread_once( + self, client, db_session + ): + user_id = _user_id(db_session) + marker = "older-message-thread-match-7f19" + _add_thread( + db_session, + user_id, + "thread-q-old-message", + [ + _message_spec("message-q-old-a", _at(1, 1), marker), + _message_spec("message-q-old-b", _at(1, 2), marker), + _message_spec( + "message-q-newest", + _at(1, 3), + "The newest message does not contain the search marker.", + ), + ], + ) + + response = client.get("/gmail/v1/users/me/threads", params={"q": marker}) + + assert _listed_thread_ids(response) == ["thread-q-old-message"] + assert response.json()["resultSizeEstimate"] == 1 + + def test_same_subject_different_thread_ids_remain_separate( + self, client, db_session + ): + user_id = _user_id(db_session) + subject = "Shared subject 4da36d" + for suffix in ("a", "b"): + _add_thread( + db_session, + user_id, + f"thread-same-subject-{suffix}", + [ + _message_spec( + f"message-same-subject-{suffix}", + _at(2, 1, minute=ord(suffix) - ord("a")), + f"Message {suffix}", + ) + ], + subject=subject, + ) + + response = client.get( + "/gmail/v1/users/me/threads", + params={"q": f'subject:"{subject}"'}, + ) + + assert set(_listed_thread_ids(response)) == { + "thread-same-subject-a", + "thread-same-subject-b", + } + assert response.json()["resultSizeEstimate"] == 2 + + def test_all_labels_must_be_on_the_same_message(self, client, db_session): + user_id = _user_id(db_session) + marker = "thread-label-union-135b" + scenarios = { + "thread-label-same-message": (["INBOX", "STARRED"], []), + "thread-label-split": (["INBOX"], ["STARRED"]), + "thread-label-inbox-only": (["INBOX"], ["INBOX"]), + "thread-label-starred-only": (["STARRED"], ["STARRED"]), + } + for thread_id, message_labels in scenarios.items(): + _add_thread( + db_session, + user_id, + thread_id, + [ + _message_spec( + f"{thread_id}-message-{index}", + _at(3, index + 1), + marker, + labels, + ) + for index, labels in enumerate(message_labels) + ], + ) + + response = client.get( + "/gmail/v1/users/me/threads", + params=[ + ("q", marker), + ("labelIds", "INBOX"), + ("labelIds", "STARRED"), + ], + ) + + assert _listed_thread_ids(response) == ["thread-label-same-message"] + assert response.json()["resultSizeEstimate"] == 1 + + def test_include_spam_trash_controls_hidden_threads(self, client, db_session): + user_id = _user_id(db_session) + marker = "thread-hidden-7f921" + scenarios = { + "thread-visible": [["INBOX"]], + "thread-trash-only": [["TRASH"]], + "thread-spam-only": [["SPAM"]], + } + for thread_number, (thread_id, message_labels) in enumerate( + scenarios.items(), start=1 + ): + _add_thread( + db_session, + user_id, + thread_id, + [ + _message_spec( + f"{thread_id}-message-{message_number}", + _at(4, thread_number, hour=message_number), + marker, + labels, + ) + for message_number, labels in enumerate(message_labels, start=1) + ], + ) + + default_response = client.get( + "/gmail/v1/users/me/threads", + params={"q": marker}, + ) + included_response = client.get( + "/gmail/v1/users/me/threads", + params={"q": marker, "includeSpamTrash": "true"}, + ) + + assert _listed_thread_ids(default_response) == ["thread-visible"] + assert default_response.json()["resultSizeEstimate"] == 1 + # Fully hidden threads use their latest member message as the + # deterministic fallback sort timestamp when explicitly included. + assert _listed_thread_ids(included_response) == [ + "thread-spam-only", + "thread-trash-only", + "thread-visible", + ] + assert included_response.json()["resultSizeEstimate"] == 3 + + @pytest.mark.parametrize("hidden_label", ["TRASH", "SPAM"]) + def test_newer_hidden_message_does_not_change_thread_order( + self, client, db_session, hidden_label + ): + user_id = _user_id(db_session) + hidden_kind = hidden_label.lower() + subject_prefix = f"Mixed {hidden_kind} ordering 6e4b2" + thread_a = f"thread-mixed-{hidden_kind}-a" + thread_b = f"thread-mixed-{hidden_kind}-b" + _add_thread( + db_session, + user_id, + thread_a, + [ + _message_spec( + f"message-mixed-{hidden_kind}-a-visible", + _at(5, 1), + "visible message A1", + ["INBOX"], + ), + _message_spec( + f"message-mixed-{hidden_kind}-a-hidden", + _at(5, 3), + "hidden message A2", + [hidden_label], + ), + ], + subject=f"{subject_prefix}-A", + ) + _add_thread( + db_session, + user_id, + thread_b, + [ + _message_spec( + f"message-mixed-{hidden_kind}-b-visible", + _at(5, 2), + "visible message B1", + ["INBOX"], + ) + ], + subject=f"{subject_prefix}-B", + ) + + expected = [thread_b, thread_a] + for include_spam_trash in (False, True): + response = client.get( + "/gmail/v1/users/me/threads", + params={ + "q": f'subject:"{subject_prefix}"', + "includeSpamTrash": str(include_spam_trash).lower(), + }, + ) + assert _listed_thread_ids(response) == expected + assert response.json()["resultSizeEstimate"] == 2 + + def test_latest_message_order_and_pagination_are_stable(self, client, db_session): + user_id = _user_id(db_session) + marker = "thread-page-order-7c981" + # Insert in deliberately different order from the API contract. + thread_dates = [ + ("thread-order-oldest", _at(5, 1)), + ("thread-order-tie-z", _at(5, 3)), + ("thread-order-newest", _at(5, 5)), + ("thread-order-tie-a", _at(5, 3)), + ("thread-order-second", _at(5, 4)), + ] + for thread_id, internal_date in thread_dates: + messages = [] + latest_body = marker + if thread_id == "thread-order-newest": + messages.append( + _message_spec(f"{thread_id}-older-message", _at(4, 30), marker) + ) + latest_body = "The latest message does not match the list query." + messages.append( + _message_spec( + f"{thread_id}-latest-message", internal_date, latest_body + ) + ) + _add_thread( + db_session, + user_id, + thread_id, + messages, + ) + + expected = [ + "thread-order-newest", + "thread-order-second", + "thread-order-tie-a", + "thread-order-tie-z", + "thread-order-oldest", + ] + + full_response = client.get( + "/gmail/v1/users/me/threads", + params={"q": marker, "maxResults": 500}, + ) + assert _listed_thread_ids(full_response) == expected + assert full_response.json()["resultSizeEstimate"] == len(expected) + + def walk_pages() -> tuple[list[str], list[str]]: + collected = [] + seen_tokens = set() + tokens = [] + page_token = None + for _ in range(len(expected)): + params = {"q": marker, "maxResults": 2} + if page_token is not None: + params["pageToken"] = page_token + response = client.get("/gmail/v1/users/me/threads", params=params) + data = response.json() + assert data["resultSizeEstimate"] == len(expected) + collected.extend(_listed_thread_ids(response)) + page_token = data.get("nextPageToken") + if page_token is None: + return collected, tokens + assert page_token not in seen_tokens + seen_tokens.add(page_token) + tokens.append(page_token) + pytest.fail("Pagination did not terminate") + + first_walk, first_tokens = walk_pages() + second_walk, second_tokens = walk_pages() + assert first_walk == expected + assert second_walk == expected + assert first_tokens == second_tokens + assert len(first_walk) == len(set(first_walk)) + + +class TestThreadsGetBehavior: + def test_messages_are_ordered_by_date_then_id(self, client, db_session): + user_id = _user_id(db_session) + _add_thread( + db_session, + user_id, + "thread-get-order", + [ + _message_spec("message-get-newest", _at(6, 3), "newest"), + _message_spec("message-get-tie-z", _at(6, 2), "tie z"), + _message_spec("message-get-oldest", _at(6, 1), "oldest"), + _message_spec("message-get-tie-a", _at(6, 2), "tie a"), + ], + ) + + response = client.get("/gmail/v1/users/me/threads/thread-get-order") + + assert response.status_code == 200 + assert [message["id"] for message in response.json()["messages"]] == [ + "message-get-oldest", + "message-get-tie-a", + "message-get-tie-z", + "message-get-newest", + ] + + def test_supported_formats_shape_every_message(self, client, db_session): + user_id = _user_id(db_session) + _add_thread( + db_session, + user_id, + "thread-get-formats", + [ + _message_spec("message-format-a", _at(7, 1), "alpha"), + _message_spec("message-format-b", _at(7, 2), "beta"), + ], + ) + + default = client.get("/gmail/v1/users/me/threads/thread-get-formats").json() + full = client.get( + "/gmail/v1/users/me/threads/thread-get-formats", + params={"format": "full"}, + ).json() + metadata = client.get( + "/gmail/v1/users/me/threads/thread-get-formats", + params={"format": "metadata"}, + ).json() + minimal = client.get( + "/gmail/v1/users/me/threads/thread-get-formats", + params={"format": "minimal"}, + ).json() + + assert default == full + assert set(full) == {"id", "historyId", "messages"} + assert set(metadata) == set(full) + assert set(minimal) == set(full) + for message in full["messages"]: + assert "payload" in message + assert "raw" not in message + for message in metadata["messages"]: + assert set(message["payload"]) == {"mimeType", "headers"} + assert "raw" not in message + for message in minimal["messages"]: + assert set(message) == { + "id", + "threadId", + "labelIds", + "snippet", + "historyId", + "internalDate", + "sizeEstimate", + } + assert "payload" not in message + assert "raw" not in message + + for unsupported_format in ("raw", "unsupported"): + response = client.get( + "/gmail/v1/users/me/threads/thread-get-formats", + params={"format": unsupported_format}, + ) + assert response.status_code == 400 + + def test_not_found_and_empty_thread_return_not_found(self, client, db_session): + missing = client.get("/gmail/v1/users/me/threads/7fffffffffffffff") + assert missing.status_code == 404 + + user_id = _user_id(db_session) + _add_thread(db_session, user_id, "thread-empty", []) + + empty = client.get("/gmail/v1/users/me/threads/thread-empty") + assert empty.status_code == 404 + + listed = client.get( + "/gmail/v1/users/me/threads", + params={"includeSpamTrash": "true", "maxResults": 500}, + ) + assert "thread-empty" not in _listed_thread_ids(listed)