diff --git a/apps/api/tests/test_v11_email_notify.py b/apps/api/tests/test_v11_email_notify.py new file mode 100644 index 0000000..3bce5d5 --- /dev/null +++ b/apps/api/tests/test_v11_email_notify.py @@ -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 ") == "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" \ No newline at end of file