Implements docs/api-contract-v2.md. Migration 002 adds follow-up fields. EchoTransport replaced by SMTP->Clipboard selection behind unchanged approval gate. Scheduler (APScheduler) daily 07:00 fetch+score, env-gated, default off. Test image installs workspace packages; build context moved to repo root. Recovered and committed by integration lead after W2 worker hit iteration limit.
398 lines
No EOL
15 KiB
Python
398 lines
No EOL
15 KiB
Python
"""Tests for v1 batch scoring, today digest, interview prep, seed demo, and SMTP transport."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import datetime, timedelta, timezone
|
|
|
|
import psycopg
|
|
import pytest
|
|
from fastapi.testclient import TestClient
|
|
|
|
from app.config import DATABASE_URL
|
|
from app.db import repo_app, repo_profile
|
|
from app.transport import (
|
|
ClipboardTransport,
|
|
SmtpTransport,
|
|
get_transport,
|
|
is_smtp_configured,
|
|
reset_transport,
|
|
set_transport,
|
|
)
|
|
|
|
|
|
@pytest.fixture()
|
|
def client():
|
|
from app.main import app
|
|
return TestClient(app)
|
|
|
|
|
|
# --- Batch Scoring ---
|
|
|
|
class TestBatchScoring:
|
|
def test_batch_score_multiple(self, client):
|
|
"""Batch score multiple applications."""
|
|
ids = []
|
|
for i in range(3):
|
|
resp = client.post(
|
|
"/api/postings",
|
|
json={"url": f"https://example.com/batch/{i}"},
|
|
)
|
|
ids.append(resp.json()["id"])
|
|
|
|
resp = client.post(
|
|
"/api/scoring/batch",
|
|
json={"application_ids": ids},
|
|
)
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert len(data["results"]) == 3
|
|
for r in data["results"]:
|
|
assert "score" in r
|
|
assert "red_flags" in r
|
|
assert isinstance(r["red_flags"], list)
|
|
|
|
def test_batch_score_empty_list(self, client):
|
|
"""Empty application_ids list -> 200 with empty results."""
|
|
resp = client.post(
|
|
"/api/scoring/batch",
|
|
json={"application_ids": []},
|
|
)
|
|
assert resp.status_code == 200
|
|
assert resp.json()["results"] == []
|
|
|
|
def test_batch_score_nonexistent_app_skipped(self, client):
|
|
"""Nonexistent application IDs are silently skipped."""
|
|
resp = client.post(
|
|
"/api/scoring/batch",
|
|
json={"application_ids": ["00000000-0000-0000-0000-000000000000"]},
|
|
)
|
|
assert resp.status_code == 200
|
|
assert resp.json()["results"] == []
|
|
|
|
def test_batch_score_includes_red_flags(self, client):
|
|
"""Batch score results include red_flags field."""
|
|
resp = client.post(
|
|
"/api/postings",
|
|
json={"url": "https://example.com/redflag/test"},
|
|
)
|
|
app_id = resp.json()["id"]
|
|
|
|
resp = client.post(
|
|
"/api/scoring/batch",
|
|
json={"application_ids": [app_id]},
|
|
)
|
|
assert resp.status_code == 200
|
|
result = resp.json()["results"][0]
|
|
assert "red_flags" in result
|
|
assert isinstance(result["red_flags"], list)
|
|
|
|
def test_batch_score_updates_application_state(self, client):
|
|
"""After batch scoring, application state should be 'scored'."""
|
|
resp = client.post(
|
|
"/api/postings",
|
|
json={"url": "https://example.com/state/test"},
|
|
)
|
|
app_id = resp.json()["id"]
|
|
|
|
client.post(
|
|
"/api/scoring/batch",
|
|
json={"application_ids": [app_id]},
|
|
)
|
|
|
|
apps = client.get("/api/applications").json()
|
|
scored = [a for a in apps if a["id"] == app_id]
|
|
assert len(scored) == 1
|
|
assert scored[0]["state"] == "scored"
|
|
assert scored[0]["score"] is not None
|
|
|
|
|
|
# --- Today ---
|
|
|
|
class TestToday:
|
|
def test_today_empty(self, client):
|
|
"""Today endpoint with no data returns empty digest and zero pending."""
|
|
resp = client.get("/api/today")
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert data["digest"] == []
|
|
assert data["nudges"] == []
|
|
assert data["pending_approvals"] == 0
|
|
|
|
def test_today_with_scored_applications(self, client):
|
|
"""Today digest includes scored applications ordered by score desc."""
|
|
# Create and score two applications
|
|
for url in ["https://example.com/today/1", "https://example.com/today/2"]:
|
|
client.post("/api/postings", json={"url": url})
|
|
|
|
apps = client.get("/api/applications").json()
|
|
client.post(
|
|
"/api/scoring/batch",
|
|
json={"application_ids": [a["id"] for a in apps]},
|
|
)
|
|
|
|
resp = client.get("/api/today")
|
|
assert resp.status_code == 200
|
|
digest = resp.json()["digest"]
|
|
assert len(digest) == 2
|
|
# Should be ordered by score desc
|
|
assert digest[0]["score"] >= digest[1]["score"]
|
|
|
|
def test_today_nudge_for_backdated_sent(self, client):
|
|
"""A sent application backdated 8 days should appear in nudges."""
|
|
# Create posting + application
|
|
resp = client.post("/api/postings", json={"url": "https://example.com/nudge/1"})
|
|
app_id = resp.json()["id"]
|
|
|
|
# Score it, then move through states to sent
|
|
client.post("/api/scoring/batch", json={"application_ids": [app_id]})
|
|
client.post(f"/api/applications/{app_id}/transition", json={"to": "approved"})
|
|
client.post(f"/api/applications/{app_id}/transition", json={"to": "drafting"})
|
|
|
|
# Directly set state to sent (bypassing guard for test)
|
|
with psycopg.connect(DATABASE_URL) as conn:
|
|
conn.execute(
|
|
"UPDATE application SET state = 'sent', last_activity_at = %s WHERE id = %s",
|
|
(datetime.now(timezone.utc) - timedelta(days=8), app_id),
|
|
)
|
|
conn.commit()
|
|
|
|
resp = client.get("/api/today")
|
|
assert resp.status_code == 200
|
|
nudges = resp.json()["nudges"]
|
|
assert len(nudges) == 1
|
|
assert nudges[0]["application_id"] == app_id
|
|
assert nudges[0]["days_since_sent"] >= 8
|
|
|
|
def test_today_nudge_snoozed_excluded(self, client):
|
|
"""A snoozed application should not appear in nudges."""
|
|
resp = client.post("/api/postings", json={"url": "https://example.com/nudge/2"})
|
|
app_id = resp.json()["id"]
|
|
|
|
# Score and set to sent with backdated activity
|
|
client.post("/api/scoring/batch", json={"application_ids": [app_id]})
|
|
with psycopg.connect(DATABASE_URL) as conn:
|
|
conn.execute(
|
|
"UPDATE application SET state = 'sent', last_activity_at = %s WHERE id = %s",
|
|
(datetime.now(timezone.utc) - timedelta(days=10), app_id),
|
|
)
|
|
conn.commit()
|
|
|
|
# Verify it shows up first
|
|
resp = client.get("/api/today")
|
|
assert len(resp.json()["nudges"]) == 1
|
|
|
|
# Snooze it for a future date
|
|
future = (datetime.now(timezone.utc) + timedelta(days=7)).date()
|
|
with psycopg.connect(DATABASE_URL) as conn:
|
|
conn.execute(
|
|
"UPDATE application SET follow_up_snoozed_until = %s WHERE id = %s",
|
|
(future, app_id),
|
|
)
|
|
conn.commit()
|
|
|
|
resp = client.get("/api/today")
|
|
assert len(resp.json()["nudges"]) == 0
|
|
|
|
def test_today_pending_approvals_count(self, client):
|
|
"""Today endpoint counts pending (unconfirmed, unexpired) approvals."""
|
|
# Create posting + application + artifact
|
|
resp = client.post("/api/postings", json={"url": "https://example.com/today/3"})
|
|
app_id = resp.json()["id"]
|
|
|
|
client.post(
|
|
f"/api/applications/{app_id}/artifacts",
|
|
json={"kind": "email", "content": "test content"},
|
|
)
|
|
artifacts = client.get(f"/api/applications/{app_id}/artifacts").json()
|
|
artifact_id = artifacts[0]["id"]
|
|
|
|
# Create approval (not confirmed)
|
|
client.post(
|
|
f"/api/applications/{app_id}/approvals",
|
|
json={"action": "send_email", "artifact_id": artifact_id},
|
|
)
|
|
|
|
resp = client.get("/api/today")
|
|
assert resp.json()["pending_approvals"] == 1
|
|
|
|
|
|
# --- Interview Prep ---
|
|
|
|
class TestInterviewPrep:
|
|
def test_interview_prep_creates_artifact(self, client):
|
|
"""POST /applications/{id}/interview-prep creates an artifact."""
|
|
resp = client.post("/api/postings", json={"url": "https://example.com/iprep/1"})
|
|
app_id = resp.json()["id"]
|
|
|
|
resp = client.post(f"/api/applications/{app_id}/interview-prep")
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert "artifact_id" in data
|
|
assert "content" in data
|
|
assert len(data["content"]) > 0
|
|
assert "Q1" in data["content"]
|
|
|
|
def test_interview_prep_artifact_in_list(self, client):
|
|
"""Interview prep artifact appears in GET artifacts list."""
|
|
resp = client.post("/api/postings", json={"url": "https://example.com/iprep/2"})
|
|
app_id = resp.json()["id"]
|
|
|
|
resp = client.post(f"/api/applications/{app_id}/interview-prep")
|
|
artifact_id = resp.json()["artifact_id"]
|
|
|
|
resp = client.get(f"/api/applications/{app_id}/artifacts")
|
|
artifacts = resp.json()
|
|
assert any(a["id"] == artifact_id for a in artifacts)
|
|
|
|
def test_interview_prep_sets_artifact_id_on_app(self, client):
|
|
"""interview_prep_artifact_id is set on the application."""
|
|
resp = client.post("/api/postings", json={"url": "https://example.com/iprep/3"})
|
|
app_id = resp.json()["id"]
|
|
|
|
resp = client.post(f"/api/applications/{app_id}/interview-prep")
|
|
artifact_id = resp.json()["artifact_id"]
|
|
|
|
apps = client.get("/api/applications").json()
|
|
app_row = [a for a in apps if a["id"] == app_id][0]
|
|
assert app_row["interview_prep_artifact_id"] == artifact_id
|
|
|
|
def test_interview_prep_404_nonexistent_app(self, client):
|
|
"""404 for nonexistent application."""
|
|
resp = client.post(
|
|
"/api/applications/00000000-0000-0000-0000-000000000000/interview-prep"
|
|
)
|
|
assert resp.status_code == 404
|
|
|
|
def test_interview_prep_creates_telemetry(self, client):
|
|
"""Interview prep creates a task_run entry."""
|
|
resp = client.post("/api/postings", json={"url": "https://example.com/iprep/4"})
|
|
app_id = resp.json()["id"]
|
|
|
|
client.post(f"/api/applications/{app_id}/interview-prep")
|
|
|
|
resp = client.get("/api/telemetry/tasks")
|
|
tasks = resp.json()
|
|
assert any(t["task"] == "interview_prep" for t in tasks)
|
|
|
|
|
|
# --- Seed Demo ---
|
|
|
|
class TestSeedDemo:
|
|
def test_seed_demo_creates_data(self, client):
|
|
"""POST /concierge/seed-demo creates profile, postings, applications."""
|
|
resp = client.post("/api/concierge/seed-demo")
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert data["profile"] == "Demo Demosson"
|
|
assert data["postings"] == 6
|
|
assert data["applications"] == 6
|
|
assert data["sections"] == 4
|
|
|
|
def test_seed_demo_idempotent(self, client):
|
|
"""Running seed-demo twice returns the same counts."""
|
|
resp1 = client.post("/api/concierge/seed-demo")
|
|
assert resp1.status_code == 200
|
|
|
|
resp2 = client.post("/api/concierge/seed-demo")
|
|
assert resp2.status_code == 200
|
|
assert resp2.json()["postings"] == resp1.json()["postings"]
|
|
assert resp2.json()["applications"] == resp1.json()["applications"]
|
|
assert resp2.json()["sections"] == resp1.json()["sections"]
|
|
|
|
def test_seed_demo_has_nudge_candidate(self, client):
|
|
"""After seeding, /today should show a nudge for the backdated sent app."""
|
|
client.post("/api/concierge/seed-demo")
|
|
resp = client.get("/api/today")
|
|
nudges = resp.json()["nudges"]
|
|
assert len(nudges) >= 1
|
|
|
|
def test_seed_demo_has_scored_digest(self, client):
|
|
"""After seeding, /today digest should have scored applications."""
|
|
client.post("/api/concierge/seed-demo")
|
|
resp = client.get("/api/today")
|
|
digest = resp.json()["digest"]
|
|
assert len(digest) >= 1
|
|
|
|
|
|
# --- SMTP Transport ---
|
|
|
|
class TestSmtpTransportSelection:
|
|
def test_clipboard_when_no_smtp(self, monkeypatch):
|
|
"""Without SMTP_HOST, transport is ClipboardTransport."""
|
|
monkeypatch.delenv("SMTP_HOST", raising=False)
|
|
reset_transport()
|
|
t = get_transport()
|
|
assert isinstance(t, ClipboardTransport)
|
|
|
|
def test_smtp_when_configured(self, monkeypatch):
|
|
"""With SMTP_HOST set, transport is SmtpTransport."""
|
|
monkeypatch.setenv("SMTP_HOST", "smtp.example.com")
|
|
monkeypatch.setenv("SMTP_PORT", "587")
|
|
monkeypatch.setenv("SMTP_USER", "user@example.com")
|
|
monkeypatch.setenv("SMTP_PASS", "pass")
|
|
monkeypatch.setenv("SMTP_FROM", "from@example.com")
|
|
reset_transport()
|
|
t = get_transport()
|
|
assert isinstance(t, SmtpTransport)
|
|
assert t.host == "smtp.example.com"
|
|
assert t.port == 587
|
|
reset_transport()
|
|
|
|
def test_smtp_ssl_on_465(self, monkeypatch):
|
|
"""SMTP port 465 triggers SSL."""
|
|
monkeypatch.setenv("SMTP_HOST", "smtp.example.com")
|
|
monkeypatch.setenv("SMTP_PORT", "465")
|
|
reset_transport()
|
|
t = get_transport()
|
|
assert isinstance(t, SmtpTransport)
|
|
assert t.port == 465
|
|
reset_transport()
|
|
|
|
def test_clipboard_send_success(self):
|
|
"""ClipboardTransport.send returns success with payload."""
|
|
t = ClipboardTransport()
|
|
payload = {"to": "test@example.com", "subject": "Hi", "body": "Hello"}
|
|
result = t.send(payload)
|
|
assert result["success"] is True
|
|
assert result["transport"] == "clipboard"
|
|
assert result["payload"] == payload
|
|
|
|
def test_set_transport_override(self):
|
|
"""set_transport overrides the default."""
|
|
custom = ClipboardTransport()
|
|
set_transport(custom)
|
|
assert get_transport() is custom
|
|
reset_transport()
|
|
|
|
def test_is_smtp_configured_false(self, monkeypatch):
|
|
"""is_smtp_configured returns False when no SMTP_HOST."""
|
|
monkeypatch.delenv("SMTP_HOST", raising=False)
|
|
assert not is_smtp_configured()
|
|
|
|
def test_is_smtp_configured_true(self, monkeypatch):
|
|
"""is_smtp_configured returns True when SMTP_HOST is set."""
|
|
monkeypatch.setenv("SMTP_HOST", "smtp.example.com")
|
|
assert is_smtp_configured()
|
|
|
|
|
|
# --- Scheduler ---
|
|
|
|
class TestScheduler:
|
|
def test_scheduler_disabled_by_default(self, monkeypatch):
|
|
"""SCHEDULER_ENABLED defaults to false."""
|
|
from app.scheduler import is_scheduler_enabled
|
|
monkeypatch.delenv("SCHEDULER_ENABLED", raising=False)
|
|
assert not is_scheduler_enabled()
|
|
|
|
def test_scheduler_enabled_when_true(self, monkeypatch):
|
|
"""SCHEDULER_ENABLED=true enables scheduler."""
|
|
from app.scheduler import is_scheduler_enabled
|
|
monkeypatch.setenv("SCHEDULER_ENABLED", "true")
|
|
assert is_scheduler_enabled()
|
|
|
|
def test_start_scheduler_noop_when_disabled(self, monkeypatch):
|
|
"""start_scheduler does nothing when disabled."""
|
|
from app import scheduler
|
|
monkeypatch.setenv("SCHEDULER_ENABLED", "false")
|
|
scheduler.start_scheduler() # should not raise
|
|
scheduler.stop_scheduler() |