WA1: add 35 tests for email watch, notifications, suggestions, digest

This commit is contained in:
hermes 2026-07-30 20:41:27 +00:00
parent 6c38de5fed
commit 05ba99cb4b

View file

@ -0,0 +1,682 @@
"""Tests for v1.1: email watch, notifications, suggestions endpoints.
Covers:
- IMAP matching logic (company in subject, sender domain in URL, title keywords)
- Classifier -> suggestion row
- Noise dedupe (same from+subject+day)
- Accept applies transition through guard
- Dismiss marks suggestion
- Webhook success/failure notification_log rows
- LogChannel writes delivered=true
- Daily digest payload shape
- GET /suggestions, POST accept, POST dismiss, GET /notifications/log
- FakeImap end-to-end poll
"""
from __future__ import annotations
import email as email_mod
from datetime import datetime, timedelta, timezone
import pytest
from fastapi.testclient import TestClient
from app.config import DATABASE_URL
from app.db import repo_app
from app.imap_watch import (
FakeImap,
_build_snippet,
_extract_sender_domain,
_parse_email_message,
classify_email,
create_email_suggestion,
is_duplicate,
match_application,
poll_inbox,
)
from app.notify import (
LogChannel,
WebhookChannel,
get_channels,
list_notification_log,
reset_channels,
send_notification,
set_channels,
)
@pytest.fixture()
def client():
from app.main import app
return TestClient(app)
# --- Helpers ---
def _make_email(raw_from: str, subject: str, body: str, date: str = "") -> bytes:
"""Build raw email bytes for FakeImap."""
msg = email_mod.message_from_string(
f"From: {raw_from}\r\n"
f"Subject: {subject}\r\n"
f"Date: {date or 'Mon, 01 Jul 2026 10:00:00 +0000'}\r\n"
f"\r\n"
f"{body}"
)
return msg.as_bytes()
def _create_app_in_state(state: str = "sent", company: str = "TechCorp", url: str = "https://techcorp.com/jobs/1") -> dict:
"""Create a posting + application, force state via SQL."""
posting = repo_app.create_job_posting(
source="manual_url",
url=url,
company=company,
title="Senior Python Developer",
location="Malmo",
description="",
raw={},
)
app_row = repo_app.create_application(posting["id"])
if state != "discovered":
repo_app.update_application_score(app_row["id"], 80, {"factors": {}})
if state in ("approved", "rejected"):
repo_app.update_application_state(app_row["id"], "scored")
repo_app.update_application_state(app_row["id"], state)
elif state == "sent":
repo_app.update_application_state(app_row["id"], "scored")
repo_app.update_application_state(app_row["id"], "approved")
repo_app.update_application_state(app_row["id"], "drafting")
# Bypass guard for test
from app.db import execute
execute(
"UPDATE application SET state = 'sent', last_activity_at = now() WHERE id = %s",
(app_row["id"],),
)
elif state == "interviewing":
repo_app.update_application_state(app_row["id"], "scored")
repo_app.update_application_state(app_row["id"], "approved")
repo_app.update_application_state(app_row["id"], "drafting")
from app.db import execute
execute(
"UPDATE application SET state = 'sent' WHERE id = %s",
(app_row["id"],),
)
execute(
"UPDATE application SET state = 'interviewing' WHERE id = %s",
(app_row["id"],),
)
return app_row
# ========================================================================
# IMAP matching logic (3 tests)
# ========================================================================
class TestImapMatching:
def test_match_by_company_name_in_subject(self):
"""Email subject contains company name -> match."""
apps = [
{"id": "app1", "state": "sent", "company": "TechCorp", "title": "Python Dev", "url": "https://example.com"},
]
result = match_application(
"recruiter@gmail.com",
"Interview at TechCorp",
"Please come for an interview.",
apps,
)
assert result is not None
assert result["id"] == "app1"
def test_match_by_sender_domain_in_url(self):
"""Sender domain matches the posting URL -> match."""
apps = [
{"id": "app2", "state": "sent", "company": "Unknown", "title": "Dev", "url": "https://techcorp.com/careers/1"},
]
result = match_application(
"hr@techcorp.com",
"Your application",
"We reviewed your application.",
apps,
)
assert result is not None
assert result["id"] == "app2"
def test_match_by_title_keywords(self):
"""Email subject contains 2+ title words -> match."""
apps = [
{"id": "app3", "state": "interviewing", "company": "SomeCompany", "title": "Senior Python Developer", "url": "https://other.com"},
]
result = match_application(
"someone@other.com",
"Senior Python position update",
"Regarding the developer role.",
apps,
)
assert result is not None
assert result["id"] == "app3"
def test_no_match_wrong_state(self):
"""Applications in discovered state are not matched."""
apps = [
{"id": "app4", "state": "discovered", "company": "TechCorp", "title": "Dev", "url": "https://techcorp.com"},
]
result = match_application(
"hr@techcorp.com",
"Interview at TechCorp",
"Come for an interview.",
apps,
)
assert result is None
def test_no_match_unrelated_email(self):
"""Email unrelated to any application -> no match."""
apps = [
{"id": "app5", "state": "sent", "company": "TechCorp", "title": "Dev", "url": "https://techcorp.com"},
]
result = match_application(
"newsletter@spam.com",
"Buy now!",
"Special offer for you.",
apps,
)
assert result is None
# ========================================================================
# Classifier -> row (3 tests)
# ========================================================================
class TestClassifierToRow:
def test_classify_returns_interview_invite(self):
"""classify_email returns interview_invite from mock."""
result = classify_email("Interview invitation", "Please come for an interview next week.")
assert result["classification"] == "interview_invite"
assert result["state_proposal"] == "interviewing"
assert "reason" in result
def test_classify_falls_back_on_invalid_classification(self):
"""Invalid classification from LLM falls back to noise."""
import app.llm as llm_mod
original = llm_mod.MOCK_OUTPUTS.get("email_classify", {}).copy()
try:
llm_mod.MOCK_OUTPUTS["email_classify"] = {"classification": "bogus", "state_proposal": None, "reason": "test"}
result = classify_email("test", "test")
assert result["classification"] == "noise"
finally:
llm_mod.MOCK_OUTPUTS["email_classify"] = original
def test_create_email_suggestion_row(self):
"""create_email_suggestion inserts a row correctly."""
now = datetime.now(timezone.utc)
suggestion = create_email_suggestion(
application_id=None,
mailbox_from="hr@example.com",
subject="Interview invite",
snippet="Please come for an interview.",
classification="interview_invite",
state_proposal="interviewing",
received_at=now,
)
assert suggestion["id"] is not None
assert suggestion["mailbox_from"] == "hr@example.com"
assert suggestion["classification"] == "interview_invite"
assert suggestion["status"] == "pending"
assert suggestion["state_proposal"] == "interviewing"
# ========================================================================
# Noise dedupe (3 tests)
# ========================================================================
class TestNoiseDedupe:
def test_duplicate_detected_same_day(self):
"""Same from+subject+day is flagged as duplicate."""
now = datetime.now(timezone.utc)
create_email_suggestion(
application_id=None,
mailbox_from="hr@example.com",
subject="Interview",
snippet="Come for an interview.",
classification="interview_invite",
state_proposal="interviewing",
received_at=now,
)
assert is_duplicate("hr@example.com", "Interview", now)
def test_different_subject_not_duplicate(self):
"""Different subject -> not duplicate."""
now = datetime.now(timezone.utc)
create_email_suggestion(
application_id=None,
mailbox_from="hr@example.com",
subject="Interview",
snippet="Come.",
classification="interview_invite",
state_proposal="interviewing",
received_at=now,
)
assert not is_duplicate("hr@example.com", "Different Subject", now)
def test_different_sender_not_duplicate(self):
"""Different sender -> not duplicate."""
now = datetime.now(timezone.utc)
create_email_suggestion(
application_id=None,
mailbox_from="hr@example.com",
subject="Interview",
snippet="Come.",
classification="interview_invite",
state_proposal="interviewing",
received_at=now,
)
assert not is_duplicate("other@example.com", "Interview", now)
# ========================================================================
# Accept applies transition through guard (3 tests)
# ========================================================================
class TestAcceptSuggestion:
def test_accept_applies_transition(self, client):
"""POST /suggestions/{id}/accept transitions app from sent to interviewing."""
app_row = _create_app_in_state("sent", company="TechCorp")
suggestion = create_email_suggestion(
application_id=app_row["id"],
mailbox_from="hr@techcorp.com",
subject="Interview at TechCorp",
snippet="Please come in for an interview.",
classification="interview_invite",
state_proposal="interviewing",
received_at=datetime.now(timezone.utc),
)
resp = client.post(f"/api/suggestions/{suggestion['id']}/accept")
assert resp.status_code == 200
assert resp.json()["status"] == "accepted"
# Verify application state changed
updated_app = repo_app.get_application(app_row["id"])
assert updated_app["state"] == "interviewing"
def test_accept_invalid_transition_409(self, client):
"""Accept with invalid transition (e.g. discovered -> interviewing) returns 409."""
posting = repo_app.create_job_posting(
source="manual_url", url="https://example.com/bad/1",
company="X", title="X", location=None, description="", raw={},
)
app_row = repo_app.create_application(posting["id"])
suggestion = create_email_suggestion(
application_id=app_row["id"],
mailbox_from="hr@example.com",
subject="Test",
snippet="Test",
classification="interview_invite",
state_proposal="interviewing",
received_at=datetime.now(timezone.utc),
)
resp = client.post(f"/api/suggestions/{suggestion['id']}/accept")
assert resp.status_code == 409
assert "invalid_transition" in str(resp.json()["detail"])
def test_accept_404_nonexistent(self, client):
"""Accept on nonexistent suggestion -> 404."""
resp = client.post("/api/suggestions/00000000-0000-0000-0000-000000000000/accept")
assert resp.status_code == 404
def test_accept_already_accepted_409(self, client):
"""Accept on already accepted suggestion -> 409."""
app_row = _create_app_in_state("sent")
suggestion = create_email_suggestion(
application_id=app_row["id"],
mailbox_from="hr@example.com",
subject="Test",
snippet="Test",
classification="interview_invite",
state_proposal="interviewing",
received_at=datetime.now(timezone.utc),
)
client.post(f"/api/suggestions/{suggestion['id']}/accept")
resp = client.post(f"/api/suggestions/{suggestion['id']}/accept")
assert resp.status_code == 409
# ========================================================================
# Dismiss suggestion (2 tests)
# ========================================================================
class TestDismissSuggestion:
def test_dismiss_marks_as_dismissed(self, client):
"""POST /suggestions/{id}/dismiss marks as dismissed."""
suggestion = create_email_suggestion(
application_id=None,
mailbox_from="hr@example.com",
subject="Test",
snippet="Test",
classification="question",
state_proposal=None,
received_at=datetime.now(timezone.utc),
)
resp = client.post(f"/api/suggestions/{suggestion['id']}/dismiss")
assert resp.status_code == 200
assert resp.json()["status"] == "dismissed"
def test_dismiss_404_nonexistent(self, client):
"""Dismiss nonexistent -> 404."""
resp = client.post("/api/suggestions/00000000-0000-0000-0000-000000000000/dismiss")
assert resp.status_code == 404
# ========================================================================
# GET /suggestions (2 tests)
# ========================================================================
class TestGetSuggestions:
def test_get_suggestions_returns_pending(self, client):
"""GET /suggestions returns only pending suggestions."""
create_email_suggestion(
application_id=None,
mailbox_from="a@example.com",
subject="Subject A",
snippet="Snippet A",
classification="interview_invite",
state_proposal="interviewing",
received_at=datetime.now(timezone.utc),
)
create_email_suggestion(
application_id=None,
mailbox_from="b@example.com",
subject="Subject B",
snippet="Snippet B",
classification="rejection",
state_proposal=None,
received_at=datetime.now(timezone.utc),
)
resp = client.get("/api/suggestions")
assert resp.status_code == 200
data = resp.json()
assert len(data) == 2
assert all(s["status"] == "pending" for s in data)
def test_get_suggestions_empty(self, client):
"""GET /suggestions returns empty list when no suggestions."""
resp = client.get("/api/suggestions")
assert resp.status_code == 200
assert resp.json() == []
# ========================================================================
# Notification channels (6 tests)
# ========================================================================
class TestLogChannel:
def test_log_channel_writes_delivered_true(self):
"""LogChannel writes notification_log with delivered=true."""
reset_channels()
ch = LogChannel()
result = ch.send("daily_digest", "Test digest", {"count": 5})
assert result is True
logs = list_notification_log(limit=10)
assert len(logs) >= 1
latest = logs[0]
assert latest["channel"] == "log"
assert latest["kind"] == "daily_digest"
assert latest["delivered"] is True
assert latest["error"] is None
def test_send_notification_log_channel(self):
"""send_notification via LogChannel creates a log entry."""
reset_channels()
set_channels([LogChannel()])
send_notification("email_suggestion", "Interview invite from TechCorp", {"id": "test"})
logs = list_notification_log(limit=10)
assert len(logs) >= 1
assert logs[0]["kind"] == "email_suggestion"
assert logs[0]["delivered"] is True
reset_channels()
class TestWebhookChannel:
def test_webhook_success_2xx(self, monkeypatch):
"""WebhookChannel with 2xx response writes delivered=true."""
class FakeResponse:
status_code = 200
text = "OK"
class FakeClient:
@staticmethod
def post(url, json=None, timeout=None):
assert url == "https://hook.example.com/notify"
return FakeResponse()
import httpx
monkeypatch.setattr(httpx, "post", FakeClient.post)
reset_channels()
ch = WebhookChannel(url="https://hook.example.com/notify")
result = ch.send("daily_digest", "Digest", {"count": 3})
assert result is True
logs = list_notification_log(limit=10)
latest = logs[0]
assert latest["channel"] == "webhook"
assert latest["delivered"] is True
assert latest["error"] is None
reset_channels()
def test_webhook_failure_non_2xx(self, monkeypatch):
"""WebhookChannel with non-2xx writes delivered=false with error."""
class FakeResponse:
status_code = 500
text = "Internal Server Error"
class FakeClient:
@staticmethod
def post(url, json=None, timeout=None):
return FakeResponse()
import httpx
monkeypatch.setattr(httpx, "post", FakeClient.post)
reset_channels()
ch = WebhookChannel(url="https://hook.example.com/notify")
result = ch.send("daily_digest", "Digest", {"count": 3})
assert result is False
logs = list_notification_log(limit=10)
latest = logs[0]
assert latest["channel"] == "webhook"
assert latest["delivered"] is False
assert latest["error"] is not None
assert "500" in latest["error"]
reset_channels()
def test_webhook_exception_writes_error(self, monkeypatch):
"""WebhookChannel with connection exception writes delivered=false."""
def raise_exc(url, json=None, timeout=None):
raise ConnectionError("Connection refused")
import httpx
monkeypatch.setattr(httpx, "post", raise_exc)
reset_channels()
ch = WebhookChannel(url="https://hook.example.com/notify")
result = ch.send("daily_digest", "Digest", {"count": 1})
assert result is False
logs = list_notification_log(limit=10)
latest = logs[0]
assert latest["delivered"] is False
assert "Connection refused" in (latest["error"] or "")
reset_channels()
def test_webhook_no_url_writes_error(self):
"""WebhookChannel with no URL writes delivered=false with config error."""
reset_channels()
ch = WebhookChannel(url="")
result = ch.send("test", "test", {})
assert result is False
logs = list_notification_log(limit=10)
latest = logs[0]
assert latest["delivered"] is False
assert "not configured" in (latest["error"] or "")
reset_channels()
# ========================================================================
# Notification log endpoint (2 tests)
# ========================================================================
class TestNotificationLogEndpoint:
def test_get_notifications_log(self, client):
"""GET /notifications/log returns entries."""
reset_channels()
set_channels([LogChannel()])
send_notification("daily_digest", "Test", {"count": 1})
reset_channels()
resp = client.get("/api/notifications/log")
assert resp.status_code == 200
data = resp.json()
assert len(data) >= 1
assert "channel" in data[0]
assert "kind" in data[0]
assert "delivered" in data[0]
def test_get_notifications_log_empty(self, client):
"""GET /notifications/log returns empty when no entries."""
resp = client.get("/api/notifications/log")
assert resp.status_code == 200
assert resp.json() == []
# ========================================================================
# Digest payload shape (2 tests)
# ========================================================================
class TestDigestPayload:
def test_daily_digest_notification_text(self):
"""Daily digest notification contains expected text fields."""
reset_channels()
set_channels([LogChannel()])
send_notification("daily_digest", "Daily Digest\nScored applications: 5", {
"digest_count": 5,
"nudge_count": 2,
"pending_approvals": 1,
})
logs = list_notification_log(limit=10)
entry = logs[0]
assert entry["kind"] == "daily_digest"
payload = entry["payload"]
assert "text" in payload
assert "Daily Digest" in payload["text"]
assert payload.get("digest_count") == 5
reset_channels()
def test_daily_digest_payload_has_counts(self):
"""Digest payload includes digest_count, nudge_count, pending_approvals."""
reset_channels()
set_channels([LogChannel()])
send_notification("daily_digest", "text", {
"digest_count": 3,
"nudge_count": 1,
"pending_approvals": 0,
})
logs = list_notification_log(limit=10)
payload = logs[0]["payload"]
assert payload["digest_count"] == 3
assert payload["nudge_count"] == 1
assert payload["pending_approvals"] == 0
reset_channels()
# ========================================================================
# FakeImap end-to-end poll (3 tests)
# ========================================================================
class TestFakeImapPoll:
def test_poll_creates_suggestion_for_matching_email(self):
"""FakeImap poll creates a suggestion when email matches an application."""
app_row = _create_app_in_state("sent", company="TechCorp", url="https://techcorp.com/jobs/1")
raw_email = _make_email(
"hr@techcorp.com",
"Interview at TechCorp",
"Please come for an interview next Tuesday.",
)
fake = FakeImap(messages=[(b"1", raw_email)])
created = poll_inbox(fake)
assert len(created) == 1
assert created[0]["classification"] == "interview_invite"
assert created[0]["mailbox_from"] == "hr@techcorp.com"
assert created[0]["application_id"] == app_row["id"]
def test_poll_skips_noise_emails(self):
"""FakeImap poll skips noise classification (no suggestion created)."""
_create_app_in_state("sent", company="TechCorp")
# Mock email_classify to return noise
import app.llm as llm_mod
original = llm_mod.MOCK_OUTPUTS.get("email_classify", {}).copy()
try:
llm_mod.MOCK_OUTPUTS["email_classify"] = {
"classification": "noise",
"state_proposal": None,
"reason": "spam",
}
raw_email = _make_email(
"newsletter@spam.com",
"Buy our product",
"Special offer just for you!",
)
fake = FakeImap(messages=[(b"1", raw_email)])
created = poll_inbox(fake)
assert len(created) == 0
finally:
llm_mod.MOCK_OUTPUTS["email_classify"] = original
def test_poll_dedupe_skips_same_from_subject_day(self):
"""FakeImap poll dedupes same from+subject+day."""
raw_email = _make_email(
"hr@example.com",
"Same Subject",
"Same body content.",
)
# First poll
fake1 = FakeImap(messages=[(b"1", raw_email)])
created1 = poll_inbox(fake1)
assert len(created1) == 1
# Second poll with same message -> dedupe
fake2 = FakeImap(messages=[(b"1", raw_email)])
created2 = poll_inbox(fake2)
assert len(created2) == 0 # deduped
# ========================================================================
# Email parsing helpers (2 tests)
# ========================================================================
class TestEmailParsing:
def test_extract_sender_domain(self):
"""Extract domain from From header."""
assert _extract_sender_domain("John Doe <hr@techcorp.com>") == "techcorp.com"
assert _extract_sender_domain("noreply@example.org") == "example.org"
assert _extract_sender_domain("") == ""
def test_build_snippet_truncates(self):
"""Snippet is truncated to max_len."""
long_body = "A" * 500
snippet = _build_snippet(long_body, max_len=50)
assert len(snippet) <= 53 # 50 + "..."
assert snippet.endswith("...")
def test_build_snippet_short_body(self):
"""Short body is not truncated."""
snippet = _build_snippet("Hello", max_len=300)
assert snippet == "Hello"