mirror of
https://github.com/open-jarvis/OpenJarvis.git
synced 2026-07-28 05:12:26 +00:00
Remove the ARG002 noqa suppression and translate the `since` datetime parameter into a Gmail `after:<epoch>` query string passed to `_gmail_api_list_messages`, so incremental syncs only fetch messages newer than the requested cutoff. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
271 lines
9.2 KiB
Python
271 lines
9.2 KiB
Python
"""Tests for GmailConnector — OAuth-authenticated Gmail sync connector.
|
|
|
|
All Gmail API calls are mocked; no network access is required.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from pathlib import Path
|
|
from typing import List
|
|
from unittest.mock import patch
|
|
|
|
import pytest
|
|
|
|
from openjarvis.connectors._stubs import Document
|
|
from openjarvis.core.registry import ConnectorRegistry
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers — fake API payloads
|
|
# ---------------------------------------------------------------------------
|
|
|
|
# base64url("Hello world") == "SGVsbG8gd29ybGQ="
|
|
# base64url("Budget reply") == "QnVkZ2V0IHJlcGx5"
|
|
|
|
_MSG1 = {
|
|
"id": "msg1",
|
|
"threadId": "thread1",
|
|
"labelIds": ["INBOX"],
|
|
"payload": {
|
|
"mimeType": "text/plain",
|
|
"headers": [
|
|
{"name": "From", "value": "alice@example.com"},
|
|
{"name": "To", "value": "me@example.com"},
|
|
{"name": "Subject", "value": "Q3 Planning"},
|
|
{"name": "Date", "value": "Mon, 01 Jan 2024 10:00:00 +0000"},
|
|
],
|
|
"body": {"data": "SGVsbG8gd29ybGQ="},
|
|
},
|
|
}
|
|
|
|
_MSG2 = {
|
|
"id": "msg2",
|
|
"threadId": "thread2",
|
|
"labelIds": ["INBOX"],
|
|
"payload": {
|
|
"mimeType": "text/plain",
|
|
"headers": [
|
|
{"name": "From", "value": "bob@example.com"},
|
|
{"name": "To", "value": "me@example.com"},
|
|
{"name": "Subject", "value": "Re: Budget"},
|
|
{"name": "Date", "value": "Tue, 02 Jan 2024 12:00:00 +0000"},
|
|
],
|
|
"body": {"data": "QnVkZ2V0IHJlcGx5"},
|
|
},
|
|
}
|
|
|
|
_LIST_RESPONSE = {
|
|
"messages": [{"id": "msg1"}, {"id": "msg2"}],
|
|
# No nextPageToken → single page
|
|
}
|
|
|
|
|
|
def _make_credentials(tmp_path: Path) -> Path:
|
|
"""Write a minimal fake credentials file and return its path."""
|
|
creds = tmp_path / "gmail.json"
|
|
creds.write_text(json.dumps({"token": "fake-access-token"}), encoding="utf-8")
|
|
return creds
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Fixtures
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.fixture()
|
|
def connector(tmp_path: Path):
|
|
"""GmailConnector pointing at a tmp credentials path (no file yet)."""
|
|
from openjarvis.connectors.gmail import GmailConnector # noqa: PLC0415
|
|
|
|
creds_path = str(tmp_path / "gmail.json")
|
|
return GmailConnector(credentials_path=creds_path)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Test 1 — not connected without a credentials file
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_not_connected_without_credentials(connector) -> None:
|
|
"""is_connected() returns False when no credentials file exists."""
|
|
assert connector.is_connected() is False
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Test 2 — auth_type is "oauth"
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_auth_type_is_oauth(connector) -> None:
|
|
"""GmailConnector.auth_type must be 'oauth'."""
|
|
assert connector.auth_type == "oauth"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Test 3 — auth_url returns a valid Google consent URL
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_auth_url_returns_string(connector) -> None:
|
|
"""auth_url() returns a URL pointing to Google's OAuth endpoint."""
|
|
url = connector.auth_url()
|
|
assert isinstance(url, str)
|
|
assert url.startswith("https://accounts.google.com/o/oauth2/v2/auth")
|
|
assert "gmail.readonly" in url
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Test 4 — sync yields documents with correct fields (mocked API)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@patch("openjarvis.connectors.gmail._gmail_api_list_messages")
|
|
@patch("openjarvis.connectors.gmail._gmail_api_get_message")
|
|
def test_sync_yields_documents(
|
|
mock_get,
|
|
mock_list,
|
|
connector,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""sync() yields one Document per message with correct metadata."""
|
|
# Set up fake credentials so is_connected() returns True
|
|
creds_path = Path(connector._credentials_path)
|
|
creds_path.write_text(json.dumps({"token": "fake-access-token"}), encoding="utf-8")
|
|
|
|
# Configure mocks
|
|
mock_list.return_value = _LIST_RESPONSE
|
|
mock_get.side_effect = lambda token, msg_id: _MSG1 if msg_id == "msg1" else _MSG2
|
|
|
|
docs: List[Document] = list(connector.sync())
|
|
|
|
assert len(docs) == 2
|
|
|
|
# --- Message 1 ---
|
|
doc1 = next(d for d in docs if d.doc_id == "gmail:msg1")
|
|
assert doc1.source == "gmail"
|
|
assert doc1.doc_type == "email"
|
|
assert doc1.title == "Q3 Planning"
|
|
assert doc1.author == "alice@example.com"
|
|
assert doc1.content == "Hello world"
|
|
assert doc1.thread_id == "thread1"
|
|
assert "alice@example.com" in doc1.participants
|
|
|
|
# --- Message 2 ---
|
|
doc2 = next(d for d in docs if d.doc_id == "gmail:msg2")
|
|
assert doc2.title == "Re: Budget"
|
|
assert doc2.author == "bob@example.com"
|
|
assert doc2.content == "Budget reply"
|
|
assert doc2.thread_id == "thread2"
|
|
|
|
# Verify the API was called correctly
|
|
mock_list.assert_called_once()
|
|
assert mock_get.call_count == 2
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Test 5 — disconnect removes the credentials file
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_disconnect(connector, tmp_path: Path) -> None:
|
|
"""disconnect() deletes the credentials file."""
|
|
creds_path = Path(connector._credentials_path)
|
|
creds_path.write_text(json.dumps({"token": "fake-access-token"}), encoding="utf-8")
|
|
assert connector.is_connected() is True
|
|
|
|
connector.disconnect()
|
|
|
|
assert not creds_path.exists()
|
|
assert connector.is_connected() is False
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Test 6 — mcp_tools returns the three expected tool specs
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_mcp_tools(connector) -> None:
|
|
"""mcp_tools() returns exactly 3 tools with the required names."""
|
|
tools = connector.mcp_tools()
|
|
names = {t.name for t in tools}
|
|
assert len(tools) == 3
|
|
assert "gmail_search_emails" in names
|
|
assert "gmail_get_thread" in names
|
|
assert "gmail_list_unread" in names
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Test 7 — sync passes since as an after: query to the list messages API
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@patch("openjarvis.connectors.gmail._gmail_api_list_messages")
|
|
@patch("openjarvis.connectors.gmail._gmail_api_get_message")
|
|
def test_sync_passes_since_as_query(
|
|
mock_get,
|
|
mock_list,
|
|
connector,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""sync(since=...) passes an after:<epoch> query to _gmail_api_list_messages."""
|
|
from datetime import datetime, timezone # noqa: PLC0415
|
|
|
|
creds_path = Path(connector._credentials_path)
|
|
creds_path.write_text(json.dumps({"token": "fake-access-token"}), encoding="utf-8")
|
|
|
|
mock_list.return_value = {"messages": []} # no messages needed for this test
|
|
|
|
since_dt = datetime(2024, 6, 1, 0, 0, 0, tzinfo=timezone.utc)
|
|
list(connector.sync(since=since_dt))
|
|
|
|
mock_list.assert_called_once()
|
|
_, call_kwargs = mock_list.call_args
|
|
assert "query" in call_kwargs
|
|
assert call_kwargs["query"].startswith("after:")
|
|
# Verify the epoch value is correct
|
|
expected_epoch = int(since_dt.timestamp())
|
|
assert call_kwargs["query"] == f"after:{expected_epoch}"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Test 8 — sync without since passes an empty query
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@patch("openjarvis.connectors.gmail._gmail_api_list_messages")
|
|
@patch("openjarvis.connectors.gmail._gmail_api_get_message")
|
|
def test_sync_without_since_passes_empty_query(
|
|
mock_get,
|
|
mock_list,
|
|
connector,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""sync() without since= passes an empty query string."""
|
|
creds_path = Path(connector._credentials_path)
|
|
creds_path.write_text(json.dumps({"token": "fake-access-token"}), encoding="utf-8")
|
|
|
|
mock_list.return_value = {"messages": []}
|
|
|
|
list(connector.sync())
|
|
|
|
mock_list.assert_called_once()
|
|
_, call_kwargs = mock_list.call_args
|
|
assert call_kwargs.get("query", "") == ""
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Test 9 — ConnectorRegistry contains "gmail" after import
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_registry() -> None:
|
|
"""GmailConnector can be registered and retrieved via ConnectorRegistry."""
|
|
from openjarvis.connectors.gmail import GmailConnector # noqa: PLC0415
|
|
|
|
# The registry is cleared before each test by the autouse conftest fixture,
|
|
# so we imperatively re-register here (same pattern as test_obsidian.py).
|
|
ConnectorRegistry.register_value("gmail", GmailConnector)
|
|
assert ConnectorRegistry.contains("gmail")
|
|
cls = ConnectorRegistry.get("gmail")
|
|
assert cls.connector_id == "gmail"
|