test: expand gmail_service.py unit test coverage (10 new tests)

Add tests for uncovered branches:
- inject_email HttpError handling
- get_or_create_label: existing label, create new, HttpError, generic error
- inject_debug_email full flow + dedup of test label
- get_refreshed_token: token changed vs unchanged
- service property lazy initialization via build()

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot]
2026-03-29 01:01:33 +00:00
parent 8eff0833fa
commit c3ee2fc821
2 changed files with 219 additions and 1 deletions
+1
View File
@@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- Expanded `gmail_service.py` unit test coverage: 10 new tests covering `inject_email` HttpError branch, `get_or_create_label` (existing/create/HttpError/generic-error), `inject_debug_email` full flow, `get_refreshed_token` (changed/unchanged), and `service` property lazy initialization — 24 total tests in `test_gmail_service.py`
- Expanded `api.ts` test coverage from 20% to near-complete: 56 new tests covering all 10 API objects (`authApi`, `userApi`, `mailAccountsApi`, `processingRunsApi`, `gmailApi`, `smtpApi`, `adminApi`, `notificationsApi`, `adminNotificationsApi`, `versionApi`) — 65 total tests in `api.test.ts`
- Unit tests for `AddMailAccountModal` component (`AddMailAccountModal.test.tsx`): 28 tests covering create/edit mode rendering, provider wizard flow, form field changes, checkbox toggles, auto-detect, test connection, submit mutations, error extraction, and modal close interactions
- Unit tests for provider endpoints (`test_providers.py`): 23 tests covering provider presets, Gmail credential CRUD, import labels, authorize URL, debug email, and OAuth callback
+218 -1
View File
@@ -2,8 +2,13 @@
Unit tests for Gmail service module.
"""
import json
from datetime import datetime, timezone
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from unittest.mock import AsyncMock, MagicMock
from googleapiclient.errors import HttpError
from app.services.gmail_service import GmailService, GmailInjectionError, GMAIL_SCOPES
from app.utils.gmail_labels import (
DEFAULT_IMPORT_LABEL_TEMPLATES,
@@ -202,3 +207,215 @@ class TestGmailService:
)
assert label_ids == ["INBOX", "Label-source", "Label-imported"]
# ------------------------------------------------------------------
# Helper for HttpError construction
# ------------------------------------------------------------------
@staticmethod
def _make_http_error(
status_code: int = 401, reason: str = "Unauthorized"
) -> HttpError:
resp = MagicMock()
resp.status = status_code
resp.reason = reason
content = json.dumps({"error": {"message": reason}}).encode()
return HttpError(resp, content, uri="https://gmail.googleapis.com/test")
# ------------------------------------------------------------------
# inject_email HttpError branch
# ------------------------------------------------------------------
@pytest.mark.asyncio
async def test_inject_email_http_error(self):
"""HttpError in inject_email is caught and re-raised as GmailInjectionError."""
service = GmailService(access_token="test-access-token")
mock_api = MagicMock()
mock_api.users().messages().insert().execute.side_effect = (
self._make_http_error(403, "Forbidden")
)
service._service = mock_api
with pytest.raises(GmailInjectionError, match="Gmail API error"):
await service.inject_email(
raw_email=b"From: a@b.com\r\nSubject: X\r\n\r\nBody",
)
# ------------------------------------------------------------------
# get_or_create_label existing label found
# ------------------------------------------------------------------
@pytest.mark.asyncio
async def test_get_or_create_label_existing(self):
"""Returns ID of an existing label matched case-insensitively."""
service = GmailService(access_token="test-access-token")
mock_api = MagicMock()
mock_api.users().labels().list().execute.return_value = {
"labels": [
{"id": "Label_1", "name": "imported"},
{"id": "INBOX", "name": "INBOX"},
]
}
service._service = mock_api
label_id = await service.get_or_create_label("Imported")
assert label_id == "Label_1"
# ------------------------------------------------------------------
# get_or_create_label label not found, creates new one
# ------------------------------------------------------------------
@pytest.mark.asyncio
async def test_get_or_create_label_creates_new(self):
"""Creates a new label when no existing label matches."""
service = GmailService(access_token="test-access-token")
mock_api = MagicMock()
mock_api.users().labels().list().execute.return_value = {
"labels": [{"id": "INBOX", "name": "INBOX"}]
}
mock_api.users().labels().create().execute.return_value = {
"id": "Label_new",
"name": "new-label",
}
service._service = mock_api
label_id = await service.get_or_create_label("new-label")
assert label_id == "Label_new"
# ------------------------------------------------------------------
# get_or_create_label HttpError handling
# ------------------------------------------------------------------
@pytest.mark.asyncio
async def test_get_or_create_label_http_error(self):
"""HttpError during label list raises GmailInjectionError."""
service = GmailService(access_token="test-access-token")
mock_api = MagicMock()
mock_api.users().labels().list().execute.side_effect = self._make_http_error(
500, "Internal Server Error"
)
service._service = mock_api
with pytest.raises(GmailInjectionError, match="Gmail API error"):
await service.get_or_create_label("test")
# ------------------------------------------------------------------
# get_or_create_label generic Exception handling
# ------------------------------------------------------------------
@pytest.mark.asyncio
async def test_get_or_create_label_generic_exception(self):
"""Generic exception during label management raises GmailInjectionError."""
service = GmailService(access_token="test-access-token")
mock_api = MagicMock()
mock_api.users().labels().list().execute.side_effect = RuntimeError("boom")
service._service = mock_api
with pytest.raises(GmailInjectionError, match="Failed to get/create"):
await service.get_or_create_label("oops")
# ------------------------------------------------------------------
# inject_debug_email full flow
# ------------------------------------------------------------------
@pytest.mark.asyncio
async def test_inject_debug_email(self):
"""inject_debug_email creates labels and injects a test email."""
service = GmailService(access_token="test-access-token")
service.build_import_label_ids = AsyncMock( # type: ignore[method-assign]
return_value=["INBOX", "Label_imported"]
)
service.get_or_create_label = AsyncMock( # type: ignore[method-assign]
return_value="Label_test"
)
service.inject_email = AsyncMock( # type: ignore[method-assign]
return_value={
"message_id": "msg1",
"thread_id": "t1",
"label_ids": ["INBOX", "Label_imported", "Label_test"],
}
)
result = await service.inject_debug_email("user@gmail.com")
assert result["message_id"] == "msg1"
assert "Label_test" in result["label_ids"]
service.build_import_label_ids.assert_awaited_once()
service.get_or_create_label.assert_awaited_once_with("test")
service.inject_email.assert_awaited_once()
# The test label should have been appended to label_ids
call_kwargs = service.inject_email.call_args
assert "Label_test" in call_kwargs.kwargs["label_ids"]
# ------------------------------------------------------------------
# inject_debug_email test label already present in import labels
# ------------------------------------------------------------------
@pytest.mark.asyncio
async def test_inject_debug_email_test_label_already_present(self):
"""inject_debug_email does not duplicate the test label."""
service = GmailService(access_token="test-access-token")
service.build_import_label_ids = AsyncMock( # type: ignore[method-assign]
return_value=["INBOX", "Label_test"]
)
service.get_or_create_label = AsyncMock( # type: ignore[method-assign]
return_value="Label_test"
)
service.inject_email = AsyncMock( # type: ignore[method-assign]
return_value={
"message_id": "msg2",
"thread_id": "t2",
"label_ids": ["INBOX", "Label_test"],
}
)
result = await service.inject_debug_email("user@gmail.com")
assert result["message_id"] == "msg2"
call_kwargs = service.inject_email.call_args
# Label_test should appear only once
assert call_kwargs.kwargs["label_ids"].count("Label_test") == 1
# ------------------------------------------------------------------
# get_refreshed_token token was NOT refreshed
# ------------------------------------------------------------------
def test_get_refreshed_token_no_change(self):
"""Returns None when the token has not changed."""
service = GmailService(access_token="original-token")
assert service.get_refreshed_token() is None
# ------------------------------------------------------------------
# get_refreshed_token token was refreshed
# ------------------------------------------------------------------
def test_get_refreshed_token_changed(self):
"""Returns new token info when the token was refreshed."""
service = GmailService(access_token="original-token")
# Simulate an automatic token refresh by mutating credentials
new_expiry = datetime(2099, 1, 1, tzinfo=timezone.utc)
service.credentials.token = "new-refreshed-token"
service.credentials.expiry = new_expiry
result = service.get_refreshed_token()
assert result is not None
assert result["access_token"] == "new-refreshed-token"
assert result["expiry"] == new_expiry
# ------------------------------------------------------------------
# service property lazy init builds the service
# ------------------------------------------------------------------
def test_service_property_builds_service(self):
"""Accessing .service triggers googleapiclient.discovery.build."""
with patch("app.services.gmail_service.build") as mock_build:
mock_build.return_value = MagicMock()
service = GmailService(access_token="test-access-token")
assert service._service is None
api = service.service # trigger lazy init
mock_build.assert_called_once_with(
"gmail", "v1", credentials=service.credentials, cache_discovery=False
)
assert api is mock_build.return_value
# Second access should NOT call build again
_ = service.service
mock_build.assert_called_once()