From 69c3bac48004a4805ca47ceb934b4183aa4f0e83 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Sun, 29 Mar 2026 22:08:38 +0000
Subject: [PATCH] feat: add GmailClient unit tests and main.py helper tests to
meet coverage target
Agent-Logs-Url: https://github.com/christianlouis/dmarq/sessions/77ac3e98-81fa-45fa-9cc2-481896ffb48e
Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
---
backend/app/tests/test_gmail_client.py | 641 +++++++++++++++++++++++++
backend/app/tests/test_mail_sources.py | 475 ++++++++++++++++++
2 files changed, 1116 insertions(+)
create mode 100644 backend/app/tests/test_gmail_client.py
diff --git a/backend/app/tests/test_gmail_client.py b/backend/app/tests/test_gmail_client.py
new file mode 100644
index 0000000..f7fbb66
--- /dev/null
+++ b/backend/app/tests/test_gmail_client.py
@@ -0,0 +1,641 @@
+"""
+Unit tests for app.services.gmail_client.GmailClient.
+
+All external I/O (httpx, google-auth, googleapiclient) is mocked so these
+tests never make real network calls.
+"""
+
+import base64
+import email as email_mod
+import json
+from email import encoders as email_encoders
+from email.mime.multipart import MIMEMultipart
+from email.mime.base import MIMEBase
+from email.mime.text import MIMEText
+from typing import Optional
+from unittest.mock import MagicMock, patch
+
+import pytest
+
+from app.services.gmail_client import GmailClient, DMARC_GMAIL_QUERY
+
+
+# ---------------------------------------------------------------------------
+# Helpers
+# ---------------------------------------------------------------------------
+
+
+def _make_client(
+ access_token: str = "acc",
+ refresh_token: str = "ref",
+ already_ingested: Optional[list] = None,
+) -> GmailClient:
+ """Instantiate a GmailClient with real Credentials mocked out."""
+ with patch("app.services.gmail_client.Credentials") as mock_creds_class:
+ mock_creds = MagicMock()
+ mock_creds.token = access_token
+ mock_creds.refresh_token = refresh_token
+ mock_creds.expired = False
+ mock_creds_class.return_value = mock_creds
+ client = GmailClient(
+ client_id="cid",
+ client_secret="csec",
+ access_token=access_token,
+ refresh_token=refresh_token,
+ already_ingested_ids=already_ingested or [],
+ )
+ # Expose the mock so tests can manipulate it
+ client._mock_creds = mock_creds # type: ignore[attr-defined]
+ return client
+
+
+def _make_raw_email(attachments: list) -> bytes:
+ """Build a raw MIME email whose attachments are described by *attachments*.
+
+ Each item in *attachments* is a dict with keys:
+ filename: str
+ content: bytes
+ disposition: str (default "attachment")
+ """
+ msg = MIMEMultipart()
+ msg["Subject"] = "DMARC aggregate report"
+ msg["From"] = "noreply@example.com"
+ msg["To"] = "user@gmail.com"
+ msg.attach(MIMEText("See attached DMARC report.", "plain"))
+
+ for att in attachments:
+ part = MIMEBase("application", "octet-stream")
+ part.set_payload(att["content"])
+ email_encoders.encode_base64(part)
+ disposition = att.get("disposition", "attachment")
+ part.add_header(
+ "Content-Disposition",
+ disposition,
+ filename=att["filename"],
+ )
+ msg.attach(part)
+
+ return msg.as_bytes()
+
+
+def _b64_raw(raw_bytes: bytes) -> str:
+ """URL-safe base64-encode bytes (as Gmail API returns them)."""
+ return base64.urlsafe_b64encode(raw_bytes).decode()
+
+
+# ===========================================================================
+# __init__ / basic construction
+# ===========================================================================
+
+
+class TestGmailClientInit:
+ def test_init_stores_tokens(self):
+ client = _make_client(access_token="my-acc", refresh_token="my-ref")
+ assert client._initial_access_token == "my-acc"
+ assert client.client_id == "cid"
+ assert client.client_secret == "csec"
+
+ def test_init_already_ingested_defaults_to_empty(self):
+ client = _make_client()
+ assert client.already_ingested_ids == []
+
+ def test_init_already_ingested_is_copied(self):
+ ids = ["a", "b"]
+ client = _make_client(already_ingested=ids)
+ assert client.already_ingested_ids == ["a", "b"]
+ # Mutating the original should not affect the client
+ ids.append("c")
+ assert "c" not in client.already_ingested_ids
+
+
+# ===========================================================================
+# get_refreshed_tokens
+# ===========================================================================
+
+
+class TestGetRefreshedTokens:
+ def test_returns_none_when_token_unchanged(self):
+ client = _make_client(access_token="original")
+ # credentials.token == _initial_access_token → no refresh happened
+ client._mock_creds.token = "original"
+ assert client.get_refreshed_tokens() is None
+
+ def test_returns_new_access_token_when_changed(self):
+ client = _make_client(access_token="original")
+ client._mock_creds.token = "new-token"
+ client._mock_creds.refresh_token = None
+ result = client.get_refreshed_tokens()
+ assert result is not None
+ assert result["access_token"] == "new-token"
+ assert "refresh_token" not in result
+
+ def test_returns_both_tokens_when_refresh_token_present(self):
+ client = _make_client(access_token="original")
+ client._mock_creds.token = "new-token"
+ client._mock_creds.refresh_token = "new-refresh"
+ result = client.get_refreshed_tokens()
+ assert result is not None
+ assert result["access_token"] == "new-token"
+ assert result["refresh_token"] == "new-refresh"
+
+ def test_returns_none_when_token_is_none(self):
+ client = _make_client(access_token="original")
+ client._mock_creds.token = None
+ assert client.get_refreshed_tokens() is None
+
+
+# ===========================================================================
+# build_authorization_url (already partly covered; extend for completeness)
+# ===========================================================================
+
+
+class TestBuildAuthorizationUrl:
+ def test_includes_all_required_params(self):
+ url = GmailClient.build_authorization_url(
+ client_id="cid",
+ redirect_uri="https://example.com/cb",
+ state="99",
+ )
+ assert "client_id=cid" in url
+ assert "response_type=code" in url
+ assert "gmail.readonly" in url
+ assert "access_type=offline" in url
+ assert "prompt=consent" in url
+ assert "state=99" in url
+
+ def test_state_omitted_when_none(self):
+ url = GmailClient.build_authorization_url(
+ client_id="cid",
+ redirect_uri="https://example.com/cb",
+ )
+ assert "state=" not in url
+
+
+# ===========================================================================
+# exchange_code_for_tokens
+# ===========================================================================
+
+
+class TestExchangeCodeForTokens:
+ def test_success_returns_json(self):
+ mock_resp = MagicMock()
+ mock_resp.status_code = 200
+ mock_resp.json.return_value = {"access_token": "acc", "refresh_token": "ref"}
+
+ with patch("app.services.gmail_client.httpx.post", return_value=mock_resp):
+ result = GmailClient.exchange_code_for_tokens(
+ client_id="cid",
+ client_secret="csec",
+ code="auth-code",
+ redirect_uri="https://example.com/cb",
+ )
+
+ assert result["access_token"] == "acc"
+ assert result["refresh_token"] == "ref"
+
+ def test_non_200_raises_value_error(self):
+ mock_resp = MagicMock()
+ mock_resp.status_code = 400
+ mock_resp.text = '{"error": "invalid_grant"}'
+
+ with patch("app.services.gmail_client.httpx.post", return_value=mock_resp):
+ with pytest.raises(ValueError, match="400"):
+ GmailClient.exchange_code_for_tokens(
+ client_id="cid",
+ client_secret="csec",
+ code="bad-code",
+ redirect_uri="https://example.com/cb",
+ )
+
+
+# ===========================================================================
+# get_gmail_email
+# ===========================================================================
+
+
+class TestGetGmailEmail:
+ def test_returns_email_on_200(self):
+ mock_resp = MagicMock()
+ mock_resp.status_code = 200
+ mock_resp.json.return_value = {"email": "user@gmail.com"}
+
+ with patch("app.services.gmail_client.httpx.get", return_value=mock_resp):
+ result = GmailClient.get_gmail_email("valid-token")
+
+ assert result == "user@gmail.com"
+
+ def test_returns_none_on_non_200(self):
+ mock_resp = MagicMock()
+ mock_resp.status_code = 401
+
+ with patch("app.services.gmail_client.httpx.get", return_value=mock_resp):
+ result = GmailClient.get_gmail_email("expired-token")
+
+ assert result is None
+
+ def test_returns_none_on_exception(self):
+ with patch(
+ "app.services.gmail_client.httpx.get", side_effect=Exception("network error")
+ ):
+ result = GmailClient.get_gmail_email("some-token")
+
+ assert result is None
+
+
+# ===========================================================================
+# _build_service
+# ===========================================================================
+
+
+class TestBuildService:
+ def test_returns_service_when_not_expired(self):
+ client = _make_client()
+ client._mock_creds.expired = False
+
+ mock_service = MagicMock()
+ with patch("app.services.gmail_client.build", return_value=mock_service):
+ svc = client._build_service()
+
+ assert svc is mock_service
+
+ def test_refreshes_token_when_expired(self):
+ client = _make_client()
+ client._mock_creds.expired = True
+ client._mock_creds.refresh_token = "ref"
+
+ mock_service = MagicMock()
+ with patch("app.services.gmail_client.build", return_value=mock_service), patch(
+ "app.services.gmail_client.Request"
+ ):
+ svc = client._build_service()
+
+ client._mock_creds.refresh.assert_called_once()
+ assert svc is mock_service
+
+ def test_raises_when_refresh_fails(self):
+ client = _make_client()
+ client._mock_creds.expired = True
+ client._mock_creds.refresh_token = "ref"
+ client._mock_creds.refresh.side_effect = Exception("refresh failed")
+
+ with patch("app.services.gmail_client.Request"), patch(
+ "app.services.gmail_client.build"
+ ):
+ with pytest.raises(Exception, match="refresh failed"):
+ client._build_service()
+
+
+# ===========================================================================
+# _list_dmarc_message_ids
+# ===========================================================================
+
+
+class TestListDmarcMessageIds:
+ def test_returns_empty_when_no_messages(self):
+ client = _make_client()
+ service = MagicMock()
+ service.users.return_value.messages.return_value.list.return_value.execute.return_value = {
+ "messages": []
+ }
+ ids = client._list_dmarc_message_ids(service)
+ assert ids == []
+
+ def test_returns_ids_from_single_page(self):
+ client = _make_client()
+ service = MagicMock()
+ service.users.return_value.messages.return_value.list.return_value.execute.return_value = {
+ "messages": [{"id": "id1"}, {"id": "id2"}]
+ }
+ ids = client._list_dmarc_message_ids(service)
+ assert ids == ["id1", "id2"]
+
+ def test_follows_next_page_token(self):
+ client = _make_client()
+ # First page has a nextPageToken; second page has none
+ service = MagicMock()
+ execute = service.users.return_value.messages.return_value.list.return_value.execute
+ execute.side_effect = [
+ {"messages": [{"id": "id1"}], "nextPageToken": "page2"},
+ {"messages": [{"id": "id2"}]},
+ ]
+ ids = client._list_dmarc_message_ids(service)
+ assert ids == ["id1", "id2"]
+ # list() should have been called twice
+ assert service.users.return_value.messages.return_value.list.call_count == 2
+
+ def test_raises_on_http_error(self):
+ from googleapiclient.errors import HttpError
+
+ client = _make_client()
+ service = MagicMock()
+ execute = service.users.return_value.messages.return_value.list.return_value.execute
+ fake_error = HttpError(MagicMock(status=403), b"forbidden")
+ execute.side_effect = fake_error
+ with pytest.raises(HttpError):
+ client._list_dmarc_message_ids(service)
+
+
+# ===========================================================================
+# _decode_part_filename
+# ===========================================================================
+
+
+class TestDecodePartFilename:
+ def test_plain_ascii_filename(self):
+ part = MagicMock()
+ part.get_filename.return_value = "report.xml"
+ assert GmailClient._decode_part_filename(part) == "report.xml"
+
+ def test_none_filename_returns_empty(self):
+ part = MagicMock()
+ part.get_filename.return_value = None
+ assert GmailClient._decode_part_filename(part) == ""
+
+ def test_rfc2047_encoded_filename(self):
+ # Build an RFC 2047 encoded filename
+ encoded = "=?utf-8?b?cmVwb3J0LnhtbA==?=" # base64("report.xml")
+ part = MagicMock()
+ part.get_filename.return_value = encoded
+ result = GmailClient._decode_part_filename(part)
+ assert result == "report.xml"
+
+
+# ===========================================================================
+# _is_dmarc_attachment
+# ===========================================================================
+
+
+class TestIsDmarcAttachment:
+ @pytest.mark.parametrize(
+ "filename",
+ [
+ "report.xml",
+ "report.XML", # case-insensitive
+ "report.zip",
+ "report.gz",
+ "report.gzip",
+ "Report.ZIP",
+ ],
+ )
+ def test_dmarc_extensions_return_true(self, filename):
+ assert GmailClient._is_dmarc_attachment(filename) is True
+
+ @pytest.mark.parametrize(
+ "filename",
+ ["report.txt", "image.png", "report.pdf", "report.tar", ""],
+ )
+ def test_non_dmarc_extensions_return_false(self, filename):
+ assert GmailClient._is_dmarc_attachment(filename) is False
+
+
+# ===========================================================================
+# _process_message
+# ===========================================================================
+
+
+class TestProcessMessage:
+ def test_fetches_and_processes_message(self):
+ """Happy path: message fetched, attachments processed."""
+ client = _make_client()
+ raw_email = _make_raw_email(
+ [{"filename": "report.xml", "content": b""}]
+ )
+ raw_b64 = _b64_raw(raw_email)
+
+ service = MagicMock()
+ service.users.return_value.messages.return_value.get.return_value.execute.return_value = {
+ "raw": raw_b64
+ }
+
+ stats = {"reports_found": 0, "errors": []}
+ with patch.object(client, "_process_attachments", return_value=0) as mock_proc:
+ count = client._process_message(service, "msg1", stats)
+
+ mock_proc.assert_called_once()
+ assert count == 0 # our mock returns 0
+
+ def test_http_error_recorded_and_returns_zero(self):
+ from googleapiclient.errors import HttpError
+
+ client = _make_client()
+ service = MagicMock()
+ fake_error = HttpError(MagicMock(status=404), b"not found")
+ service.users.return_value.messages.return_value.get.return_value.execute.side_effect = (
+ fake_error
+ )
+
+ stats = {"reports_found": 0, "errors": []}
+ count = client._process_message(service, "bad-id", stats)
+
+ assert count == 0
+ assert len(stats["errors"]) == 1
+ assert "bad-id" in stats["errors"][0]
+
+
+# ===========================================================================
+# _process_attachments
+# ===========================================================================
+
+
+class TestProcessAttachments:
+ def test_no_attachments_returns_zero(self):
+ client = _make_client()
+ msg = email_mod.message_from_bytes(
+ b"From: a@b.com\r\nTo: c@d.com\r\n\r\nHello"
+ )
+ stats = {"reports_found": 0, "errors": []}
+ count = client._process_attachments(msg, stats)
+ assert count == 0
+
+ def test_non_dmarc_attachment_skipped(self):
+ """An inline or non-DMARC file should not count as a report."""
+ client = _make_client()
+ raw = _make_raw_email(
+ [{"filename": "photo.png", "content": b"\x89PNG"}]
+ )
+ msg = email_mod.message_from_bytes(raw)
+ stats = {"reports_found": 0, "errors": []}
+ count = client._process_attachments(msg, stats)
+ assert count == 0
+ assert stats["reports_found"] == 0
+
+ def test_dmarc_xml_attachment_is_parsed(self):
+ """A .xml attachment is parsed via DMARCParser and counts as a report."""
+ client = _make_client()
+ raw = _make_raw_email(
+ [{"filename": "report.xml", "content": b""}]
+ )
+ msg = email_mod.message_from_bytes(raw)
+ stats = {"reports_found": 0, "errors": []}
+
+ mock_report = {"domain": "example.com", "records": []}
+ with patch("app.services.gmail_client.DMARCParser") as mock_parser_class:
+ mock_parser = MagicMock()
+ mock_parser.parse.return_value = [mock_report]
+ mock_parser_class.return_value = mock_parser
+ # Also mock report_store.add_report to avoid real persistence
+ with patch.object(client.report_store, "add_report"):
+ count = client._process_attachments(msg, stats)
+
+ assert count == 1
+ assert stats["reports_found"] == 1
+
+ def test_dmarc_attachment_with_empty_content_skipped(self):
+ """A DMARC-named attachment with truly empty payload is skipped gracefully."""
+ client = _make_client()
+ # Build an attachment with empty bytes – base64 of b"" is b""
+ raw = _make_raw_email(
+ [{"filename": "report.zip", "content": b""}]
+ )
+ msg = email_mod.message_from_bytes(raw)
+ stats = {"reports_found": 0, "errors": []}
+ count = client._process_attachments(msg, stats)
+ # Empty payload → `get_payload(decode=True)` returns b"" which is
+ # falsy, so the attachment is skipped
+ assert count == 0
+
+ def test_parse_exception_adds_error_and_continues(self):
+ """A parse error should be recorded in stats but not raise."""
+ client = _make_client()
+ raw = _make_raw_email(
+ [
+ {"filename": "bad.xml", "content": b"corrupt"},
+ {"filename": "good.xml", "content": b""},
+ ]
+ )
+ msg = email_mod.message_from_bytes(raw)
+ stats = {"reports_found": 0, "errors": []}
+
+ good_report = {"domain": "example.com", "records": []}
+ call_count = 0
+
+ def parse_side_effect(content, filename):
+ nonlocal call_count
+ call_count += 1
+ if call_count == 1:
+ raise ValueError("bad xml")
+ return [good_report]
+
+ with patch("app.services.gmail_client.DMARCParser") as mock_parser_class:
+ mock_parser = MagicMock()
+ mock_parser.parse.side_effect = parse_side_effect
+ mock_parser_class.return_value = mock_parser
+ with patch.object(client.report_store, "add_report"):
+ count = client._process_attachments(msg, stats)
+
+ assert len(stats["errors"]) == 1
+ assert "bad.xml" in stats["errors"][0]
+ assert count == 1 # second attachment still parsed
+
+
+# ===========================================================================
+# fetch_reports
+# ===========================================================================
+
+
+class TestFetchReports:
+ def test_returns_failure_when_build_service_raises(self):
+ client = _make_client()
+ with patch.object(
+ client, "_build_service", side_effect=Exception("auth error")
+ ):
+ result = client.fetch_reports()
+
+ assert result["success"] is False
+ assert "auth error" in result.get("error", "")
+
+ def test_returns_failure_when_list_messages_raises(self):
+ client = _make_client()
+ mock_service = MagicMock()
+ with patch.object(client, "_build_service", return_value=mock_service), patch.object(
+ client, "_list_dmarc_message_ids", side_effect=Exception("list error")
+ ):
+ result = client.fetch_reports()
+
+ assert result["success"] is False
+
+ def test_returns_success_with_no_messages(self):
+ client = _make_client()
+ mock_service = MagicMock()
+ with patch.object(client, "_build_service", return_value=mock_service), patch.object(
+ client, "_list_dmarc_message_ids", return_value=[]
+ ):
+ result = client.fetch_reports()
+
+ assert result["success"] is True
+ assert result["processed"] == 0
+
+ def test_skips_already_ingested_messages(self):
+ client = _make_client(already_ingested=["id1"])
+ mock_service = MagicMock()
+ with patch.object(client, "_build_service", return_value=mock_service), patch.object(
+ client, "_list_dmarc_message_ids", return_value=["id1", "id2"]
+ ), patch.object(client, "_process_message", return_value=0) as mock_proc:
+ result = client.fetch_reports()
+
+ # Only id2 should be processed; id1 is already ingested
+ assert mock_proc.call_count == 1
+ call_args = mock_proc.call_args_list[0][0]
+ assert call_args[1] == "id2"
+ assert result["processed"] == 1
+
+ def test_tracks_new_ingested_ids(self):
+ client = _make_client()
+ mock_service = MagicMock()
+ with patch.object(client, "_build_service", return_value=mock_service), patch.object(
+ client, "_list_dmarc_message_ids", return_value=["id1", "id2"]
+ ), patch.object(client, "_process_message", return_value=0):
+ result = client.fetch_reports()
+
+ assert "id1" in result["new_ingested_ids"]
+ assert "id2" in result["new_ingested_ids"]
+
+ def test_reports_new_domains(self):
+ """fetch_reports should report domains that appear after ingestion."""
+ from app.services.report_store import ReportStore
+
+ client = _make_client()
+ mock_service = MagicMock()
+
+ def _process_side_effect(service, msg_id, stats):
+ # Simulate adding a domain to the report store
+ ReportStore.get_instance().add_report(
+ {
+ "org_name": "Test Org",
+ "report_id": "r1",
+ "begin_date": "2024-01-01",
+ "end_date": "2024-01-02",
+ "domain": "newdomain.example",
+ "records": [],
+ }
+ )
+ stats["reports_found"] += 1
+ return 1
+
+ with patch.object(client, "_build_service", return_value=mock_service), patch.object(
+ client, "_list_dmarc_message_ids", return_value=["id1"]
+ ), patch.object(client, "_process_message", side_effect=_process_side_effect):
+ result = client.fetch_reports()
+
+ assert "newdomain.example" in result["new_domains"]
+
+
+# ===========================================================================
+# load_ingested_ids / dump_ingested_ids (already tested in TestGmailClientHelpers
+# in test_mail_sources.py; add a few edge-cases here)
+# ===========================================================================
+
+
+class TestIngestedIdHelpers:
+ def test_load_non_list_json_returns_no_error(self):
+ # Valid JSON but not a list – should gracefully not raise
+ result = GmailClient.load_ingested_ids('{"key": "value"}')
+ assert result is not None # no crash
+
+ def test_dump_preserves_order(self):
+ ids = ["z", "a", "m"]
+ dumped = GmailClient.dump_ingested_ids(ids)
+ loaded = json.loads(dumped)
+ assert loaded == ["z", "a", "m"]
+
+
diff --git a/backend/app/tests/test_mail_sources.py b/backend/app/tests/test_mail_sources.py
index db4c95f..8efa9cb 100644
--- a/backend/app/tests/test_mail_sources.py
+++ b/backend/app/tests/test_mail_sources.py
@@ -1220,6 +1220,481 @@ class TestGmailTestConnectionFailure:
assert "check server logs" in data["message"].lower()
+# ---------------------------------------------------------------------------
+# Tests for new main.py helper functions (_poll_single_gmail_source,
+# _trigger_poll_imap_source, _trigger_poll_gmail_source, _poll_source_for_trigger)
+# ---------------------------------------------------------------------------
+
+
+class TestPollSingleGmailSource:
+ """Unit tests for app.main._poll_single_gmail_source."""
+
+ def _make_source(self, *, access_token="tok", refresh_token="ref"):
+ src = MagicMock()
+ src.id = 1
+ src.gmail_access_token = access_token
+ src.gmail_refresh_token = refresh_token
+ src.gmail_client_id = "cid"
+ src.gmail_client_secret = "csec"
+ src.gmail_ingested_ids = "[]"
+ src.gmail_email = "u@gmail.com"
+ return src
+
+ def test_skips_when_no_access_token(self):
+ """Source without OAuth token → early return, no GmailClient created."""
+ from app.main import _poll_single_gmail_source
+
+ src = self._make_source(access_token=None)
+
+ with patch("app.main.GmailClient") as mock_gc:
+ _poll_single_gmail_source(src)
+
+ mock_gc.assert_not_called()
+
+ def test_fetches_reports_and_persists_ids(self):
+ """Happy-path: client is created, reports fetched, IDs saved to DB."""
+ from app.main import _poll_single_gmail_source
+
+ src = self._make_source()
+
+ mock_client = MagicMock()
+ mock_client.fetch_reports.return_value = {
+ "success": True,
+ "processed": 2,
+ "reports_found": 1,
+ "new_domains": [],
+ "errors": [],
+ "new_ingested_ids": ["id1", "id2"],
+ }
+ mock_client.get_refreshed_tokens.return_value = None
+
+ mock_db_source = MagicMock()
+ mock_db = MagicMock()
+ mock_db.__enter__ = MagicMock(return_value=mock_db)
+ mock_db.__exit__ = MagicMock(return_value=False)
+ mock_db.query.return_value.get.return_value = mock_db_source
+
+ with patch("app.main.GmailClient", return_value=mock_client), patch(
+ "app.main.SessionLocal", return_value=mock_db
+ ), patch("app.main.GmailClient.load_ingested_ids", return_value=[]), patch(
+ "app.main.GmailClient.dump_ingested_ids", return_value='["id1","id2"]'
+ ):
+ _poll_single_gmail_source(src)
+
+ mock_client.fetch_reports.assert_called_once()
+
+ def test_logs_new_domains_on_success(self):
+ """When results include new_domains, the function logs them."""
+ from app.main import _poll_single_gmail_source
+
+ src = self._make_source()
+
+ mock_client = MagicMock()
+ mock_client.fetch_reports.return_value = {
+ "success": True,
+ "processed": 1,
+ "reports_found": 1,
+ "new_domains": ["example.com"],
+ "errors": [],
+ "new_ingested_ids": [],
+ }
+ mock_client.get_refreshed_tokens.return_value = None
+
+ mock_db = MagicMock()
+ mock_db.query.return_value.get.return_value = MagicMock()
+
+ with patch("app.main.GmailClient", return_value=mock_client), patch(
+ "app.main.SessionLocal", return_value=mock_db
+ ), patch("app.main.GmailClient.load_ingested_ids", return_value=[]):
+ _poll_single_gmail_source(src) # should not raise
+
+ def test_logs_error_on_failure(self):
+ """When results['success'] is False, the function logs an error."""
+ from app.main import _poll_single_gmail_source
+
+ src = self._make_source()
+
+ mock_client = MagicMock()
+ mock_client.fetch_reports.return_value = {
+ "success": False,
+ "error": "auth failed",
+ "processed": 0,
+ "reports_found": 0,
+ "new_domains": [],
+ "errors": [],
+ "new_ingested_ids": [],
+ }
+ mock_client.get_refreshed_tokens.return_value = None
+
+ mock_db = MagicMock()
+ mock_db.query.return_value.get.return_value = MagicMock()
+
+ with patch("app.main.GmailClient", return_value=mock_client), patch(
+ "app.main.SessionLocal", return_value=mock_db
+ ), patch("app.main.GmailClient.load_ingested_ids", return_value=[]):
+ _poll_single_gmail_source(src) # should not raise
+
+ def test_persists_refreshed_tokens(self):
+ """When GmailClient reports refreshed tokens, they are saved to the DB row."""
+ from app.main import _poll_single_gmail_source
+
+ src = self._make_source()
+
+ mock_client = MagicMock()
+ mock_client.fetch_reports.return_value = {
+ "success": True,
+ "processed": 0,
+ "reports_found": 0,
+ "new_domains": [],
+ "errors": [],
+ "new_ingested_ids": [],
+ }
+ mock_client.get_refreshed_tokens.return_value = {
+ "access_token": "new-acc",
+ "refresh_token": "new-ref",
+ }
+
+ mock_db_source = MagicMock()
+ mock_db = MagicMock()
+ mock_db.query.return_value.get.return_value = mock_db_source
+
+ with patch("app.main.GmailClient", return_value=mock_client), patch(
+ "app.main.SessionLocal", return_value=mock_db
+ ), patch("app.main.GmailClient.load_ingested_ids", return_value=[]):
+ _poll_single_gmail_source(src)
+
+ assert mock_db_source.gmail_access_token == "new-acc"
+ assert mock_db_source.gmail_refresh_token == "new-ref"
+
+
+class TestTriggerPollImapSource:
+ """Unit tests for app.main._trigger_poll_imap_source."""
+
+ def test_returns_result_dict_on_success(self):
+ from app.main import _trigger_poll_imap_source
+
+ src = MagicMock()
+ src.id = 5
+ src.name = "My IMAP"
+ src.server = "imap.example.com"
+ src.port = 993
+ src.username = "u"
+ src.password = "p"
+
+ mock_imap = MagicMock()
+ mock_imap.fetch_reports.return_value = {
+ "success": True,
+ "processed": 3,
+ "reports_found": 2,
+ "new_domains": ["dom.example"],
+ }
+
+ mock_db = MagicMock()
+
+ with patch("app.main.IMAPClient", return_value=mock_imap):
+ result = _trigger_poll_imap_source(src, mock_db)
+
+ assert result["success"] is True
+ assert result["source_id"] == 5
+ assert result["name"] == "My IMAP"
+ assert result["processed"] == 3
+ assert result["reports_found"] == 2
+ assert result["new_domains"] == ["dom.example"]
+ mock_db.commit.assert_called_once()
+
+
+class TestTriggerPollGmailSource:
+ """Unit tests for app.main._trigger_poll_gmail_source."""
+
+ def _make_src(self):
+ src = MagicMock()
+ src.id = 7
+ src.name = "My Gmail"
+ src.gmail_client_id = "cid"
+ src.gmail_client_secret = "csec"
+ src.gmail_access_token = "tok"
+ src.gmail_refresh_token = "ref"
+ src.gmail_ingested_ids = "[]"
+ return src
+
+ def test_returns_result_dict_on_success(self):
+ from app.main import _trigger_poll_gmail_source
+
+ src = self._make_src()
+ mock_gc = MagicMock()
+ mock_gc.fetch_reports.return_value = {
+ "success": True,
+ "processed": 1,
+ "reports_found": 1,
+ "new_domains": [],
+ "new_ingested_ids": ["id1"],
+ }
+ mock_gc.get_refreshed_tokens.return_value = None
+ mock_db = MagicMock()
+
+ with patch("app.main.GmailClient", return_value=mock_gc), patch(
+ "app.main.GmailClient.load_ingested_ids", return_value=[]
+ ), patch("app.main.GmailClient.dump_ingested_ids", return_value='["id1"]'):
+ result = _trigger_poll_gmail_source(src, mock_db)
+
+ assert result["success"] is True
+ assert result["source_id"] == 7
+ mock_db.commit.assert_called_once()
+
+ def test_persists_refreshed_tokens(self):
+ from app.main import _trigger_poll_gmail_source
+
+ src = self._make_src()
+ mock_gc = MagicMock()
+ mock_gc.fetch_reports.return_value = {
+ "success": True,
+ "processed": 0,
+ "reports_found": 0,
+ "new_domains": [],
+ "new_ingested_ids": [],
+ }
+ mock_gc.get_refreshed_tokens.return_value = {
+ "access_token": "new-acc",
+ "refresh_token": "new-ref",
+ }
+ mock_db = MagicMock()
+
+ with patch("app.main.GmailClient", return_value=mock_gc), patch(
+ "app.main.GmailClient.load_ingested_ids", return_value=[]
+ ):
+ _trigger_poll_gmail_source(src, mock_db)
+
+ assert src.gmail_access_token == "new-acc"
+ assert src.gmail_refresh_token == "new-ref"
+
+
+class TestPollSourceForTrigger:
+ """Unit tests for app.main._poll_source_for_trigger."""
+
+ def test_gmail_no_token_returns_skipped(self):
+ from app.main import _poll_source_for_trigger
+
+ src = MagicMock()
+ src.method = "GMAIL_API"
+ src.gmail_access_token = None
+ src.id = 1
+ src.name = "Gmail no token"
+
+ result = _poll_source_for_trigger(src, MagicMock())
+
+ assert result["skipped"] is True
+ assert "authorised" in result["reason"].lower()
+
+ def test_gmail_with_token_delegates_to_trigger_poll(self):
+ from app.main import _poll_source_for_trigger
+
+ src = MagicMock()
+ src.method = "GMAIL_API"
+ src.gmail_access_token = "tok"
+ src.id = 2
+ src.name = "Gmail"
+
+ expected = {"source_id": 2, "name": "Gmail", "success": True}
+ with patch("app.main._trigger_poll_gmail_source", return_value=expected) as mock_fn:
+ result = _poll_source_for_trigger(src, MagicMock())
+
+ assert result is expected
+ mock_fn.assert_called_once()
+
+ def test_gmail_exception_returns_failure_dict(self):
+ from app.main import _poll_source_for_trigger
+
+ src = MagicMock()
+ src.method = "GMAIL_API"
+ src.gmail_access_token = "tok"
+ src.id = 3
+ src.name = "Gmail exc"
+
+ with patch(
+ "app.main._trigger_poll_gmail_source", side_effect=Exception("boom")
+ ):
+ result = _poll_source_for_trigger(src, MagicMock())
+
+ assert result["success"] is False
+ assert "boom" not in result.get("error", "") # raw msg not exposed
+
+ def test_imap_delegates_to_trigger_poll(self):
+ from app.main import _poll_source_for_trigger
+
+ src = MagicMock()
+ src.method = "IMAP"
+ src.id = 4
+ src.name = "IMAP src"
+
+ expected = {"source_id": 4, "success": True}
+ with patch("app.main._trigger_poll_imap_source", return_value=expected) as mock_fn:
+ result = _poll_source_for_trigger(src, MagicMock())
+
+ assert result is expected
+ mock_fn.assert_called_once()
+
+ def test_imap_exception_returns_failure_dict(self):
+ from app.main import _poll_source_for_trigger
+
+ src = MagicMock()
+ src.method = "IMAP"
+ src.id = 5
+ src.name = "IMAP exc"
+
+ with patch(
+ "app.main._trigger_poll_imap_source", side_effect=Exception("imap fail")
+ ):
+ result = _poll_source_for_trigger(src, MagicMock())
+
+ assert result["success"] is False
+
+ def test_unknown_method_returns_skipped(self):
+ from app.main import _poll_source_for_trigger
+
+ src = MagicMock()
+ src.method = "POP3"
+ src.id = 6
+ src.name = "POP3 src"
+
+ result = _poll_source_for_trigger(src, MagicMock())
+
+ assert result["skipped"] is True
+ assert "POP3" in result["reason"]
+
+
+class TestPollAllEnabledSources:
+ """Unit tests for app.main._poll_all_enabled_sources dispatch logic."""
+
+ def test_dispatches_gmail_api_source(self):
+ """GMAIL_API sources are forwarded to _poll_single_gmail_source."""
+ from app.main import _poll_all_enabled_sources
+
+ src = MagicMock()
+ src.id = 1
+ src.method = "GMAIL_API"
+
+ mock_db = MagicMock()
+ mock_db.query.return_value.filter.return_value.all.return_value = [src]
+
+ with patch("app.main.SessionLocal", return_value=mock_db), patch(
+ "app.main._poll_single_gmail_source"
+ ) as mock_gmail:
+ _poll_all_enabled_sources()
+
+ mock_gmail.assert_called_once_with(src)
+
+ def test_dispatches_imap_source(self):
+ """IMAP sources are forwarded to _poll_single_imap_source."""
+ from app.main import _poll_all_enabled_sources
+
+ src = MagicMock()
+ src.id = 2
+ src.method = "IMAP"
+
+ mock_db = MagicMock()
+ mock_db.query.return_value.filter.return_value.all.return_value = [src]
+
+ with patch("app.main.SessionLocal", return_value=mock_db), patch(
+ "app.main._poll_single_imap_source"
+ ) as mock_imap:
+ _poll_all_enabled_sources()
+
+ mock_imap.assert_called_once_with(src)
+
+ def test_gmail_exception_is_caught(self):
+ """Exception from _poll_single_gmail_source must not propagate."""
+ from app.main import _poll_all_enabled_sources
+
+ src = MagicMock()
+ src.id = 3
+ src.method = "GMAIL_API"
+
+ mock_db = MagicMock()
+ mock_db.query.return_value.filter.return_value.all.return_value = [src]
+
+ with patch("app.main.SessionLocal", return_value=mock_db), patch(
+ "app.main._poll_single_gmail_source", side_effect=Exception("crash")
+ ):
+ _poll_all_enabled_sources() # should not raise
+
+ def test_imap_exception_is_caught(self):
+ """Exception from _poll_single_imap_source must not propagate."""
+ from app.main import _poll_all_enabled_sources
+
+ src = MagicMock()
+ src.id = 4
+ src.method = "IMAP"
+
+ mock_db = MagicMock()
+ mock_db.query.return_value.filter.return_value.all.return_value = [src]
+
+ with patch("app.main.SessionLocal", return_value=mock_db), patch(
+ "app.main._poll_single_imap_source", side_effect=Exception("imap crash")
+ ):
+ _poll_all_enabled_sources() # should not raise
+
+ def test_unknown_method_skipped(self):
+ """An unknown method logs a skip message and does not raise."""
+ from app.main import _poll_all_enabled_sources
+
+ src = MagicMock()
+ src.id = 5
+ src.method = "POP3"
+
+ mock_db = MagicMock()
+ mock_db.query.return_value.filter.return_value.all.return_value = [src]
+
+ with patch("app.main.SessionLocal", return_value=mock_db):
+ _poll_all_enabled_sources() # should not raise
+
+
+class TestTriggerPollEndpoint:
+ """Tests for the POST /api/v1/admin/trigger-poll endpoint with sources."""
+
+ def test_trigger_poll_with_enabled_sources(self):
+ """With enabled sources, the endpoint dispatches and returns results."""
+ from app.main import app as main_app
+ from app.core.security import require_admin_auth
+
+ async def mock_auth():
+ return {"auth_type": "api_key"}
+
+ main_app.dependency_overrides[require_admin_auth] = mock_auth
+
+ try:
+ mock_source = MagicMock()
+ mock_source.id = 1
+ mock_source.name = "Trigger GMAIL"
+ mock_source.method = "GMAIL_API"
+ mock_source.enabled = True
+
+ mock_db = MagicMock()
+ mock_db.query.return_value.filter.return_value.all.return_value = [mock_source]
+
+ mock_result = {
+ "source_id": 1,
+ "name": "Trigger GMAIL",
+ "success": True,
+ "processed": 0,
+ "reports_found": 0,
+ "new_domains": [],
+ }
+
+ with TestClient(main_app) as tc:
+ with patch("app.main.SessionLocal", return_value=mock_db), patch(
+ "app.main._poll_source_for_trigger", return_value=mock_result
+ ):
+ resp = tc.post("/api/v1/admin/trigger-poll")
+
+ assert resp.status_code == 200
+ data = resp.json()
+ assert "sources" in data
+ assert len(data["sources"]) == 1
+ assert data["sources"][0]["success"] is True
+ finally:
+ main_app.dependency_overrides.clear()
+
+
# ---------------------------------------------------------------------------
# Pytest marker to avoid warnings for test methods without assertions
# ---------------------------------------------------------------------------