jobhunt-platform/apps/api/tests/test_api.py
hermes 17759ef564 T1: FastAPI backend core (models, state machine, approval gate) + compose test runner
- 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
2026-07-30 18:10:59 +00:00

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"