test: add unit and integration coverage across auth, adapters, and unsubscribe
parent
be9bbf4f83
commit
e74b29381c
@ -0,0 +1,112 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import replace
|
||||
from types import SimpleNamespace
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
import app.a2a.router as a2a_module
|
||||
from app.config import get_settings
|
||||
from app.security.auth import AuthBackend
|
||||
|
||||
|
||||
class _DummyCoreService:
|
||||
def check_availability(
|
||||
self,
|
||||
start: str,
|
||||
end: str,
|
||||
calendar_ids: list[str] | None,
|
||||
) -> SimpleNamespace:
|
||||
checked = calendar_ids or ["primary"]
|
||||
return SimpleNamespace(
|
||||
start=start,
|
||||
end=end,
|
||||
available=True,
|
||||
busy_slots=[],
|
||||
checked_calendars=checked,
|
||||
)
|
||||
|
||||
|
||||
def _build_test_app() -> FastAPI:
|
||||
app = FastAPI()
|
||||
app.include_router(a2a_module.router)
|
||||
return app
|
||||
|
||||
|
||||
def test_a2a_agent_card_endpoint(monkeypatch) -> None:
|
||||
monkeypatch.setattr(a2a_module, "core_service", _DummyCoreService())
|
||||
app = _build_test_app()
|
||||
|
||||
with TestClient(app) as client:
|
||||
response = client.get("/.well-known/agent-card.json")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.headers["A2A-Version"] == "1.0"
|
||||
payload = response.json()
|
||||
assert payload["protocolVersion"] == "1.0"
|
||||
assert payload["url"].endswith("/a2a/rpc")
|
||||
|
||||
|
||||
def test_a2a_send_message_requires_auth(monkeypatch) -> None:
|
||||
auth_settings = replace(
|
||||
get_settings(),
|
||||
auth_mode="api_key",
|
||||
agent_api_key="integration-key",
|
||||
auth_jwt_secret="",
|
||||
)
|
||||
monkeypatch.setattr(a2a_module, "auth_backend", AuthBackend(auth_settings))
|
||||
monkeypatch.setattr(a2a_module, "core_service", _DummyCoreService())
|
||||
app = _build_test_app()
|
||||
|
||||
with TestClient(app) as client:
|
||||
response = client.post(
|
||||
"/a2a/rpc",
|
||||
json={
|
||||
"jsonrpc": "2.0",
|
||||
"id": "r1",
|
||||
"method": "SendMessage",
|
||||
"params": {
|
||||
"start": "2026-03-10T09:00:00+01:00",
|
||||
"end": "2026-03-10T10:00:00+01:00",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert payload["error"]["code"] == -32001
|
||||
|
||||
|
||||
def test_a2a_send_message_with_api_key(monkeypatch) -> None:
|
||||
auth_settings = replace(
|
||||
get_settings(),
|
||||
auth_mode="api_key",
|
||||
agent_api_key="integration-key",
|
||||
auth_jwt_secret="",
|
||||
)
|
||||
monkeypatch.setattr(a2a_module, "auth_backend", AuthBackend(auth_settings))
|
||||
monkeypatch.setattr(a2a_module, "core_service", _DummyCoreService())
|
||||
app = _build_test_app()
|
||||
|
||||
with TestClient(app) as client:
|
||||
response = client.post(
|
||||
"/a2a/rpc",
|
||||
headers={"X-API-Key": "integration-key"},
|
||||
json={
|
||||
"jsonrpc": "2.0",
|
||||
"id": "r2",
|
||||
"method": "SendMessage",
|
||||
"params": {
|
||||
"start": "2026-03-10T09:00:00+01:00",
|
||||
"end": "2026-03-10T10:00:00+01:00",
|
||||
"calendar_ids": ["primary"],
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert payload["error"] is None
|
||||
assert payload["result"]["availability"]["available"] is True
|
||||
assert payload["result"]["availability"]["checked_calendars"] == ["primary"]
|
||||
@ -0,0 +1,162 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
from dataclasses import replace
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import time
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
from app.config import get_settings
|
||||
from app.security.auth import AuthBackend
|
||||
|
||||
|
||||
def _make_settings(**overrides: object):
|
||||
return replace(get_settings(), **overrides)
|
||||
|
||||
|
||||
def _build_jwt(secret: str, claims: dict[str, object]) -> str:
|
||||
header = {"alg": "HS256", "typ": "JWT"}
|
||||
header_b64 = _b64url_json(header)
|
||||
payload_b64 = _b64url_json(claims)
|
||||
signing_input = f"{header_b64}.{payload_b64}".encode("utf-8")
|
||||
signature = hmac.new(secret.encode("utf-8"), signing_input, hashlib.sha256).digest()
|
||||
signature_b64 = base64.urlsafe_b64encode(signature).decode("utf-8").rstrip("=")
|
||||
return f"{header_b64}.{payload_b64}.{signature_b64}"
|
||||
|
||||
|
||||
def _b64url_json(value: dict[str, object]) -> str:
|
||||
raw = json.dumps(value, separators=(",", ":"), sort_keys=True).encode("utf-8")
|
||||
return base64.urlsafe_b64encode(raw).decode("utf-8").rstrip("=")
|
||||
|
||||
|
||||
def test_auth_backend_api_key_mode_accepts_x_api_key() -> None:
|
||||
settings = _make_settings(
|
||||
auth_mode="api_key",
|
||||
agent_api_key="test-api-key",
|
||||
auth_jwt_secret="",
|
||||
)
|
||||
backend = AuthBackend(settings=settings)
|
||||
|
||||
context = backend.authenticate(
|
||||
x_api_key="test-api-key",
|
||||
authorization=None,
|
||||
required_scopes={"availability:read"},
|
||||
)
|
||||
|
||||
assert context.auth_type == "api_key"
|
||||
assert context.subject == "api-key"
|
||||
assert "*" in context.scopes
|
||||
|
||||
|
||||
def test_auth_backend_api_key_mode_rejects_invalid_key() -> None:
|
||||
settings = _make_settings(
|
||||
auth_mode="api_key",
|
||||
agent_api_key="expected",
|
||||
auth_jwt_secret="",
|
||||
)
|
||||
backend = AuthBackend(settings=settings)
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
backend.authenticate(
|
||||
x_api_key="wrong",
|
||||
authorization=None,
|
||||
required_scopes={"availability:read"},
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 401
|
||||
assert str(exc_info.value.detail) == "Invalid API key."
|
||||
|
||||
|
||||
def test_auth_backend_jwt_mode_validates_scope_and_claims() -> None:
|
||||
secret = "jwt-secret"
|
||||
settings = _make_settings(
|
||||
auth_mode="jwt",
|
||||
auth_jwt_secret=secret,
|
||||
auth_jwt_issuer="https://issuer.example",
|
||||
auth_jwt_audience="personal-agent",
|
||||
agent_api_key="",
|
||||
)
|
||||
backend = AuthBackend(settings=settings)
|
||||
token = _build_jwt(
|
||||
secret=secret,
|
||||
claims={
|
||||
"sub": "agent-123",
|
||||
"iss": "https://issuer.example",
|
||||
"aud": "personal-agent",
|
||||
"scope": "availability:read unsubscribe:read",
|
||||
"exp": int(time.time()) + 3600,
|
||||
},
|
||||
)
|
||||
|
||||
context = backend.authenticate(
|
||||
x_api_key=None,
|
||||
authorization=f"Bearer {token}",
|
||||
required_scopes={"availability:read"},
|
||||
)
|
||||
|
||||
assert context.auth_type == "jwt"
|
||||
assert context.subject == "agent-123"
|
||||
assert "availability:read" in context.scopes
|
||||
|
||||
|
||||
def test_auth_backend_jwt_mode_rejects_missing_scope() -> None:
|
||||
secret = "jwt-secret"
|
||||
settings = _make_settings(
|
||||
auth_mode="jwt",
|
||||
auth_jwt_secret=secret,
|
||||
auth_jwt_issuer=None,
|
||||
auth_jwt_audience=None,
|
||||
agent_api_key="",
|
||||
)
|
||||
backend = AuthBackend(settings=settings)
|
||||
token = _build_jwt(
|
||||
secret=secret,
|
||||
claims={
|
||||
"sub": "agent-123",
|
||||
"scope": "unsubscribe:read",
|
||||
"exp": int(time.time()) + 3600,
|
||||
},
|
||||
)
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
backend.authenticate(
|
||||
x_api_key=None,
|
||||
authorization=f"Bearer {token}",
|
||||
required_scopes={"availability:read"},
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 403
|
||||
assert str(exc_info.value.detail) == "Missing required scope."
|
||||
|
||||
|
||||
def test_auth_backend_hybrid_mode_uses_jwt_when_api_key_missing() -> None:
|
||||
secret = "jwt-secret"
|
||||
settings = _make_settings(
|
||||
auth_mode="hybrid",
|
||||
agent_api_key="expected-api-key",
|
||||
auth_jwt_secret=secret,
|
||||
auth_jwt_issuer=None,
|
||||
auth_jwt_audience=None,
|
||||
)
|
||||
backend = AuthBackend(settings=settings)
|
||||
token = _build_jwt(
|
||||
secret=secret,
|
||||
claims={
|
||||
"sub": "fallback-jwt",
|
||||
"scope": "availability:read",
|
||||
"exp": int(time.time()) + 3600,
|
||||
},
|
||||
)
|
||||
|
||||
context = backend.authenticate(
|
||||
x_api_key=None,
|
||||
authorization=f"Bearer {token}",
|
||||
required_scopes={"availability:read"},
|
||||
)
|
||||
|
||||
assert context.auth_type == "jwt"
|
||||
assert context.subject == "fallback-jwt"
|
||||
@ -0,0 +1,66 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from app.gmail_agent import GmailTriageAgent
|
||||
|
||||
|
||||
class _FailingClassifier:
|
||||
def classify(self, **kwargs): # type: ignore[no-untyped-def]
|
||||
raise RuntimeError("model unavailable")
|
||||
|
||||
|
||||
def test_build_effective_query_enforces_inbox_and_unread() -> None:
|
||||
agent = GmailTriageAgent(gmail_service=object(), query="-label:AgentProcessed")
|
||||
assert (
|
||||
agent._build_effective_query()
|
||||
== "-label:AgentProcessed in:inbox is:unread"
|
||||
)
|
||||
|
||||
|
||||
def test_build_effective_query_keeps_existing_requirements() -> None:
|
||||
agent = GmailTriageAgent(
|
||||
gmail_service=object(),
|
||||
query="IN:INBOX is:unread -label:AgentProcessed",
|
||||
)
|
||||
assert agent._build_effective_query() == "IN:INBOX is:unread -label:AgentProcessed"
|
||||
|
||||
|
||||
def test_classify_email_returns_other_when_model_fails_and_no_rules_fallback() -> None:
|
||||
agent = GmailTriageAgent(
|
||||
gmail_service=object(),
|
||||
query="",
|
||||
classifier=_FailingClassifier(), # type: ignore[arg-type]
|
||||
fallback_to_rules=False,
|
||||
)
|
||||
|
||||
label = agent._classify_email(
|
||||
message_id="m1",
|
||||
sender="newsletter@example.com",
|
||||
subject="50% OFF today",
|
||||
snippet="promo content",
|
||||
list_unsubscribe="<https://example.com/unsubscribe>",
|
||||
precedence="bulk",
|
||||
message_label_ids={"CATEGORY_PROMOTIONS"},
|
||||
)
|
||||
|
||||
assert label == "OTHER"
|
||||
|
||||
|
||||
def test_classify_email_prioritizes_linkedin_over_advertising_signals() -> None:
|
||||
agent = GmailTriageAgent(
|
||||
gmail_service=object(),
|
||||
query="",
|
||||
classifier=None,
|
||||
fallback_to_rules=True,
|
||||
)
|
||||
|
||||
label = agent._classify_email(
|
||||
message_id="m2",
|
||||
sender="jobs-noreply@linkedin.com",
|
||||
subject="Limited time offer for your profile",
|
||||
snippet="promotional snippet",
|
||||
list_unsubscribe="<https://example.com/unsubscribe>",
|
||||
precedence="bulk",
|
||||
message_label_ids={"CATEGORY_PROMOTIONS"},
|
||||
)
|
||||
|
||||
assert label == "LINKEDIN"
|
||||
@ -0,0 +1,93 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import replace
|
||||
from types import SimpleNamespace
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
import app.main as main_module
|
||||
from app.config import get_settings
|
||||
from app.security.auth import AuthBackend
|
||||
|
||||
|
||||
class _DummyCoreService:
|
||||
def scan_mailbox(self, max_results: int) -> SimpleNamespace:
|
||||
return SimpleNamespace(
|
||||
scanned=max_results,
|
||||
linkedin=1,
|
||||
advertising=2,
|
||||
veille_techno=0,
|
||||
skipped=3,
|
||||
failed=0,
|
||||
)
|
||||
|
||||
def check_availability(
|
||||
self,
|
||||
start: str,
|
||||
end: str,
|
||||
calendar_ids: list[str] | None,
|
||||
) -> SimpleNamespace:
|
||||
return SimpleNamespace(
|
||||
start=start,
|
||||
end=end,
|
||||
available=False,
|
||||
busy_slots=[
|
||||
{
|
||||
"calendar_id": "primary",
|
||||
"start": start,
|
||||
"end": end,
|
||||
}
|
||||
],
|
||||
checked_calendars=calendar_ids or ["primary"],
|
||||
)
|
||||
|
||||
|
||||
async def _noop_task() -> None:
|
||||
return None
|
||||
|
||||
|
||||
def _setup_main_test_context(monkeypatch) -> None:
|
||||
auth_settings = replace(
|
||||
get_settings(),
|
||||
auth_mode="api_key",
|
||||
agent_api_key="integration-key",
|
||||
auth_jwt_secret="",
|
||||
)
|
||||
monkeypatch.setattr(main_module, "auth_backend", AuthBackend(auth_settings))
|
||||
monkeypatch.setattr(main_module, "core_service", _DummyCoreService())
|
||||
# Prevent scheduler jobs from executing real background work during lifespan startup.
|
||||
monkeypatch.setattr(main_module, "_scheduled_scan", _noop_task)
|
||||
monkeypatch.setattr(main_module, "_scheduled_unsubscribe_digest", _noop_task)
|
||||
monkeypatch.setattr(main_module, "_scheduled_unsubscribe_auto", _noop_task)
|
||||
|
||||
|
||||
def test_main_scan_endpoint_with_api_key(monkeypatch) -> None:
|
||||
_setup_main_test_context(monkeypatch)
|
||||
|
||||
with TestClient(main_module.app) as client:
|
||||
response = client.post(
|
||||
"/scan?max_results=15",
|
||||
headers={"X-API-Key": "integration-key"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert payload["scanned"] == 15
|
||||
assert payload["linkedin"] == 1
|
||||
assert payload["advertising"] == 2
|
||||
|
||||
|
||||
def test_main_availability_endpoint_rejects_missing_key(monkeypatch) -> None:
|
||||
_setup_main_test_context(monkeypatch)
|
||||
|
||||
with TestClient(main_module.app) as client:
|
||||
response = client.post(
|
||||
"/availability",
|
||||
json={
|
||||
"start": "2026-03-10T09:00:00+01:00",
|
||||
"end": "2026-03-10T10:00:00+01:00",
|
||||
"calendar_ids": ["primary"],
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
@ -0,0 +1,100 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import replace
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
import app.mcp.tools as mcp_tools_module
|
||||
from app.config import get_settings
|
||||
from app.security.auth import AuthBackend
|
||||
|
||||
|
||||
class _DummyCoreService:
|
||||
def check_availability(
|
||||
self,
|
||||
start: str,
|
||||
end: str,
|
||||
calendar_ids: list[str] | None,
|
||||
) -> SimpleNamespace:
|
||||
return SimpleNamespace(
|
||||
start=start,
|
||||
end=end,
|
||||
available=True,
|
||||
busy_slots=[],
|
||||
checked_calendars=calendar_ids or ["primary"],
|
||||
)
|
||||
|
||||
def scan_mailbox(self, max_results: int) -> SimpleNamespace:
|
||||
return SimpleNamespace(
|
||||
scanned=max_results,
|
||||
linkedin=0,
|
||||
advertising=0,
|
||||
veille_techno=0,
|
||||
skipped=0,
|
||||
failed=0,
|
||||
)
|
||||
|
||||
|
||||
class _DummyCtx:
|
||||
def __init__(self, headers: dict[str, str]) -> None:
|
||||
self.request_context = SimpleNamespace(
|
||||
request=SimpleNamespace(headers=headers)
|
||||
)
|
||||
|
||||
|
||||
def test_mcp_check_availability_requires_auth(monkeypatch) -> None:
|
||||
auth_settings = replace(
|
||||
get_settings(),
|
||||
auth_mode="api_key",
|
||||
agent_api_key="mcp-key",
|
||||
auth_jwt_secret="",
|
||||
)
|
||||
monkeypatch.setattr(mcp_tools_module, "auth_backend", AuthBackend(auth_settings))
|
||||
monkeypatch.setattr(mcp_tools_module, "core_service", _DummyCoreService())
|
||||
|
||||
with pytest.raises(PermissionError):
|
||||
mcp_tools_module.check_availability(
|
||||
start="2026-03-10T09:00:00+01:00",
|
||||
end="2026-03-10T10:00:00+01:00",
|
||||
calendar_ids=["primary"],
|
||||
ctx=_DummyCtx(headers={}),
|
||||
)
|
||||
|
||||
|
||||
def test_mcp_check_availability_with_api_key(monkeypatch) -> None:
|
||||
auth_settings = replace(
|
||||
get_settings(),
|
||||
auth_mode="api_key",
|
||||
agent_api_key="mcp-key",
|
||||
auth_jwt_secret="",
|
||||
)
|
||||
monkeypatch.setattr(mcp_tools_module, "auth_backend", AuthBackend(auth_settings))
|
||||
monkeypatch.setattr(mcp_tools_module, "core_service", _DummyCoreService())
|
||||
|
||||
payload = mcp_tools_module.check_availability(
|
||||
start="2026-03-10T09:00:00+01:00",
|
||||
end="2026-03-10T10:00:00+01:00",
|
||||
calendar_ids=["primary"],
|
||||
ctx=_DummyCtx(headers={"x-api-key": "mcp-key"}),
|
||||
)
|
||||
|
||||
assert payload["available"] is True
|
||||
assert payload["checked_calendars"] == ["primary"]
|
||||
|
||||
|
||||
def test_mcp_scan_mailbox_requires_mail_scan_scope(monkeypatch) -> None:
|
||||
auth_settings = replace(
|
||||
get_settings(),
|
||||
auth_mode="api_key",
|
||||
agent_api_key="mcp-key",
|
||||
auth_jwt_secret="",
|
||||
)
|
||||
monkeypatch.setattr(mcp_tools_module, "auth_backend", AuthBackend(auth_settings))
|
||||
monkeypatch.setattr(mcp_tools_module, "core_service", _DummyCoreService())
|
||||
|
||||
payload = mcp_tools_module.scan_mailbox(
|
||||
max_results=10,
|
||||
ctx=_DummyCtx(headers={"x-api-key": "mcp-key"}),
|
||||
)
|
||||
assert payload["scanned"] == 10
|
||||
@ -0,0 +1,107 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from app.unsubscribe_agent import UnsubscribeDigestAgent
|
||||
|
||||
|
||||
def _b64url_text(value: str) -> str:
|
||||
return base64.urlsafe_b64encode(value.encode("utf-8")).decode("utf-8").rstrip("=")
|
||||
|
||||
|
||||
class _Executable:
|
||||
def __init__(self, callback):
|
||||
self._callback = callback
|
||||
|
||||
def execute(self): # type: ignore[no-untyped-def]
|
||||
return self._callback()
|
||||
|
||||
|
||||
class _FakeMessagesApi:
|
||||
def __init__(self, message_payload_by_id: dict[str, dict[str, Any]]) -> None:
|
||||
self._message_payload_by_id = message_payload_by_id
|
||||
self.sent_messages: list[dict[str, Any]] = []
|
||||
|
||||
def list(self, userId: str, q: str, maxResults: int): # type: ignore[no-untyped-def]
|
||||
message_ids = [{"id": key} for key in self._message_payload_by_id.keys()]
|
||||
return _Executable(lambda: {"messages": message_ids[:maxResults]})
|
||||
|
||||
def get(self, userId: str, id: str, format: str): # type: ignore[no-untyped-def]
|
||||
return _Executable(lambda: self._message_payload_by_id[id])
|
||||
|
||||
def send(self, userId: str, body: dict[str, Any]): # type: ignore[no-untyped-def]
|
||||
self.sent_messages.append(body)
|
||||
return _Executable(lambda: {"id": "sent-1"})
|
||||
|
||||
|
||||
class _FakeUsersApi:
|
||||
def __init__(self, messages_api: _FakeMessagesApi) -> None:
|
||||
self._messages_api = messages_api
|
||||
|
||||
def messages(self) -> _FakeMessagesApi:
|
||||
return self._messages_api
|
||||
|
||||
def getProfile(self, userId: str): # type: ignore[no-untyped-def]
|
||||
return _Executable(lambda: {"emailAddress": "owner@example.com"})
|
||||
|
||||
|
||||
class _FakeGmailService:
|
||||
def __init__(self, payload_by_id: dict[str, dict[str, Any]]) -> None:
|
||||
self.messages_api = _FakeMessagesApi(payload_by_id)
|
||||
self.users_api = _FakeUsersApi(self.messages_api)
|
||||
|
||||
def users(self) -> _FakeUsersApi:
|
||||
return self.users_api
|
||||
|
||||
|
||||
def test_unsubscribe_digest_deduplicates_and_persists_state(tmp_path: Path) -> None:
|
||||
unsubscribe_url_1 = "https://example.com/unsubscribe?u=abc&utm_source=mail"
|
||||
unsubscribe_url_2 = "https://example.com/unsubscribe?fbclid=tracking&u=abc"
|
||||
|
||||
message_payloads = {
|
||||
"m1": {
|
||||
"payload": {
|
||||
"headers": [
|
||||
{"name": "List-Unsubscribe", "value": f"<{unsubscribe_url_1}>"},
|
||||
],
|
||||
"mimeType": "text/plain",
|
||||
"body": {"data": _b64url_text(f"Unsubscribe here: {unsubscribe_url_1}")},
|
||||
}
|
||||
},
|
||||
"m2": {
|
||||
"payload": {
|
||||
"headers": [],
|
||||
"mimeType": "text/plain",
|
||||
"body": {"data": _b64url_text(f"Click to unsubscribe: {unsubscribe_url_2}")},
|
||||
}
|
||||
},
|
||||
}
|
||||
state_file = tmp_path / "sent_links.json"
|
||||
service = _FakeGmailService(message_payloads)
|
||||
agent = UnsubscribeDigestAgent(
|
||||
gmail_service=service,
|
||||
query="label:Advertising",
|
||||
state_file=str(state_file),
|
||||
recipient_email="owner@example.com",
|
||||
send_empty_digest=False,
|
||||
)
|
||||
|
||||
first = agent.scan_and_send_digest(max_results=50)
|
||||
second = agent.scan_and_send_digest(max_results=50)
|
||||
|
||||
assert first.scanned_messages == 2
|
||||
assert first.extracted_unique_links == 1
|
||||
assert first.new_links == 1
|
||||
assert first.email_sent is True
|
||||
|
||||
assert second.scanned_messages == 2
|
||||
assert second.extracted_unique_links == 1
|
||||
assert second.new_links == 0
|
||||
assert second.email_sent is False
|
||||
|
||||
assert len(service.messages_api.sent_messages) == 1
|
||||
persisted = json.loads(state_file.read_text(encoding="utf-8"))
|
||||
assert persisted["sent_links"] == ["https://example.com/unsubscribe?u=abc"]
|
||||
Loading…
Reference in New Issue