"""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