From c3ee2fc821267411bada74cf7e798e134c6f0515 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 29 Mar 2026 01:01:33 +0000 Subject: [PATCH] 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> --- CHANGELOG.md | 1 + backend/tests/unit/test_gmail_service.py | 219 ++++++++++++++++++++++- 2 files changed, 219 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9694379..5bf6593 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/backend/tests/unit/test_gmail_service.py b/backend/tests/unit/test_gmail_service.py index 4289104..31a582b 100644 --- a/backend/tests/unit/test_gmail_service.py +++ b/backend/tests/unit/test_gmail_service.py @@ -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()