Merge branch 'main' of github.com:taylorwilsdon/google_workspace_mcp into feature/create-drive-folder

This commit is contained in:
Taylor Wilsdon
2026-02-19 09:55:07 -05:00
59 changed files with 5738 additions and 1234 deletions

0
tests/core/__init__.py Normal file
View File

112
tests/core/test_comments.py Normal file
View 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

View File

@@ -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
View File

View 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
View File

View 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() == ""

View 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

View File

@@ -0,0 +1 @@

View 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")

View File

View 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
View 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)