Merge branch 'main' of github.com:taylorwilsdon/google_workspace_mcp into feature/create-drive-folder
This commit is contained in:
0
tests/core/__init__.py
Normal file
0
tests/core/__init__.py
Normal file
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 AsyncMock, Mock, patch
|
||||
|
||||
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
|
||||
@@ -62,12 +62,19 @@ async def test_list_script_projects():
|
||||
async def test_get_script_project():
|
||||
"""Test retrieving complete project details"""
|
||||
mock_service = Mock()
|
||||
mock_response = {
|
||||
|
||||
# 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",
|
||||
@@ -77,7 +84,8 @@ async def test_get_script_project():
|
||||
],
|
||||
}
|
||||
|
||||
mock_service.projects().get().execute.return_value = mock_response
|
||||
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"
|
||||
|
||||
0
tests/gchat/__init__.py
Normal file
0
tests/gchat/__init__.py
Normal file
415
tests/gchat/test_chat_tools.py
Normal file
415
tests/gchat/test_chat_tools.py
Normal file
@@ -0,0 +1,415 @@
|
||||
"""
|
||||
Unit tests for Google Chat MCP tools — attachment support
|
||||
"""
|
||||
|
||||
import base64
|
||||
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 = tool.fn # FunctionTool stores the wrapped callable in .fn
|
||||
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]" 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]
|
||||
assert "chat.googleapis.com" in url_used
|
||||
assert "alt=media" in url_used
|
||||
assert "spaces/S/attachments/A" in url_used
|
||||
assert "/messages/" not in url_used
|
||||
|
||||
# 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
|
||||
0
tests/gdocs/__init__.py
Normal file
0
tests/gdocs/__init__.py
Normal file
392
tests/gdocs/test_docs_markdown.py
Normal file
392
tests/gdocs/test_docs_markdown.py
Normal file
@@ -0,0 +1,392 @@
|
||||
"""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
|
||||
|
||||
|
||||
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
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
|
||||
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/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"
|
||||
197
tests/test_scopes.py
Normal file
197
tests/test_scopes.py
Normal file
@@ -0,0 +1,197 @@
|
||||
"""
|
||||
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 (
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
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)
|
||||
Reference in New Issue
Block a user