Merge branch 'main' of github.com:taylorwilsdon/google_workspace_mcp into feat/chat-attachment-download

This commit is contained in:
Taylor Wilsdon
2026-02-19 09:41:06 -05:00
7 changed files with 793 additions and 1 deletions

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

110
tests/core/test_comments.py Normal file
View File

@@ -0,0 +1,110 @@
"""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

@@ -0,0 +1,267 @@
"""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() == ""