howl/tests/conftest.py
lorentz 3bfda9e585 Initial commit: Howl M365 email management daemon
Full implementation of the Howl email triage system:
- Microsoft Graph API integration with MSAL auth (client-credentials and delegated modes)
- Claude LLM classification via tool use for structured output
- PostgreSQL database with customers, vendors, whitelist, and email_log tables
- Alembic migration for full schema
- APScheduler daemon with graceful shutdown
- Typer CLI (run, dry-run, status commands)
- Business rule classifier with overrides (whitelist protection, low-confidence fallback)
- Action executor (move to folders, flag, escalate with webhook)
- 35 passing unit tests
- README, SETUP, and NEXT_STEPS documentation

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 16:02:24 -04:00

157 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",
)