- apps/api: psycopg v3 repositories, schema.sql + migration runner, state machine implementing the data-model transition table, approval gate with sha256 hash match + 24h expiry enforced at send time, EchoTransport pluggable sender, LLM calls routed through packages.llm-gateway with mock mode, 47 pytest tests green - apps/api/Dockerfile.test: python 3.13-slim test image (DinD-safe: migrations copied as directory) - docker-compose.yml: add api-test service on compose network (host port publishing is broken in this sandbox; container-to-container networking used) - Fix: replace masked placeholder password in config.py/conftest.py defaults
265 lines
No EOL
9.1 KiB
Python
265 lines
No EOL
9.1 KiB
Python
"""API-level happy path tests using FastAPI TestClient."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import pytest
|
|
from fastapi.testclient import TestClient
|
|
|
|
from app.db import repo_app, repo_profile
|
|
|
|
|
|
@pytest.fixture()
|
|
def client():
|
|
from app.main import app
|
|
return TestClient(app)
|
|
|
|
|
|
class TestHealth:
|
|
def test_health(self, client):
|
|
resp = client.get("/api/health")
|
|
assert resp.status_code == 200
|
|
assert resp.json() == {"status": "ok"}
|
|
|
|
|
|
class TestProfileFlow:
|
|
def test_get_profile_creates_default(self, client):
|
|
resp = client.get("/api/profile")
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert data["full_name"] == ""
|
|
assert data["email"] == ""
|
|
|
|
def test_update_profile(self, client):
|
|
# First create
|
|
client.get("/api/profile")
|
|
|
|
resp = client.put(
|
|
"/api/profile",
|
|
json={"full_name": "Test Person", "email": "test@example.com"},
|
|
)
|
|
assert resp.status_code == 200
|
|
assert resp.json()["full_name"] == "Test Person"
|
|
assert resp.json()["email"] == "test@example.com"
|
|
|
|
|
|
class TestCvSectionsFlow:
|
|
def test_create_and_list_sections(self, client):
|
|
# Ensure profile exists
|
|
client.get("/api/profile")
|
|
|
|
# Create a section
|
|
resp = client.post(
|
|
"/api/profile/sections",
|
|
json={
|
|
"kind": "experience",
|
|
"title": "Software Engineer",
|
|
"org": "TechCorp",
|
|
"bullets": ["Built feature X", "Improved performance by 20%"],
|
|
"tags": ["python", "fastapi"],
|
|
"sort_order": 0,
|
|
},
|
|
)
|
|
assert resp.status_code == 201
|
|
section = resp.json()
|
|
assert section["kind"] == "experience"
|
|
assert section["title"] == "Software Engineer"
|
|
assert "python" in section["tags"]
|
|
|
|
# List sections
|
|
resp = client.get("/api/profile/sections")
|
|
assert resp.status_code == 200
|
|
assert len(resp.json()) == 1
|
|
|
|
def test_update_and_delete_section(self, client):
|
|
client.get("/api/profile")
|
|
resp = client.post(
|
|
"/api/profile/sections",
|
|
json={"kind": "education", "title": "MSc", "org": "University"},
|
|
)
|
|
section_id = resp.json()["id"]
|
|
|
|
# Update
|
|
resp = client.put(
|
|
f"/api/profile/sections/{section_id}",
|
|
json={"title": "MSc Computer Science"},
|
|
)
|
|
assert resp.status_code == 200
|
|
assert resp.json()["title"] == "MSc Computer Science"
|
|
|
|
# Delete
|
|
resp = client.delete(f"/api/profile/sections/{section_id}")
|
|
assert resp.status_code == 204
|
|
|
|
def test_ai_assist_mock_mode(self, client):
|
|
"""AI assist returns mock suggestions when no API key is set."""
|
|
client.get("/api/profile")
|
|
resp = client.post(
|
|
"/api/profile/sections",
|
|
json={"kind": "experience", "title": "Dev", "bullets": ["did stuff"]},
|
|
)
|
|
section_id = resp.json()["id"]
|
|
|
|
resp = client.post(
|
|
f"/api/profile/sections/{section_id}/ai-assist",
|
|
json={"instruction": "improve this bullet"},
|
|
)
|
|
assert resp.status_code == 200
|
|
assert "suggestions" in resp.json()
|
|
assert len(resp.json()["suggestions"]) > 0
|
|
|
|
|
|
class TestJobPostingFlow:
|
|
def test_create_posting_and_application(self, client):
|
|
"""POST /postings creates a job_posting + application(discovered)."""
|
|
resp = client.post("/api/postings", json={"url": "https://example.com/job/456"})
|
|
assert resp.status_code == 201
|
|
app_data = resp.json()
|
|
assert app_data["state"] == "discovered"
|
|
|
|
# List postings
|
|
resp = client.get("/api/postings")
|
|
assert resp.status_code == 200
|
|
assert len(resp.json()) == 1
|
|
assert resp.json()[0]["url"] == "https://example.com/job/456"
|
|
|
|
def test_score_posting(self, client):
|
|
"""POST /postings/{id}/score returns score + rationale."""
|
|
# Create posting
|
|
resp = client.post("/api/postings", json={"url": "https://example.com/job/789"})
|
|
assert resp.status_code == 201
|
|
|
|
# Get the posting id
|
|
resp_postings = client.get("/api/postings")
|
|
posting_id = resp_postings.json()[0]["id"]
|
|
|
|
# Score it
|
|
resp = client.post(f"/api/postings/{posting_id}/score")
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert "score" in data
|
|
assert "rationale" in data
|
|
assert data["score"] > 0
|
|
|
|
|
|
class TestApplicationsFlow:
|
|
def test_list_applications(self, client):
|
|
client.post("/api/postings", json={"url": "https://example.com/job/list1"})
|
|
resp = client.get("/api/applications")
|
|
assert resp.status_code == 200
|
|
assert len(resp.json()) >= 1
|
|
assert resp.json()[0]["state"] == "discovered"
|
|
|
|
def test_transition_to_rejected(self, client):
|
|
"""discovered -> rejected is valid (user action)."""
|
|
resp = client.post("/api/postings", json={"url": "https://example.com/job/trans1"})
|
|
app_id = resp.json()["id"]
|
|
|
|
resp = client.post(
|
|
f"/api/applications/{app_id}/transition",
|
|
json={"to": "rejected"},
|
|
)
|
|
assert resp.status_code == 200
|
|
assert resp.json()["state"] == "rejected"
|
|
|
|
def test_transition_invalid_409(self, client):
|
|
"""discovered -> sent is invalid -> 409."""
|
|
resp = client.post("/api/postings", json={"url": "https://example.com/job/trans2"})
|
|
app_id = resp.json()["id"]
|
|
|
|
resp = client.post(
|
|
f"/api/applications/{app_id}/transition",
|
|
json={"to": "sent"},
|
|
)
|
|
assert resp.status_code == 409
|
|
|
|
def test_transition_to_scored_without_score(self, client):
|
|
"""discovered -> scored without scoring guard -> 409."""
|
|
resp = client.post("/api/postings", json={"url": "https://example.com/job/trans3"})
|
|
app_id = resp.json()["id"]
|
|
|
|
resp = client.post(
|
|
f"/api/applications/{app_id}/transition",
|
|
json={"to": "scored"},
|
|
)
|
|
assert resp.status_code == 409
|
|
|
|
def test_transition_after_score(self, client):
|
|
"""discovered -> scored after scoring task completes -> success."""
|
|
resp = client.post("/api/postings", json={"url": "https://example.com/job/trans4"})
|
|
app_id = resp.json()["id"]
|
|
|
|
# Score first
|
|
resp_postings = client.get("/api/postings")
|
|
posting_id = resp_postings.json()[0]["id"]
|
|
client.post(f"/api/postings/{posting_id}/score")
|
|
|
|
# Now transition to scored should succeed (score is set, state already scored by scorer)
|
|
# Actually the scorer already sets state to 'scored', so let's test scored -> approved
|
|
resp = client.post(
|
|
f"/api/applications/{app_id}/transition",
|
|
json={"to": "approved"},
|
|
)
|
|
assert resp.status_code == 200
|
|
assert resp.json()["state"] == "approved"
|
|
|
|
|
|
class TestArtifactAndCoverLetter:
|
|
def test_create_artifact(self, client):
|
|
resp = client.post("/api/postings", json={"url": "https://example.com/job/art1"})
|
|
app_id = resp.json()["id"]
|
|
|
|
resp = client.post(
|
|
f"/api/applications/{app_id}/artifacts",
|
|
json={"kind": "email", "content": "Dear hiring manager..."},
|
|
)
|
|
assert resp.status_code == 201
|
|
assert resp.json()["kind"] == "email"
|
|
assert len(resp.json()["content_hash"]) == 64
|
|
|
|
def test_cover_letter_with_critique(self, client):
|
|
resp = client.post("/api/postings", json={"url": "https://example.com/job/art2"})
|
|
app_id = resp.json()["id"]
|
|
|
|
resp = client.post(
|
|
f"/api/applications/{app_id}/artifacts/cover-letter",
|
|
json={"letter_text": "I am writing to apply for the position. I have experience in many things."},
|
|
)
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert "artifact" in data
|
|
assert "critique" in data
|
|
assert len(data["critique"]) > 0
|
|
|
|
def test_list_artifacts(self, client):
|
|
resp = client.post("/api/postings", json={"url": "https://example.com/job/art3"})
|
|
app_id = resp.json()["id"]
|
|
|
|
client.post(
|
|
f"/api/applications/{app_id}/artifacts",
|
|
json={"kind": "cv", "content": "CV content"},
|
|
)
|
|
|
|
resp = client.get(f"/api/applications/{app_id}/artifacts")
|
|
assert resp.status_code == 200
|
|
assert len(resp.json()) >= 1
|
|
|
|
|
|
class TestTelemetry:
|
|
def test_telemetry_list(self, client):
|
|
# Generate a task run via ai-assist
|
|
client.get("/api/profile")
|
|
resp = client.post(
|
|
"/api/profile/sections",
|
|
json={"kind": "experience", "title": "Dev"},
|
|
)
|
|
section_id = resp.json()["id"]
|
|
client.post(
|
|
f"/api/profile/sections/{section_id}/ai-assist",
|
|
json={"instruction": "test"},
|
|
)
|
|
|
|
resp = client.get("/api/telemetry/tasks")
|
|
assert resp.status_code == 200
|
|
assert len(resp.json()) >= 1
|
|
assert resp.json()[0]["task"] == "cv_assist"
|
|
assert resp.json()[0]["model"] == "mock" |