feat: initial commit from workspace-mcp
Some checks failed
Check Maintainer Edits Enabled / check-maintainer-edits (pull_request) Has been cancelled
Check Maintainer Edits Enabled / check-maintainer-edits-internal (pull_request) Has been cancelled
Docker Build and Push to GHCR / build-and-push (pull_request) Has been cancelled
Ruff / ruff (pull_request) Has been cancelled
Some checks failed
Check Maintainer Edits Enabled / check-maintainer-edits (pull_request) Has been cancelled
Check Maintainer Edits Enabled / check-maintainer-edits-internal (pull_request) Has been cancelled
Docker Build and Push to GHCR / build-and-push (pull_request) Has been cancelled
Ruff / ruff (pull_request) Has been cancelled
This commit is contained in:
0
tests/__init__.py
Normal file
0
tests/__init__.py
Normal file
128
tests/auth/test_google_auth_callback_refresh_token.py
Normal file
128
tests/auth/test_google_auth_callback_refresh_token.py
Normal file
@@ -0,0 +1,128 @@
|
||||
from google.oauth2.credentials import Credentials
|
||||
|
||||
from auth.google_auth import handle_auth_callback
|
||||
|
||||
|
||||
class _DummyFlow:
|
||||
def __init__(self, credentials):
|
||||
self.credentials = credentials
|
||||
|
||||
def fetch_token(self, authorization_response): # noqa: ARG002
|
||||
return None
|
||||
|
||||
|
||||
class _DummyOAuthStore:
|
||||
def __init__(self, session_credentials=None):
|
||||
self._session_credentials = session_credentials
|
||||
self.stored_refresh_token = None
|
||||
|
||||
def validate_and_consume_oauth_state(self, state, session_id=None): # noqa: ARG002
|
||||
return {"session_id": session_id, "code_verifier": "verifier"}
|
||||
|
||||
def get_credentials_by_mcp_session(self, mcp_session_id): # noqa: ARG002
|
||||
return self._session_credentials
|
||||
|
||||
def store_session(self, **kwargs):
|
||||
self.stored_refresh_token = kwargs.get("refresh_token")
|
||||
|
||||
|
||||
class _DummyCredentialStore:
|
||||
def __init__(self, existing_credentials=None):
|
||||
self._existing_credentials = existing_credentials
|
||||
self.saved_credentials = None
|
||||
|
||||
def get_credential(self, user_email): # noqa: ARG002
|
||||
return self._existing_credentials
|
||||
|
||||
def store_credential(self, user_email, credentials): # noqa: ARG002
|
||||
self.saved_credentials = credentials
|
||||
return True
|
||||
|
||||
|
||||
def _make_credentials(refresh_token):
|
||||
return Credentials(
|
||||
token="access-token",
|
||||
refresh_token=refresh_token,
|
||||
token_uri="https://oauth2.googleapis.com/token",
|
||||
client_id="client-id",
|
||||
client_secret="client-secret",
|
||||
scopes=["scope.a"],
|
||||
)
|
||||
|
||||
|
||||
def test_callback_preserves_refresh_token_from_credential_store(monkeypatch):
|
||||
callback_credentials = _make_credentials(refresh_token=None)
|
||||
oauth_store = _DummyOAuthStore(session_credentials=None)
|
||||
credential_store = _DummyCredentialStore(
|
||||
existing_credentials=_make_credentials(refresh_token="file-refresh-token")
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"auth.google_auth.create_oauth_flow",
|
||||
lambda **kwargs: _DummyFlow(callback_credentials), # noqa: ARG005
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"auth.google_auth.get_oauth21_session_store", lambda: oauth_store
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"auth.google_auth.get_credential_store", lambda: credential_store
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"auth.google_auth.get_user_info",
|
||||
lambda credentials: {"email": "user@gmail.com"}, # noqa: ARG005
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"auth.google_auth.save_credentials_to_session", lambda *args: None
|
||||
)
|
||||
monkeypatch.setattr("auth.google_auth.is_stateless_mode", lambda: False)
|
||||
|
||||
_email, credentials = handle_auth_callback(
|
||||
scopes=["scope.a"],
|
||||
authorization_response="http://localhost/callback?state=abc123&code=code123",
|
||||
redirect_uri="http://localhost/callback",
|
||||
session_id="session-1",
|
||||
)
|
||||
|
||||
assert credentials.refresh_token == "file-refresh-token"
|
||||
assert credential_store.saved_credentials.refresh_token == "file-refresh-token"
|
||||
assert oauth_store.stored_refresh_token == "file-refresh-token"
|
||||
|
||||
|
||||
def test_callback_prefers_session_refresh_token_over_credential_store(monkeypatch):
|
||||
callback_credentials = _make_credentials(refresh_token=None)
|
||||
oauth_store = _DummyOAuthStore(
|
||||
session_credentials=_make_credentials(refresh_token="session-refresh-token")
|
||||
)
|
||||
credential_store = _DummyCredentialStore(
|
||||
existing_credentials=_make_credentials(refresh_token="file-refresh-token")
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"auth.google_auth.create_oauth_flow",
|
||||
lambda **kwargs: _DummyFlow(callback_credentials), # noqa: ARG005
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"auth.google_auth.get_oauth21_session_store", lambda: oauth_store
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"auth.google_auth.get_credential_store", lambda: credential_store
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"auth.google_auth.get_user_info",
|
||||
lambda credentials: {"email": "user@gmail.com"}, # noqa: ARG005
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"auth.google_auth.save_credentials_to_session", lambda *args: None
|
||||
)
|
||||
monkeypatch.setattr("auth.google_auth.is_stateless_mode", lambda: False)
|
||||
|
||||
_email, credentials = handle_auth_callback(
|
||||
scopes=["scope.a"],
|
||||
authorization_response="http://localhost/callback?state=abc123&code=code123",
|
||||
redirect_uri="http://localhost/callback",
|
||||
session_id="session-1",
|
||||
)
|
||||
|
||||
assert credentials.refresh_token == "session-refresh-token"
|
||||
assert credential_store.saved_credentials.refresh_token == "session-refresh-token"
|
||||
assert oauth_store.stored_refresh_token == "session-refresh-token"
|
||||
118
tests/auth/test_google_auth_pkce.py
Normal file
118
tests/auth/test_google_auth_pkce.py
Normal file
@@ -0,0 +1,118 @@
|
||||
"""Regression tests for OAuth PKCE flow wiring."""
|
||||
|
||||
import os
|
||||
import sys
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../..")))
|
||||
|
||||
from auth.google_auth import create_oauth_flow # noqa: E402
|
||||
|
||||
|
||||
DUMMY_CLIENT_CONFIG = {
|
||||
"web": {
|
||||
"client_id": "dummy-client-id.apps.googleusercontent.com",
|
||||
"client_secret": "dummy-secret",
|
||||
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
|
||||
"token_uri": "https://oauth2.googleapis.com/token",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def test_create_oauth_flow_autogenerates_verifier_when_missing():
|
||||
expected_flow = object()
|
||||
with (
|
||||
patch(
|
||||
"auth.google_auth.load_client_secrets_from_env",
|
||||
return_value=DUMMY_CLIENT_CONFIG,
|
||||
),
|
||||
patch(
|
||||
"auth.google_auth.Flow.from_client_config",
|
||||
return_value=expected_flow,
|
||||
) as mock_from_client_config,
|
||||
):
|
||||
flow = create_oauth_flow(
|
||||
scopes=["openid"],
|
||||
redirect_uri="http://localhost/callback",
|
||||
state="oauth-state-1",
|
||||
)
|
||||
|
||||
assert flow is expected_flow
|
||||
args, kwargs = mock_from_client_config.call_args
|
||||
assert args[0] == DUMMY_CLIENT_CONFIG
|
||||
assert kwargs["autogenerate_code_verifier"] is True
|
||||
assert "code_verifier" not in kwargs
|
||||
|
||||
|
||||
def test_create_oauth_flow_preserves_callback_verifier():
|
||||
expected_flow = object()
|
||||
with (
|
||||
patch(
|
||||
"auth.google_auth.load_client_secrets_from_env",
|
||||
return_value=DUMMY_CLIENT_CONFIG,
|
||||
),
|
||||
patch(
|
||||
"auth.google_auth.Flow.from_client_config",
|
||||
return_value=expected_flow,
|
||||
) as mock_from_client_config,
|
||||
):
|
||||
flow = create_oauth_flow(
|
||||
scopes=["openid"],
|
||||
redirect_uri="http://localhost/callback",
|
||||
state="oauth-state-2",
|
||||
code_verifier="saved-verifier",
|
||||
)
|
||||
|
||||
assert flow is expected_flow
|
||||
args, kwargs = mock_from_client_config.call_args
|
||||
assert args[0] == DUMMY_CLIENT_CONFIG
|
||||
assert kwargs["code_verifier"] == "saved-verifier"
|
||||
assert kwargs["autogenerate_code_verifier"] is False
|
||||
|
||||
|
||||
def test_create_oauth_flow_file_config_still_enables_pkce():
|
||||
expected_flow = object()
|
||||
with (
|
||||
patch("auth.google_auth.load_client_secrets_from_env", return_value=None),
|
||||
patch("auth.google_auth.os.path.exists", return_value=True),
|
||||
patch(
|
||||
"auth.google_auth.Flow.from_client_secrets_file",
|
||||
return_value=expected_flow,
|
||||
) as mock_from_file,
|
||||
):
|
||||
flow = create_oauth_flow(
|
||||
scopes=["openid"],
|
||||
redirect_uri="http://localhost/callback",
|
||||
state="oauth-state-3",
|
||||
)
|
||||
|
||||
assert flow is expected_flow
|
||||
_args, kwargs = mock_from_file.call_args
|
||||
assert kwargs["autogenerate_code_verifier"] is True
|
||||
assert "code_verifier" not in kwargs
|
||||
|
||||
|
||||
def test_create_oauth_flow_allows_disabling_autogenerate_without_verifier():
|
||||
expected_flow = object()
|
||||
with (
|
||||
patch(
|
||||
"auth.google_auth.load_client_secrets_from_env",
|
||||
return_value=DUMMY_CLIENT_CONFIG,
|
||||
),
|
||||
patch(
|
||||
"auth.google_auth.Flow.from_client_config",
|
||||
return_value=expected_flow,
|
||||
) as mock_from_client_config,
|
||||
):
|
||||
flow = create_oauth_flow(
|
||||
scopes=["openid"],
|
||||
redirect_uri="http://localhost/callback",
|
||||
state="oauth-state-4",
|
||||
autogenerate_code_verifier=False,
|
||||
)
|
||||
|
||||
assert flow is expected_flow
|
||||
_args, kwargs = mock_from_client_config.call_args
|
||||
assert kwargs["autogenerate_code_verifier"] is False
|
||||
assert "code_verifier" not in kwargs
|
||||
119
tests/auth/test_google_auth_prompt_selection.py
Normal file
119
tests/auth/test_google_auth_prompt_selection.py
Normal file
@@ -0,0 +1,119 @@
|
||||
from types import SimpleNamespace
|
||||
|
||||
from auth.google_auth import _determine_oauth_prompt
|
||||
|
||||
|
||||
class _DummyCredentialStore:
|
||||
def __init__(self, credentials_by_email=None):
|
||||
self._credentials_by_email = credentials_by_email or {}
|
||||
|
||||
def get_credential(self, user_email):
|
||||
return self._credentials_by_email.get(user_email)
|
||||
|
||||
|
||||
class _DummySessionStore:
|
||||
def __init__(self, user_by_session=None, credentials_by_session=None):
|
||||
self._user_by_session = user_by_session or {}
|
||||
self._credentials_by_session = credentials_by_session or {}
|
||||
|
||||
def get_user_by_mcp_session(self, mcp_session_id):
|
||||
return self._user_by_session.get(mcp_session_id)
|
||||
|
||||
def get_credentials_by_mcp_session(self, mcp_session_id):
|
||||
return self._credentials_by_session.get(mcp_session_id)
|
||||
|
||||
|
||||
def _credentials_with_scopes(scopes):
|
||||
return SimpleNamespace(scopes=scopes)
|
||||
|
||||
|
||||
def test_prompt_select_account_when_existing_credentials_cover_scopes(monkeypatch):
|
||||
required_scopes = ["scope.a", "scope.b"]
|
||||
monkeypatch.setattr(
|
||||
"auth.google_auth.get_oauth21_session_store",
|
||||
lambda: _DummySessionStore(),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"auth.google_auth.get_credential_store",
|
||||
lambda: _DummyCredentialStore(
|
||||
{"user@gmail.com": _credentials_with_scopes(required_scopes)}
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr("auth.google_auth.is_stateless_mode", lambda: False)
|
||||
|
||||
prompt = _determine_oauth_prompt(
|
||||
user_google_email="user@gmail.com",
|
||||
required_scopes=required_scopes,
|
||||
session_id=None,
|
||||
)
|
||||
|
||||
assert prompt == "select_account"
|
||||
|
||||
|
||||
def test_prompt_consent_when_existing_credentials_missing_scopes(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"auth.google_auth.get_oauth21_session_store",
|
||||
lambda: _DummySessionStore(),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"auth.google_auth.get_credential_store",
|
||||
lambda: _DummyCredentialStore(
|
||||
{"user@gmail.com": _credentials_with_scopes(["scope.a"])}
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr("auth.google_auth.is_stateless_mode", lambda: False)
|
||||
|
||||
prompt = _determine_oauth_prompt(
|
||||
user_google_email="user@gmail.com",
|
||||
required_scopes=["scope.a", "scope.b"],
|
||||
session_id=None,
|
||||
)
|
||||
|
||||
assert prompt == "consent"
|
||||
|
||||
|
||||
def test_prompt_consent_when_no_existing_credentials(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"auth.google_auth.get_oauth21_session_store",
|
||||
lambda: _DummySessionStore(),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"auth.google_auth.get_credential_store",
|
||||
lambda: _DummyCredentialStore(),
|
||||
)
|
||||
monkeypatch.setattr("auth.google_auth.is_stateless_mode", lambda: False)
|
||||
|
||||
prompt = _determine_oauth_prompt(
|
||||
user_google_email="new_user@gmail.com",
|
||||
required_scopes=["scope.a"],
|
||||
session_id=None,
|
||||
)
|
||||
|
||||
assert prompt == "consent"
|
||||
|
||||
|
||||
def test_prompt_uses_session_mapping_when_email_not_provided(monkeypatch):
|
||||
session_id = "session-123"
|
||||
required_scopes = ["scope.a"]
|
||||
monkeypatch.setattr(
|
||||
"auth.google_auth.get_oauth21_session_store",
|
||||
lambda: _DummySessionStore(
|
||||
user_by_session={session_id: "mapped@gmail.com"},
|
||||
credentials_by_session={
|
||||
session_id: _credentials_with_scopes(required_scopes)
|
||||
},
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"auth.google_auth.get_credential_store",
|
||||
lambda: _DummyCredentialStore(),
|
||||
)
|
||||
monkeypatch.setattr("auth.google_auth.is_stateless_mode", lambda: False)
|
||||
|
||||
prompt = _determine_oauth_prompt(
|
||||
user_google_email=None,
|
||||
required_scopes=required_scopes,
|
||||
session_id=session_id,
|
||||
)
|
||||
|
||||
assert prompt == "select_account"
|
||||
0
tests/core/__init__.py
Normal file
0
tests/core/__init__.py
Normal file
69
tests/core/test_attachment_route.py
Normal file
69
tests/core/test_attachment_route.py
Normal file
@@ -0,0 +1,69 @@
|
||||
import pytest
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import FileResponse, JSONResponse
|
||||
|
||||
from core.server import serve_attachment
|
||||
|
||||
|
||||
def _build_request(file_id: str) -> Request:
|
||||
scope = {
|
||||
"type": "http",
|
||||
"asgi": {"version": "3.0"},
|
||||
"http_version": "1.1",
|
||||
"method": "GET",
|
||||
"scheme": "http",
|
||||
"path": f"/attachments/{file_id}",
|
||||
"raw_path": f"/attachments/{file_id}".encode(),
|
||||
"query_string": b"",
|
||||
"headers": [],
|
||||
"client": ("127.0.0.1", 12345),
|
||||
"server": ("localhost", 8000),
|
||||
"path_params": {"file_id": file_id},
|
||||
}
|
||||
|
||||
async def receive():
|
||||
return {"type": "http.request", "body": b"", "more_body": False}
|
||||
|
||||
return Request(scope, receive)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_serve_attachment_uses_path_param_file_id(monkeypatch, tmp_path):
|
||||
file_path = tmp_path / "sample.pdf"
|
||||
file_path.write_bytes(b"%PDF-1.3\n")
|
||||
captured = {}
|
||||
|
||||
class DummyStorage:
|
||||
def get_attachment_metadata(self, file_id):
|
||||
captured["file_id"] = file_id
|
||||
return {"filename": "sample.pdf", "mime_type": "application/pdf"}
|
||||
|
||||
def get_attachment_path(self, _file_id):
|
||||
return file_path
|
||||
|
||||
monkeypatch.setattr(
|
||||
"core.attachment_storage.get_attachment_storage", lambda: DummyStorage()
|
||||
)
|
||||
|
||||
response = await serve_attachment(_build_request("abc123"))
|
||||
|
||||
assert captured["file_id"] == "abc123"
|
||||
assert isinstance(response, FileResponse)
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_serve_attachment_404_when_metadata_missing(monkeypatch):
|
||||
class DummyStorage:
|
||||
def get_attachment_metadata(self, _file_id):
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(
|
||||
"core.attachment_storage.get_attachment_storage", lambda: DummyStorage()
|
||||
)
|
||||
|
||||
response = await serve_attachment(_build_request("missing"))
|
||||
|
||||
assert isinstance(response, JSONResponse)
|
||||
assert response.status_code == 404
|
||||
assert b"Attachment not found or expired" in response.body
|
||||
112
tests/core/test_comments.py
Normal file
112
tests/core/test_comments.py
Normal file
@@ -0,0 +1,112 @@
|
||||
"""Tests for core comments module."""
|
||||
|
||||
import sys
|
||||
import os
|
||||
import pytest
|
||||
from unittest.mock import Mock
|
||||
|
||||
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../..")))
|
||||
|
||||
from core.comments import _read_comments_impl
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_comments_includes_quoted_text():
|
||||
"""Verify that quotedFileContent.value is surfaced in the output."""
|
||||
mock_service = Mock()
|
||||
mock_service.comments.return_value.list.return_value.execute = Mock(
|
||||
return_value={
|
||||
"comments": [
|
||||
{
|
||||
"id": "c1",
|
||||
"content": "Needs a citation here.",
|
||||
"author": {"displayName": "Alice"},
|
||||
"createdTime": "2025-01-15T10:00:00Z",
|
||||
"modifiedTime": "2025-01-15T10:00:00Z",
|
||||
"resolved": False,
|
||||
"quotedFileContent": {
|
||||
"mimeType": "text/html",
|
||||
"value": "the specific text that was highlighted",
|
||||
},
|
||||
"replies": [],
|
||||
},
|
||||
{
|
||||
"id": "c2",
|
||||
"content": "General comment without anchor.",
|
||||
"author": {"displayName": "Bob"},
|
||||
"createdTime": "2025-01-16T09:00:00Z",
|
||||
"modifiedTime": "2025-01-16T09:00:00Z",
|
||||
"resolved": False,
|
||||
"replies": [],
|
||||
},
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
result = await _read_comments_impl(mock_service, "document", "doc123")
|
||||
|
||||
# Comment with anchor text should show the quoted text
|
||||
assert "Quoted text: the specific text that was highlighted" in result
|
||||
assert "Needs a citation here." in result
|
||||
|
||||
# Comment without anchor text should not have a "Quoted text" line between Bob's author and content
|
||||
# The output uses literal \n joins, so split on that
|
||||
parts = result.split("\\n")
|
||||
bob_section_started = False
|
||||
for part in parts:
|
||||
if "Author: Bob" in part:
|
||||
bob_section_started = True
|
||||
if bob_section_started and "Quoted text:" in part:
|
||||
pytest.fail(
|
||||
"Comment without quotedFileContent should not show 'Quoted text'"
|
||||
)
|
||||
if bob_section_started and "Content: General comment" in part:
|
||||
break
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_comments_empty():
|
||||
"""Verify empty comments returns appropriate message."""
|
||||
mock_service = Mock()
|
||||
mock_service.comments.return_value.list.return_value.execute = Mock(
|
||||
return_value={"comments": []}
|
||||
)
|
||||
|
||||
result = await _read_comments_impl(mock_service, "document", "doc123")
|
||||
assert "No comments found" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_comments_with_replies():
|
||||
"""Verify replies are included in output."""
|
||||
mock_service = Mock()
|
||||
mock_service.comments.return_value.list.return_value.execute = Mock(
|
||||
return_value={
|
||||
"comments": [
|
||||
{
|
||||
"id": "c1",
|
||||
"content": "Question?",
|
||||
"author": {"displayName": "Alice"},
|
||||
"createdTime": "2025-01-15T10:00:00Z",
|
||||
"modifiedTime": "2025-01-15T10:00:00Z",
|
||||
"resolved": False,
|
||||
"quotedFileContent": {"value": "some text"},
|
||||
"replies": [
|
||||
{
|
||||
"id": "r1",
|
||||
"content": "Answer.",
|
||||
"author": {"displayName": "Bob"},
|
||||
"createdTime": "2025-01-15T11:00:00Z",
|
||||
"modifiedTime": "2025-01-15T11:00:00Z",
|
||||
}
|
||||
],
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
result = await _read_comments_impl(mock_service, "document", "doc123")
|
||||
assert "Question?" in result
|
||||
assert "Answer." in result
|
||||
assert "Bob" in result
|
||||
assert "Quoted text: some text" in result
|
||||
90
tests/core/test_well_known_cache_control_middleware.py
Normal file
90
tests/core/test_well_known_cache_control_middleware.py
Normal file
@@ -0,0 +1,90 @@
|
||||
import importlib
|
||||
|
||||
from starlette.applications import Starlette
|
||||
from starlette.middleware import Middleware
|
||||
from starlette.responses import Response
|
||||
from starlette.routing import Route
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
|
||||
def test_well_known_cache_control_middleware_rewrites_headers():
|
||||
from core.server import WellKnownCacheControlMiddleware, _compute_scope_fingerprint
|
||||
|
||||
async def well_known_endpoint(request):
|
||||
response = Response("ok")
|
||||
response.headers["Cache-Control"] = "public, max-age=3600"
|
||||
response.set_cookie("a", "1")
|
||||
response.set_cookie("b", "2")
|
||||
return response
|
||||
|
||||
async def regular_endpoint(request):
|
||||
response = Response("ok")
|
||||
response.headers["Cache-Control"] = "public, max-age=3600"
|
||||
return response
|
||||
|
||||
app = Starlette(
|
||||
routes=[
|
||||
Route("/.well-known/oauth-authorization-server", well_known_endpoint),
|
||||
Route("/.well-known/oauth-authorization-server-extra", regular_endpoint),
|
||||
Route("/health", regular_endpoint),
|
||||
],
|
||||
middleware=[Middleware(WellKnownCacheControlMiddleware)],
|
||||
)
|
||||
client = TestClient(app)
|
||||
|
||||
well_known = client.get("/.well-known/oauth-authorization-server")
|
||||
assert well_known.status_code == 200
|
||||
assert well_known.headers["cache-control"] == "no-store, must-revalidate"
|
||||
assert well_known.headers["etag"] == f'"{_compute_scope_fingerprint()}"'
|
||||
assert sorted(well_known.headers.get_list("set-cookie")) == sorted(
|
||||
["a=1; Path=/; SameSite=lax", "b=2; Path=/; SameSite=lax"]
|
||||
)
|
||||
|
||||
regular = client.get("/health")
|
||||
assert regular.status_code == 200
|
||||
assert regular.headers["cache-control"] == "public, max-age=3600"
|
||||
assert "etag" not in regular.headers
|
||||
|
||||
extra = client.get("/.well-known/oauth-authorization-server-extra")
|
||||
assert extra.status_code == 200
|
||||
assert extra.headers["cache-control"] == "public, max-age=3600"
|
||||
assert "etag" not in extra.headers
|
||||
|
||||
|
||||
def test_configured_server_applies_no_cache_to_served_oauth_discovery_routes(
|
||||
monkeypatch,
|
||||
):
|
||||
monkeypatch.setenv("MCP_ENABLE_OAUTH21", "true")
|
||||
monkeypatch.setenv("GOOGLE_OAUTH_CLIENT_ID", "dummy-client")
|
||||
monkeypatch.setenv("GOOGLE_OAUTH_CLIENT_SECRET", "dummy-secret")
|
||||
monkeypatch.setenv("WORKSPACE_MCP_BASE_URI", "http://localhost")
|
||||
monkeypatch.setenv("WORKSPACE_MCP_PORT", "8000")
|
||||
monkeypatch.delenv("WORKSPACE_EXTERNAL_URL", raising=False)
|
||||
monkeypatch.setenv("EXTERNAL_OAUTH21_PROVIDER", "false")
|
||||
|
||||
import core.server as core_server
|
||||
from auth.oauth_config import reload_oauth_config
|
||||
|
||||
reload_oauth_config()
|
||||
core_server = importlib.reload(core_server)
|
||||
core_server.set_transport_mode("streamable-http")
|
||||
core_server.configure_server_for_http()
|
||||
|
||||
app = core_server.server.http_app(transport="streamable-http", path="/mcp")
|
||||
client = TestClient(app)
|
||||
|
||||
authorization_server = client.get("/.well-known/oauth-authorization-server")
|
||||
assert authorization_server.status_code == 200
|
||||
assert authorization_server.headers["cache-control"] == "no-store, must-revalidate"
|
||||
assert authorization_server.headers["etag"].startswith('"')
|
||||
assert authorization_server.headers["etag"].endswith('"')
|
||||
|
||||
protected_resource = client.get("/.well-known/oauth-protected-resource/mcp")
|
||||
assert protected_resource.status_code == 200
|
||||
assert protected_resource.headers["cache-control"] == "no-store, must-revalidate"
|
||||
assert protected_resource.headers["etag"].startswith('"')
|
||||
assert protected_resource.headers["etag"].endswith('"')
|
||||
|
||||
# Ensure we did not create a shadow route at the wrong path.
|
||||
wrong_path = client.get("/.well-known/oauth-protected-resource")
|
||||
assert wrong_path.status_code == 404
|
||||
0
tests/gappsscript/__init__.py
Normal file
0
tests/gappsscript/__init__.py
Normal file
373
tests/gappsscript/manual_test.py
Normal file
373
tests/gappsscript/manual_test.py
Normal file
@@ -0,0 +1,373 @@
|
||||
"""
|
||||
Manual E2E test script for Apps Script integration.
|
||||
|
||||
This script tests Apps Script tools against the real Google API.
|
||||
Requires valid OAuth credentials and enabled Apps Script API.
|
||||
|
||||
Usage:
|
||||
python tests/gappsscript/manual_test.py
|
||||
|
||||
Environment Variables:
|
||||
GOOGLE_CLIENT_SECRET_PATH: Path to client_secret.json (default: ./client_secret.json)
|
||||
GOOGLE_TOKEN_PATH: Path to store OAuth token (default: ./test_token.pickle)
|
||||
|
||||
Note: This will create real Apps Script projects in your account.
|
||||
Delete test projects manually after running.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import sys
|
||||
import os
|
||||
|
||||
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../..")))
|
||||
|
||||
from googleapiclient.discovery import build
|
||||
from google_auth_oauthlib.flow import InstalledAppFlow
|
||||
from google.auth.transport.requests import Request
|
||||
import pickle
|
||||
|
||||
|
||||
SCOPES = [
|
||||
"https://www.googleapis.com/auth/script.projects",
|
||||
"https://www.googleapis.com/auth/script.deployments",
|
||||
"https://www.googleapis.com/auth/script.processes",
|
||||
"https://www.googleapis.com/auth/drive.readonly", # For listing script projects
|
||||
"https://www.googleapis.com/auth/userinfo.email", # Basic user info
|
||||
"openid", # Required by Google OAuth
|
||||
]
|
||||
|
||||
# Allow http://localhost for OAuth (required for headless auth)
|
||||
os.environ["OAUTHLIB_INSECURE_TRANSPORT"] = "1"
|
||||
|
||||
# Default paths (can be overridden via environment variables)
|
||||
PROJECT_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "../.."))
|
||||
DEFAULT_CLIENT_SECRET = os.path.join(PROJECT_ROOT, "client_secret.json")
|
||||
DEFAULT_TOKEN_PATH = os.path.join(PROJECT_ROOT, "test_token.pickle")
|
||||
|
||||
|
||||
def get_credentials():
|
||||
"""
|
||||
Get OAuth credentials for Apps Script API.
|
||||
|
||||
Credential paths can be configured via environment variables:
|
||||
- GOOGLE_CLIENT_SECRET_PATH: Path to client_secret.json
|
||||
- GOOGLE_TOKEN_PATH: Path to store/load OAuth token
|
||||
|
||||
Returns:
|
||||
Credentials object
|
||||
"""
|
||||
creds = None
|
||||
token_path = os.environ.get("GOOGLE_TOKEN_PATH", DEFAULT_TOKEN_PATH)
|
||||
client_secret_path = os.environ.get(
|
||||
"GOOGLE_CLIENT_SECRET_PATH", DEFAULT_CLIENT_SECRET
|
||||
)
|
||||
|
||||
if os.path.exists(token_path):
|
||||
with open(token_path, "rb") as token:
|
||||
creds = pickle.load(token)
|
||||
|
||||
if not creds or not creds.valid:
|
||||
if creds and creds.expired and creds.refresh_token:
|
||||
creds.refresh(Request())
|
||||
else:
|
||||
if not os.path.exists(client_secret_path):
|
||||
print(f"Error: {client_secret_path} not found")
|
||||
print("\nTo fix this:")
|
||||
print("1. Go to Google Cloud Console > APIs & Services > Credentials")
|
||||
print("2. Create an OAuth 2.0 Client ID (Desktop application type)")
|
||||
print("3. Download the JSON and save as client_secret.json")
|
||||
print(f"\nExpected path: {client_secret_path}")
|
||||
print("\nOr set GOOGLE_CLIENT_SECRET_PATH environment variable")
|
||||
sys.exit(1)
|
||||
|
||||
flow = InstalledAppFlow.from_client_secrets_file(client_secret_path, SCOPES)
|
||||
# Set redirect URI to match client_secret.json
|
||||
flow.redirect_uri = "http://localhost"
|
||||
# Headless flow: user copies redirect URL after auth
|
||||
auth_url, _ = flow.authorization_url(prompt="consent")
|
||||
print("\n" + "=" * 60)
|
||||
print("HEADLESS AUTH")
|
||||
print("=" * 60)
|
||||
print("\n1. Open this URL in any browser:\n")
|
||||
print(auth_url)
|
||||
print("\n2. Sign in and authorize the app")
|
||||
print("3. You'll be redirected to http://localhost (won't load)")
|
||||
print("4. Copy the FULL URL from browser address bar")
|
||||
print(" (looks like: http://localhost/?code=4/0A...&scope=...)")
|
||||
print("5. Paste it below:\n")
|
||||
redirect_response = input("Paste full redirect URL: ").strip()
|
||||
flow.fetch_token(authorization_response=redirect_response)
|
||||
creds = flow.credentials
|
||||
|
||||
with open(token_path, "wb") as token:
|
||||
pickle.dump(creds, token)
|
||||
|
||||
return creds
|
||||
|
||||
|
||||
async def test_list_projects(drive_service):
|
||||
"""Test listing Apps Script projects using Drive API"""
|
||||
print("\n=== Test: List Projects ===")
|
||||
|
||||
from gappsscript.apps_script_tools import _list_script_projects_impl
|
||||
|
||||
try:
|
||||
result = await _list_script_projects_impl(
|
||||
service=drive_service, user_google_email="test@example.com", page_size=10
|
||||
)
|
||||
print(result)
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"Error: {e}")
|
||||
return False
|
||||
|
||||
|
||||
async def test_create_project(service):
|
||||
"""Test creating a new Apps Script project"""
|
||||
print("\n=== Test: Create Project ===")
|
||||
|
||||
from gappsscript.apps_script_tools import _create_script_project_impl
|
||||
|
||||
try:
|
||||
result = await _create_script_project_impl(
|
||||
service=service,
|
||||
user_google_email="test@example.com",
|
||||
title="MCP Test Project",
|
||||
)
|
||||
print(result)
|
||||
|
||||
if "Script ID:" in result:
|
||||
script_id = result.split("Script ID: ")[1].split("\n")[0]
|
||||
return script_id
|
||||
return None
|
||||
except Exception as e:
|
||||
print(f"Error: {e}")
|
||||
return None
|
||||
|
||||
|
||||
async def test_get_project(service, script_id):
|
||||
"""Test retrieving project details"""
|
||||
print(f"\n=== Test: Get Project {script_id} ===")
|
||||
|
||||
from gappsscript.apps_script_tools import _get_script_project_impl
|
||||
|
||||
try:
|
||||
result = await _get_script_project_impl(
|
||||
service=service, user_google_email="test@example.com", script_id=script_id
|
||||
)
|
||||
print(result)
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"Error: {e}")
|
||||
return False
|
||||
|
||||
|
||||
async def test_update_content(service, script_id):
|
||||
"""Test updating script content"""
|
||||
print(f"\n=== Test: Update Content {script_id} ===")
|
||||
|
||||
from gappsscript.apps_script_tools import _update_script_content_impl
|
||||
|
||||
files = [
|
||||
{
|
||||
"name": "appsscript",
|
||||
"type": "JSON",
|
||||
"source": """{
|
||||
"timeZone": "America/New_York",
|
||||
"dependencies": {},
|
||||
"exceptionLogging": "STACKDRIVER",
|
||||
"runtimeVersion": "V8"
|
||||
}""",
|
||||
},
|
||||
{
|
||||
"name": "Code",
|
||||
"type": "SERVER_JS",
|
||||
"source": """function testFunction() {
|
||||
Logger.log('Hello from MCP test!');
|
||||
return 'Test successful';
|
||||
}""",
|
||||
},
|
||||
]
|
||||
|
||||
try:
|
||||
result = await _update_script_content_impl(
|
||||
service=service,
|
||||
user_google_email="test@example.com",
|
||||
script_id=script_id,
|
||||
files=files,
|
||||
)
|
||||
print(result)
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"Error: {e}")
|
||||
return False
|
||||
|
||||
|
||||
async def test_run_function(service, script_id):
|
||||
"""Test running a script function"""
|
||||
print(f"\n=== Test: Run Function {script_id} ===")
|
||||
|
||||
from gappsscript.apps_script_tools import _run_script_function_impl
|
||||
|
||||
try:
|
||||
result = await _run_script_function_impl(
|
||||
service=service,
|
||||
user_google_email="test@example.com",
|
||||
script_id=script_id,
|
||||
function_name="testFunction",
|
||||
dev_mode=True,
|
||||
)
|
||||
print(result)
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"Error: {e}")
|
||||
return False
|
||||
|
||||
|
||||
async def test_create_deployment(service, script_id):
|
||||
"""Test creating a deployment"""
|
||||
print(f"\n=== Test: Create Deployment {script_id} ===")
|
||||
|
||||
from gappsscript.apps_script_tools import _create_deployment_impl
|
||||
|
||||
try:
|
||||
result = await _create_deployment_impl(
|
||||
service=service,
|
||||
user_google_email="test@example.com",
|
||||
script_id=script_id,
|
||||
description="MCP Test Deployment",
|
||||
)
|
||||
print(result)
|
||||
|
||||
if "Deployment ID:" in result:
|
||||
deployment_id = result.split("Deployment ID: ")[1].split("\n")[0]
|
||||
return deployment_id
|
||||
return None
|
||||
except Exception as e:
|
||||
print(f"Error: {e}")
|
||||
return None
|
||||
|
||||
|
||||
async def test_list_deployments(service, script_id):
|
||||
"""Test listing deployments"""
|
||||
print(f"\n=== Test: List Deployments {script_id} ===")
|
||||
|
||||
from gappsscript.apps_script_tools import _list_deployments_impl
|
||||
|
||||
try:
|
||||
result = await _list_deployments_impl(
|
||||
service=service, user_google_email="test@example.com", script_id=script_id
|
||||
)
|
||||
print(result)
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"Error: {e}")
|
||||
return False
|
||||
|
||||
|
||||
async def test_list_processes(service):
|
||||
"""Test listing script processes"""
|
||||
print("\n=== Test: List Processes ===")
|
||||
|
||||
from gappsscript.apps_script_tools import _list_script_processes_impl
|
||||
|
||||
try:
|
||||
result = await _list_script_processes_impl(
|
||||
service=service, user_google_email="test@example.com", page_size=10
|
||||
)
|
||||
print(result)
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"Error: {e}")
|
||||
return False
|
||||
|
||||
|
||||
async def cleanup_test_project(service, script_id):
|
||||
"""
|
||||
Cleanup test project (requires Drive API).
|
||||
Note: Apps Script API does not have a delete endpoint.
|
||||
Projects must be deleted via Drive API by moving to trash.
|
||||
"""
|
||||
print(f"\n=== Cleanup: Delete Project {script_id} ===")
|
||||
print("Note: Apps Script projects must be deleted via Drive API")
|
||||
print(f"Please manually delete: https://script.google.com/d/{script_id}/edit")
|
||||
|
||||
|
||||
async def run_all_tests():
|
||||
"""Run all manual tests"""
|
||||
print("=" * 60)
|
||||
print("Apps Script MCP Manual Test Suite")
|
||||
print("=" * 60)
|
||||
|
||||
print("\nGetting OAuth credentials...")
|
||||
creds = get_credentials()
|
||||
|
||||
print("Building API services...")
|
||||
script_service = build("script", "v1", credentials=creds)
|
||||
drive_service = build("drive", "v3", credentials=creds)
|
||||
|
||||
test_script_id = None
|
||||
deployment_id = None
|
||||
|
||||
try:
|
||||
success = await test_list_projects(drive_service)
|
||||
if not success:
|
||||
print("\nWarning: List projects failed")
|
||||
|
||||
test_script_id = await test_create_project(script_service)
|
||||
if test_script_id:
|
||||
print(f"\nCreated test project: {test_script_id}")
|
||||
|
||||
await test_get_project(script_service, test_script_id)
|
||||
await test_update_content(script_service, test_script_id)
|
||||
|
||||
await asyncio.sleep(2)
|
||||
|
||||
await test_run_function(script_service, test_script_id)
|
||||
|
||||
deployment_id = await test_create_deployment(script_service, test_script_id)
|
||||
if deployment_id:
|
||||
print(f"\nCreated deployment: {deployment_id}")
|
||||
|
||||
await test_list_deployments(script_service, test_script_id)
|
||||
else:
|
||||
print("\nSkipping tests that require a project (creation failed)")
|
||||
|
||||
await test_list_processes(script_service)
|
||||
|
||||
finally:
|
||||
if test_script_id:
|
||||
await cleanup_test_project(script_service, test_script_id)
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("Manual Test Suite Complete")
|
||||
print("=" * 60)
|
||||
|
||||
|
||||
def main():
|
||||
"""Main entry point"""
|
||||
import argparse
|
||||
|
||||
parser = argparse.ArgumentParser(description="Manual E2E test for Apps Script")
|
||||
parser.add_argument(
|
||||
"--yes", "-y", action="store_true", help="Skip confirmation prompt"
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
print("\nIMPORTANT: This script will:")
|
||||
print("1. Create a test Apps Script project in your account")
|
||||
print("2. Run various operations on it")
|
||||
print("3. Leave the project for manual cleanup")
|
||||
print("\nYou must manually delete the test project after running this.")
|
||||
|
||||
if not args.yes:
|
||||
response = input("\nContinue? (yes/no): ")
|
||||
if response.lower() not in ["yes", "y"]:
|
||||
print("Aborted")
|
||||
return
|
||||
|
||||
asyncio.run(run_all_tests())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
432
tests/gappsscript/test_apps_script_tools.py
Normal file
432
tests/gappsscript/test_apps_script_tools.py
Normal file
@@ -0,0 +1,432 @@
|
||||
"""
|
||||
Unit tests for Google Apps Script MCP tools
|
||||
|
||||
Tests all Apps Script tools with mocked API responses
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import Mock
|
||||
import sys
|
||||
import os
|
||||
|
||||
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../..")))
|
||||
|
||||
# Import the internal implementation functions (not the decorated ones)
|
||||
from gappsscript.apps_script_tools import (
|
||||
_list_script_projects_impl,
|
||||
_get_script_project_impl,
|
||||
_create_script_project_impl,
|
||||
_update_script_content_impl,
|
||||
_run_script_function_impl,
|
||||
_create_deployment_impl,
|
||||
_list_deployments_impl,
|
||||
_update_deployment_impl,
|
||||
_delete_deployment_impl,
|
||||
_list_script_processes_impl,
|
||||
_delete_script_project_impl,
|
||||
_list_versions_impl,
|
||||
_create_version_impl,
|
||||
_get_version_impl,
|
||||
_get_script_metrics_impl,
|
||||
_generate_trigger_code_impl,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_script_projects():
|
||||
"""Test listing Apps Script projects via Drive API"""
|
||||
mock_service = Mock()
|
||||
mock_response = {
|
||||
"files": [
|
||||
{
|
||||
"id": "test123",
|
||||
"name": "Test Project",
|
||||
"createdTime": "2025-01-10T10:00:00Z",
|
||||
"modifiedTime": "2026-01-12T15:30:00Z",
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
mock_service.files().list().execute.return_value = mock_response
|
||||
|
||||
result = await _list_script_projects_impl(
|
||||
service=mock_service, user_google_email="test@example.com", page_size=50
|
||||
)
|
||||
|
||||
assert "Found 1 Apps Script projects" in result
|
||||
assert "Test Project" in result
|
||||
assert "test123" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_script_project():
|
||||
"""Test retrieving complete project details"""
|
||||
mock_service = Mock()
|
||||
|
||||
# projects().get() returns metadata only (no files)
|
||||
mock_metadata_response = {
|
||||
"scriptId": "test123",
|
||||
"title": "Test Project",
|
||||
"creator": {"email": "creator@example.com"},
|
||||
"createTime": "2025-01-10T10:00:00Z",
|
||||
"updateTime": "2026-01-12T15:30:00Z",
|
||||
}
|
||||
|
||||
# projects().getContent() returns files with source code
|
||||
mock_content_response = {
|
||||
"scriptId": "test123",
|
||||
"files": [
|
||||
{
|
||||
"name": "Code",
|
||||
"type": "SERVER_JS",
|
||||
"source": "function test() { return 'hello'; }",
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
mock_service.projects().get().execute.return_value = mock_metadata_response
|
||||
mock_service.projects().getContent().execute.return_value = mock_content_response
|
||||
|
||||
result = await _get_script_project_impl(
|
||||
service=mock_service, user_google_email="test@example.com", script_id="test123"
|
||||
)
|
||||
|
||||
assert "Test Project" in result
|
||||
assert "creator@example.com" in result
|
||||
assert "Code" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_script_project():
|
||||
"""Test creating new Apps Script project"""
|
||||
mock_service = Mock()
|
||||
mock_response = {"scriptId": "new123", "title": "New Project"}
|
||||
|
||||
mock_service.projects().create().execute.return_value = mock_response
|
||||
|
||||
result = await _create_script_project_impl(
|
||||
service=mock_service, user_google_email="test@example.com", title="New Project"
|
||||
)
|
||||
|
||||
assert "Script ID: new123" in result
|
||||
assert "New Project" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_script_content():
|
||||
"""Test updating script project files"""
|
||||
mock_service = Mock()
|
||||
files_to_update = [
|
||||
{"name": "Code", "type": "SERVER_JS", "source": "function main() {}"}
|
||||
]
|
||||
mock_response = {"files": files_to_update}
|
||||
|
||||
mock_service.projects().updateContent().execute.return_value = mock_response
|
||||
|
||||
result = await _update_script_content_impl(
|
||||
service=mock_service,
|
||||
user_google_email="test@example.com",
|
||||
script_id="test123",
|
||||
files=files_to_update,
|
||||
)
|
||||
|
||||
assert "Updated script project: test123" in result
|
||||
assert "Code" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_script_function():
|
||||
"""Test executing script function"""
|
||||
mock_service = Mock()
|
||||
mock_response = {"response": {"result": "Success"}}
|
||||
|
||||
mock_service.scripts().run().execute.return_value = mock_response
|
||||
|
||||
result = await _run_script_function_impl(
|
||||
service=mock_service,
|
||||
user_google_email="test@example.com",
|
||||
script_id="test123",
|
||||
function_name="myFunction",
|
||||
dev_mode=True,
|
||||
)
|
||||
|
||||
assert "Execution successful" in result
|
||||
assert "myFunction" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_deployment():
|
||||
"""Test creating deployment"""
|
||||
mock_service = Mock()
|
||||
|
||||
# Mock version creation (called first)
|
||||
mock_version_response = {"versionNumber": 1}
|
||||
mock_service.projects().versions().create().execute.return_value = (
|
||||
mock_version_response
|
||||
)
|
||||
|
||||
# Mock deployment creation (called second)
|
||||
mock_deploy_response = {
|
||||
"deploymentId": "deploy123",
|
||||
"deploymentConfig": {},
|
||||
}
|
||||
mock_service.projects().deployments().create().execute.return_value = (
|
||||
mock_deploy_response
|
||||
)
|
||||
|
||||
result = await _create_deployment_impl(
|
||||
service=mock_service,
|
||||
user_google_email="test@example.com",
|
||||
script_id="test123",
|
||||
description="Test deployment",
|
||||
)
|
||||
|
||||
assert "Deployment ID: deploy123" in result
|
||||
assert "Test deployment" in result
|
||||
assert "Version: 1" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_deployments():
|
||||
"""Test listing deployments"""
|
||||
mock_service = Mock()
|
||||
mock_response = {
|
||||
"deployments": [
|
||||
{
|
||||
"deploymentId": "deploy123",
|
||||
"description": "Production",
|
||||
"updateTime": "2026-01-12T15:30:00Z",
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
mock_service.projects().deployments().list().execute.return_value = mock_response
|
||||
|
||||
result = await _list_deployments_impl(
|
||||
service=mock_service, user_google_email="test@example.com", script_id="test123"
|
||||
)
|
||||
|
||||
assert "Production" in result
|
||||
assert "deploy123" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_deployment():
|
||||
"""Test updating deployment"""
|
||||
mock_service = Mock()
|
||||
mock_response = {
|
||||
"deploymentId": "deploy123",
|
||||
"description": "Updated description",
|
||||
}
|
||||
|
||||
mock_service.projects().deployments().update().execute.return_value = mock_response
|
||||
|
||||
result = await _update_deployment_impl(
|
||||
service=mock_service,
|
||||
user_google_email="test@example.com",
|
||||
script_id="test123",
|
||||
deployment_id="deploy123",
|
||||
description="Updated description",
|
||||
)
|
||||
|
||||
assert "Updated deployment: deploy123" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_deployment():
|
||||
"""Test deleting deployment"""
|
||||
mock_service = Mock()
|
||||
mock_service.projects().deployments().delete().execute.return_value = {}
|
||||
|
||||
result = await _delete_deployment_impl(
|
||||
service=mock_service,
|
||||
user_google_email="test@example.com",
|
||||
script_id="test123",
|
||||
deployment_id="deploy123",
|
||||
)
|
||||
|
||||
assert "Deleted deployment: deploy123 from script: test123" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_script_processes():
|
||||
"""Test listing script processes"""
|
||||
mock_service = Mock()
|
||||
mock_response = {
|
||||
"processes": [
|
||||
{
|
||||
"functionName": "myFunction",
|
||||
"processStatus": "COMPLETED",
|
||||
"startTime": "2026-01-12T15:30:00Z",
|
||||
"duration": "5s",
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
mock_service.processes().list().execute.return_value = mock_response
|
||||
|
||||
result = await _list_script_processes_impl(
|
||||
service=mock_service, user_google_email="test@example.com", page_size=50
|
||||
)
|
||||
|
||||
assert "myFunction" in result
|
||||
assert "COMPLETED" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_script_project():
|
||||
"""Test deleting a script project"""
|
||||
mock_service = Mock()
|
||||
mock_service.files().delete().execute.return_value = {}
|
||||
|
||||
result = await _delete_script_project_impl(
|
||||
service=mock_service, user_google_email="test@example.com", script_id="test123"
|
||||
)
|
||||
|
||||
assert "Deleted Apps Script project: test123" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_versions():
|
||||
"""Test listing script versions"""
|
||||
mock_service = Mock()
|
||||
mock_response = {
|
||||
"versions": [
|
||||
{
|
||||
"versionNumber": 1,
|
||||
"description": "Initial version",
|
||||
"createTime": "2025-01-10T10:00:00Z",
|
||||
},
|
||||
{
|
||||
"versionNumber": 2,
|
||||
"description": "Bug fix",
|
||||
"createTime": "2026-01-12T15:30:00Z",
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
mock_service.projects().versions().list().execute.return_value = mock_response
|
||||
|
||||
result = await _list_versions_impl(
|
||||
service=mock_service, user_google_email="test@example.com", script_id="test123"
|
||||
)
|
||||
|
||||
assert "Version 1" in result
|
||||
assert "Initial version" in result
|
||||
assert "Version 2" in result
|
||||
assert "Bug fix" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_version():
|
||||
"""Test creating a new version"""
|
||||
mock_service = Mock()
|
||||
mock_response = {
|
||||
"versionNumber": 3,
|
||||
"createTime": "2026-01-13T10:00:00Z",
|
||||
}
|
||||
|
||||
mock_service.projects().versions().create().execute.return_value = mock_response
|
||||
|
||||
result = await _create_version_impl(
|
||||
service=mock_service,
|
||||
user_google_email="test@example.com",
|
||||
script_id="test123",
|
||||
description="New feature",
|
||||
)
|
||||
|
||||
assert "Created version 3" in result
|
||||
assert "New feature" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_version():
|
||||
"""Test getting a specific version"""
|
||||
mock_service = Mock()
|
||||
mock_response = {
|
||||
"versionNumber": 2,
|
||||
"description": "Bug fix",
|
||||
"createTime": "2026-01-12T15:30:00Z",
|
||||
}
|
||||
|
||||
mock_service.projects().versions().get().execute.return_value = mock_response
|
||||
|
||||
result = await _get_version_impl(
|
||||
service=mock_service,
|
||||
user_google_email="test@example.com",
|
||||
script_id="test123",
|
||||
version_number=2,
|
||||
)
|
||||
|
||||
assert "Version 2" in result
|
||||
assert "Bug fix" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_script_metrics():
|
||||
"""Test getting script metrics"""
|
||||
mock_service = Mock()
|
||||
mock_response = {
|
||||
"activeUsers": [
|
||||
{"startTime": "2026-01-01", "endTime": "2026-01-02", "value": "10"}
|
||||
],
|
||||
"totalExecutions": [
|
||||
{"startTime": "2026-01-01", "endTime": "2026-01-02", "value": "100"}
|
||||
],
|
||||
"failedExecutions": [
|
||||
{"startTime": "2026-01-01", "endTime": "2026-01-02", "value": "5"}
|
||||
],
|
||||
}
|
||||
|
||||
mock_service.projects().getMetrics().execute.return_value = mock_response
|
||||
|
||||
result = await _get_script_metrics_impl(
|
||||
service=mock_service,
|
||||
user_google_email="test@example.com",
|
||||
script_id="test123",
|
||||
metrics_granularity="DAILY",
|
||||
)
|
||||
|
||||
assert "Active Users" in result
|
||||
assert "10 users" in result
|
||||
assert "Total Executions" in result
|
||||
assert "100 executions" in result
|
||||
assert "Failed Executions" in result
|
||||
assert "5 failures" in result
|
||||
|
||||
|
||||
def test_generate_trigger_code_daily():
|
||||
"""Test generating daily trigger code"""
|
||||
result = _generate_trigger_code_impl(
|
||||
trigger_type="time_daily",
|
||||
function_name="sendReport",
|
||||
schedule="9",
|
||||
)
|
||||
|
||||
assert "INSTALLABLE TRIGGER" in result
|
||||
assert "createDailyTrigger_sendReport" in result
|
||||
assert "everyDays(1)" in result
|
||||
assert "atHour(9)" in result
|
||||
|
||||
|
||||
def test_generate_trigger_code_on_edit():
|
||||
"""Test generating onEdit trigger code"""
|
||||
result = _generate_trigger_code_impl(
|
||||
trigger_type="on_edit",
|
||||
function_name="processEdit",
|
||||
)
|
||||
|
||||
assert "SIMPLE TRIGGER" in result
|
||||
assert "function onEdit" in result
|
||||
assert "processEdit()" in result
|
||||
|
||||
|
||||
def test_generate_trigger_code_invalid():
|
||||
"""Test generating trigger code with invalid type"""
|
||||
result = _generate_trigger_code_impl(
|
||||
trigger_type="invalid_type",
|
||||
function_name="test",
|
||||
)
|
||||
|
||||
assert "Unknown trigger type" in result
|
||||
assert "Valid types:" in result
|
||||
0
tests/gchat/__init__.py
Normal file
0
tests/gchat/__init__.py
Normal file
419
tests/gchat/test_chat_tools.py
Normal file
419
tests/gchat/test_chat_tools.py
Normal file
@@ -0,0 +1,419 @@
|
||||
"""
|
||||
Unit tests for Google Chat MCP tools — attachment support
|
||||
"""
|
||||
|
||||
import base64
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, Mock, patch
|
||||
import sys
|
||||
import os
|
||||
|
||||
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../..")))
|
||||
|
||||
|
||||
def _make_message(text="Hello", attachments=None, msg_name="spaces/S/messages/M"):
|
||||
"""Build a minimal Chat API message dict for testing."""
|
||||
msg = {
|
||||
"name": msg_name,
|
||||
"text": text,
|
||||
"createTime": "2025-01-01T00:00:00Z",
|
||||
"sender": {"name": "users/123", "displayName": "Test User"},
|
||||
}
|
||||
if attachments is not None:
|
||||
msg["attachment"] = attachments
|
||||
return msg
|
||||
|
||||
|
||||
def _make_attachment(
|
||||
name="spaces/S/messages/M/attachments/A",
|
||||
content_name="image.png",
|
||||
content_type="image/png",
|
||||
resource_name="spaces/S/attachments/A",
|
||||
):
|
||||
att = {
|
||||
"name": name,
|
||||
"contentName": content_name,
|
||||
"contentType": content_type,
|
||||
"source": "UPLOADED_CONTENT",
|
||||
}
|
||||
if resource_name:
|
||||
att["attachmentDataRef"] = {"resourceName": resource_name}
|
||||
return att
|
||||
|
||||
|
||||
def _unwrap(tool):
|
||||
"""Unwrap a FunctionTool + decorator chain to the original async function."""
|
||||
fn = getattr(tool, "fn", tool)
|
||||
while hasattr(fn, "__wrapped__"):
|
||||
fn = fn.__wrapped__
|
||||
return fn
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# get_messages: attachment metadata appears in output
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("gchat.chat_tools._resolve_sender", new_callable=AsyncMock)
|
||||
async def test_get_messages_shows_attachment_metadata(mock_resolve):
|
||||
"""When a message has attachments, get_messages should surface their metadata."""
|
||||
mock_resolve.return_value = "Test User"
|
||||
|
||||
att = _make_attachment()
|
||||
msg = _make_message(attachments=[att])
|
||||
|
||||
chat_service = Mock()
|
||||
chat_service.spaces().get().execute.return_value = {"displayName": "Test Space"}
|
||||
chat_service.spaces().messages().list().execute.return_value = {"messages": [msg]}
|
||||
|
||||
people_service = Mock()
|
||||
|
||||
from gchat.chat_tools import get_messages
|
||||
|
||||
result = await _unwrap(get_messages)(
|
||||
chat_service=chat_service,
|
||||
people_service=people_service,
|
||||
user_google_email="test@example.com",
|
||||
space_id="spaces/S",
|
||||
)
|
||||
|
||||
assert "[attachment 0: image.png (image/png)]" in result
|
||||
assert "download_chat_attachment" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("gchat.chat_tools._resolve_sender", new_callable=AsyncMock)
|
||||
async def test_get_messages_no_attachments_unchanged(mock_resolve):
|
||||
"""Messages without attachments should not include attachment lines."""
|
||||
mock_resolve.return_value = "Test User"
|
||||
|
||||
msg = _make_message(text="Plain text message")
|
||||
|
||||
chat_service = Mock()
|
||||
chat_service.spaces().get().execute.return_value = {"displayName": "Test Space"}
|
||||
chat_service.spaces().messages().list().execute.return_value = {"messages": [msg]}
|
||||
|
||||
people_service = Mock()
|
||||
|
||||
from gchat.chat_tools import get_messages
|
||||
|
||||
result = await _unwrap(get_messages)(
|
||||
chat_service=chat_service,
|
||||
people_service=people_service,
|
||||
user_google_email="test@example.com",
|
||||
space_id="spaces/S",
|
||||
)
|
||||
|
||||
assert "Plain text message" in result
|
||||
assert "[attachment" not in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("gchat.chat_tools._resolve_sender", new_callable=AsyncMock)
|
||||
async def test_get_messages_multiple_attachments(mock_resolve):
|
||||
"""Multiple attachments should each appear with their index."""
|
||||
mock_resolve.return_value = "Test User"
|
||||
|
||||
attachments = [
|
||||
_make_attachment(content_name="photo.jpg", content_type="image/jpeg"),
|
||||
_make_attachment(
|
||||
name="spaces/S/messages/M/attachments/B",
|
||||
content_name="doc.pdf",
|
||||
content_type="application/pdf",
|
||||
),
|
||||
]
|
||||
msg = _make_message(attachments=attachments)
|
||||
|
||||
chat_service = Mock()
|
||||
chat_service.spaces().get().execute.return_value = {"displayName": "Test Space"}
|
||||
chat_service.spaces().messages().list().execute.return_value = {"messages": [msg]}
|
||||
|
||||
people_service = Mock()
|
||||
|
||||
from gchat.chat_tools import get_messages
|
||||
|
||||
result = await _unwrap(get_messages)(
|
||||
chat_service=chat_service,
|
||||
people_service=people_service,
|
||||
user_google_email="test@example.com",
|
||||
space_id="spaces/S",
|
||||
)
|
||||
|
||||
assert "[attachment 0: photo.jpg (image/jpeg)]" in result
|
||||
assert "[attachment 1: doc.pdf (application/pdf)]" in result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# search_messages: attachment indicator
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("gchat.chat_tools._resolve_sender", new_callable=AsyncMock)
|
||||
async def test_search_messages_shows_attachment_indicator(mock_resolve):
|
||||
"""search_messages should show [attachment: filename] for messages with attachments."""
|
||||
mock_resolve.return_value = "Test User"
|
||||
|
||||
att = _make_attachment(content_name="report.pdf", content_type="application/pdf")
|
||||
msg = _make_message(text="Here is the report", attachments=[att])
|
||||
msg["_space_name"] = "General"
|
||||
|
||||
chat_service = Mock()
|
||||
chat_service.spaces().list().execute.return_value = {
|
||||
"spaces": [{"name": "spaces/S", "displayName": "General"}]
|
||||
}
|
||||
chat_service.spaces().messages().list().execute.return_value = {"messages": [msg]}
|
||||
|
||||
people_service = Mock()
|
||||
|
||||
from gchat.chat_tools import search_messages
|
||||
|
||||
result = await _unwrap(search_messages)(
|
||||
chat_service=chat_service,
|
||||
people_service=people_service,
|
||||
user_google_email="test@example.com",
|
||||
query="report",
|
||||
)
|
||||
|
||||
assert "[attachment: report.pdf (application/pdf)]" in result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# download_chat_attachment: edge cases
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_download_no_attachments():
|
||||
"""Should return a clear message when the message has no attachments."""
|
||||
service = Mock()
|
||||
service.spaces().messages().get().execute.return_value = _make_message()
|
||||
|
||||
from gchat.chat_tools import download_chat_attachment
|
||||
|
||||
result = await _unwrap(download_chat_attachment)(
|
||||
service=service,
|
||||
user_google_email="test@example.com",
|
||||
message_id="spaces/S/messages/M",
|
||||
)
|
||||
|
||||
assert "No attachments found" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_download_invalid_index():
|
||||
"""Should return an error for out-of-range attachment_index."""
|
||||
msg = _make_message(attachments=[_make_attachment()])
|
||||
service = Mock()
|
||||
service.spaces().messages().get().execute.return_value = msg
|
||||
|
||||
from gchat.chat_tools import download_chat_attachment
|
||||
|
||||
result = await _unwrap(download_chat_attachment)(
|
||||
service=service,
|
||||
user_google_email="test@example.com",
|
||||
message_id="spaces/S/messages/M",
|
||||
attachment_index=5,
|
||||
)
|
||||
|
||||
assert "Invalid attachment_index" in result
|
||||
assert "1 attachment(s)" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_download_uses_api_media_endpoint():
|
||||
"""Should always use chat.googleapis.com media endpoint, not downloadUri."""
|
||||
fake_bytes = b"fake image content"
|
||||
att = _make_attachment()
|
||||
# Even with a downloadUri present, we should use the API endpoint
|
||||
att["downloadUri"] = "https://chat.google.com/api/get_attachment_url?bad=url"
|
||||
msg = _make_message(attachments=[att])
|
||||
|
||||
service = Mock()
|
||||
service.spaces().messages().get().execute.return_value = msg
|
||||
service._http.credentials.token = "fake-access-token"
|
||||
|
||||
from gchat.chat_tools import download_chat_attachment
|
||||
|
||||
saved = Mock()
|
||||
saved.path = "/tmp/image_abc.png"
|
||||
saved.file_id = "abc"
|
||||
|
||||
mock_response = Mock()
|
||||
mock_response.content = fake_bytes
|
||||
mock_response.status_code = 200
|
||||
|
||||
mock_client = AsyncMock()
|
||||
mock_client.get.return_value = mock_response
|
||||
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||
mock_client.__aexit__ = AsyncMock(return_value=False)
|
||||
|
||||
with (
|
||||
patch("gchat.chat_tools.httpx.AsyncClient", return_value=mock_client),
|
||||
patch("auth.oauth_config.is_stateless_mode", return_value=False),
|
||||
patch("core.config.get_transport_mode", return_value="stdio"),
|
||||
patch("core.attachment_storage.get_attachment_storage") as mock_get_storage,
|
||||
):
|
||||
mock_get_storage.return_value.save_attachment.return_value = saved
|
||||
|
||||
result = await _unwrap(download_chat_attachment)(
|
||||
service=service,
|
||||
user_google_email="test@example.com",
|
||||
message_id="spaces/S/messages/M",
|
||||
attachment_index=0,
|
||||
)
|
||||
|
||||
assert "image.png" in result
|
||||
assert "/tmp/image_abc.png" in result
|
||||
assert "Saved to:" in result
|
||||
|
||||
# Verify we used the API endpoint with attachmentDataRef.resourceName
|
||||
call_args = mock_client.get.call_args
|
||||
url_used = call_args.args[0]
|
||||
parsed = urlparse(url_used)
|
||||
assert parsed.scheme == "https"
|
||||
assert parsed.hostname == "chat.googleapis.com"
|
||||
assert "alt=media" in url_used
|
||||
assert "spaces/S/attachments/A" in parsed.path
|
||||
assert "/messages/" not in parsed.path
|
||||
|
||||
# Verify Bearer token
|
||||
assert call_args.kwargs["headers"]["Authorization"] == "Bearer fake-access-token"
|
||||
|
||||
# Verify save_attachment was called with correct base64 data
|
||||
save_args = mock_get_storage.return_value.save_attachment.call_args
|
||||
assert save_args.kwargs["filename"] == "image.png"
|
||||
assert save_args.kwargs["mime_type"] == "image/png"
|
||||
decoded = base64.urlsafe_b64decode(save_args.kwargs["base64_data"])
|
||||
assert decoded == fake_bytes
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_download_falls_back_to_att_name():
|
||||
"""When attachmentDataRef is missing, should fall back to attachment name."""
|
||||
fake_bytes = b"fetched content"
|
||||
att = _make_attachment(name="spaces/S/messages/M/attachments/A", resource_name=None)
|
||||
msg = _make_message(attachments=[att])
|
||||
|
||||
service = Mock()
|
||||
service.spaces().messages().get().execute.return_value = msg
|
||||
service._http.credentials.token = "fake-access-token"
|
||||
|
||||
saved = Mock()
|
||||
saved.path = "/tmp/image_fetched.png"
|
||||
saved.file_id = "f1"
|
||||
|
||||
mock_response = Mock()
|
||||
mock_response.content = fake_bytes
|
||||
mock_response.status_code = 200
|
||||
|
||||
mock_client = AsyncMock()
|
||||
mock_client.get.return_value = mock_response
|
||||
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||
mock_client.__aexit__ = AsyncMock(return_value=False)
|
||||
|
||||
from gchat.chat_tools import download_chat_attachment
|
||||
|
||||
with (
|
||||
patch("gchat.chat_tools.httpx.AsyncClient", return_value=mock_client),
|
||||
patch("auth.oauth_config.is_stateless_mode", return_value=False),
|
||||
patch("core.config.get_transport_mode", return_value="stdio"),
|
||||
patch("core.attachment_storage.get_attachment_storage") as mock_get_storage,
|
||||
):
|
||||
mock_get_storage.return_value.save_attachment.return_value = saved
|
||||
|
||||
result = await _unwrap(download_chat_attachment)(
|
||||
service=service,
|
||||
user_google_email="test@example.com",
|
||||
message_id="spaces/S/messages/M",
|
||||
attachment_index=0,
|
||||
)
|
||||
|
||||
assert "image.png" in result
|
||||
assert "/tmp/image_fetched.png" in result
|
||||
|
||||
# Falls back to attachment name when no attachmentDataRef
|
||||
call_args = mock_client.get.call_args
|
||||
assert "spaces/S/messages/M/attachments/A" in call_args.args[0]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_download_http_mode_returns_url():
|
||||
"""In HTTP mode, should return a download URL instead of file path."""
|
||||
fake_bytes = b"image data"
|
||||
att = _make_attachment()
|
||||
msg = _make_message(attachments=[att])
|
||||
|
||||
service = Mock()
|
||||
service.spaces().messages().get().execute.return_value = msg
|
||||
service._http.credentials.token = "fake-token"
|
||||
|
||||
mock_response = Mock()
|
||||
mock_response.content = fake_bytes
|
||||
mock_response.status_code = 200
|
||||
|
||||
mock_client = AsyncMock()
|
||||
mock_client.get.return_value = mock_response
|
||||
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||
mock_client.__aexit__ = AsyncMock(return_value=False)
|
||||
|
||||
saved = Mock()
|
||||
saved.path = "/tmp/image_alt.png"
|
||||
saved.file_id = "alt1"
|
||||
|
||||
from gchat.chat_tools import download_chat_attachment
|
||||
|
||||
with (
|
||||
patch("gchat.chat_tools.httpx.AsyncClient", return_value=mock_client),
|
||||
patch("auth.oauth_config.is_stateless_mode", return_value=False),
|
||||
patch("core.config.get_transport_mode", return_value="http"),
|
||||
patch("core.attachment_storage.get_attachment_storage") as mock_get_storage,
|
||||
patch(
|
||||
"core.attachment_storage.get_attachment_url",
|
||||
return_value="http://localhost:8005/attachments/alt1",
|
||||
),
|
||||
):
|
||||
mock_get_storage.return_value.save_attachment.return_value = saved
|
||||
|
||||
result = await _unwrap(download_chat_attachment)(
|
||||
service=service,
|
||||
user_google_email="test@example.com",
|
||||
message_id="spaces/S/messages/M",
|
||||
attachment_index=0,
|
||||
)
|
||||
|
||||
assert "Download URL:" in result
|
||||
assert "expire after 1 hour" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_download_returns_error_on_failure():
|
||||
"""When download fails, should return a clear error message."""
|
||||
att = _make_attachment()
|
||||
att["downloadUri"] = "https://storage.googleapis.com/fake?alt=media"
|
||||
msg = _make_message(attachments=[att])
|
||||
|
||||
service = Mock()
|
||||
service.spaces().messages().get().execute.return_value = msg
|
||||
service._http.credentials.token = "fake-token"
|
||||
|
||||
mock_client = AsyncMock()
|
||||
mock_client.get.side_effect = Exception("connection refused")
|
||||
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||
mock_client.__aexit__ = AsyncMock(return_value=False)
|
||||
|
||||
from gchat.chat_tools import download_chat_attachment
|
||||
|
||||
with patch("gchat.chat_tools.httpx.AsyncClient", return_value=mock_client):
|
||||
result = await _unwrap(download_chat_attachment)(
|
||||
service=service,
|
||||
user_google_email="test@example.com",
|
||||
message_id="spaces/S/messages/M",
|
||||
attachment_index=0,
|
||||
)
|
||||
|
||||
assert "Failed to download" in result
|
||||
assert "connection refused" in result
|
||||
1
tests/gcontacts/__init__.py
Normal file
1
tests/gcontacts/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Tests for Google Contacts tools
|
||||
339
tests/gcontacts/test_contacts_tools.py
Normal file
339
tests/gcontacts/test_contacts_tools.py
Normal file
@@ -0,0 +1,339 @@
|
||||
"""
|
||||
Unit tests for Google Contacts (People API) tools.
|
||||
|
||||
Tests helper functions and formatting utilities.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
|
||||
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../..")))
|
||||
|
||||
from gcontacts.contacts_tools import (
|
||||
_format_contact,
|
||||
_build_person_body,
|
||||
)
|
||||
|
||||
|
||||
class TestFormatContact:
|
||||
"""Tests for _format_contact helper function."""
|
||||
|
||||
def test_format_basic_contact(self):
|
||||
"""Test formatting a contact with basic fields."""
|
||||
person = {
|
||||
"resourceName": "people/c1234567890",
|
||||
"names": [{"displayName": "John Doe"}],
|
||||
"emailAddresses": [{"value": "john@example.com"}],
|
||||
"phoneNumbers": [{"value": "+1234567890"}],
|
||||
}
|
||||
|
||||
result = _format_contact(person)
|
||||
|
||||
assert "Contact ID: c1234567890" in result
|
||||
assert "Name: John Doe" in result
|
||||
assert "Email: john@example.com" in result
|
||||
assert "Phone: +1234567890" in result
|
||||
|
||||
def test_format_contact_with_organization(self):
|
||||
"""Test formatting a contact with organization info."""
|
||||
person = {
|
||||
"resourceName": "people/c123",
|
||||
"names": [{"displayName": "Jane Smith"}],
|
||||
"organizations": [{"name": "Acme Corp", "title": "Engineer"}],
|
||||
}
|
||||
|
||||
result = _format_contact(person)
|
||||
|
||||
assert "Name: Jane Smith" in result
|
||||
assert "Organization: Engineer at Acme Corp" in result
|
||||
|
||||
def test_format_contact_organization_name_only(self):
|
||||
"""Test formatting a contact with only organization name."""
|
||||
person = {
|
||||
"resourceName": "people/c123",
|
||||
"organizations": [{"name": "Acme Corp"}],
|
||||
}
|
||||
|
||||
result = _format_contact(person)
|
||||
|
||||
assert "Organization: at Acme Corp" in result
|
||||
|
||||
def test_format_contact_job_title_only(self):
|
||||
"""Test formatting a contact with only job title."""
|
||||
person = {
|
||||
"resourceName": "people/c123",
|
||||
"organizations": [{"title": "CEO"}],
|
||||
}
|
||||
|
||||
result = _format_contact(person)
|
||||
|
||||
assert "Organization: CEO" in result
|
||||
|
||||
def test_format_contact_detailed(self):
|
||||
"""Test formatting a contact with detailed fields."""
|
||||
person = {
|
||||
"resourceName": "people/c123",
|
||||
"names": [{"displayName": "Test User"}],
|
||||
"addresses": [{"formattedValue": "123 Main St, City"}],
|
||||
"birthdays": [{"date": {"year": 1990, "month": 5, "day": 15}}],
|
||||
"urls": [{"value": "https://example.com"}],
|
||||
"biographies": [{"value": "A short bio"}],
|
||||
"metadata": {"sources": [{"type": "CONTACT"}]},
|
||||
}
|
||||
|
||||
result = _format_contact(person, detailed=True)
|
||||
|
||||
assert "Address: 123 Main St, City" in result
|
||||
assert "Birthday: 1990/5/15" in result
|
||||
assert "URLs: https://example.com" in result
|
||||
assert "Notes: A short bio" in result
|
||||
assert "Sources: CONTACT" in result
|
||||
|
||||
def test_format_contact_detailed_birthday_without_year(self):
|
||||
"""Test formatting birthday without year."""
|
||||
person = {
|
||||
"resourceName": "people/c123",
|
||||
"birthdays": [{"date": {"month": 5, "day": 15}}],
|
||||
}
|
||||
|
||||
result = _format_contact(person, detailed=True)
|
||||
|
||||
assert "Birthday: 5/15" in result
|
||||
|
||||
def test_format_contact_detailed_long_biography(self):
|
||||
"""Test formatting truncates long biographies."""
|
||||
long_bio = "A" * 300
|
||||
person = {
|
||||
"resourceName": "people/c123",
|
||||
"biographies": [{"value": long_bio}],
|
||||
}
|
||||
|
||||
result = _format_contact(person, detailed=True)
|
||||
|
||||
assert "Notes:" in result
|
||||
assert "..." in result
|
||||
assert len(result.split("Notes: ")[1].split("\n")[0]) <= 203 # 200 + "..."
|
||||
|
||||
def test_format_contact_empty(self):
|
||||
"""Test formatting a contact with minimal fields."""
|
||||
person = {"resourceName": "people/c999"}
|
||||
|
||||
result = _format_contact(person)
|
||||
|
||||
assert "Contact ID: c999" in result
|
||||
|
||||
def test_format_contact_unknown_resource(self):
|
||||
"""Test formatting a contact without resourceName."""
|
||||
person = {}
|
||||
|
||||
result = _format_contact(person)
|
||||
|
||||
assert "Contact ID: Unknown" in result
|
||||
|
||||
def test_format_contact_multiple_emails(self):
|
||||
"""Test formatting a contact with multiple emails."""
|
||||
person = {
|
||||
"resourceName": "people/c123",
|
||||
"emailAddresses": [
|
||||
{"value": "work@example.com"},
|
||||
{"value": "personal@example.com"},
|
||||
],
|
||||
}
|
||||
|
||||
result = _format_contact(person)
|
||||
|
||||
assert "work@example.com" in result
|
||||
assert "personal@example.com" in result
|
||||
|
||||
def test_format_contact_multiple_phones(self):
|
||||
"""Test formatting a contact with multiple phone numbers."""
|
||||
person = {
|
||||
"resourceName": "people/c123",
|
||||
"phoneNumbers": [
|
||||
{"value": "+1111111111"},
|
||||
{"value": "+2222222222"},
|
||||
],
|
||||
}
|
||||
|
||||
result = _format_contact(person)
|
||||
|
||||
assert "+1111111111" in result
|
||||
assert "+2222222222" in result
|
||||
|
||||
def test_format_contact_multiple_urls(self):
|
||||
"""Test formatting a contact with multiple URLs."""
|
||||
person = {
|
||||
"resourceName": "people/c123",
|
||||
"urls": [
|
||||
{"value": "https://linkedin.com/user"},
|
||||
{"value": "https://twitter.com/user"},
|
||||
],
|
||||
}
|
||||
|
||||
result = _format_contact(person, detailed=True)
|
||||
|
||||
assert "https://linkedin.com/user" in result
|
||||
assert "https://twitter.com/user" in result
|
||||
|
||||
|
||||
class TestBuildPersonBody:
|
||||
"""Tests for _build_person_body helper function."""
|
||||
|
||||
def test_build_basic_body(self):
|
||||
"""Test building a basic person body."""
|
||||
body = _build_person_body(
|
||||
given_name="John",
|
||||
family_name="Doe",
|
||||
email="john@example.com",
|
||||
)
|
||||
|
||||
assert body["names"][0]["givenName"] == "John"
|
||||
assert body["names"][0]["familyName"] == "Doe"
|
||||
assert body["emailAddresses"][0]["value"] == "john@example.com"
|
||||
|
||||
def test_build_body_with_phone(self):
|
||||
"""Test building a person body with phone."""
|
||||
body = _build_person_body(phone="+1234567890")
|
||||
|
||||
assert body["phoneNumbers"][0]["value"] == "+1234567890"
|
||||
|
||||
def test_build_body_with_organization(self):
|
||||
"""Test building a person body with organization."""
|
||||
body = _build_person_body(
|
||||
given_name="Jane",
|
||||
organization="Acme Corp",
|
||||
job_title="Engineer",
|
||||
)
|
||||
|
||||
assert body["names"][0]["givenName"] == "Jane"
|
||||
assert body["organizations"][0]["name"] == "Acme Corp"
|
||||
assert body["organizations"][0]["title"] == "Engineer"
|
||||
|
||||
def test_build_body_organization_only(self):
|
||||
"""Test building a person body with only organization name."""
|
||||
body = _build_person_body(organization="Acme Corp")
|
||||
|
||||
assert body["organizations"][0]["name"] == "Acme Corp"
|
||||
assert "title" not in body["organizations"][0]
|
||||
|
||||
def test_build_body_job_title_only(self):
|
||||
"""Test building a person body with only job title."""
|
||||
body = _build_person_body(job_title="CEO")
|
||||
|
||||
assert body["organizations"][0]["title"] == "CEO"
|
||||
assert "name" not in body["organizations"][0]
|
||||
|
||||
def test_build_body_with_notes(self):
|
||||
"""Test building a person body with notes."""
|
||||
body = _build_person_body(notes="Important contact")
|
||||
|
||||
assert body["biographies"][0]["value"] == "Important contact"
|
||||
assert body["biographies"][0]["contentType"] == "TEXT_PLAIN"
|
||||
|
||||
def test_build_body_with_address(self):
|
||||
"""Test building a person body with address."""
|
||||
body = _build_person_body(address="123 Main St, City, State 12345")
|
||||
|
||||
assert (
|
||||
body["addresses"][0]["formattedValue"] == "123 Main St, City, State 12345"
|
||||
)
|
||||
|
||||
def test_build_empty_body(self):
|
||||
"""Test building an empty person body."""
|
||||
body = _build_person_body()
|
||||
|
||||
assert body == {}
|
||||
|
||||
def test_build_body_given_name_only(self):
|
||||
"""Test building a person body with only given name."""
|
||||
body = _build_person_body(given_name="John")
|
||||
|
||||
assert body["names"][0]["givenName"] == "John"
|
||||
assert body["names"][0]["familyName"] == ""
|
||||
|
||||
def test_build_body_family_name_only(self):
|
||||
"""Test building a person body with only family name."""
|
||||
body = _build_person_body(family_name="Doe")
|
||||
|
||||
assert body["names"][0]["givenName"] == ""
|
||||
assert body["names"][0]["familyName"] == "Doe"
|
||||
|
||||
def test_build_full_body(self):
|
||||
"""Test building a person body with all fields."""
|
||||
body = _build_person_body(
|
||||
given_name="John",
|
||||
family_name="Doe",
|
||||
email="john@example.com",
|
||||
phone="+1234567890",
|
||||
organization="Acme Corp",
|
||||
job_title="Engineer",
|
||||
notes="VIP contact",
|
||||
address="123 Main St",
|
||||
)
|
||||
|
||||
assert body["names"][0]["givenName"] == "John"
|
||||
assert body["names"][0]["familyName"] == "Doe"
|
||||
assert body["emailAddresses"][0]["value"] == "john@example.com"
|
||||
assert body["phoneNumbers"][0]["value"] == "+1234567890"
|
||||
assert body["organizations"][0]["name"] == "Acme Corp"
|
||||
assert body["organizations"][0]["title"] == "Engineer"
|
||||
assert body["biographies"][0]["value"] == "VIP contact"
|
||||
assert body["addresses"][0]["formattedValue"] == "123 Main St"
|
||||
|
||||
|
||||
class TestImports:
|
||||
"""Tests to verify module imports work correctly."""
|
||||
|
||||
def test_import_contacts_tools(self):
|
||||
"""Test that contacts_tools module can be imported."""
|
||||
from gcontacts import contacts_tools
|
||||
|
||||
assert hasattr(contacts_tools, "list_contacts")
|
||||
assert hasattr(contacts_tools, "get_contact")
|
||||
assert hasattr(contacts_tools, "search_contacts")
|
||||
assert hasattr(contacts_tools, "manage_contact")
|
||||
|
||||
def test_import_group_tools(self):
|
||||
"""Test that group tools can be imported."""
|
||||
from gcontacts import contacts_tools
|
||||
|
||||
assert hasattr(contacts_tools, "list_contact_groups")
|
||||
assert hasattr(contacts_tools, "get_contact_group")
|
||||
assert hasattr(contacts_tools, "manage_contact_group")
|
||||
|
||||
def test_import_batch_tools(self):
|
||||
"""Test that batch tools can be imported."""
|
||||
from gcontacts import contacts_tools
|
||||
|
||||
assert hasattr(contacts_tools, "manage_contacts_batch")
|
||||
|
||||
|
||||
class TestConstants:
|
||||
"""Tests for module constants."""
|
||||
|
||||
def test_default_person_fields(self):
|
||||
"""Test default person fields constant."""
|
||||
from gcontacts.contacts_tools import DEFAULT_PERSON_FIELDS
|
||||
|
||||
assert "names" in DEFAULT_PERSON_FIELDS
|
||||
assert "emailAddresses" in DEFAULT_PERSON_FIELDS
|
||||
assert "phoneNumbers" in DEFAULT_PERSON_FIELDS
|
||||
assert "organizations" in DEFAULT_PERSON_FIELDS
|
||||
|
||||
def test_detailed_person_fields(self):
|
||||
"""Test detailed person fields constant."""
|
||||
from gcontacts.contacts_tools import DETAILED_PERSON_FIELDS
|
||||
|
||||
assert "names" in DETAILED_PERSON_FIELDS
|
||||
assert "emailAddresses" in DETAILED_PERSON_FIELDS
|
||||
assert "addresses" in DETAILED_PERSON_FIELDS
|
||||
assert "birthdays" in DETAILED_PERSON_FIELDS
|
||||
assert "biographies" in DETAILED_PERSON_FIELDS
|
||||
|
||||
def test_contact_group_fields(self):
|
||||
"""Test contact group fields constant."""
|
||||
from gcontacts.contacts_tools import CONTACT_GROUP_FIELDS
|
||||
|
||||
assert "name" in CONTACT_GROUP_FIELDS
|
||||
assert "groupType" in CONTACT_GROUP_FIELDS
|
||||
assert "memberCount" in CONTACT_GROUP_FIELDS
|
||||
0
tests/gdocs/__init__.py
Normal file
0
tests/gdocs/__init__.py
Normal file
455
tests/gdocs/test_docs_markdown.py
Normal file
455
tests/gdocs/test_docs_markdown.py
Normal file
@@ -0,0 +1,455 @@
|
||||
"""Tests for the Google Docs to Markdown converter."""
|
||||
|
||||
import sys
|
||||
import os
|
||||
|
||||
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../..")))
|
||||
|
||||
from gdocs.docs_markdown import (
|
||||
convert_doc_to_markdown,
|
||||
format_comments_appendix,
|
||||
format_comments_inline,
|
||||
parse_drive_comments,
|
||||
)
|
||||
|
||||
|
||||
# --- Fixtures ---
|
||||
|
||||
SIMPLE_DOC = {
|
||||
"title": "Simple Test",
|
||||
"body": {
|
||||
"content": [
|
||||
{"sectionBreak": {"sectionStyle": {}}},
|
||||
{
|
||||
"paragraph": {
|
||||
"elements": [
|
||||
{"textRun": {"content": "Hello world\n", "textStyle": {}}}
|
||||
],
|
||||
"paragraphStyle": {"namedStyleType": "NORMAL_TEXT"},
|
||||
}
|
||||
},
|
||||
{
|
||||
"paragraph": {
|
||||
"elements": [
|
||||
{"textRun": {"content": "This is ", "textStyle": {}}},
|
||||
{"textRun": {"content": "bold", "textStyle": {"bold": True}}},
|
||||
{"textRun": {"content": " and ", "textStyle": {}}},
|
||||
{
|
||||
"textRun": {
|
||||
"content": "italic",
|
||||
"textStyle": {"italic": True},
|
||||
}
|
||||
},
|
||||
{"textRun": {"content": " text.\n", "textStyle": {}}},
|
||||
],
|
||||
"paragraphStyle": {"namedStyleType": "NORMAL_TEXT"},
|
||||
}
|
||||
},
|
||||
]
|
||||
},
|
||||
}
|
||||
|
||||
HEADINGS_DOC = {
|
||||
"title": "Headings",
|
||||
"body": {
|
||||
"content": [
|
||||
{"sectionBreak": {"sectionStyle": {}}},
|
||||
{
|
||||
"paragraph": {
|
||||
"elements": [{"textRun": {"content": "Title\n", "textStyle": {}}}],
|
||||
"paragraphStyle": {"namedStyleType": "TITLE"},
|
||||
}
|
||||
},
|
||||
{
|
||||
"paragraph": {
|
||||
"elements": [
|
||||
{"textRun": {"content": "Heading one\n", "textStyle": {}}}
|
||||
],
|
||||
"paragraphStyle": {"namedStyleType": "HEADING_1"},
|
||||
}
|
||||
},
|
||||
{
|
||||
"paragraph": {
|
||||
"elements": [
|
||||
{"textRun": {"content": "Heading two\n", "textStyle": {}}}
|
||||
],
|
||||
"paragraphStyle": {"namedStyleType": "HEADING_2"},
|
||||
}
|
||||
},
|
||||
]
|
||||
},
|
||||
}
|
||||
|
||||
TABLE_DOC = {
|
||||
"title": "Table Test",
|
||||
"body": {
|
||||
"content": [
|
||||
{"sectionBreak": {"sectionStyle": {}}},
|
||||
{
|
||||
"table": {
|
||||
"rows": 2,
|
||||
"columns": 2,
|
||||
"tableRows": [
|
||||
{
|
||||
"tableCells": [
|
||||
{
|
||||
"content": [
|
||||
{
|
||||
"paragraph": {
|
||||
"elements": [
|
||||
{
|
||||
"textRun": {
|
||||
"content": "Name\n",
|
||||
"textStyle": {},
|
||||
}
|
||||
}
|
||||
],
|
||||
"paragraphStyle": {
|
||||
"namedStyleType": "NORMAL_TEXT"
|
||||
},
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"content": [
|
||||
{
|
||||
"paragraph": {
|
||||
"elements": [
|
||||
{
|
||||
"textRun": {
|
||||
"content": "Age\n",
|
||||
"textStyle": {},
|
||||
}
|
||||
}
|
||||
],
|
||||
"paragraphStyle": {
|
||||
"namedStyleType": "NORMAL_TEXT"
|
||||
},
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableCells": [
|
||||
{
|
||||
"content": [
|
||||
{
|
||||
"paragraph": {
|
||||
"elements": [
|
||||
{
|
||||
"textRun": {
|
||||
"content": "Alice\n",
|
||||
"textStyle": {},
|
||||
}
|
||||
}
|
||||
],
|
||||
"paragraphStyle": {
|
||||
"namedStyleType": "NORMAL_TEXT"
|
||||
},
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"content": [
|
||||
{
|
||||
"paragraph": {
|
||||
"elements": [
|
||||
{
|
||||
"textRun": {
|
||||
"content": "30\n",
|
||||
"textStyle": {},
|
||||
}
|
||||
}
|
||||
],
|
||||
"paragraphStyle": {
|
||||
"namedStyleType": "NORMAL_TEXT"
|
||||
},
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
]
|
||||
},
|
||||
],
|
||||
}
|
||||
},
|
||||
]
|
||||
},
|
||||
}
|
||||
|
||||
LIST_DOC = {
|
||||
"title": "List Test",
|
||||
"lists": {
|
||||
"kix.list001": {
|
||||
"listProperties": {
|
||||
"nestingLevels": [
|
||||
{"glyphType": "GLYPH_TYPE_UNSPECIFIED", "glyphSymbol": "\u2022"},
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"body": {
|
||||
"content": [
|
||||
{"sectionBreak": {"sectionStyle": {}}},
|
||||
{
|
||||
"paragraph": {
|
||||
"elements": [
|
||||
{"textRun": {"content": "Item one\n", "textStyle": {}}}
|
||||
],
|
||||
"paragraphStyle": {"namedStyleType": "NORMAL_TEXT"},
|
||||
"bullet": {"listId": "kix.list001", "nestingLevel": 0},
|
||||
}
|
||||
},
|
||||
{
|
||||
"paragraph": {
|
||||
"elements": [
|
||||
{"textRun": {"content": "Item two\n", "textStyle": {}}}
|
||||
],
|
||||
"paragraphStyle": {"namedStyleType": "NORMAL_TEXT"},
|
||||
"bullet": {"listId": "kix.list001", "nestingLevel": 0},
|
||||
}
|
||||
},
|
||||
]
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# --- Converter tests ---
|
||||
|
||||
|
||||
class TestTextFormatting:
|
||||
def test_plain_text(self):
|
||||
md = convert_doc_to_markdown(SIMPLE_DOC)
|
||||
assert "Hello world" in md
|
||||
|
||||
def test_bold(self):
|
||||
md = convert_doc_to_markdown(SIMPLE_DOC)
|
||||
assert "**bold**" in md
|
||||
|
||||
def test_italic(self):
|
||||
md = convert_doc_to_markdown(SIMPLE_DOC)
|
||||
assert "*italic*" in md
|
||||
|
||||
|
||||
class TestHeadings:
|
||||
def test_title(self):
|
||||
md = convert_doc_to_markdown(HEADINGS_DOC)
|
||||
assert "# Title" in md
|
||||
|
||||
def test_h1(self):
|
||||
md = convert_doc_to_markdown(HEADINGS_DOC)
|
||||
assert "# Heading one" in md
|
||||
|
||||
def test_h2(self):
|
||||
md = convert_doc_to_markdown(HEADINGS_DOC)
|
||||
assert "## Heading two" in md
|
||||
|
||||
|
||||
class TestTables:
|
||||
def test_table_header(self):
|
||||
md = convert_doc_to_markdown(TABLE_DOC)
|
||||
assert "| Name | Age |" in md
|
||||
|
||||
def test_table_separator(self):
|
||||
md = convert_doc_to_markdown(TABLE_DOC)
|
||||
assert "| --- | --- |" in md
|
||||
|
||||
def test_table_row(self):
|
||||
md = convert_doc_to_markdown(TABLE_DOC)
|
||||
assert "| Alice | 30 |" in md
|
||||
|
||||
|
||||
class TestLists:
|
||||
def test_unordered(self):
|
||||
md = convert_doc_to_markdown(LIST_DOC)
|
||||
assert "- Item one" in md
|
||||
assert "- Item two" in md
|
||||
|
||||
|
||||
CHECKLIST_DOC = {
|
||||
"title": "Checklist Test",
|
||||
"lists": {
|
||||
"kix.checklist001": {
|
||||
"listProperties": {
|
||||
"nestingLevels": [
|
||||
{"glyphType": "GLYPH_TYPE_UNSPECIFIED"},
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"body": {
|
||||
"content": [
|
||||
{"sectionBreak": {"sectionStyle": {}}},
|
||||
{
|
||||
"paragraph": {
|
||||
"elements": [
|
||||
{"textRun": {"content": "Buy groceries\n", "textStyle": {}}}
|
||||
],
|
||||
"paragraphStyle": {"namedStyleType": "NORMAL_TEXT"},
|
||||
"bullet": {"listId": "kix.checklist001", "nestingLevel": 0},
|
||||
}
|
||||
},
|
||||
{
|
||||
"paragraph": {
|
||||
"elements": [
|
||||
{
|
||||
"textRun": {
|
||||
"content": "Walk the dog\n",
|
||||
"textStyle": {"strikethrough": True},
|
||||
}
|
||||
}
|
||||
],
|
||||
"paragraphStyle": {"namedStyleType": "NORMAL_TEXT"},
|
||||
"bullet": {"listId": "kix.checklist001", "nestingLevel": 0},
|
||||
}
|
||||
},
|
||||
]
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class TestChecklists:
|
||||
def test_unchecked(self):
|
||||
md = convert_doc_to_markdown(CHECKLIST_DOC)
|
||||
assert "- [ ] Buy groceries" in md
|
||||
|
||||
def test_checked(self):
|
||||
md = convert_doc_to_markdown(CHECKLIST_DOC)
|
||||
assert "- [x] Walk the dog" in md
|
||||
|
||||
def test_checked_no_strikethrough(self):
|
||||
"""Checked items should not have redundant ~~strikethrough~~ markdown."""
|
||||
md = convert_doc_to_markdown(CHECKLIST_DOC)
|
||||
assert "~~Walk the dog~~" not in md
|
||||
|
||||
def test_regular_bullet_not_checklist(self):
|
||||
"""Bullet lists with glyphSymbol should remain as plain bullets."""
|
||||
md = convert_doc_to_markdown(LIST_DOC)
|
||||
assert "[ ]" not in md
|
||||
assert "[x]" not in md
|
||||
|
||||
|
||||
class TestEmptyDoc:
|
||||
def test_empty(self):
|
||||
md = convert_doc_to_markdown({"title": "Empty", "body": {"content": []}})
|
||||
assert md.strip() == ""
|
||||
|
||||
|
||||
# --- Comment parsing tests ---
|
||||
|
||||
|
||||
class TestParseComments:
|
||||
def test_filters_resolved(self):
|
||||
response = {
|
||||
"comments": [
|
||||
{
|
||||
"content": "open",
|
||||
"resolved": False,
|
||||
"author": {"displayName": "A"},
|
||||
"replies": [],
|
||||
},
|
||||
{
|
||||
"content": "closed",
|
||||
"resolved": True,
|
||||
"author": {"displayName": "B"},
|
||||
"replies": [],
|
||||
},
|
||||
]
|
||||
}
|
||||
result = parse_drive_comments(response, include_resolved=False)
|
||||
assert len(result) == 1
|
||||
assert result[0]["content"] == "open"
|
||||
|
||||
def test_includes_resolved(self):
|
||||
response = {
|
||||
"comments": [
|
||||
{
|
||||
"content": "open",
|
||||
"resolved": False,
|
||||
"author": {"displayName": "A"},
|
||||
"replies": [],
|
||||
},
|
||||
{
|
||||
"content": "closed",
|
||||
"resolved": True,
|
||||
"author": {"displayName": "B"},
|
||||
"replies": [],
|
||||
},
|
||||
]
|
||||
}
|
||||
result = parse_drive_comments(response, include_resolved=True)
|
||||
assert len(result) == 2
|
||||
|
||||
def test_anchor_text(self):
|
||||
response = {
|
||||
"comments": [
|
||||
{
|
||||
"content": "note",
|
||||
"resolved": False,
|
||||
"author": {"displayName": "A"},
|
||||
"quotedFileContent": {"value": "highlighted text"},
|
||||
"replies": [],
|
||||
}
|
||||
]
|
||||
}
|
||||
result = parse_drive_comments(response)
|
||||
assert result[0]["anchor_text"] == "highlighted text"
|
||||
|
||||
|
||||
# --- Comment formatting tests ---
|
||||
|
||||
|
||||
class TestInlineComments:
|
||||
def test_inserts_footnote(self):
|
||||
md = "Some text here."
|
||||
comments = [
|
||||
{
|
||||
"author": "Alice",
|
||||
"content": "Note.",
|
||||
"anchor_text": "text",
|
||||
"replies": [],
|
||||
"resolved": False,
|
||||
}
|
||||
]
|
||||
result = format_comments_inline(md, comments)
|
||||
assert "text[^c1]" in result
|
||||
assert "[^c1]: **Alice**: Note." in result
|
||||
|
||||
def test_unmatched_goes_to_appendix(self):
|
||||
md = "No match."
|
||||
comments = [
|
||||
{
|
||||
"author": "Alice",
|
||||
"content": "Note.",
|
||||
"anchor_text": "missing",
|
||||
"replies": [],
|
||||
"resolved": False,
|
||||
}
|
||||
]
|
||||
result = format_comments_inline(md, comments)
|
||||
assert "## Comments" in result
|
||||
assert "> missing" in result
|
||||
|
||||
|
||||
class TestAppendixComments:
|
||||
def test_structure(self):
|
||||
comments = [
|
||||
{
|
||||
"author": "Alice",
|
||||
"content": "Note.",
|
||||
"anchor_text": "some text",
|
||||
"replies": [],
|
||||
"resolved": False,
|
||||
}
|
||||
]
|
||||
result = format_comments_appendix(comments)
|
||||
assert "## Comments" in result
|
||||
assert "> some text" in result
|
||||
assert "**Alice**: Note." in result
|
||||
|
||||
def test_empty(self):
|
||||
assert format_comments_appendix([]).strip() == ""
|
||||
139
tests/gdocs/test_paragraph_style.py
Normal file
139
tests/gdocs/test_paragraph_style.py
Normal file
@@ -0,0 +1,139 @@
|
||||
"""
|
||||
Tests for update_paragraph_style batch operation support.
|
||||
|
||||
Covers the helpers, validation, and batch manager integration.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
|
||||
from gdocs.docs_helpers import (
|
||||
build_paragraph_style,
|
||||
create_update_paragraph_style_request,
|
||||
)
|
||||
from gdocs.managers.validation_manager import ValidationManager
|
||||
|
||||
|
||||
class TestBuildParagraphStyle:
|
||||
def test_no_params_returns_empty(self):
|
||||
style, fields = build_paragraph_style()
|
||||
assert style == {}
|
||||
assert fields == []
|
||||
|
||||
def test_heading_zero_maps_to_normal_text(self):
|
||||
style, fields = build_paragraph_style(heading_level=0)
|
||||
assert style["namedStyleType"] == "NORMAL_TEXT"
|
||||
|
||||
def test_heading_maps_to_named_style(self):
|
||||
style, _ = build_paragraph_style(heading_level=3)
|
||||
assert style["namedStyleType"] == "HEADING_3"
|
||||
|
||||
def test_heading_out_of_range_raises(self):
|
||||
with pytest.raises(ValueError):
|
||||
build_paragraph_style(heading_level=7)
|
||||
|
||||
def test_line_spacing_scaled_to_percentage(self):
|
||||
style, _ = build_paragraph_style(line_spacing=1.5)
|
||||
assert style["lineSpacing"] == 150.0
|
||||
|
||||
def test_dimension_field_uses_pt_unit(self):
|
||||
style, _ = build_paragraph_style(indent_start=36.0)
|
||||
assert style["indentStart"] == {"magnitude": 36.0, "unit": "PT"}
|
||||
|
||||
def test_multiple_params_combined(self):
|
||||
style, fields = build_paragraph_style(
|
||||
heading_level=2, alignment="CENTER", space_below=12.0
|
||||
)
|
||||
assert len(fields) == 3
|
||||
assert style["alignment"] == "CENTER"
|
||||
|
||||
|
||||
class TestCreateUpdateParagraphStyleRequest:
|
||||
def test_returns_none_when_no_styles(self):
|
||||
assert create_update_paragraph_style_request(1, 10) is None
|
||||
|
||||
def test_produces_correct_api_structure(self):
|
||||
result = create_update_paragraph_style_request(1, 10, heading_level=1)
|
||||
inner = result["updateParagraphStyle"]
|
||||
assert inner["range"] == {"startIndex": 1, "endIndex": 10}
|
||||
assert inner["paragraphStyle"]["namedStyleType"] == "HEADING_1"
|
||||
assert inner["fields"] == "namedStyleType"
|
||||
|
||||
|
||||
class TestValidateParagraphStyleParams:
|
||||
@pytest.fixture()
|
||||
def vm(self):
|
||||
return ValidationManager()
|
||||
|
||||
def test_all_none_rejected(self, vm):
|
||||
is_valid, _ = vm.validate_paragraph_style_params()
|
||||
assert not is_valid
|
||||
|
||||
def test_wrong_types_rejected(self, vm):
|
||||
assert not vm.validate_paragraph_style_params(heading_level=1.5)[0]
|
||||
assert not vm.validate_paragraph_style_params(alignment=123)[0]
|
||||
assert not vm.validate_paragraph_style_params(line_spacing="double")[0]
|
||||
|
||||
def test_negative_indent_start_rejected(self, vm):
|
||||
is_valid, msg = vm.validate_paragraph_style_params(indent_start=-5.0)
|
||||
assert not is_valid
|
||||
assert "non-negative" in msg
|
||||
|
||||
def test_negative_indent_first_line_allowed(self, vm):
|
||||
"""Hanging indent requires negative first-line indent."""
|
||||
assert vm.validate_paragraph_style_params(indent_first_line=-18.0)[0]
|
||||
|
||||
def test_batch_validation_wired_up(self, vm):
|
||||
valid_ops = [
|
||||
{
|
||||
"type": "update_paragraph_style",
|
||||
"start_index": 1,
|
||||
"end_index": 20,
|
||||
"heading_level": 2,
|
||||
},
|
||||
]
|
||||
assert vm.validate_batch_operations(valid_ops)[0]
|
||||
|
||||
no_style_ops = [
|
||||
{"type": "update_paragraph_style", "start_index": 1, "end_index": 20},
|
||||
]
|
||||
assert not vm.validate_batch_operations(no_style_ops)[0]
|
||||
|
||||
|
||||
class TestBatchManagerIntegration:
|
||||
@pytest.fixture()
|
||||
def manager(self):
|
||||
from gdocs.managers.batch_operation_manager import BatchOperationManager
|
||||
|
||||
return BatchOperationManager(Mock())
|
||||
|
||||
def test_build_request_and_description(self, manager):
|
||||
op = {
|
||||
"type": "update_paragraph_style",
|
||||
"start_index": 1,
|
||||
"end_index": 50,
|
||||
"heading_level": 2,
|
||||
"alignment": "CENTER",
|
||||
"line_spacing": 1.5,
|
||||
}
|
||||
request, desc = manager._build_operation_request(op, "update_paragraph_style")
|
||||
assert "updateParagraphStyle" in request
|
||||
assert "heading: H2" in desc
|
||||
assert "1.5x" in desc
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_end_to_end_execute(self, manager):
|
||||
manager._execute_batch_requests = AsyncMock(return_value={"replies": [{}]})
|
||||
success, message, meta = await manager.execute_batch_operations(
|
||||
"doc-123",
|
||||
[
|
||||
{
|
||||
"type": "update_paragraph_style",
|
||||
"start_index": 1,
|
||||
"end_index": 20,
|
||||
"heading_level": 1,
|
||||
}
|
||||
],
|
||||
)
|
||||
assert success
|
||||
assert meta["operations_count"] == 1
|
||||
1
tests/gdrive/__init__.py
Normal file
1
tests/gdrive/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
147
tests/gdrive/test_create_drive_folder.py
Normal file
147
tests/gdrive/test_create_drive_folder.py
Normal file
@@ -0,0 +1,147 @@
|
||||
"""
|
||||
Unit tests for create_drive_folder tool.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../..")))
|
||||
|
||||
from gdrive.drive_tools import _create_drive_folder_impl as _raw_create_drive_folder
|
||||
|
||||
|
||||
def _make_service(created_response):
|
||||
"""Build a mock Drive service whose files().create().execute returns *created_response*."""
|
||||
execute = MagicMock(return_value=created_response)
|
||||
create = MagicMock()
|
||||
create.return_value.execute = execute
|
||||
files = MagicMock()
|
||||
files.return_value.create = create
|
||||
service = MagicMock()
|
||||
service.files = files
|
||||
return service
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_folder_root_skips_resolve():
|
||||
"""Parent 'root' should pass through resolve_folder_id and produce correct output."""
|
||||
api_response = {
|
||||
"id": "new-folder-id",
|
||||
"name": "My Folder",
|
||||
"webViewLink": "https://drive.google.com/drive/folders/new-folder-id",
|
||||
}
|
||||
service = _make_service(api_response)
|
||||
|
||||
with patch(
|
||||
"gdrive.drive_tools.resolve_folder_id",
|
||||
new_callable=AsyncMock,
|
||||
return_value="root",
|
||||
):
|
||||
result = await _raw_create_drive_folder(
|
||||
service,
|
||||
user_google_email="user@example.com",
|
||||
folder_name="My Folder",
|
||||
parent_folder_id="root",
|
||||
)
|
||||
|
||||
assert "new-folder-id" in result
|
||||
assert "My Folder" in result
|
||||
assert "https://drive.google.com/drive/folders/new-folder-id" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_folder_custom_parent_resolves():
|
||||
"""A non-root parent_folder_id should go through resolve_folder_id."""
|
||||
api_response = {
|
||||
"id": "new-folder-id",
|
||||
"name": "Sub Folder",
|
||||
"webViewLink": "https://drive.google.com/drive/folders/new-folder-id",
|
||||
}
|
||||
service = _make_service(api_response)
|
||||
|
||||
with patch(
|
||||
"gdrive.drive_tools.resolve_folder_id",
|
||||
new_callable=AsyncMock,
|
||||
return_value="resolved-parent-id",
|
||||
) as mock_resolve:
|
||||
result = await _raw_create_drive_folder(
|
||||
service,
|
||||
user_google_email="user@example.com",
|
||||
folder_name="Sub Folder",
|
||||
parent_folder_id="shortcut-id",
|
||||
)
|
||||
|
||||
mock_resolve.assert_awaited_once_with(service, "shortcut-id")
|
||||
# The output message uses the original parent_folder_id, not the resolved one
|
||||
assert "shortcut-id" in result
|
||||
# But the API call should use the resolved ID
|
||||
service.files().create.assert_called_once_with(
|
||||
body={
|
||||
"name": "Sub Folder",
|
||||
"mimeType": "application/vnd.google-apps.folder",
|
||||
"parents": ["resolved-parent-id"],
|
||||
},
|
||||
fields="id, name, webViewLink",
|
||||
supportsAllDrives=True,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_folder_passes_correct_metadata():
|
||||
"""Verify the metadata dict sent to the Drive API is correct."""
|
||||
api_response = {
|
||||
"id": "abc123",
|
||||
"name": "Test",
|
||||
"webViewLink": "https://drive.google.com/drive/folders/abc123",
|
||||
}
|
||||
service = _make_service(api_response)
|
||||
|
||||
with patch(
|
||||
"gdrive.drive_tools.resolve_folder_id",
|
||||
new_callable=AsyncMock,
|
||||
return_value="resolved-id",
|
||||
):
|
||||
await _raw_create_drive_folder(
|
||||
service,
|
||||
user_google_email="user@example.com",
|
||||
folder_name="Test",
|
||||
parent_folder_id="some-parent",
|
||||
)
|
||||
|
||||
service.files().create.assert_called_once_with(
|
||||
body={
|
||||
"name": "Test",
|
||||
"mimeType": "application/vnd.google-apps.folder",
|
||||
"parents": ["resolved-id"],
|
||||
},
|
||||
fields="id, name, webViewLink",
|
||||
supportsAllDrives=True,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_folder_missing_webviewlink():
|
||||
"""When the API omits webViewLink, the result should have an empty link."""
|
||||
api_response = {
|
||||
"id": "abc123",
|
||||
"name": "NoLink",
|
||||
}
|
||||
service = _make_service(api_response)
|
||||
|
||||
with patch(
|
||||
"gdrive.drive_tools.resolve_folder_id",
|
||||
new_callable=AsyncMock,
|
||||
return_value="root",
|
||||
):
|
||||
result = await _raw_create_drive_folder(
|
||||
service,
|
||||
user_google_email="user@example.com",
|
||||
folder_name="NoLink",
|
||||
parent_folder_id="root",
|
||||
)
|
||||
|
||||
assert "abc123" in result
|
||||
assert "NoLink" in result
|
||||
970
tests/gdrive/test_drive_tools.py
Normal file
970
tests/gdrive/test_drive_tools.py
Normal file
@@ -0,0 +1,970 @@
|
||||
"""
|
||||
Unit tests for Google Drive MCP tools.
|
||||
|
||||
Tests create_drive_folder with mocked API responses, plus coverage for
|
||||
`search_drive_files` and `list_drive_items` pagination, `detailed` output,
|
||||
and `file_type` filtering behaviors.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import Mock, AsyncMock, patch
|
||||
import sys
|
||||
import os
|
||||
|
||||
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../..")))
|
||||
|
||||
from gdrive.drive_helpers import build_drive_list_params
|
||||
from gdrive.drive_tools import list_drive_items, search_drive_files
|
||||
|
||||
|
||||
def _unwrap(tool):
|
||||
"""Unwrap a FunctionTool + decorator chain to the original async function.
|
||||
|
||||
Handles both older FastMCP (FunctionTool with .fn) and newer FastMCP
|
||||
(server.tool() returns the function directly).
|
||||
"""
|
||||
fn = tool.fn if hasattr(tool, "fn") else tool
|
||||
while hasattr(fn, "__wrapped__"):
|
||||
fn = fn.__wrapped__
|
||||
return fn
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# search_drive_files — page_token
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_drive_files_page_token_passed_to_api():
|
||||
"""page_token is forwarded to the Drive API as pageToken."""
|
||||
mock_service = Mock()
|
||||
mock_service.files().list().execute.return_value = {
|
||||
"files": [
|
||||
{
|
||||
"id": "f1",
|
||||
"name": "Report.pdf",
|
||||
"mimeType": "application/pdf",
|
||||
"webViewLink": "https://drive.google.com/file/f1",
|
||||
"modifiedTime": "2024-01-01T00:00:00Z",
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
await _unwrap(search_drive_files)(
|
||||
service=mock_service,
|
||||
user_google_email="user@example.com",
|
||||
query="budget",
|
||||
page_token="tok_abc123",
|
||||
)
|
||||
|
||||
call_kwargs = mock_service.files.return_value.list.call_args.kwargs
|
||||
assert call_kwargs.get("pageToken") == "tok_abc123"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_drive_files_next_page_token_in_output():
|
||||
"""nextPageToken from the API response is appended at the end of the output."""
|
||||
mock_service = Mock()
|
||||
mock_service.files().list().execute.return_value = {
|
||||
"files": [
|
||||
{
|
||||
"id": "f2",
|
||||
"name": "Notes.docx",
|
||||
"mimeType": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
"webViewLink": "https://drive.google.com/file/f2",
|
||||
"modifiedTime": "2024-02-01T00:00:00Z",
|
||||
}
|
||||
],
|
||||
"nextPageToken": "next_tok_xyz",
|
||||
}
|
||||
|
||||
result = await _unwrap(search_drive_files)(
|
||||
service=mock_service,
|
||||
user_google_email="user@example.com",
|
||||
query="notes",
|
||||
)
|
||||
|
||||
assert result.endswith("nextPageToken: next_tok_xyz")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_drive_files_no_next_page_token_when_absent():
|
||||
"""nextPageToken does not appear in output when the API has no more pages."""
|
||||
mock_service = Mock()
|
||||
mock_service.files().list().execute.return_value = {
|
||||
"files": [
|
||||
{
|
||||
"id": "f3",
|
||||
"name": "Summary.txt",
|
||||
"mimeType": "text/plain",
|
||||
"webViewLink": "https://drive.google.com/file/f3",
|
||||
"modifiedTime": "2024-03-01T00:00:00Z",
|
||||
}
|
||||
]
|
||||
# no nextPageToken key
|
||||
}
|
||||
|
||||
result = await _unwrap(search_drive_files)(
|
||||
service=mock_service,
|
||||
user_google_email="user@example.com",
|
||||
query="summary",
|
||||
)
|
||||
|
||||
assert "nextPageToken" not in result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# list_drive_items — page_token
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("gdrive.drive_tools.resolve_folder_id", new_callable=AsyncMock)
|
||||
async def test_list_drive_items_page_token_passed_to_api(mock_resolve_folder):
|
||||
"""page_token is forwarded to the Drive API as pageToken."""
|
||||
mock_resolve_folder.return_value = "root"
|
||||
mock_service = Mock()
|
||||
mock_service.files().list().execute.return_value = {
|
||||
"files": [
|
||||
{
|
||||
"id": "folder1",
|
||||
"name": "Archive",
|
||||
"mimeType": "application/vnd.google-apps.folder",
|
||||
"webViewLink": "https://drive.google.com/drive/folders/folder1",
|
||||
"modifiedTime": "2024-01-15T00:00:00Z",
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
await _unwrap(list_drive_items)(
|
||||
service=mock_service,
|
||||
user_google_email="user@example.com",
|
||||
page_token="tok_page2",
|
||||
)
|
||||
|
||||
call_kwargs = mock_service.files.return_value.list.call_args.kwargs
|
||||
assert call_kwargs.get("pageToken") == "tok_page2"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("gdrive.drive_tools.resolve_folder_id", new_callable=AsyncMock)
|
||||
async def test_list_drive_items_next_page_token_in_output(mock_resolve_folder):
|
||||
"""nextPageToken from the API response is appended at the end of the output."""
|
||||
mock_resolve_folder.return_value = "root"
|
||||
mock_service = Mock()
|
||||
mock_service.files().list().execute.return_value = {
|
||||
"files": [
|
||||
{
|
||||
"id": "file99",
|
||||
"name": "data.csv",
|
||||
"mimeType": "text/csv",
|
||||
"webViewLink": "https://drive.google.com/file/file99",
|
||||
"modifiedTime": "2024-04-01T00:00:00Z",
|
||||
}
|
||||
],
|
||||
"nextPageToken": "next_list_tok",
|
||||
}
|
||||
|
||||
result = await _unwrap(list_drive_items)(
|
||||
service=mock_service,
|
||||
user_google_email="user@example.com",
|
||||
)
|
||||
|
||||
assert result.endswith("nextPageToken: next_list_tok")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("gdrive.drive_tools.resolve_folder_id", new_callable=AsyncMock)
|
||||
async def test_list_drive_items_no_next_page_token_when_absent(mock_resolve_folder):
|
||||
"""nextPageToken does not appear in output when the API has no more pages."""
|
||||
mock_resolve_folder.return_value = "root"
|
||||
mock_service = Mock()
|
||||
mock_service.files().list().execute.return_value = {
|
||||
"files": [
|
||||
{
|
||||
"id": "file100",
|
||||
"name": "readme.txt",
|
||||
"mimeType": "text/plain",
|
||||
"webViewLink": "https://drive.google.com/file/file100",
|
||||
"modifiedTime": "2024-05-01T00:00:00Z",
|
||||
}
|
||||
]
|
||||
# no nextPageToken key
|
||||
}
|
||||
|
||||
result = await _unwrap(list_drive_items)(
|
||||
service=mock_service,
|
||||
user_google_email="user@example.com",
|
||||
)
|
||||
|
||||
assert "nextPageToken" not in result
|
||||
|
||||
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_file(
|
||||
file_id: str,
|
||||
name: str,
|
||||
mime_type: str,
|
||||
link: str = "http://link",
|
||||
modified: str = "2024-01-01T00:00:00Z",
|
||||
size: str | None = None,
|
||||
) -> dict:
|
||||
item = {
|
||||
"id": file_id,
|
||||
"name": name,
|
||||
"mimeType": mime_type,
|
||||
"webViewLink": link,
|
||||
"modifiedTime": modified,
|
||||
}
|
||||
if size is not None:
|
||||
item["size"] = size
|
||||
return item
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# create_drive_folder
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_drive_folder():
|
||||
"""Test create_drive_folder returns success message with folder id, name, and link."""
|
||||
from gdrive.drive_tools import _create_drive_folder_impl
|
||||
|
||||
mock_service = Mock()
|
||||
mock_response = {
|
||||
"id": "folder123",
|
||||
"name": "My Folder",
|
||||
"webViewLink": "https://drive.google.com/drive/folders/folder123",
|
||||
}
|
||||
mock_request = Mock()
|
||||
mock_request.execute.return_value = mock_response
|
||||
mock_service.files.return_value.create.return_value = mock_request
|
||||
|
||||
with patch(
|
||||
"gdrive.drive_tools.resolve_folder_id",
|
||||
new_callable=AsyncMock,
|
||||
return_value="root",
|
||||
):
|
||||
result = await _create_drive_folder_impl(
|
||||
service=mock_service,
|
||||
user_google_email="user@example.com",
|
||||
folder_name="My Folder",
|
||||
parent_folder_id="root",
|
||||
)
|
||||
|
||||
assert "Successfully created folder" in result
|
||||
assert "My Folder" in result
|
||||
assert "folder123" in result
|
||||
assert "user@example.com" in result
|
||||
assert "https://drive.google.com/drive/folders/folder123" in result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# build_drive_list_params — detailed flag (pure unit tests, no I/O)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_build_params_detailed_true_includes_extra_fields():
|
||||
"""detailed=True requests modifiedTime, webViewLink, and size from the API."""
|
||||
params = build_drive_list_params(query="name='x'", page_size=10, detailed=True)
|
||||
assert "modifiedTime" in params["fields"]
|
||||
assert "webViewLink" in params["fields"]
|
||||
assert "size" in params["fields"]
|
||||
|
||||
|
||||
def test_build_params_detailed_false_omits_extra_fields():
|
||||
"""detailed=False omits modifiedTime, webViewLink, and size from the API request."""
|
||||
params = build_drive_list_params(query="name='x'", page_size=10, detailed=False)
|
||||
assert "modifiedTime" not in params["fields"]
|
||||
assert "webViewLink" not in params["fields"]
|
||||
assert "size" not in params["fields"]
|
||||
|
||||
|
||||
def test_build_params_detailed_false_keeps_core_fields():
|
||||
"""detailed=False still requests id, name, and mimeType."""
|
||||
params = build_drive_list_params(query="name='x'", page_size=10, detailed=False)
|
||||
assert "id" in params["fields"]
|
||||
assert "name" in params["fields"]
|
||||
assert "mimeType" in params["fields"]
|
||||
|
||||
|
||||
def test_build_params_default_is_detailed():
|
||||
"""Omitting detailed behaves identically to detailed=True."""
|
||||
params_default = build_drive_list_params(query="q", page_size=5)
|
||||
params_true = build_drive_list_params(query="q", page_size=5, detailed=True)
|
||||
assert params_default["fields"] == params_true["fields"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# search_drive_files — detailed flag
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_detailed_true_output_includes_metadata():
|
||||
"""detailed=True (default) includes modified time and link in output."""
|
||||
mock_service = Mock()
|
||||
mock_service.files().list().execute.return_value = {
|
||||
"files": [
|
||||
_make_file(
|
||||
"f1",
|
||||
"My Doc",
|
||||
"application/vnd.google-apps.document",
|
||||
modified="2024-06-01T12:00:00Z",
|
||||
link="http://link/f1",
|
||||
)
|
||||
]
|
||||
}
|
||||
|
||||
result = await _unwrap(search_drive_files)(
|
||||
service=mock_service,
|
||||
user_google_email="user@example.com",
|
||||
query="my doc",
|
||||
detailed=True,
|
||||
)
|
||||
|
||||
assert "My Doc" in result
|
||||
assert "2024-06-01T12:00:00Z" in result
|
||||
assert "http://link/f1" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_detailed_false_output_excludes_metadata():
|
||||
"""detailed=False omits modified time and link from output."""
|
||||
mock_service = Mock()
|
||||
mock_service.files().list().execute.return_value = {
|
||||
"files": [
|
||||
_make_file(
|
||||
"f1",
|
||||
"My Doc",
|
||||
"application/vnd.google-apps.document",
|
||||
modified="2024-06-01T12:00:00Z",
|
||||
link="http://link/f1",
|
||||
)
|
||||
]
|
||||
}
|
||||
|
||||
result = await _unwrap(search_drive_files)(
|
||||
service=mock_service,
|
||||
user_google_email="user@example.com",
|
||||
query="my doc",
|
||||
detailed=False,
|
||||
)
|
||||
|
||||
assert "My Doc" in result
|
||||
assert "f1" in result
|
||||
assert "2024-06-01T12:00:00Z" not in result
|
||||
assert "http://link/f1" not in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_detailed_true_with_size():
|
||||
"""When the item has a size field, detailed=True includes it in output."""
|
||||
mock_service = Mock()
|
||||
mock_service.files().list().execute.return_value = {
|
||||
"files": [
|
||||
_make_file("f2", "Big File", "application/pdf", size="102400"),
|
||||
]
|
||||
}
|
||||
|
||||
result = await _unwrap(search_drive_files)(
|
||||
service=mock_service,
|
||||
user_google_email="user@example.com",
|
||||
query="big",
|
||||
detailed=True,
|
||||
)
|
||||
|
||||
assert "102400" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_detailed_true_requests_extra_api_fields():
|
||||
"""detailed=True passes full fields string to the Drive API."""
|
||||
mock_service = Mock()
|
||||
mock_service.files().list().execute.return_value = {"files": []}
|
||||
|
||||
await _unwrap(search_drive_files)(
|
||||
service=mock_service,
|
||||
user_google_email="user@example.com",
|
||||
query="anything",
|
||||
detailed=True,
|
||||
)
|
||||
|
||||
call_kwargs = mock_service.files.return_value.list.call_args.kwargs
|
||||
assert "modifiedTime" in call_kwargs["fields"]
|
||||
assert "webViewLink" in call_kwargs["fields"]
|
||||
assert "size" in call_kwargs["fields"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_detailed_false_requests_compact_api_fields():
|
||||
"""detailed=False passes compact fields string to the Drive API."""
|
||||
mock_service = Mock()
|
||||
mock_service.files().list().execute.return_value = {"files": []}
|
||||
|
||||
await _unwrap(search_drive_files)(
|
||||
service=mock_service,
|
||||
user_google_email="user@example.com",
|
||||
query="anything",
|
||||
detailed=False,
|
||||
)
|
||||
|
||||
call_kwargs = mock_service.files.return_value.list.call_args.kwargs
|
||||
assert "modifiedTime" not in call_kwargs["fields"]
|
||||
assert "webViewLink" not in call_kwargs["fields"]
|
||||
assert "size" not in call_kwargs["fields"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_default_detailed_matches_detailed_true():
|
||||
"""Omitting detailed produces the same output as detailed=True."""
|
||||
file = _make_file(
|
||||
"f1",
|
||||
"Doc",
|
||||
"application/vnd.google-apps.document",
|
||||
modified="2024-01-01T00:00:00Z",
|
||||
link="http://l",
|
||||
)
|
||||
|
||||
mock_service = Mock()
|
||||
mock_service.files().list().execute.return_value = {"files": [file]}
|
||||
result_default = await _unwrap(search_drive_files)(
|
||||
service=mock_service,
|
||||
user_google_email="user@example.com",
|
||||
query="doc",
|
||||
)
|
||||
|
||||
mock_service.files().list().execute.return_value = {"files": [file]}
|
||||
result_true = await _unwrap(search_drive_files)(
|
||||
service=mock_service,
|
||||
user_google_email="user@example.com",
|
||||
query="doc",
|
||||
detailed=True,
|
||||
)
|
||||
|
||||
assert result_default == result_true
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# list_drive_items — detailed flag
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("gdrive.drive_tools.resolve_folder_id", new_callable=AsyncMock)
|
||||
async def test_list_detailed_true_output_includes_metadata(mock_resolve_folder):
|
||||
"""detailed=True (default) includes modified time and link in output."""
|
||||
mock_resolve_folder.return_value = "resolved_root"
|
||||
mock_service = Mock()
|
||||
mock_service.files().list().execute.return_value = {
|
||||
"files": [
|
||||
_make_file(
|
||||
"id1",
|
||||
"Report",
|
||||
"application/vnd.google-apps.document",
|
||||
modified="2024-03-15T08:00:00Z",
|
||||
link="http://link/id1",
|
||||
)
|
||||
]
|
||||
}
|
||||
|
||||
result = await _unwrap(list_drive_items)(
|
||||
service=mock_service,
|
||||
user_google_email="user@example.com",
|
||||
folder_id="root",
|
||||
detailed=True,
|
||||
)
|
||||
|
||||
assert "Report" in result
|
||||
assert "2024-03-15T08:00:00Z" in result
|
||||
assert "http://link/id1" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("gdrive.drive_tools.resolve_folder_id", new_callable=AsyncMock)
|
||||
async def test_list_detailed_false_output_excludes_metadata(mock_resolve_folder):
|
||||
"""detailed=False omits modified time and link from output."""
|
||||
mock_resolve_folder.return_value = "resolved_root"
|
||||
mock_service = Mock()
|
||||
mock_service.files().list().execute.return_value = {
|
||||
"files": [
|
||||
_make_file(
|
||||
"id1",
|
||||
"Report",
|
||||
"application/vnd.google-apps.document",
|
||||
modified="2024-03-15T08:00:00Z",
|
||||
link="http://link/id1",
|
||||
)
|
||||
]
|
||||
}
|
||||
|
||||
result = await _unwrap(list_drive_items)(
|
||||
service=mock_service,
|
||||
user_google_email="user@example.com",
|
||||
folder_id="root",
|
||||
detailed=False,
|
||||
)
|
||||
|
||||
assert "Report" in result
|
||||
assert "id1" in result
|
||||
assert "2024-03-15T08:00:00Z" not in result
|
||||
assert "http://link/id1" not in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("gdrive.drive_tools.resolve_folder_id", new_callable=AsyncMock)
|
||||
async def test_list_detailed_true_with_size(mock_resolve_folder):
|
||||
"""When item has a size field, detailed=True includes it in output."""
|
||||
mock_resolve_folder.return_value = "resolved_root"
|
||||
mock_service = Mock()
|
||||
mock_service.files().list().execute.return_value = {
|
||||
"files": [
|
||||
_make_file("id2", "Big File", "application/pdf", size="204800"),
|
||||
]
|
||||
}
|
||||
|
||||
result = await _unwrap(list_drive_items)(
|
||||
service=mock_service,
|
||||
user_google_email="user@example.com",
|
||||
folder_id="root",
|
||||
detailed=True,
|
||||
)
|
||||
|
||||
assert "204800" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("gdrive.drive_tools.resolve_folder_id", new_callable=AsyncMock)
|
||||
async def test_list_detailed_true_requests_extra_api_fields(mock_resolve_folder):
|
||||
"""detailed=True passes full fields string to the Drive API."""
|
||||
mock_resolve_folder.return_value = "resolved_root"
|
||||
mock_service = Mock()
|
||||
mock_service.files().list().execute.return_value = {"files": []}
|
||||
|
||||
await _unwrap(list_drive_items)(
|
||||
service=mock_service,
|
||||
user_google_email="user@example.com",
|
||||
folder_id="root",
|
||||
detailed=True,
|
||||
)
|
||||
|
||||
call_kwargs = mock_service.files.return_value.list.call_args.kwargs
|
||||
assert "modifiedTime" in call_kwargs["fields"]
|
||||
assert "webViewLink" in call_kwargs["fields"]
|
||||
assert "size" in call_kwargs["fields"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("gdrive.drive_tools.resolve_folder_id", new_callable=AsyncMock)
|
||||
async def test_list_detailed_false_requests_compact_api_fields(mock_resolve_folder):
|
||||
"""detailed=False passes compact fields string to the Drive API."""
|
||||
mock_resolve_folder.return_value = "resolved_root"
|
||||
mock_service = Mock()
|
||||
mock_service.files().list().execute.return_value = {"files": []}
|
||||
|
||||
await _unwrap(list_drive_items)(
|
||||
service=mock_service,
|
||||
user_google_email="user@example.com",
|
||||
folder_id="root",
|
||||
detailed=False,
|
||||
)
|
||||
|
||||
call_kwargs = mock_service.files.return_value.list.call_args.kwargs
|
||||
assert "modifiedTime" not in call_kwargs["fields"]
|
||||
assert "webViewLink" not in call_kwargs["fields"]
|
||||
assert "size" not in call_kwargs["fields"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Existing behavior coverage
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_free_text_returns_results():
|
||||
"""Free-text query is wrapped in fullText contains and results are formatted."""
|
||||
mock_service = Mock()
|
||||
mock_service.files().list().execute.return_value = {
|
||||
"files": [
|
||||
_make_file("f1", "My Doc", "application/vnd.google-apps.document"),
|
||||
]
|
||||
}
|
||||
|
||||
result = await _unwrap(search_drive_files)(
|
||||
service=mock_service,
|
||||
user_google_email="user@example.com",
|
||||
query="my doc",
|
||||
)
|
||||
|
||||
assert "Found 1 files" in result
|
||||
assert "My Doc" in result
|
||||
assert "f1" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_no_results():
|
||||
"""No results returns a clear message."""
|
||||
mock_service = Mock()
|
||||
mock_service.files().list().execute.return_value = {"files": []}
|
||||
|
||||
result = await _unwrap(search_drive_files)(
|
||||
service=mock_service,
|
||||
user_google_email="user@example.com",
|
||||
query="nothing here",
|
||||
)
|
||||
|
||||
assert "No files found" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("gdrive.drive_tools.resolve_folder_id", new_callable=AsyncMock)
|
||||
async def test_list_items_basic(mock_resolve_folder):
|
||||
"""Basic listing without filters returns all items."""
|
||||
mock_resolve_folder.return_value = "resolved_root"
|
||||
mock_service = Mock()
|
||||
mock_service.files().list().execute.return_value = {
|
||||
"files": [
|
||||
_make_file("id1", "Folder A", "application/vnd.google-apps.folder"),
|
||||
_make_file("id2", "Doc B", "application/vnd.google-apps.document"),
|
||||
]
|
||||
}
|
||||
|
||||
result = await _unwrap(list_drive_items)(
|
||||
service=mock_service,
|
||||
user_google_email="user@example.com",
|
||||
folder_id="root",
|
||||
)
|
||||
|
||||
assert "Found 2 items" in result
|
||||
assert "Folder A" in result
|
||||
assert "Doc B" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("gdrive.drive_tools.resolve_folder_id", new_callable=AsyncMock)
|
||||
async def test_list_items_no_results(mock_resolve_folder):
|
||||
"""Empty folder returns a clear message."""
|
||||
mock_resolve_folder.return_value = "resolved_root"
|
||||
mock_service = Mock()
|
||||
mock_service.files().list().execute.return_value = {"files": []}
|
||||
|
||||
result = await _unwrap(list_drive_items)(
|
||||
service=mock_service,
|
||||
user_google_email="user@example.com",
|
||||
folder_id="root",
|
||||
)
|
||||
|
||||
assert "No items found" in result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# file_type filtering
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_file_type_folder_adds_mime_filter():
|
||||
"""file_type='folder' appends the folder MIME type to the query."""
|
||||
mock_service = Mock()
|
||||
mock_service.files().list().execute.return_value = {
|
||||
"files": [
|
||||
_make_file("fold1", "My Folder", "application/vnd.google-apps.folder")
|
||||
]
|
||||
}
|
||||
|
||||
result = await _unwrap(search_drive_files)(
|
||||
service=mock_service,
|
||||
user_google_email="user@example.com",
|
||||
query="my",
|
||||
file_type="folder",
|
||||
)
|
||||
|
||||
assert "Found 1 files" in result
|
||||
assert "My Folder" in result
|
||||
|
||||
call_kwargs = mock_service.files.return_value.list.call_args.kwargs
|
||||
assert "mimeType = 'application/vnd.google-apps.folder'" in call_kwargs["q"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_file_type_document_alias():
|
||||
"""Alias 'doc' resolves to the Google Docs MIME type."""
|
||||
mock_service = Mock()
|
||||
mock_service.files().list().execute.return_value = {"files": []}
|
||||
|
||||
await _unwrap(search_drive_files)(
|
||||
service=mock_service,
|
||||
user_google_email="user@example.com",
|
||||
query="report",
|
||||
file_type="doc",
|
||||
)
|
||||
|
||||
call_kwargs = mock_service.files.return_value.list.call_args.kwargs
|
||||
assert "mimeType = 'application/vnd.google-apps.document'" in call_kwargs["q"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_file_type_plural_alias():
|
||||
"""Plural aliases are resolved for friendlier natural-language usage."""
|
||||
mock_service = Mock()
|
||||
mock_service.files().list().execute.return_value = {"files": []}
|
||||
|
||||
await _unwrap(search_drive_files)(
|
||||
service=mock_service,
|
||||
user_google_email="user@example.com",
|
||||
query="project",
|
||||
file_type="folders",
|
||||
)
|
||||
|
||||
call_kwargs = mock_service.files.return_value.list.call_args.kwargs
|
||||
assert "mimeType = 'application/vnd.google-apps.folder'" in call_kwargs["q"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_file_type_sheet_alias():
|
||||
"""Alias 'sheet' resolves to the Google Sheets MIME type."""
|
||||
mock_service = Mock()
|
||||
mock_service.files().list().execute.return_value = {"files": []}
|
||||
|
||||
await _unwrap(search_drive_files)(
|
||||
service=mock_service,
|
||||
user_google_email="user@example.com",
|
||||
query="budget",
|
||||
file_type="sheet",
|
||||
)
|
||||
|
||||
call_kwargs = mock_service.files.return_value.list.call_args.kwargs
|
||||
assert "mimeType = 'application/vnd.google-apps.spreadsheet'" in call_kwargs["q"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_file_type_raw_mime():
|
||||
"""A raw MIME type string is passed through unchanged."""
|
||||
mock_service = Mock()
|
||||
mock_service.files().list().execute.return_value = {
|
||||
"files": [_make_file("p1", "Report.pdf", "application/pdf")]
|
||||
}
|
||||
|
||||
result = await _unwrap(search_drive_files)(
|
||||
service=mock_service,
|
||||
user_google_email="user@example.com",
|
||||
query="report",
|
||||
file_type="application/pdf",
|
||||
)
|
||||
|
||||
assert "Report.pdf" in result
|
||||
call_kwargs = mock_service.files.return_value.list.call_args.kwargs
|
||||
assert "mimeType = 'application/pdf'" in call_kwargs["q"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_file_type_none_no_mime_filter():
|
||||
"""When file_type is None no mimeType clause is added to the query."""
|
||||
mock_service = Mock()
|
||||
mock_service.files().list().execute.return_value = {"files": []}
|
||||
|
||||
await _unwrap(search_drive_files)(
|
||||
service=mock_service,
|
||||
user_google_email="user@example.com",
|
||||
query="anything",
|
||||
file_type=None,
|
||||
)
|
||||
|
||||
call_kwargs = mock_service.files.return_value.list.call_args.kwargs
|
||||
assert "mimeType" not in call_kwargs["q"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_file_type_structured_query_combined():
|
||||
"""file_type filter is appended even when the query is already structured."""
|
||||
mock_service = Mock()
|
||||
mock_service.files().list().execute.return_value = {"files": []}
|
||||
|
||||
await _unwrap(search_drive_files)(
|
||||
service=mock_service,
|
||||
user_google_email="user@example.com",
|
||||
query="name contains 'budget'",
|
||||
file_type="spreadsheet",
|
||||
)
|
||||
|
||||
call_kwargs = mock_service.files.return_value.list.call_args.kwargs
|
||||
q = call_kwargs["q"]
|
||||
assert "name contains 'budget'" in q
|
||||
assert "mimeType = 'application/vnd.google-apps.spreadsheet'" in q
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_file_type_unknown_raises_value_error():
|
||||
"""An unrecognised friendly type name raises ValueError immediately."""
|
||||
mock_service = Mock()
|
||||
|
||||
with pytest.raises(ValueError, match="Unknown file_type"):
|
||||
await _unwrap(search_drive_files)(
|
||||
service=mock_service,
|
||||
user_google_email="user@example.com",
|
||||
query="something",
|
||||
file_type="notatype",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("gdrive.drive_tools.resolve_folder_id", new_callable=AsyncMock)
|
||||
async def test_list_items_file_type_folder_adds_mime_filter(mock_resolve_folder):
|
||||
"""file_type='folder' appends the folder MIME clause to the query."""
|
||||
mock_resolve_folder.return_value = "resolved_root"
|
||||
mock_service = Mock()
|
||||
mock_service.files().list().execute.return_value = {
|
||||
"files": [_make_file("sub1", "SubFolder", "application/vnd.google-apps.folder")]
|
||||
}
|
||||
|
||||
result = await _unwrap(list_drive_items)(
|
||||
service=mock_service,
|
||||
user_google_email="user@example.com",
|
||||
folder_id="root",
|
||||
file_type="folder",
|
||||
)
|
||||
|
||||
assert "Found 1 items" in result
|
||||
assert "SubFolder" in result
|
||||
|
||||
call_kwargs = mock_service.files.return_value.list.call_args.kwargs
|
||||
q = call_kwargs["q"]
|
||||
assert "'resolved_root' in parents" in q
|
||||
assert "trashed=false" in q
|
||||
assert "mimeType = 'application/vnd.google-apps.folder'" in q
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("gdrive.drive_tools.resolve_folder_id", new_callable=AsyncMock)
|
||||
async def test_list_items_file_type_spreadsheet(mock_resolve_folder):
|
||||
"""file_type='spreadsheet' appends the Sheets MIME clause."""
|
||||
mock_resolve_folder.return_value = "folder_xyz"
|
||||
mock_service = Mock()
|
||||
mock_service.files().list().execute.return_value = {"files": []}
|
||||
|
||||
await _unwrap(list_drive_items)(
|
||||
service=mock_service,
|
||||
user_google_email="user@example.com",
|
||||
folder_id="folder_xyz",
|
||||
file_type="spreadsheet",
|
||||
)
|
||||
|
||||
call_kwargs = mock_service.files.return_value.list.call_args.kwargs
|
||||
assert "mimeType = 'application/vnd.google-apps.spreadsheet'" in call_kwargs["q"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("gdrive.drive_tools.resolve_folder_id", new_callable=AsyncMock)
|
||||
async def test_list_items_file_type_raw_mime(mock_resolve_folder):
|
||||
"""A raw MIME type string is passed through unchanged."""
|
||||
mock_resolve_folder.return_value = "folder_abc"
|
||||
mock_service = Mock()
|
||||
mock_service.files().list().execute.return_value = {"files": []}
|
||||
|
||||
await _unwrap(list_drive_items)(
|
||||
service=mock_service,
|
||||
user_google_email="user@example.com",
|
||||
folder_id="folder_abc",
|
||||
file_type="application/pdf",
|
||||
)
|
||||
|
||||
call_kwargs = mock_service.files.return_value.list.call_args.kwargs
|
||||
assert "mimeType = 'application/pdf'" in call_kwargs["q"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("gdrive.drive_tools.resolve_folder_id", new_callable=AsyncMock)
|
||||
async def test_list_items_file_type_none_no_mime_filter(mock_resolve_folder):
|
||||
"""When file_type is None no mimeType clause is added."""
|
||||
mock_resolve_folder.return_value = "resolved_root"
|
||||
mock_service = Mock()
|
||||
mock_service.files().list().execute.return_value = {"files": []}
|
||||
|
||||
await _unwrap(list_drive_items)(
|
||||
service=mock_service,
|
||||
user_google_email="user@example.com",
|
||||
folder_id="root",
|
||||
file_type=None,
|
||||
)
|
||||
|
||||
call_kwargs = mock_service.files.return_value.list.call_args.kwargs
|
||||
assert "mimeType" not in call_kwargs["q"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("gdrive.drive_tools.resolve_folder_id", new_callable=AsyncMock)
|
||||
async def test_list_items_file_type_unknown_raises(mock_resolve_folder):
|
||||
"""An unrecognised friendly type name raises ValueError."""
|
||||
mock_resolve_folder.return_value = "resolved_root"
|
||||
mock_service = Mock()
|
||||
|
||||
with pytest.raises(ValueError, match="Unknown file_type"):
|
||||
await _unwrap(list_drive_items)(
|
||||
service=mock_service,
|
||||
user_google_email="user@example.com",
|
||||
folder_id="root",
|
||||
file_type="unknowntype",
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# OR-precedence grouping
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_or_query_is_grouped_before_mime_filter():
|
||||
"""An OR structured query is wrapped in parentheses so MIME filter precedence is correct."""
|
||||
mock_service = Mock()
|
||||
mock_service.files().list().execute.return_value = {"files": []}
|
||||
|
||||
await _unwrap(search_drive_files)(
|
||||
service=mock_service,
|
||||
user_google_email="user@example.com",
|
||||
query="name contains 'a' or name contains 'b'",
|
||||
file_type="document",
|
||||
)
|
||||
|
||||
q = mock_service.files.return_value.list.call_args.kwargs["q"]
|
||||
assert q.startswith("(")
|
||||
assert "name contains 'a' or name contains 'b'" in q
|
||||
assert ") and mimeType = 'application/vnd.google-apps.document'" in q
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# MIME type validation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_resolve_file_type_mime_invalid_mime_raises():
|
||||
"""A raw string with '/' but containing quotes raises ValueError."""
|
||||
from gdrive.drive_helpers import resolve_file_type_mime
|
||||
|
||||
with pytest.raises(ValueError, match="Invalid MIME type"):
|
||||
resolve_file_type_mime("application/pdf' or '1'='1")
|
||||
|
||||
|
||||
def test_resolve_file_type_mime_strips_whitespace():
|
||||
"""Leading/trailing whitespace is stripped from raw MIME strings."""
|
||||
from gdrive.drive_helpers import resolve_file_type_mime
|
||||
|
||||
assert resolve_file_type_mime(" application/pdf ") == "application/pdf"
|
||||
|
||||
|
||||
def test_resolve_file_type_mime_normalizes_case():
|
||||
"""Raw MIME types are normalized to lowercase for Drive query consistency."""
|
||||
from gdrive.drive_helpers import resolve_file_type_mime
|
||||
|
||||
assert resolve_file_type_mime("Application/PDF") == "application/pdf"
|
||||
|
||||
|
||||
def test_resolve_file_type_mime_empty_raises():
|
||||
"""Blank values are rejected with a clear validation error."""
|
||||
from gdrive.drive_helpers import resolve_file_type_mime
|
||||
|
||||
with pytest.raises(ValueError, match="cannot be empty"):
|
||||
resolve_file_type_mime(" ")
|
||||
165
tests/gdrive/test_ssrf_protections.py
Normal file
165
tests/gdrive/test_ssrf_protections.py
Normal file
@@ -0,0 +1,165 @@
|
||||
"""
|
||||
Unit tests for Drive SSRF protections and DNS pinning helpers.
|
||||
"""
|
||||
|
||||
import os
|
||||
import socket
|
||||
import sys
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../..")))
|
||||
|
||||
from gdrive import drive_tools
|
||||
|
||||
|
||||
def test_resolve_and_validate_host_fails_closed_on_dns_error(monkeypatch):
|
||||
"""DNS resolution failures must fail closed."""
|
||||
|
||||
def fake_getaddrinfo(hostname, port):
|
||||
raise socket.gaierror("mocked resolution failure")
|
||||
|
||||
monkeypatch.setattr(socket, "getaddrinfo", fake_getaddrinfo)
|
||||
|
||||
with pytest.raises(ValueError, match="Refusing request \\(fail-closed\\)"):
|
||||
drive_tools._resolve_and_validate_host("example.com")
|
||||
|
||||
|
||||
def test_resolve_and_validate_host_rejects_ipv6_private(monkeypatch):
|
||||
"""IPv6 internal addresses must be rejected."""
|
||||
|
||||
def fake_getaddrinfo(hostname, port):
|
||||
return [
|
||||
(
|
||||
socket.AF_INET6,
|
||||
socket.SOCK_STREAM,
|
||||
6,
|
||||
"",
|
||||
("fd00::1", 0, 0, 0),
|
||||
)
|
||||
]
|
||||
|
||||
monkeypatch.setattr(socket, "getaddrinfo", fake_getaddrinfo)
|
||||
|
||||
with pytest.raises(ValueError, match="private/internal networks"):
|
||||
drive_tools._resolve_and_validate_host("ipv6-internal.example")
|
||||
|
||||
|
||||
def test_resolve_and_validate_host_deduplicates_addresses(monkeypatch):
|
||||
"""Duplicate DNS answers should be de-duplicated while preserving order."""
|
||||
|
||||
def fake_getaddrinfo(hostname, port):
|
||||
return [
|
||||
(
|
||||
socket.AF_INET,
|
||||
socket.SOCK_STREAM,
|
||||
6,
|
||||
"",
|
||||
("93.184.216.34", 0),
|
||||
),
|
||||
(
|
||||
socket.AF_INET,
|
||||
socket.SOCK_STREAM,
|
||||
6,
|
||||
"",
|
||||
("93.184.216.34", 0),
|
||||
),
|
||||
(
|
||||
socket.AF_INET6,
|
||||
socket.SOCK_STREAM,
|
||||
6,
|
||||
"",
|
||||
("2606:2800:220:1:248:1893:25c8:1946", 0, 0, 0),
|
||||
),
|
||||
]
|
||||
|
||||
monkeypatch.setattr(socket, "getaddrinfo", fake_getaddrinfo)
|
||||
|
||||
assert drive_tools._resolve_and_validate_host("example.com") == [
|
||||
"93.184.216.34",
|
||||
"2606:2800:220:1:248:1893:25c8:1946",
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetch_url_with_pinned_ip_uses_pinned_target_and_host_header(monkeypatch):
|
||||
"""Requests should target a validated IP while preserving Host + SNI hostname."""
|
||||
captured = {}
|
||||
|
||||
class FakeAsyncClient:
|
||||
def __init__(self, *args, **kwargs):
|
||||
captured["client_kwargs"] = kwargs
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
def build_request(self, method, url, headers=None, extensions=None):
|
||||
captured["method"] = method
|
||||
captured["url"] = url
|
||||
captured["headers"] = headers or {}
|
||||
captured["extensions"] = extensions or {}
|
||||
return {"url": url}
|
||||
|
||||
async def send(self, request):
|
||||
return httpx.Response(200, request=httpx.Request("GET", request["url"]))
|
||||
|
||||
monkeypatch.setattr(
|
||||
drive_tools, "_validate_url_not_internal", lambda url: ["93.184.216.34"]
|
||||
)
|
||||
monkeypatch.setattr(drive_tools.httpx, "AsyncClient", FakeAsyncClient)
|
||||
|
||||
response = await drive_tools._fetch_url_with_pinned_ip(
|
||||
"https://example.com/path/to/file.txt?x=1"
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert captured["method"] == "GET"
|
||||
assert captured["url"] == "https://93.184.216.34/path/to/file.txt?x=1"
|
||||
assert captured["headers"]["Host"] == "example.com"
|
||||
assert captured["extensions"]["sni_hostname"] == "example.com"
|
||||
assert captured["client_kwargs"]["trust_env"] is False
|
||||
assert captured["client_kwargs"]["follow_redirects"] is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ssrf_safe_fetch_follows_relative_redirects(monkeypatch):
|
||||
"""Relative redirects should be resolved and re-checked."""
|
||||
calls = []
|
||||
|
||||
async def fake_fetch(url):
|
||||
calls.append(url)
|
||||
if len(calls) == 1:
|
||||
return httpx.Response(
|
||||
302,
|
||||
headers={"location": "/next"},
|
||||
request=httpx.Request("GET", url),
|
||||
)
|
||||
return httpx.Response(200, request=httpx.Request("GET", url), content=b"ok")
|
||||
|
||||
monkeypatch.setattr(drive_tools, "_fetch_url_with_pinned_ip", fake_fetch)
|
||||
|
||||
response = await drive_tools._ssrf_safe_fetch("https://example.com/start")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert calls == ["https://example.com/start", "https://example.com/next"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ssrf_safe_fetch_rejects_disallowed_redirect_scheme(monkeypatch):
|
||||
"""Redirects to non-http(s) schemes should be blocked."""
|
||||
|
||||
async def fake_fetch(url):
|
||||
return httpx.Response(
|
||||
302,
|
||||
headers={"location": "file:///etc/passwd"},
|
||||
request=httpx.Request("GET", url),
|
||||
)
|
||||
|
||||
monkeypatch.setattr(drive_tools, "_fetch_url_with_pinned_ip", fake_fetch)
|
||||
|
||||
with pytest.raises(ValueError, match="Redirect to disallowed scheme"):
|
||||
await drive_tools._ssrf_safe_fetch("https://example.com/start")
|
||||
0
tests/gforms/__init__.py
Normal file
0
tests/gforms/__init__.py
Normal file
344
tests/gforms/test_forms_tools.py
Normal file
344
tests/gforms/test_forms_tools.py
Normal file
@@ -0,0 +1,344 @@
|
||||
"""
|
||||
Unit tests for Google Forms MCP tools
|
||||
|
||||
Tests the batch_update_form tool with mocked API responses
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import Mock
|
||||
import sys
|
||||
import os
|
||||
|
||||
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../..")))
|
||||
|
||||
# Import internal implementation functions (not decorated tool wrappers)
|
||||
from gforms.forms_tools import _batch_update_form_impl, _serialize_form_item, get_form
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_batch_update_form_multiple_requests():
|
||||
"""Test batch update with multiple requests returns formatted results"""
|
||||
mock_service = Mock()
|
||||
mock_response = {
|
||||
"replies": [
|
||||
{"createItem": {"itemId": "item001", "questionId": ["q001"]}},
|
||||
{"createItem": {"itemId": "item002", "questionId": ["q002"]}},
|
||||
],
|
||||
"writeControl": {"requiredRevisionId": "rev123"},
|
||||
}
|
||||
|
||||
mock_service.forms().batchUpdate().execute.return_value = mock_response
|
||||
|
||||
requests = [
|
||||
{
|
||||
"createItem": {
|
||||
"item": {
|
||||
"title": "What is your name?",
|
||||
"questionItem": {
|
||||
"question": {"textQuestion": {"paragraph": False}}
|
||||
},
|
||||
},
|
||||
"location": {"index": 0},
|
||||
}
|
||||
},
|
||||
{
|
||||
"createItem": {
|
||||
"item": {
|
||||
"title": "What is your email?",
|
||||
"questionItem": {
|
||||
"question": {"textQuestion": {"paragraph": False}}
|
||||
},
|
||||
},
|
||||
"location": {"index": 1},
|
||||
}
|
||||
},
|
||||
]
|
||||
|
||||
result = await _batch_update_form_impl(
|
||||
service=mock_service,
|
||||
form_id="test_form_123",
|
||||
requests=requests,
|
||||
)
|
||||
|
||||
assert "Batch Update Completed" in result
|
||||
assert "test_form_123" in result
|
||||
assert "Requests Applied: 2" in result
|
||||
assert "Replies Received: 2" in result
|
||||
assert "item001" in result
|
||||
assert "item002" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_batch_update_form_single_request():
|
||||
"""Test batch update with a single request"""
|
||||
mock_service = Mock()
|
||||
mock_response = {
|
||||
"replies": [
|
||||
{"createItem": {"itemId": "item001", "questionId": ["q001"]}},
|
||||
],
|
||||
}
|
||||
|
||||
mock_service.forms().batchUpdate().execute.return_value = mock_response
|
||||
|
||||
requests = [
|
||||
{
|
||||
"createItem": {
|
||||
"item": {
|
||||
"title": "Favourite colour?",
|
||||
"questionItem": {
|
||||
"question": {
|
||||
"choiceQuestion": {
|
||||
"type": "RADIO",
|
||||
"options": [
|
||||
{"value": "Red"},
|
||||
{"value": "Blue"},
|
||||
],
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
"location": {"index": 0},
|
||||
}
|
||||
},
|
||||
]
|
||||
|
||||
result = await _batch_update_form_impl(
|
||||
service=mock_service,
|
||||
form_id="single_form_456",
|
||||
requests=requests,
|
||||
)
|
||||
|
||||
assert "single_form_456" in result
|
||||
assert "Requests Applied: 1" in result
|
||||
assert "Replies Received: 1" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_batch_update_form_empty_replies():
|
||||
"""Test batch update when API returns no replies"""
|
||||
mock_service = Mock()
|
||||
mock_response = {
|
||||
"replies": [],
|
||||
}
|
||||
|
||||
mock_service.forms().batchUpdate().execute.return_value = mock_response
|
||||
|
||||
requests = [
|
||||
{
|
||||
"updateFormInfo": {
|
||||
"info": {"description": "Updated description"},
|
||||
"updateMask": "description",
|
||||
}
|
||||
},
|
||||
]
|
||||
|
||||
result = await _batch_update_form_impl(
|
||||
service=mock_service,
|
||||
form_id="info_form_789",
|
||||
requests=requests,
|
||||
)
|
||||
|
||||
assert "info_form_789" in result
|
||||
assert "Requests Applied: 1" in result
|
||||
assert "Replies Received: 0" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_batch_update_form_no_replies_key():
|
||||
"""Test batch update when API response lacks replies key"""
|
||||
mock_service = Mock()
|
||||
mock_response = {}
|
||||
|
||||
mock_service.forms().batchUpdate().execute.return_value = mock_response
|
||||
|
||||
requests = [
|
||||
{
|
||||
"updateSettings": {
|
||||
"settings": {"quizSettings": {"isQuiz": True}},
|
||||
"updateMask": "quizSettings.isQuiz",
|
||||
}
|
||||
},
|
||||
]
|
||||
|
||||
result = await _batch_update_form_impl(
|
||||
service=mock_service,
|
||||
form_id="quiz_form_000",
|
||||
requests=requests,
|
||||
)
|
||||
|
||||
assert "quiz_form_000" in result
|
||||
assert "Requests Applied: 1" in result
|
||||
assert "Replies Received: 0" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_batch_update_form_url_in_response():
|
||||
"""Test that the edit URL is included in the response"""
|
||||
mock_service = Mock()
|
||||
mock_response = {
|
||||
"replies": [{}],
|
||||
}
|
||||
|
||||
mock_service.forms().batchUpdate().execute.return_value = mock_response
|
||||
|
||||
requests = [
|
||||
{"updateFormInfo": {"info": {"title": "New Title"}, "updateMask": "title"}}
|
||||
]
|
||||
|
||||
result = await _batch_update_form_impl(
|
||||
service=mock_service,
|
||||
form_id="url_form_abc",
|
||||
requests=requests,
|
||||
)
|
||||
|
||||
assert "https://docs.google.com/forms/d/url_form_abc/edit" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_batch_update_form_mixed_reply_types():
|
||||
"""Test batch update with createItem replies containing different fields"""
|
||||
mock_service = Mock()
|
||||
mock_response = {
|
||||
"replies": [
|
||||
{"createItem": {"itemId": "item_a", "questionId": ["qa"]}},
|
||||
{},
|
||||
{"createItem": {"itemId": "item_c"}},
|
||||
],
|
||||
}
|
||||
|
||||
mock_service.forms().batchUpdate().execute.return_value = mock_response
|
||||
|
||||
requests = [
|
||||
{"createItem": {"item": {"title": "Q1"}, "location": {"index": 0}}},
|
||||
{
|
||||
"updateFormInfo": {
|
||||
"info": {"description": "Desc"},
|
||||
"updateMask": "description",
|
||||
}
|
||||
},
|
||||
{"createItem": {"item": {"title": "Q2"}, "location": {"index": 1}}},
|
||||
]
|
||||
|
||||
result = await _batch_update_form_impl(
|
||||
service=mock_service,
|
||||
form_id="mixed_form_xyz",
|
||||
requests=requests,
|
||||
)
|
||||
|
||||
assert "Requests Applied: 3" in result
|
||||
assert "Replies Received: 3" in result
|
||||
assert "item_a" in result
|
||||
assert "item_c" in result
|
||||
|
||||
|
||||
def test_serialize_form_item_choice_question_includes_ids_and_options():
|
||||
"""Choice question items should expose questionId/options/type metadata."""
|
||||
item = {
|
||||
"itemId": "item_123",
|
||||
"title": "Favorite color?",
|
||||
"questionItem": {
|
||||
"question": {
|
||||
"questionId": "q_123",
|
||||
"required": True,
|
||||
"choiceQuestion": {
|
||||
"type": "RADIO",
|
||||
"options": [{"value": "Red"}, {"value": "Blue"}],
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
serialized = _serialize_form_item(item, 1)
|
||||
|
||||
assert serialized["index"] == 1
|
||||
assert serialized["itemId"] == "item_123"
|
||||
assert serialized["type"] == "RADIO"
|
||||
assert serialized["questionId"] == "q_123"
|
||||
assert serialized["required"] is True
|
||||
assert serialized["options"] == [{"value": "Red"}, {"value": "Blue"}]
|
||||
|
||||
|
||||
def test_serialize_form_item_grid_includes_row_and_column_structure():
|
||||
"""Grid question groups should expose row labels/IDs and column options."""
|
||||
item = {
|
||||
"itemId": "grid_item_1",
|
||||
"title": "Weekly chores",
|
||||
"questionGroupItem": {
|
||||
"questions": [
|
||||
{
|
||||
"questionId": "row_q1",
|
||||
"required": True,
|
||||
"rowQuestion": {"title": "Laundry"},
|
||||
},
|
||||
{
|
||||
"questionId": "row_q2",
|
||||
"required": False,
|
||||
"rowQuestion": {"title": "Dishes"},
|
||||
},
|
||||
],
|
||||
"grid": {"columns": {"options": [{"value": "Never"}, {"value": "Often"}]}},
|
||||
},
|
||||
}
|
||||
|
||||
serialized = _serialize_form_item(item, 2)
|
||||
|
||||
assert serialized["index"] == 2
|
||||
assert serialized["type"] == "GRID"
|
||||
assert serialized["grid"]["columns"] == [{"value": "Never"}, {"value": "Often"}]
|
||||
assert serialized["grid"]["rows"] == [
|
||||
{"title": "Laundry", "questionId": "row_q1", "required": True},
|
||||
{"title": "Dishes", "questionId": "row_q2", "required": False},
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_form_returns_structured_item_metadata():
|
||||
"""get_form should include question IDs, options, and grid structure."""
|
||||
mock_service = Mock()
|
||||
mock_service.forms().get().execute.return_value = {
|
||||
"formId": "form_1",
|
||||
"info": {"title": "Survey", "description": "Test survey"},
|
||||
"items": [
|
||||
{
|
||||
"itemId": "item_1",
|
||||
"title": "Favorite fruit?",
|
||||
"questionItem": {
|
||||
"question": {
|
||||
"questionId": "q_1",
|
||||
"required": True,
|
||||
"choiceQuestion": {
|
||||
"type": "RADIO",
|
||||
"options": [{"value": "Apple"}, {"value": "Banana"}],
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
"itemId": "item_2",
|
||||
"title": "Household chores",
|
||||
"questionGroupItem": {
|
||||
"questions": [
|
||||
{
|
||||
"questionId": "row_1",
|
||||
"required": True,
|
||||
"rowQuestion": {"title": "Laundry"},
|
||||
}
|
||||
],
|
||||
"grid": {"columns": {"options": [{"value": "Never"}]}},
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
# Bypass decorators and call the core implementation directly.
|
||||
result = await get_form.__wrapped__.__wrapped__(
|
||||
mock_service, "user@example.com", "form_1"
|
||||
)
|
||||
|
||||
assert "- Items (structured):" in result
|
||||
assert '"questionId": "q_1"' in result
|
||||
assert '"options": [' in result
|
||||
assert '"Apple"' in result
|
||||
assert '"type": "GRID"' in result
|
||||
assert '"columns": [' in result
|
||||
assert '"rows": [' in result
|
||||
101
tests/gmail/test_attachment_fix.py
Normal file
101
tests/gmail/test_attachment_fix.py
Normal file
@@ -0,0 +1,101 @@
|
||||
import base64
|
||||
import os
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def test_urlsafe_b64decode_already_handles_crlf():
|
||||
"""Verify Python's urlsafe_b64decode ignores embedded CR/LF without manual stripping."""
|
||||
original = b"Testdata"
|
||||
b64 = base64.urlsafe_b64encode(original).decode()
|
||||
|
||||
assert base64.urlsafe_b64decode(b64 + "\n") == original
|
||||
assert base64.urlsafe_b64decode(b64[:4] + "\r\n" + b64[4:]) == original
|
||||
assert base64.urlsafe_b64decode(b64[:4] + "\r\r\n" + b64[4:]) == original
|
||||
|
||||
|
||||
def test_os_open_without_o_binary_corrupts_on_windows(tmp_path):
|
||||
"""On Windows, os.open without O_BINARY translates LF to CRLF in written bytes."""
|
||||
payload = b"\x89PNG\r\n\x1a\n" + b"\x00" * 50
|
||||
|
||||
tmp = str(tmp_path / "test_no_binary.bin")
|
||||
fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
||||
try:
|
||||
os.write(fd, payload)
|
||||
finally:
|
||||
os.close(fd)
|
||||
|
||||
with open(tmp, "rb") as f:
|
||||
written = f.read()
|
||||
|
||||
if sys.platform == "win32":
|
||||
assert written != payload, "Expected corruption without O_BINARY on Windows"
|
||||
assert len(written) > len(payload)
|
||||
else:
|
||||
assert written == payload
|
||||
|
||||
|
||||
def test_os_open_with_o_binary_preserves_bytes(tmp_path):
|
||||
"""os.open with O_BINARY writes binary data correctly on all platforms."""
|
||||
payload = b"\x89PNG\r\n\x1a\n" + b"\x00" * 50
|
||||
|
||||
tmp = str(tmp_path / "test_with_binary.bin")
|
||||
flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC | getattr(os, "O_BINARY", 0)
|
||||
|
||||
fd = os.open(tmp, flags, 0o600)
|
||||
try:
|
||||
os.write(fd, payload)
|
||||
finally:
|
||||
os.close(fd)
|
||||
|
||||
with open(tmp, "rb") as f:
|
||||
written = f.read()
|
||||
|
||||
assert written == payload
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def isolated_storage(tmp_path, monkeypatch):
|
||||
"""Create an AttachmentStorage that writes to a temp directory."""
|
||||
import core.attachment_storage as storage_module
|
||||
|
||||
monkeypatch.setattr(storage_module, "STORAGE_DIR", tmp_path)
|
||||
return storage_module.AttachmentStorage()
|
||||
|
||||
|
||||
def test_save_attachment_uses_binary_mode(isolated_storage):
|
||||
"""Verify that AttachmentStorage.save_attachment writes files in binary mode."""
|
||||
payload = b"\x89PNG\r\n\x1a\n" + b"\x00" * 100
|
||||
b64_data = base64.urlsafe_b64encode(payload).decode()
|
||||
|
||||
result = isolated_storage.save_attachment(
|
||||
b64_data, filename="test.png", mime_type="image/png"
|
||||
)
|
||||
|
||||
with open(result.path, "rb") as f:
|
||||
saved_bytes = f.read()
|
||||
|
||||
assert saved_bytes == payload, (
|
||||
f"Binary corruption detected: wrote {len(payload)} bytes, "
|
||||
f"read back {len(saved_bytes)} bytes"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"payload",
|
||||
[
|
||||
b"\x89PNG\r\n\x1a\n" + b"\xff" * 200, # PNG header
|
||||
b"%PDF-1.7\n" + b"\x00" * 200, # PDF header
|
||||
bytes(range(256)) * 4, # All byte values
|
||||
],
|
||||
)
|
||||
def test_save_attachment_preserves_various_binary_formats(isolated_storage, payload):
|
||||
"""Ensure binary integrity for payloads containing LF/CR bytes."""
|
||||
b64_data = base64.urlsafe_b64encode(payload).decode()
|
||||
result = isolated_storage.save_attachment(b64_data, filename="test.bin")
|
||||
|
||||
with open(result.path, "rb") as f:
|
||||
saved_bytes = f.read()
|
||||
|
||||
assert saved_bytes == payload
|
||||
288
tests/gmail/test_draft_gmail_message.py
Normal file
288
tests/gmail/test_draft_gmail_message.py
Normal file
@@ -0,0 +1,288 @@
|
||||
import base64
|
||||
import os
|
||||
import sys
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../..")))
|
||||
|
||||
from core.utils import UserInputError
|
||||
from gmail.gmail_tools import draft_gmail_message
|
||||
|
||||
|
||||
def _unwrap(tool):
|
||||
"""Unwrap FunctionTool + decorators to the original async function."""
|
||||
fn = tool.fn if hasattr(tool, "fn") else tool
|
||||
while hasattr(fn, "__wrapped__"):
|
||||
fn = fn.__wrapped__
|
||||
return fn
|
||||
|
||||
|
||||
def _thread_response(*message_ids):
|
||||
return {
|
||||
"messages": [
|
||||
{
|
||||
"payload": {
|
||||
"headers": [{"name": "Message-ID", "value": message_id}],
|
||||
}
|
||||
}
|
||||
for message_id in message_ids
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_draft_gmail_message_reports_actual_attachment_count(
|
||||
tmp_path, monkeypatch
|
||||
):
|
||||
monkeypatch.setenv("ALLOWED_FILE_DIRS", str(tmp_path))
|
||||
attachment_path = tmp_path / "sample.txt"
|
||||
attachment_path.write_text("hello attachment", encoding="utf-8")
|
||||
|
||||
mock_service = Mock()
|
||||
mock_service.users().drafts().create().execute.return_value = {"id": "draft123"}
|
||||
|
||||
result = await _unwrap(draft_gmail_message)(
|
||||
service=mock_service,
|
||||
user_google_email="user@example.com",
|
||||
to="recipient@example.com",
|
||||
subject="Attachment test",
|
||||
body="Please see attached.",
|
||||
attachments=[{"path": str(attachment_path)}],
|
||||
include_signature=False,
|
||||
)
|
||||
|
||||
assert "Draft created with 1 attachment(s)! Draft ID: draft123" in result
|
||||
|
||||
create_kwargs = (
|
||||
mock_service.users.return_value.drafts.return_value.create.call_args.kwargs
|
||||
)
|
||||
raw_message = create_kwargs["body"]["message"]["raw"]
|
||||
raw_bytes = base64.urlsafe_b64decode(raw_message)
|
||||
|
||||
assert b"Content-Disposition: attachment;" in raw_bytes
|
||||
assert b"sample.txt" in raw_bytes
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_draft_gmail_message_raises_when_no_attachments_are_added(
|
||||
tmp_path, monkeypatch
|
||||
):
|
||||
monkeypatch.setenv("ALLOWED_FILE_DIRS", str(tmp_path))
|
||||
missing_path = tmp_path / "missing.txt"
|
||||
|
||||
mock_service = Mock()
|
||||
mock_service.users().drafts().create().execute.return_value = {"id": "draft123"}
|
||||
|
||||
with pytest.raises(UserInputError, match="No valid attachments were added"):
|
||||
await _unwrap(draft_gmail_message)(
|
||||
service=mock_service,
|
||||
user_google_email="user@example.com",
|
||||
to="recipient@example.com",
|
||||
subject="Attachment test",
|
||||
body="Please see attached.",
|
||||
attachments=[{"path": str(missing_path)}],
|
||||
include_signature=False,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_draft_gmail_message_appends_gmail_signature_html():
|
||||
mock_service = Mock()
|
||||
mock_service.users().drafts().create().execute.return_value = {"id": "draft_sig"}
|
||||
mock_service.users().settings().sendAs().list().execute.return_value = {
|
||||
"sendAs": [
|
||||
{
|
||||
"sendAsEmail": "user@example.com",
|
||||
"isPrimary": True,
|
||||
"signature": "<div>Best,<br>Alice</div>",
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
result = await _unwrap(draft_gmail_message)(
|
||||
service=mock_service,
|
||||
user_google_email="user@example.com",
|
||||
to="recipient@example.com",
|
||||
subject="Signature test",
|
||||
body="<p>Hello</p>",
|
||||
body_format="html",
|
||||
include_signature=True,
|
||||
)
|
||||
|
||||
assert "Draft created! Draft ID: draft_sig" in result
|
||||
|
||||
create_kwargs = (
|
||||
mock_service.users.return_value.drafts.return_value.create.call_args.kwargs
|
||||
)
|
||||
raw_message = create_kwargs["body"]["message"]["raw"]
|
||||
raw_text = base64.urlsafe_b64decode(raw_message).decode("utf-8", errors="ignore")
|
||||
|
||||
assert "<p>Hello</p>" in raw_text
|
||||
assert "Best,<br>Alice" in raw_text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_draft_gmail_message_autofills_reply_headers_from_thread():
|
||||
mock_service = Mock()
|
||||
mock_service.users().drafts().create().execute.return_value = {"id": "draft_reply"}
|
||||
mock_service.users().threads().get().execute.return_value = _thread_response(
|
||||
"<msg1@example.com>",
|
||||
"<msg2@example.com>",
|
||||
"<msg3@example.com>",
|
||||
)
|
||||
|
||||
result = await _unwrap(draft_gmail_message)(
|
||||
service=mock_service,
|
||||
user_google_email="user@example.com",
|
||||
to="recipient@example.com",
|
||||
subject="Meeting tomorrow",
|
||||
body="Thanks for the update.",
|
||||
thread_id="thread123",
|
||||
include_signature=False,
|
||||
)
|
||||
|
||||
# Verify threads().get() was called with correct parameters
|
||||
thread_get_kwargs = (
|
||||
mock_service.users.return_value.threads.return_value.get.call_args.kwargs
|
||||
)
|
||||
assert thread_get_kwargs["userId"] == "me"
|
||||
assert thread_get_kwargs["id"] == "thread123"
|
||||
assert thread_get_kwargs["format"] == "metadata"
|
||||
assert "Message-ID" in thread_get_kwargs["metadataHeaders"]
|
||||
|
||||
assert "Draft created! Draft ID: draft_reply" in result
|
||||
|
||||
create_kwargs = (
|
||||
mock_service.users.return_value.drafts.return_value.create.call_args.kwargs
|
||||
)
|
||||
raw_message = create_kwargs["body"]["message"]["raw"]
|
||||
raw_text = base64.urlsafe_b64decode(raw_message).decode("utf-8", errors="ignore")
|
||||
|
||||
assert "In-Reply-To: <msg3@example.com>" in raw_text
|
||||
assert (
|
||||
"References: <msg1@example.com> <msg2@example.com> <msg3@example.com>"
|
||||
in raw_text
|
||||
)
|
||||
assert create_kwargs["body"]["message"]["threadId"] == "thread123"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_draft_gmail_message_uses_explicit_in_reply_to_when_filling_references():
|
||||
mock_service = Mock()
|
||||
mock_service.users().drafts().create().execute.return_value = {"id": "draft_reply"}
|
||||
mock_service.users().threads().get().execute.return_value = _thread_response(
|
||||
"<msg1@example.com>",
|
||||
"<msg2@example.com>",
|
||||
"<msg3@example.com>",
|
||||
)
|
||||
|
||||
await _unwrap(draft_gmail_message)(
|
||||
service=mock_service,
|
||||
user_google_email="user@example.com",
|
||||
to="recipient@example.com",
|
||||
subject="Meeting tomorrow",
|
||||
body="Replying to an earlier message.",
|
||||
thread_id="thread123",
|
||||
in_reply_to="<msg2@example.com>",
|
||||
include_signature=False,
|
||||
)
|
||||
|
||||
create_kwargs = (
|
||||
mock_service.users.return_value.drafts.return_value.create.call_args.kwargs
|
||||
)
|
||||
raw_message = create_kwargs["body"]["message"]["raw"]
|
||||
raw_text = base64.urlsafe_b64decode(raw_message).decode("utf-8", errors="ignore")
|
||||
|
||||
assert "In-Reply-To: <msg2@example.com>" in raw_text
|
||||
assert "References: <msg1@example.com> <msg2@example.com>" in raw_text
|
||||
assert "<msg3@example.com>" not in raw_text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_draft_gmail_message_uses_explicit_references_when_filling_in_reply_to():
|
||||
mock_service = Mock()
|
||||
mock_service.users().drafts().create().execute.return_value = {"id": "draft_reply"}
|
||||
mock_service.users().threads().get().execute.return_value = _thread_response(
|
||||
"<msg1@example.com>",
|
||||
"<msg2@example.com>",
|
||||
"<msg3@example.com>",
|
||||
)
|
||||
|
||||
await _unwrap(draft_gmail_message)(
|
||||
service=mock_service,
|
||||
user_google_email="user@example.com",
|
||||
to="recipient@example.com",
|
||||
subject="Meeting tomorrow",
|
||||
body="Replying to an earlier message.",
|
||||
thread_id="thread123",
|
||||
references="<msg1@example.com> <msg2@example.com>",
|
||||
include_signature=False,
|
||||
)
|
||||
|
||||
create_kwargs = (
|
||||
mock_service.users.return_value.drafts.return_value.create.call_args.kwargs
|
||||
)
|
||||
raw_message = create_kwargs["body"]["message"]["raw"]
|
||||
raw_text = base64.urlsafe_b64decode(raw_message).decode("utf-8", errors="ignore")
|
||||
|
||||
assert "In-Reply-To: <msg2@example.com>" in raw_text
|
||||
assert "References: <msg1@example.com> <msg2@example.com>" in raw_text
|
||||
assert "<msg3@example.com>" not in raw_text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_draft_gmail_message_gracefully_degrades_when_thread_fetch_fails():
|
||||
mock_service = Mock()
|
||||
mock_service.users().drafts().create().execute.return_value = {"id": "draft_reply"}
|
||||
mock_service.users().threads().get().execute.side_effect = RuntimeError("boom")
|
||||
|
||||
result = await _unwrap(draft_gmail_message)(
|
||||
service=mock_service,
|
||||
user_google_email="user@example.com",
|
||||
to="recipient@example.com",
|
||||
subject="Meeting tomorrow",
|
||||
body="Thanks for the update.",
|
||||
thread_id="thread123",
|
||||
include_signature=False,
|
||||
)
|
||||
|
||||
assert "Draft created! Draft ID: draft_reply" in result
|
||||
|
||||
create_kwargs = (
|
||||
mock_service.users.return_value.drafts.return_value.create.call_args.kwargs
|
||||
)
|
||||
raw_message = create_kwargs["body"]["message"]["raw"]
|
||||
raw_text = base64.urlsafe_b64decode(raw_message).decode("utf-8", errors="ignore")
|
||||
|
||||
assert "In-Reply-To:" not in raw_text
|
||||
assert "References:" not in raw_text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_draft_gmail_message_gracefully_degrades_when_thread_has_no_messages():
|
||||
mock_service = Mock()
|
||||
mock_service.users().drafts().create().execute.return_value = {"id": "draft_reply"}
|
||||
mock_service.users().threads().get().execute.return_value = {"messages": []}
|
||||
|
||||
result = await _unwrap(draft_gmail_message)(
|
||||
service=mock_service,
|
||||
user_google_email="user@example.com",
|
||||
to="recipient@example.com",
|
||||
subject="Meeting tomorrow",
|
||||
body="Thanks for the update.",
|
||||
thread_id="thread123",
|
||||
include_signature=False,
|
||||
)
|
||||
|
||||
assert "Draft created! Draft ID: draft_reply" in result
|
||||
|
||||
create_kwargs = (
|
||||
mock_service.users.return_value.drafts.return_value.create.call_args.kwargs
|
||||
)
|
||||
raw_message = create_kwargs["body"]["message"]["raw"]
|
||||
raw_text = base64.urlsafe_b64decode(raw_message).decode("utf-8", errors="ignore")
|
||||
|
||||
assert "In-Reply-To:" not in raw_text
|
||||
assert "References:" not in raw_text
|
||||
0
tests/gsheets/__init__.py
Normal file
0
tests/gsheets/__init__.py
Normal file
436
tests/gsheets/test_format_sheet_range.py
Normal file
436
tests/gsheets/test_format_sheet_range.py
Normal file
@@ -0,0 +1,436 @@
|
||||
"""
|
||||
Unit tests for Google Sheets format_sheet_range tool enhancements
|
||||
|
||||
Tests the enhanced formatting parameters: wrap_strategy, horizontal_alignment,
|
||||
vertical_alignment, bold, italic, and font_size.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import Mock
|
||||
import sys
|
||||
import os
|
||||
|
||||
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../..")))
|
||||
|
||||
from gsheets.sheets_tools import _format_sheet_range_impl
|
||||
|
||||
|
||||
def create_mock_service():
|
||||
"""Create a properly configured mock Google Sheets service."""
|
||||
mock_service = Mock()
|
||||
|
||||
mock_metadata = {"sheets": [{"properties": {"sheetId": 0, "title": "Sheet1"}}]}
|
||||
mock_service.spreadsheets().get().execute = Mock(return_value=mock_metadata)
|
||||
mock_service.spreadsheets().batchUpdate().execute = Mock(return_value={})
|
||||
|
||||
return mock_service
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_format_wrap_strategy_wrap():
|
||||
"""Test wrap_strategy=WRAP applies text wrapping"""
|
||||
mock_service = create_mock_service()
|
||||
|
||||
result = await _format_sheet_range_impl(
|
||||
service=mock_service,
|
||||
spreadsheet_id="test_spreadsheet_123",
|
||||
range_name="A1:C10",
|
||||
wrap_strategy="WRAP",
|
||||
)
|
||||
|
||||
assert result["spreadsheet_id"] == "test_spreadsheet_123"
|
||||
assert result["range_name"] == "A1:C10"
|
||||
|
||||
call_args = mock_service.spreadsheets().batchUpdate.call_args
|
||||
request_body = call_args[1]["body"]
|
||||
cell_format = request_body["requests"][0]["repeatCell"]["cell"]["userEnteredFormat"]
|
||||
assert cell_format["wrapStrategy"] == "WRAP"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_format_wrap_strategy_clip():
|
||||
"""Test wrap_strategy=CLIP clips text at cell boundary"""
|
||||
mock_service = create_mock_service()
|
||||
|
||||
result = await _format_sheet_range_impl(
|
||||
service=mock_service,
|
||||
spreadsheet_id="test_spreadsheet_123",
|
||||
range_name="A1:B5",
|
||||
wrap_strategy="CLIP",
|
||||
)
|
||||
|
||||
assert result["spreadsheet_id"] == "test_spreadsheet_123"
|
||||
call_args = mock_service.spreadsheets().batchUpdate.call_args
|
||||
request_body = call_args[1]["body"]
|
||||
cell_format = request_body["requests"][0]["repeatCell"]["cell"]["userEnteredFormat"]
|
||||
assert cell_format["wrapStrategy"] == "CLIP"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_format_wrap_strategy_overflow():
|
||||
"""Test wrap_strategy=OVERFLOW_CELL allows text overflow"""
|
||||
mock_service = create_mock_service()
|
||||
|
||||
await _format_sheet_range_impl(
|
||||
service=mock_service,
|
||||
spreadsheet_id="test_spreadsheet_123",
|
||||
range_name="A1:A1",
|
||||
wrap_strategy="OVERFLOW_CELL",
|
||||
)
|
||||
|
||||
call_args = mock_service.spreadsheets().batchUpdate.call_args
|
||||
request_body = call_args[1]["body"]
|
||||
cell_format = request_body["requests"][0]["repeatCell"]["cell"]["userEnteredFormat"]
|
||||
assert cell_format["wrapStrategy"] == "OVERFLOW_CELL"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_format_horizontal_alignment_center():
|
||||
"""Test horizontal_alignment=CENTER centers text"""
|
||||
mock_service = create_mock_service()
|
||||
|
||||
result = await _format_sheet_range_impl(
|
||||
service=mock_service,
|
||||
spreadsheet_id="test_spreadsheet_123",
|
||||
range_name="A1:D10",
|
||||
horizontal_alignment="CENTER",
|
||||
)
|
||||
|
||||
assert result["spreadsheet_id"] == "test_spreadsheet_123"
|
||||
call_args = mock_service.spreadsheets().batchUpdate.call_args
|
||||
request_body = call_args[1]["body"]
|
||||
cell_format = request_body["requests"][0]["repeatCell"]["cell"]["userEnteredFormat"]
|
||||
assert cell_format["horizontalAlignment"] == "CENTER"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_format_horizontal_alignment_left():
|
||||
"""Test horizontal_alignment=LEFT aligns text left"""
|
||||
mock_service = create_mock_service()
|
||||
|
||||
await _format_sheet_range_impl(
|
||||
service=mock_service,
|
||||
spreadsheet_id="test_spreadsheet_123",
|
||||
range_name="A1:A10",
|
||||
horizontal_alignment="LEFT",
|
||||
)
|
||||
|
||||
call_args = mock_service.spreadsheets().batchUpdate.call_args
|
||||
request_body = call_args[1]["body"]
|
||||
cell_format = request_body["requests"][0]["repeatCell"]["cell"]["userEnteredFormat"]
|
||||
assert cell_format["horizontalAlignment"] == "LEFT"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_format_horizontal_alignment_right():
|
||||
"""Test horizontal_alignment=RIGHT aligns text right"""
|
||||
mock_service = create_mock_service()
|
||||
|
||||
await _format_sheet_range_impl(
|
||||
service=mock_service,
|
||||
spreadsheet_id="test_spreadsheet_123",
|
||||
range_name="B1:B10",
|
||||
horizontal_alignment="RIGHT",
|
||||
)
|
||||
|
||||
call_args = mock_service.spreadsheets().batchUpdate.call_args
|
||||
request_body = call_args[1]["body"]
|
||||
cell_format = request_body["requests"][0]["repeatCell"]["cell"]["userEnteredFormat"]
|
||||
assert cell_format["horizontalAlignment"] == "RIGHT"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_format_vertical_alignment_top():
|
||||
"""Test vertical_alignment=TOP aligns text to top"""
|
||||
mock_service = create_mock_service()
|
||||
|
||||
await _format_sheet_range_impl(
|
||||
service=mock_service,
|
||||
spreadsheet_id="test_spreadsheet_123",
|
||||
range_name="A1:C5",
|
||||
vertical_alignment="TOP",
|
||||
)
|
||||
|
||||
call_args = mock_service.spreadsheets().batchUpdate.call_args
|
||||
request_body = call_args[1]["body"]
|
||||
cell_format = request_body["requests"][0]["repeatCell"]["cell"]["userEnteredFormat"]
|
||||
assert cell_format["verticalAlignment"] == "TOP"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_format_vertical_alignment_middle():
|
||||
"""Test vertical_alignment=MIDDLE centers text vertically"""
|
||||
mock_service = create_mock_service()
|
||||
|
||||
await _format_sheet_range_impl(
|
||||
service=mock_service,
|
||||
spreadsheet_id="test_spreadsheet_123",
|
||||
range_name="A1:C5",
|
||||
vertical_alignment="MIDDLE",
|
||||
)
|
||||
|
||||
call_args = mock_service.spreadsheets().batchUpdate.call_args
|
||||
request_body = call_args[1]["body"]
|
||||
cell_format = request_body["requests"][0]["repeatCell"]["cell"]["userEnteredFormat"]
|
||||
assert cell_format["verticalAlignment"] == "MIDDLE"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_format_vertical_alignment_bottom():
|
||||
"""Test vertical_alignment=BOTTOM aligns text to bottom"""
|
||||
mock_service = create_mock_service()
|
||||
|
||||
await _format_sheet_range_impl(
|
||||
service=mock_service,
|
||||
spreadsheet_id="test_spreadsheet_123",
|
||||
range_name="A1:C5",
|
||||
vertical_alignment="BOTTOM",
|
||||
)
|
||||
|
||||
call_args = mock_service.spreadsheets().batchUpdate.call_args
|
||||
request_body = call_args[1]["body"]
|
||||
cell_format = request_body["requests"][0]["repeatCell"]["cell"]["userEnteredFormat"]
|
||||
assert cell_format["verticalAlignment"] == "BOTTOM"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_format_bold_true():
|
||||
"""Test bold=True applies bold text formatting"""
|
||||
mock_service = create_mock_service()
|
||||
|
||||
await _format_sheet_range_impl(
|
||||
service=mock_service,
|
||||
spreadsheet_id="test_spreadsheet_123",
|
||||
range_name="A1:A1",
|
||||
bold=True,
|
||||
)
|
||||
|
||||
call_args = mock_service.spreadsheets().batchUpdate.call_args
|
||||
request_body = call_args[1]["body"]
|
||||
cell_format = request_body["requests"][0]["repeatCell"]["cell"]["userEnteredFormat"]
|
||||
assert cell_format["textFormat"]["bold"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_format_italic_true():
|
||||
"""Test italic=True applies italic text formatting"""
|
||||
mock_service = create_mock_service()
|
||||
|
||||
await _format_sheet_range_impl(
|
||||
service=mock_service,
|
||||
spreadsheet_id="test_spreadsheet_123",
|
||||
range_name="A1:A1",
|
||||
italic=True,
|
||||
)
|
||||
|
||||
call_args = mock_service.spreadsheets().batchUpdate.call_args
|
||||
request_body = call_args[1]["body"]
|
||||
cell_format = request_body["requests"][0]["repeatCell"]["cell"]["userEnteredFormat"]
|
||||
assert cell_format["textFormat"]["italic"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_format_font_size():
|
||||
"""Test font_size applies specified font size"""
|
||||
mock_service = create_mock_service()
|
||||
|
||||
await _format_sheet_range_impl(
|
||||
service=mock_service,
|
||||
spreadsheet_id="test_spreadsheet_123",
|
||||
range_name="A1:D5",
|
||||
font_size=14,
|
||||
)
|
||||
|
||||
call_args = mock_service.spreadsheets().batchUpdate.call_args
|
||||
request_body = call_args[1]["body"]
|
||||
cell_format = request_body["requests"][0]["repeatCell"]["cell"]["userEnteredFormat"]
|
||||
assert cell_format["textFormat"]["fontSize"] == 14
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_format_combined_text_formatting():
|
||||
"""Test combining bold, italic, and font_size"""
|
||||
mock_service = create_mock_service()
|
||||
|
||||
await _format_sheet_range_impl(
|
||||
service=mock_service,
|
||||
spreadsheet_id="test_spreadsheet_123",
|
||||
range_name="A1:A1",
|
||||
bold=True,
|
||||
italic=True,
|
||||
font_size=16,
|
||||
)
|
||||
|
||||
call_args = mock_service.spreadsheets().batchUpdate.call_args
|
||||
request_body = call_args[1]["body"]
|
||||
cell_format = request_body["requests"][0]["repeatCell"]["cell"]["userEnteredFormat"]
|
||||
text_format = cell_format["textFormat"]
|
||||
assert text_format["bold"] is True
|
||||
assert text_format["italic"] is True
|
||||
assert text_format["fontSize"] == 16
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_format_combined_alignment_and_wrap():
|
||||
"""Test combining wrap_strategy with alignments"""
|
||||
mock_service = create_mock_service()
|
||||
|
||||
await _format_sheet_range_impl(
|
||||
service=mock_service,
|
||||
spreadsheet_id="test_spreadsheet_123",
|
||||
range_name="A1:C10",
|
||||
wrap_strategy="WRAP",
|
||||
horizontal_alignment="CENTER",
|
||||
vertical_alignment="TOP",
|
||||
)
|
||||
|
||||
call_args = mock_service.spreadsheets().batchUpdate.call_args
|
||||
request_body = call_args[1]["body"]
|
||||
cell_format = request_body["requests"][0]["repeatCell"]["cell"]["userEnteredFormat"]
|
||||
assert cell_format["wrapStrategy"] == "WRAP"
|
||||
assert cell_format["horizontalAlignment"] == "CENTER"
|
||||
assert cell_format["verticalAlignment"] == "TOP"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_format_all_new_params_with_existing():
|
||||
"""Test combining new params with existing color params"""
|
||||
mock_service = create_mock_service()
|
||||
|
||||
result = await _format_sheet_range_impl(
|
||||
service=mock_service,
|
||||
spreadsheet_id="test_spreadsheet_123",
|
||||
range_name="A1:D10",
|
||||
background_color="#FFFFFF",
|
||||
text_color="#000000",
|
||||
wrap_strategy="WRAP",
|
||||
horizontal_alignment="LEFT",
|
||||
vertical_alignment="MIDDLE",
|
||||
bold=True,
|
||||
font_size=12,
|
||||
)
|
||||
|
||||
assert result["spreadsheet_id"] == "test_spreadsheet_123"
|
||||
call_args = mock_service.spreadsheets().batchUpdate.call_args
|
||||
request_body = call_args[1]["body"]
|
||||
cell_format = request_body["requests"][0]["repeatCell"]["cell"]["userEnteredFormat"]
|
||||
|
||||
assert cell_format["wrapStrategy"] == "WRAP"
|
||||
assert cell_format["horizontalAlignment"] == "LEFT"
|
||||
assert cell_format["verticalAlignment"] == "MIDDLE"
|
||||
assert cell_format["textFormat"]["bold"] is True
|
||||
assert cell_format["textFormat"]["fontSize"] == 12
|
||||
assert "backgroundColor" in cell_format
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_format_invalid_wrap_strategy():
|
||||
"""Test invalid wrap_strategy raises error"""
|
||||
mock_service = create_mock_service()
|
||||
|
||||
from core.utils import UserInputError
|
||||
|
||||
with pytest.raises(UserInputError) as exc_info:
|
||||
await _format_sheet_range_impl(
|
||||
service=mock_service,
|
||||
spreadsheet_id="test_spreadsheet_123",
|
||||
range_name="A1:A1",
|
||||
wrap_strategy="INVALID",
|
||||
)
|
||||
|
||||
error_msg = str(exc_info.value).lower()
|
||||
assert "wrap_strategy" in error_msg or "wrap" in error_msg
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_format_invalid_horizontal_alignment():
|
||||
"""Test invalid horizontal_alignment raises error"""
|
||||
mock_service = create_mock_service()
|
||||
|
||||
from core.utils import UserInputError
|
||||
|
||||
with pytest.raises(UserInputError) as exc_info:
|
||||
await _format_sheet_range_impl(
|
||||
service=mock_service,
|
||||
spreadsheet_id="test_spreadsheet_123",
|
||||
range_name="A1:A1",
|
||||
horizontal_alignment="INVALID",
|
||||
)
|
||||
|
||||
error_msg = str(exc_info.value).lower()
|
||||
assert "horizontal" in error_msg or "left" in error_msg
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_format_invalid_vertical_alignment():
|
||||
"""Test invalid vertical_alignment raises error"""
|
||||
mock_service = create_mock_service()
|
||||
|
||||
from core.utils import UserInputError
|
||||
|
||||
with pytest.raises(UserInputError) as exc_info:
|
||||
await _format_sheet_range_impl(
|
||||
service=mock_service,
|
||||
spreadsheet_id="test_spreadsheet_123",
|
||||
range_name="A1:A1",
|
||||
vertical_alignment="INVALID",
|
||||
)
|
||||
|
||||
error_msg = str(exc_info.value).lower()
|
||||
assert "vertical" in error_msg or "top" in error_msg
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_format_case_insensitive_wrap_strategy():
|
||||
"""Test wrap_strategy accepts lowercase input"""
|
||||
mock_service = create_mock_service()
|
||||
|
||||
await _format_sheet_range_impl(
|
||||
service=mock_service,
|
||||
spreadsheet_id="test_spreadsheet_123",
|
||||
range_name="A1:A1",
|
||||
wrap_strategy="wrap",
|
||||
)
|
||||
|
||||
call_args = mock_service.spreadsheets().batchUpdate.call_args
|
||||
request_body = call_args[1]["body"]
|
||||
cell_format = request_body["requests"][0]["repeatCell"]["cell"]["userEnteredFormat"]
|
||||
assert cell_format["wrapStrategy"] == "WRAP"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_format_case_insensitive_alignment():
|
||||
"""Test alignments accept lowercase input"""
|
||||
mock_service = create_mock_service()
|
||||
|
||||
await _format_sheet_range_impl(
|
||||
service=mock_service,
|
||||
spreadsheet_id="test_spreadsheet_123",
|
||||
range_name="A1:A1",
|
||||
horizontal_alignment="center",
|
||||
vertical_alignment="middle",
|
||||
)
|
||||
|
||||
call_args = mock_service.spreadsheets().batchUpdate.call_args
|
||||
request_body = call_args[1]["body"]
|
||||
cell_format = request_body["requests"][0]["repeatCell"]["cell"]["userEnteredFormat"]
|
||||
assert cell_format["horizontalAlignment"] == "CENTER"
|
||||
assert cell_format["verticalAlignment"] == "MIDDLE"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_format_confirmation_message_includes_new_params():
|
||||
"""Test confirmation message mentions new formatting applied"""
|
||||
mock_service = create_mock_service()
|
||||
|
||||
result = await _format_sheet_range_impl(
|
||||
service=mock_service,
|
||||
spreadsheet_id="test_spreadsheet_123",
|
||||
range_name="A1:C10",
|
||||
wrap_strategy="WRAP",
|
||||
bold=True,
|
||||
font_size=14,
|
||||
)
|
||||
|
||||
assert result["spreadsheet_id"] == "test_spreadsheet_123"
|
||||
assert result["range_name"] == "A1:C10"
|
||||
60
tests/test_main_permissions_tier.py
Normal file
60
tests/test_main_permissions_tier.py
Normal file
@@ -0,0 +1,60 @@
|
||||
import os
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
|
||||
|
||||
import main
|
||||
|
||||
|
||||
def test_resolve_permissions_mode_selection_without_tier():
|
||||
services = ["gmail", "drive"]
|
||||
resolved_services, tier_tool_filter = main.resolve_permissions_mode_selection(
|
||||
services, None
|
||||
)
|
||||
assert resolved_services == services
|
||||
assert tier_tool_filter is None
|
||||
|
||||
|
||||
def test_resolve_permissions_mode_selection_with_tier_filters_services(monkeypatch):
|
||||
def fake_resolve_tools_from_tier(tier, services):
|
||||
assert tier == "core"
|
||||
assert services == ["gmail", "drive", "slides"]
|
||||
return ["search_gmail_messages"], ["gmail"]
|
||||
|
||||
monkeypatch.setattr(main, "resolve_tools_from_tier", fake_resolve_tools_from_tier)
|
||||
|
||||
resolved_services, tier_tool_filter = main.resolve_permissions_mode_selection(
|
||||
["gmail", "drive", "slides"], "core"
|
||||
)
|
||||
assert resolved_services == ["gmail"]
|
||||
assert tier_tool_filter == {"search_gmail_messages"}
|
||||
|
||||
|
||||
def test_narrow_permissions_to_services_keeps_selected_order():
|
||||
permissions = {"drive": "full", "gmail": "readonly", "calendar": "readonly"}
|
||||
narrowed = main.narrow_permissions_to_services(permissions, ["gmail", "drive"])
|
||||
assert narrowed == {"gmail": "readonly", "drive": "full"}
|
||||
|
||||
|
||||
def test_narrow_permissions_to_services_drops_non_selected_services():
|
||||
permissions = {"gmail": "send", "drive": "full"}
|
||||
narrowed = main.narrow_permissions_to_services(permissions, ["gmail"])
|
||||
assert narrowed == {"gmail": "send"}
|
||||
|
||||
|
||||
def test_permissions_and_tools_flags_are_rejected(monkeypatch, capsys):
|
||||
monkeypatch.setattr(main, "configure_safe_logging", lambda: None)
|
||||
monkeypatch.setattr(
|
||||
sys,
|
||||
"argv",
|
||||
["main.py", "--permissions", "gmail:readonly", "--tools", "gmail"],
|
||||
)
|
||||
|
||||
with pytest.raises(SystemExit) as exc:
|
||||
main.main()
|
||||
|
||||
assert exc.value.code == 1
|
||||
captured = capsys.readouterr()
|
||||
assert "--permissions and --tools cannot be combined" in captured.err
|
||||
201
tests/test_permissions.py
Normal file
201
tests/test_permissions.py
Normal file
@@ -0,0 +1,201 @@
|
||||
"""
|
||||
Unit tests for granular per-service permission parsing and scope resolution.
|
||||
|
||||
Covers parse_permissions_arg() validation (format, duplicates, unknown
|
||||
service/level) and cumulative scope expansion in get_scopes_for_permission().
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
|
||||
|
||||
from auth.permissions import (
|
||||
get_scopes_for_permission,
|
||||
is_action_denied,
|
||||
parse_permissions_arg,
|
||||
set_permissions,
|
||||
SERVICE_PERMISSION_LEVELS,
|
||||
)
|
||||
from auth.scopes import (
|
||||
GMAIL_READONLY_SCOPE,
|
||||
GMAIL_LABELS_SCOPE,
|
||||
GMAIL_MODIFY_SCOPE,
|
||||
GMAIL_COMPOSE_SCOPE,
|
||||
DRIVE_READONLY_SCOPE,
|
||||
DRIVE_SCOPE,
|
||||
TASKS_READONLY_SCOPE,
|
||||
TASKS_SCOPE,
|
||||
DRIVE_FILE_SCOPE,
|
||||
)
|
||||
|
||||
|
||||
class TestParsePermissionsArg:
|
||||
"""Tests for parse_permissions_arg()."""
|
||||
|
||||
def test_single_valid_entry(self):
|
||||
result = parse_permissions_arg(["gmail:readonly"])
|
||||
assert result == {"gmail": "readonly"}
|
||||
|
||||
def test_multiple_valid_entries(self):
|
||||
result = parse_permissions_arg(["gmail:organize", "drive:full"])
|
||||
assert result == {"gmail": "organize", "drive": "full"}
|
||||
|
||||
def test_all_services_at_readonly(self):
|
||||
entries = [f"{svc}:readonly" for svc in SERVICE_PERMISSION_LEVELS]
|
||||
result = parse_permissions_arg(entries)
|
||||
assert set(result.keys()) == set(SERVICE_PERMISSION_LEVELS.keys())
|
||||
|
||||
def test_missing_colon_raises(self):
|
||||
with pytest.raises(ValueError, match="Invalid permission format"):
|
||||
parse_permissions_arg(["gmail_readonly"])
|
||||
|
||||
def test_duplicate_service_raises(self):
|
||||
with pytest.raises(ValueError, match="Duplicate service"):
|
||||
parse_permissions_arg(["gmail:readonly", "gmail:full"])
|
||||
|
||||
def test_unknown_service_raises(self):
|
||||
with pytest.raises(ValueError, match="Unknown service"):
|
||||
parse_permissions_arg(["fakesvc:readonly"])
|
||||
|
||||
def test_unknown_level_raises(self):
|
||||
with pytest.raises(ValueError, match="Unknown level"):
|
||||
parse_permissions_arg(["gmail:superadmin"])
|
||||
|
||||
def test_empty_list_returns_empty(self):
|
||||
assert parse_permissions_arg([]) == {}
|
||||
|
||||
def test_extra_colon_in_value(self):
|
||||
"""A level containing a colon should fail as unknown level."""
|
||||
with pytest.raises(ValueError, match="Unknown level"):
|
||||
parse_permissions_arg(["gmail:read:only"])
|
||||
|
||||
def test_tasks_manage_is_valid_level(self):
|
||||
"""tasks:manage should be accepted by parse_permissions_arg."""
|
||||
result = parse_permissions_arg(["tasks:manage"])
|
||||
assert result == {"tasks": "manage"}
|
||||
|
||||
|
||||
class TestGetScopesForPermission:
|
||||
"""Tests for get_scopes_for_permission() cumulative scope expansion."""
|
||||
|
||||
def test_gmail_readonly_returns_readonly_scope(self):
|
||||
scopes = get_scopes_for_permission("gmail", "readonly")
|
||||
assert GMAIL_READONLY_SCOPE in scopes
|
||||
|
||||
def test_gmail_organize_includes_readonly(self):
|
||||
"""Organize level should cumulatively include readonly scopes."""
|
||||
scopes = get_scopes_for_permission("gmail", "organize")
|
||||
assert GMAIL_READONLY_SCOPE in scopes
|
||||
assert GMAIL_LABELS_SCOPE in scopes
|
||||
assert GMAIL_MODIFY_SCOPE in scopes
|
||||
|
||||
def test_gmail_drafts_includes_organize_and_readonly(self):
|
||||
scopes = get_scopes_for_permission("gmail", "drafts")
|
||||
assert GMAIL_READONLY_SCOPE in scopes
|
||||
assert GMAIL_LABELS_SCOPE in scopes
|
||||
assert GMAIL_COMPOSE_SCOPE in scopes
|
||||
|
||||
def test_drive_readonly_excludes_full(self):
|
||||
scopes = get_scopes_for_permission("drive", "readonly")
|
||||
assert DRIVE_READONLY_SCOPE in scopes
|
||||
assert DRIVE_SCOPE not in scopes
|
||||
assert DRIVE_FILE_SCOPE not in scopes
|
||||
|
||||
def test_drive_full_includes_readonly(self):
|
||||
scopes = get_scopes_for_permission("drive", "full")
|
||||
assert DRIVE_READONLY_SCOPE in scopes
|
||||
assert DRIVE_SCOPE in scopes
|
||||
|
||||
def test_unknown_service_raises(self):
|
||||
with pytest.raises(ValueError, match="Unknown service"):
|
||||
get_scopes_for_permission("nonexistent", "readonly")
|
||||
|
||||
def test_unknown_level_raises(self):
|
||||
with pytest.raises(ValueError, match="Unknown permission level"):
|
||||
get_scopes_for_permission("gmail", "nonexistent")
|
||||
|
||||
def test_no_duplicate_scopes(self):
|
||||
"""Cumulative expansion should deduplicate scopes."""
|
||||
for service, levels in SERVICE_PERMISSION_LEVELS.items():
|
||||
for level_name, _ in levels:
|
||||
scopes = get_scopes_for_permission(service, level_name)
|
||||
assert len(scopes) == len(set(scopes)), (
|
||||
f"Duplicate scopes for {service}:{level_name}"
|
||||
)
|
||||
|
||||
def test_tasks_manage_includes_write_scope(self):
|
||||
"""Manage level should cumulatively include readonly and write scopes."""
|
||||
scopes = get_scopes_for_permission("tasks", "manage")
|
||||
assert TASKS_SCOPE in scopes
|
||||
assert TASKS_READONLY_SCOPE in scopes
|
||||
|
||||
def test_tasks_full_includes_write_scope(self):
|
||||
"""Full level should include write and readonly scopes from lower levels."""
|
||||
scopes = get_scopes_for_permission("tasks", "full")
|
||||
assert TASKS_SCOPE in scopes
|
||||
assert TASKS_READONLY_SCOPE in scopes
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_permissions_state():
|
||||
"""Ensure each test starts and ends with no active permissions."""
|
||||
set_permissions(None)
|
||||
yield
|
||||
set_permissions(None)
|
||||
|
||||
|
||||
class TestIsActionDenied:
|
||||
"""Tests for is_action_denied() and SERVICE_DENIED_ACTIONS."""
|
||||
|
||||
def test_no_permissions_mode_allows_all(self):
|
||||
"""Without granular permissions, no action is denied."""
|
||||
set_permissions(None)
|
||||
assert is_action_denied("tasks", "delete") is False
|
||||
|
||||
def test_tasks_full_allows_delete(self):
|
||||
"""Full level should not deny delete."""
|
||||
set_permissions({"tasks": "full"})
|
||||
assert is_action_denied("tasks", "delete") is False
|
||||
|
||||
def test_tasks_manage_denies_delete(self):
|
||||
"""Manage level should deny delete."""
|
||||
set_permissions({"tasks": "manage"})
|
||||
assert is_action_denied("tasks", "delete") is True
|
||||
|
||||
def test_tasks_manage_allows_create(self):
|
||||
"""Manage level should allow create."""
|
||||
set_permissions({"tasks": "manage"})
|
||||
assert is_action_denied("tasks", "create") is False
|
||||
|
||||
def test_tasks_manage_allows_update(self):
|
||||
"""Manage level should allow update."""
|
||||
set_permissions({"tasks": "manage"})
|
||||
assert is_action_denied("tasks", "update") is False
|
||||
|
||||
def test_tasks_manage_allows_move(self):
|
||||
"""Manage level should allow move."""
|
||||
set_permissions({"tasks": "manage"})
|
||||
assert is_action_denied("tasks", "move") is False
|
||||
|
||||
def test_tasks_manage_denies_clear_completed(self):
|
||||
"""Manage level should deny clear_completed."""
|
||||
set_permissions({"tasks": "manage"})
|
||||
assert is_action_denied("tasks", "clear_completed") is True
|
||||
|
||||
def test_tasks_full_allows_clear_completed(self):
|
||||
"""Full level should not deny clear_completed."""
|
||||
set_permissions({"tasks": "full"})
|
||||
assert is_action_denied("tasks", "clear_completed") is False
|
||||
|
||||
def test_service_not_in_permissions_allows_all(self):
|
||||
"""A service not listed in permissions should allow all actions."""
|
||||
set_permissions({"gmail": "readonly"})
|
||||
assert is_action_denied("tasks", "delete") is False
|
||||
|
||||
def test_service_without_denied_actions_allows_all(self):
|
||||
"""A service with no SERVICE_DENIED_ACTIONS entry should allow all actions."""
|
||||
set_permissions({"gmail": "readonly"})
|
||||
assert is_action_denied("gmail", "delete") is False
|
||||
231
tests/test_scopes.py
Normal file
231
tests/test_scopes.py
Normal file
@@ -0,0 +1,231 @@
|
||||
"""
|
||||
Unit tests for cross-service scope generation.
|
||||
|
||||
Verifies that docs and sheets tools automatically include the Drive scopes
|
||||
they need for operations like search_docs, list_docs_in_folder,
|
||||
export_doc_to_pdf, and list_spreadsheets — without requiring --tools drive.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
|
||||
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
|
||||
|
||||
from auth.scopes import (
|
||||
BASE_SCOPES,
|
||||
CALENDAR_READONLY_SCOPE,
|
||||
CALENDAR_SCOPE,
|
||||
CONTACTS_READONLY_SCOPE,
|
||||
CONTACTS_SCOPE,
|
||||
DRIVE_FILE_SCOPE,
|
||||
DRIVE_READONLY_SCOPE,
|
||||
DRIVE_SCOPE,
|
||||
GMAIL_COMPOSE_SCOPE,
|
||||
GMAIL_LABELS_SCOPE,
|
||||
GMAIL_MODIFY_SCOPE,
|
||||
GMAIL_READONLY_SCOPE,
|
||||
GMAIL_SEND_SCOPE,
|
||||
GMAIL_SETTINGS_BASIC_SCOPE,
|
||||
SHEETS_READONLY_SCOPE,
|
||||
SHEETS_WRITE_SCOPE,
|
||||
get_scopes_for_tools,
|
||||
has_required_scopes,
|
||||
set_read_only,
|
||||
)
|
||||
from auth.permissions import get_scopes_for_permission, set_permissions
|
||||
import auth.permissions as permissions_module
|
||||
|
||||
|
||||
class TestDocsScopes:
|
||||
"""Tests for docs tool scope generation."""
|
||||
|
||||
def test_docs_includes_drive_readonly(self):
|
||||
"""search_docs, get_doc_content, list_docs_in_folder need drive.readonly."""
|
||||
scopes = get_scopes_for_tools(["docs"])
|
||||
assert DRIVE_READONLY_SCOPE in scopes
|
||||
|
||||
def test_docs_includes_drive_file(self):
|
||||
"""export_doc_to_pdf needs drive.file to create the PDF."""
|
||||
scopes = get_scopes_for_tools(["docs"])
|
||||
assert DRIVE_FILE_SCOPE in scopes
|
||||
|
||||
def test_docs_does_not_include_full_drive(self):
|
||||
"""docs should NOT request full drive access."""
|
||||
scopes = get_scopes_for_tools(["docs"])
|
||||
assert DRIVE_SCOPE not in scopes
|
||||
|
||||
|
||||
class TestSheetsScopes:
|
||||
"""Tests for sheets tool scope generation."""
|
||||
|
||||
def test_sheets_includes_drive_readonly(self):
|
||||
"""list_spreadsheets needs drive.readonly."""
|
||||
scopes = get_scopes_for_tools(["sheets"])
|
||||
assert DRIVE_READONLY_SCOPE in scopes
|
||||
|
||||
def test_sheets_does_not_include_full_drive(self):
|
||||
"""sheets should NOT request full drive access."""
|
||||
scopes = get_scopes_for_tools(["sheets"])
|
||||
assert DRIVE_SCOPE not in scopes
|
||||
|
||||
|
||||
class TestCombinedScopes:
|
||||
"""Tests for combined tool scope generation."""
|
||||
|
||||
def test_docs_sheets_no_duplicate_drive_readonly(self):
|
||||
"""Combined docs+sheets should deduplicate drive.readonly."""
|
||||
scopes = get_scopes_for_tools(["docs", "sheets"])
|
||||
assert scopes.count(DRIVE_READONLY_SCOPE) <= 1
|
||||
|
||||
def test_docs_sheets_returns_unique_scopes(self):
|
||||
"""All returned scopes should be unique."""
|
||||
scopes = get_scopes_for_tools(["docs", "sheets"])
|
||||
assert len(scopes) == len(set(scopes))
|
||||
|
||||
|
||||
class TestReadOnlyScopes:
|
||||
"""Tests for read-only mode scope generation."""
|
||||
|
||||
def setup_method(self):
|
||||
set_read_only(False)
|
||||
|
||||
def teardown_method(self):
|
||||
set_read_only(False)
|
||||
|
||||
def test_docs_readonly_includes_drive_readonly(self):
|
||||
"""Even in read-only mode, docs needs drive.readonly for search/list."""
|
||||
set_read_only(True)
|
||||
scopes = get_scopes_for_tools(["docs"])
|
||||
assert DRIVE_READONLY_SCOPE in scopes
|
||||
|
||||
def test_docs_readonly_excludes_drive_file(self):
|
||||
"""In read-only mode, docs should NOT request drive.file."""
|
||||
set_read_only(True)
|
||||
scopes = get_scopes_for_tools(["docs"])
|
||||
assert DRIVE_FILE_SCOPE not in scopes
|
||||
|
||||
def test_sheets_readonly_includes_drive_readonly(self):
|
||||
"""Even in read-only mode, sheets needs drive.readonly for list."""
|
||||
set_read_only(True)
|
||||
scopes = get_scopes_for_tools(["sheets"])
|
||||
assert DRIVE_READONLY_SCOPE in scopes
|
||||
|
||||
|
||||
class TestHasRequiredScopes:
|
||||
"""Tests for hierarchy-aware scope checking."""
|
||||
|
||||
def test_exact_match(self):
|
||||
"""Exact scope match should pass."""
|
||||
assert has_required_scopes([GMAIL_READONLY_SCOPE], [GMAIL_READONLY_SCOPE])
|
||||
|
||||
def test_missing_scope_fails(self):
|
||||
"""Missing scope with no covering broader scope should fail."""
|
||||
assert not has_required_scopes([GMAIL_READONLY_SCOPE], [GMAIL_SEND_SCOPE])
|
||||
|
||||
def test_empty_available_fails(self):
|
||||
"""Empty available scopes should fail when scopes are required."""
|
||||
assert not has_required_scopes([], [GMAIL_READONLY_SCOPE])
|
||||
|
||||
def test_empty_required_passes(self):
|
||||
"""No required scopes should always pass."""
|
||||
assert has_required_scopes([], [])
|
||||
assert has_required_scopes([GMAIL_READONLY_SCOPE], [])
|
||||
|
||||
def test_none_available_fails(self):
|
||||
"""None available scopes should fail when scopes are required."""
|
||||
assert not has_required_scopes(None, [GMAIL_READONLY_SCOPE])
|
||||
|
||||
def test_none_available_empty_required_passes(self):
|
||||
"""None available with no required scopes should pass."""
|
||||
assert has_required_scopes(None, [])
|
||||
|
||||
# Gmail hierarchy: gmail.modify covers readonly, send, compose, labels
|
||||
def test_gmail_modify_covers_readonly(self):
|
||||
assert has_required_scopes([GMAIL_MODIFY_SCOPE], [GMAIL_READONLY_SCOPE])
|
||||
|
||||
def test_gmail_modify_covers_send(self):
|
||||
assert has_required_scopes([GMAIL_MODIFY_SCOPE], [GMAIL_SEND_SCOPE])
|
||||
|
||||
def test_gmail_modify_covers_compose(self):
|
||||
assert has_required_scopes([GMAIL_MODIFY_SCOPE], [GMAIL_COMPOSE_SCOPE])
|
||||
|
||||
def test_gmail_modify_covers_labels(self):
|
||||
assert has_required_scopes([GMAIL_MODIFY_SCOPE], [GMAIL_LABELS_SCOPE])
|
||||
|
||||
def test_gmail_modify_does_not_cover_settings(self):
|
||||
"""gmail.modify does NOT cover gmail.settings.basic."""
|
||||
assert not has_required_scopes(
|
||||
[GMAIL_MODIFY_SCOPE], [GMAIL_SETTINGS_BASIC_SCOPE]
|
||||
)
|
||||
|
||||
def test_gmail_modify_covers_multiple_children(self):
|
||||
"""gmail.modify should satisfy multiple child scopes at once."""
|
||||
assert has_required_scopes(
|
||||
[GMAIL_MODIFY_SCOPE],
|
||||
[GMAIL_READONLY_SCOPE, GMAIL_SEND_SCOPE, GMAIL_LABELS_SCOPE],
|
||||
)
|
||||
|
||||
# Drive hierarchy: drive covers drive.readonly and drive.file
|
||||
def test_drive_covers_readonly(self):
|
||||
assert has_required_scopes([DRIVE_SCOPE], [DRIVE_READONLY_SCOPE])
|
||||
|
||||
def test_drive_covers_file(self):
|
||||
assert has_required_scopes([DRIVE_SCOPE], [DRIVE_FILE_SCOPE])
|
||||
|
||||
def test_drive_readonly_does_not_cover_full(self):
|
||||
"""Narrower scope should not satisfy broader scope."""
|
||||
assert not has_required_scopes([DRIVE_READONLY_SCOPE], [DRIVE_SCOPE])
|
||||
|
||||
# Other hierarchies
|
||||
def test_calendar_covers_readonly(self):
|
||||
assert has_required_scopes([CALENDAR_SCOPE], [CALENDAR_READONLY_SCOPE])
|
||||
|
||||
def test_sheets_write_covers_readonly(self):
|
||||
assert has_required_scopes([SHEETS_WRITE_SCOPE], [SHEETS_READONLY_SCOPE])
|
||||
|
||||
def test_contacts_covers_readonly(self):
|
||||
assert has_required_scopes([CONTACTS_SCOPE], [CONTACTS_READONLY_SCOPE])
|
||||
|
||||
# Mixed: some exact, some via hierarchy
|
||||
def test_mixed_exact_and_hierarchy(self):
|
||||
"""Combination of exact matches and hierarchy-implied scopes."""
|
||||
available = [GMAIL_MODIFY_SCOPE, DRIVE_READONLY_SCOPE]
|
||||
required = [GMAIL_READONLY_SCOPE, DRIVE_READONLY_SCOPE]
|
||||
assert has_required_scopes(available, required)
|
||||
|
||||
def test_mixed_partial_failure(self):
|
||||
"""Should fail if hierarchy covers some but not all required scopes."""
|
||||
available = [GMAIL_MODIFY_SCOPE]
|
||||
required = [GMAIL_READONLY_SCOPE, DRIVE_READONLY_SCOPE]
|
||||
assert not has_required_scopes(available, required)
|
||||
|
||||
|
||||
class TestGranularPermissionsScopes:
|
||||
"""Tests for granular permissions scope generation path."""
|
||||
|
||||
def setup_method(self):
|
||||
set_read_only(False)
|
||||
permissions_module._PERMISSIONS = None
|
||||
|
||||
def teardown_method(self):
|
||||
set_read_only(False)
|
||||
permissions_module._PERMISSIONS = None
|
||||
|
||||
def test_permissions_mode_returns_base_plus_permission_scopes(self):
|
||||
set_permissions({"gmail": "send", "drive": "readonly"})
|
||||
scopes = get_scopes_for_tools(["calendar"]) # ignored in permissions mode
|
||||
|
||||
expected = set(BASE_SCOPES)
|
||||
expected.update(get_scopes_for_permission("gmail", "send"))
|
||||
expected.update(get_scopes_for_permission("drive", "readonly"))
|
||||
assert set(scopes) == expected
|
||||
|
||||
def test_permissions_mode_overrides_read_only_and_full_maps(self):
|
||||
set_read_only(True)
|
||||
without_permissions = get_scopes_for_tools(["drive"])
|
||||
assert DRIVE_READONLY_SCOPE in without_permissions
|
||||
|
||||
set_permissions({"gmail": "readonly"})
|
||||
with_permissions = get_scopes_for_tools(["drive"])
|
||||
assert GMAIL_READONLY_SCOPE in with_permissions
|
||||
assert DRIVE_READONLY_SCOPE not in with_permissions
|
||||
Reference in New Issue
Block a user