158 lines
4.9 KiB
Python
158 lines
4.9 KiB
Python
|
|
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",
|
||
|
|
)
|