from __future__ import annotations import json import uuid from datetime import datetime, timezone from pathlib import Path from typing import Generator from unittest.mock import MagicMock, patch import pytest from howl.config import Settings from howl.db.queries import SenderMatch from howl.graph.client import GraphClient, Message from howl.llm.schemas import EmailClassification FIXTURES_DIR = Path(__file__).parent / "fixtures" # --------------------------------------------------------------------------- # Settings fixture (no real credentials needed) # --------------------------------------------------------------------------- @pytest.fixture def settings() -> Settings: return Settings( azure_tenant_id="test-tenant", azure_client_id="test-client-id", azure_client_secret="test-secret", graph_auth_mode="client_credentials", graph_mailbox="test@example.com", anthropic_api_key="test-anthropic-key", database_url="postgresql+asyncpg://test:test@localhost:5432/howl_test", dry_run=True, log_level="WARNING", log_format="text", ) # --------------------------------------------------------------------------- # Sample email messages # --------------------------------------------------------------------------- @pytest.fixture def sample_emails() -> list[dict]: with open(FIXTURES_DIR / "sample_emails.json") as f: return json.load(f) @pytest.fixture def llm_responses() -> dict: with open(FIXTURES_DIR / "llm_responses.json") as f: return json.load(f) def make_message(raw: dict) -> Message: sender = raw.get("sender", {}).get("emailAddress", {}) received_str = raw.get("receivedDateTime") received_at = datetime.fromisoformat(received_str.replace("Z", "+00:00")) if received_str else None return Message( id=raw["id"], conversation_id=raw.get("conversationId"), subject=raw.get("subject"), sender_address=sender.get("address", ""), sender_name=sender.get("name"), received_at=received_at, has_attachments=raw.get("hasAttachments", False), importance=raw.get("importance", "normal"), body_preview=raw.get("bodyPreview"), ) @pytest.fixture def customer_message(sample_emails) -> Message: return make_message(next(e for e in sample_emails if e["id"] == "msg-001-customer-inquiry")) @pytest.fixture def spam_message(sample_emails) -> Message: return make_message(next(e for e in sample_emails if e["id"] == "msg-004-spam")) @pytest.fixture def urgent_message(sample_emails) -> Message: return make_message(next(e for e in sample_emails if e["id"] == "msg-007-urgent")) # --------------------------------------------------------------------------- # Mock Graph client # --------------------------------------------------------------------------- @pytest.fixture def mock_graph(sample_emails) -> MagicMock: graph = MagicMock(spec=GraphClient) raw_messages = [make_message(e) for e in sample_emails] graph.list_unread_messages.return_value = (raw_messages[:3], None) graph.get_message_body.return_value = "This is the email body text." graph.get_or_create_folder.return_value = str(uuid.uuid4()) graph.move_message.return_value = None graph.flag_message.return_value = None graph.mark_as_read.return_value = None return graph # --------------------------------------------------------------------------- # Mock LLM client # --------------------------------------------------------------------------- @pytest.fixture def mock_llm_classification(llm_responses) -> EmailClassification: return EmailClassification.model_validate(llm_responses["customer_inquiry"]) @pytest.fixture def mock_llm(mock_llm_classification) -> MagicMock: from howl.llm.client import LLMClient llm = MagicMock(spec=LLMClient) llm.classify.return_value = ( mock_llm_classification, { "model": "claude-sonnet-4-6", "stop_reason": "tool_use", "usage": {"input_tokens": 350, "output_tokens": 80}, "tool_input": mock_llm_classification.model_dump(), }, ) return llm # --------------------------------------------------------------------------- # Sender matches # --------------------------------------------------------------------------- @pytest.fixture def customer_match() -> SenderMatch: return SenderMatch( sender_type="customer", entity_id=uuid.uuid4(), entity_table="customers", entity_name="Acme Corp", notes="Key account - handle with priority", ) @pytest.fixture def unknown_match() -> SenderMatch: return SenderMatch(sender_type="unknown") @pytest.fixture def whitelist_match() -> SenderMatch: return SenderMatch( sender_type="whitelist", entity_id=uuid.uuid4(), entity_table="whitelist", entity_name="Trusted partner domain", )