WA1: add 35 tests for email watch, notifications, suggestions, digest
This commit is contained in:
parent
6c38de5fed
commit
05ba99cb4b
1 changed files with 682 additions and 0 deletions
682
apps/api/tests/test_v11_email_notify.py
Normal file
682
apps/api/tests/test_v11_email_notify.py
Normal 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"
|
||||||
Loading…
Reference in a new issue