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
This commit is contained in:
parent
8eb8400bad
commit
17759ef564
21 changed files with 2608 additions and 0 deletions
15
apps/api/Dockerfile.test
Normal file
15
apps/api/Dockerfile.test
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
# Test/POC runner image for apps/api
|
||||
FROM python:3.13-slim
|
||||
|
||||
WORKDIR /app
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1
|
||||
|
||||
COPY pyproject.toml ./
|
||||
COPY app ./app
|
||||
COPY schema.sql ./schema.sql
|
||||
COPY migrations ./migrations
|
||||
COPY tests ./tests
|
||||
|
||||
RUN pip install --no-cache-dir -e ".[dev]"
|
||||
|
||||
CMD ["pytest", "-q"]
|
||||
1
apps/api/app/__init__.py
Normal file
1
apps/api/app/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
"""jobhunt API package."""
|
||||
16
apps/api/app/config.py
Normal file
16
apps/api/app/config.py
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
"""Configuration — reads env vars, provides defaults."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
DATABASE_URL = os.environ.get(
|
||||
"DATABASE_URL",
|
||||
"postgresql://jobhunt:jobhunt@localhost:5433/jobhunt",
|
||||
)
|
||||
|
||||
# Base directory of the apps/api package (for locating migrations/schema)
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||
MIGRATIONS_DIR = BASE_DIR / "migrations"
|
||||
SCHEMA_FILE = BASE_DIR / "schema.sql"
|
||||
80
apps/api/app/db/__init__.py
Normal file
80
apps/api/app/db/__init__.py
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
"""Database connection pool and helpers (psycopg v3)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
import psycopg
|
||||
from psycopg.rows import dict_row
|
||||
from psycopg_pool import ConnectionPool
|
||||
|
||||
from app.config import DATABASE_URL
|
||||
|
||||
_pool: ConnectionPool | None = None
|
||||
|
||||
|
||||
def get_pool() -> ConnectionPool:
|
||||
global _pool
|
||||
if _pool is None:
|
||||
_pool = ConnectionPool(
|
||||
conninfo=DATABASE_URL,
|
||||
kwargs={"row_factory": dict_row},
|
||||
min_size=1,
|
||||
max_size=8,
|
||||
open=True,
|
||||
)
|
||||
return _pool
|
||||
|
||||
|
||||
def close_pool() -> None:
|
||||
global _pool
|
||||
if _pool is not None:
|
||||
_pool.close()
|
||||
_pool = None
|
||||
|
||||
|
||||
def get_conn() -> psycopg.Connection:
|
||||
"""Get a raw connection (for use as context manager)."""
|
||||
return psycopg.connect(DATABASE_URL, row_factory=dict_row)
|
||||
|
||||
|
||||
# --- repository helpers ---
|
||||
|
||||
|
||||
def fetch_one(sql: str, params: tuple[Any, ...] = ()) -> dict[str, Any] | None:
|
||||
pool = get_pool()
|
||||
with pool.connection() as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(sql, params)
|
||||
return cur.fetchone()
|
||||
|
||||
|
||||
def fetch_all(sql: str, params: tuple[Any, ...] = ()) -> list[dict[str, Any]]:
|
||||
pool = get_pool()
|
||||
with pool.connection() as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(sql, params)
|
||||
return cur.fetchall()
|
||||
|
||||
|
||||
def execute(sql: str, params: tuple[Any, ...] = ()) -> dict[str, Any] | None:
|
||||
pool = get_pool()
|
||||
with pool.connection() as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(sql, params)
|
||||
conn.commit()
|
||||
if cur.description:
|
||||
return cur.fetchone()
|
||||
return None
|
||||
|
||||
|
||||
# JSON adaptation for psycopg v3
|
||||
def adapt_jsonb(value: Any) -> str:
|
||||
return json.dumps(value)
|
||||
|
||||
|
||||
def convert_uuid(value: Any) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
return str(value)
|
||||
81
apps/api/app/db/migrate.py
Normal file
81
apps/api/app/db/migrate.py
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
"""Tiny migration runner: applies migrations/NNN_*.sql in order.
|
||||
|
||||
Tracks applied migrations in a `schema_migrations` table.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import psycopg
|
||||
|
||||
from app.config import DATABASE_URL, MIGRATIONS_DIR
|
||||
|
||||
|
||||
def _ensure_migrations_table(conn: psycopg.Connection) -> None:
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
id int PRIMARY KEY,
|
||||
filename text NOT NULL,
|
||||
applied_at timestamptz NOT NULL DEFAULT now()
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def list_migration_files() -> list[tuple[int, Path]]:
|
||||
"""Return sorted (number, path) for all NNN_*.sql files."""
|
||||
pattern = re.compile(r"^(\d{3})_.*\.sql$")
|
||||
result: list[tuple[int, Path]] = []
|
||||
if not MIGRATIONS_DIR.exists():
|
||||
return result
|
||||
for p in sorted(MIGRATIONS_DIR.iterdir()):
|
||||
m = pattern.match(p.name)
|
||||
if m:
|
||||
result.append((int(m.group(1)), p))
|
||||
result.sort(key=lambda t: t[0])
|
||||
return result
|
||||
|
||||
|
||||
def get_applied(conn: psycopg.Connection) -> set[int]:
|
||||
_ensure_migrations_table(conn)
|
||||
rows = conn.execute("SELECT id FROM schema_migrations").fetchall()
|
||||
return {r[0] for r in rows}
|
||||
|
||||
|
||||
def run_migrations(database_url: str | None = None) -> list[int]:
|
||||
"""Apply all pending migrations. Returns list of applied migration numbers."""
|
||||
url = database_url or DATABASE_URL
|
||||
applied_ids: list[int] = []
|
||||
with psycopg.connect(url) as conn:
|
||||
applied = get_applied(conn)
|
||||
for num, path in list_migration_files():
|
||||
if num in applied:
|
||||
continue
|
||||
sql = path.read_text()
|
||||
conn.execute(sql)
|
||||
conn.execute(
|
||||
"INSERT INTO schema_migrations (id, filename) VALUES (%s, %s)",
|
||||
(num, path.name),
|
||||
)
|
||||
conn.commit()
|
||||
applied_ids.append(num)
|
||||
return applied_ids
|
||||
|
||||
|
||||
def reset_database(database_url: str | None = None) -> None:
|
||||
"""Drop all tables (for tests only) and re-run migrations."""
|
||||
url = database_url or DATABASE_URL
|
||||
with psycopg.connect(url) as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
DROP TABLE IF EXISTS task_run, outbox, approval, artifact,
|
||||
application, job_posting, cv_section, profile, schema_migrations
|
||||
CASCADE
|
||||
"""
|
||||
)
|
||||
conn.commit()
|
||||
run_migrations(url)
|
||||
389
apps/api/app/db/repo_app.py
Normal file
389
apps/api/app/db/repo_app.py
Normal file
|
|
@ -0,0 +1,389 @@
|
|||
"""Repository functions for job_posting, application, artifact, approval, outbox, task_run."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from app.db import execute, fetch_all, fetch_one
|
||||
|
||||
|
||||
# --- Job Posting ---
|
||||
|
||||
def create_job_posting(
|
||||
source: str,
|
||||
url: str,
|
||||
company: str,
|
||||
title: str,
|
||||
location: str | None = None,
|
||||
description: str = "",
|
||||
external_id: str | None = None,
|
||||
raw: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
row = execute(
|
||||
"""
|
||||
INSERT INTO job_posting (source, external_id, url, company, title, location, description, raw)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
|
||||
ON CONFLICT (source, url) DO UPDATE SET
|
||||
company = EXCLUDED.company,
|
||||
title = EXCLUDED.title,
|
||||
fetched_at = now()
|
||||
RETURNING *
|
||||
""",
|
||||
(
|
||||
source,
|
||||
external_id,
|
||||
url,
|
||||
company,
|
||||
title,
|
||||
location,
|
||||
description,
|
||||
json.dumps(raw or {}),
|
||||
),
|
||||
)
|
||||
if row is None:
|
||||
raise RuntimeError("insert job_posting failed")
|
||||
return _normalize_posting(row)
|
||||
|
||||
|
||||
def get_job_posting(posting_id: str) -> dict[str, Any] | None:
|
||||
row = fetch_one("SELECT * FROM job_posting WHERE id = %s", (posting_id,))
|
||||
if row is None:
|
||||
return None
|
||||
return _normalize_posting(row)
|
||||
|
||||
|
||||
def list_postings() -> list[dict[str, Any]]:
|
||||
rows = fetch_all("SELECT * FROM job_posting ORDER BY fetched_at DESC")
|
||||
return [_normalize_posting(r) for r in rows]
|
||||
|
||||
|
||||
def _normalize_posting(row: dict[str, Any]) -> dict[str, Any]:
|
||||
return {
|
||||
"id": str(row["id"]),
|
||||
"source": row["source"],
|
||||
"external_id": row.get("external_id"),
|
||||
"url": row["url"],
|
||||
"company": row["company"],
|
||||
"title": row["title"],
|
||||
"location": row.get("location"),
|
||||
"description": row.get("description", ""),
|
||||
"fetched_at": row["fetched_at"].isoformat() if row.get("fetched_at") else None,
|
||||
}
|
||||
|
||||
|
||||
# --- Application ---
|
||||
|
||||
def create_application(job_posting_id: str) -> dict[str, Any]:
|
||||
row = execute(
|
||||
"""
|
||||
INSERT INTO application (job_posting_id, state)
|
||||
VALUES (%s, 'discovered')
|
||||
RETURNING *
|
||||
""",
|
||||
(job_posting_id,),
|
||||
)
|
||||
if row is None:
|
||||
raise RuntimeError("insert application failed")
|
||||
return _normalize_application(row)
|
||||
|
||||
|
||||
def get_application(app_id: str) -> dict[str, Any] | None:
|
||||
row = fetch_one(
|
||||
"""
|
||||
SELECT a.*, j.company, j.title, j.location
|
||||
FROM application a
|
||||
JOIN job_posting j ON a.job_posting_id = j.id
|
||||
WHERE a.id = %s
|
||||
""",
|
||||
(app_id,),
|
||||
)
|
||||
if row is None:
|
||||
return None
|
||||
return _normalize_application(row)
|
||||
|
||||
|
||||
def list_applications() -> list[dict[str, Any]]:
|
||||
rows = fetch_all(
|
||||
"""
|
||||
SELECT a.*, j.company, j.title, j.location
|
||||
FROM application a
|
||||
JOIN job_posting j ON a.job_posting_id = j.id
|
||||
ORDER BY a.created_at DESC
|
||||
"""
|
||||
)
|
||||
return [_normalize_application(r) for r in rows]
|
||||
|
||||
|
||||
def update_application_state(
|
||||
app_id: str,
|
||||
new_state: str,
|
||||
) -> dict[str, Any] | None:
|
||||
row = execute(
|
||||
"""
|
||||
UPDATE application
|
||||
SET state = %s, state_changed_at = now()
|
||||
WHERE id = %s
|
||||
RETURNING *
|
||||
""",
|
||||
(new_state, app_id),
|
||||
)
|
||||
if row is None:
|
||||
return None
|
||||
return _normalize_application(row)
|
||||
|
||||
|
||||
def update_application_score(
|
||||
app_id: str,
|
||||
score: float,
|
||||
rationale: dict[str, Any],
|
||||
) -> dict[str, Any] | None:
|
||||
row = execute(
|
||||
"""
|
||||
UPDATE application
|
||||
SET score = %s, score_rationale = %s, state = 'scored', state_changed_at = now()
|
||||
WHERE id = %s
|
||||
RETURNING *
|
||||
""",
|
||||
(score, json.dumps(rationale), app_id),
|
||||
)
|
||||
if row is None:
|
||||
return None
|
||||
return _normalize_application(row)
|
||||
|
||||
|
||||
def _normalize_application(row: dict[str, Any]) -> dict[str, Any]:
|
||||
rationale = row.get("score_rationale")
|
||||
if rationale is not None and not isinstance(rationale, dict):
|
||||
rationale = json.loads(rationale) if isinstance(rationale, str) else rationale
|
||||
return {
|
||||
"id": str(row["id"]),
|
||||
"job_posting_id": str(row["job_posting_id"]),
|
||||
"state": row["state"],
|
||||
"score": float(row["score"]) if row.get("score") is not None else None,
|
||||
"score_rationale": rationale,
|
||||
"notes": row.get("notes"),
|
||||
"state_changed_at": row["state_changed_at"].isoformat() if row.get("state_changed_at") else None,
|
||||
"created_at": row["created_at"].isoformat() if row.get("created_at") else None,
|
||||
"company": row.get("company"),
|
||||
"title": row.get("title"),
|
||||
"location": row.get("location"),
|
||||
}
|
||||
|
||||
|
||||
# --- Artifact ---
|
||||
|
||||
def create_artifact(
|
||||
application_id: str,
|
||||
kind: str,
|
||||
filename: str,
|
||||
content_bytes: bytes,
|
||||
storage_path: str,
|
||||
origin: str = "ai_reviewed",
|
||||
version: int = 1,
|
||||
) -> dict[str, Any]:
|
||||
content_hash = hashlib.sha256(content_bytes).hexdigest()
|
||||
row = execute(
|
||||
"""
|
||||
INSERT INTO artifact (application_id, kind, filename, content_hash, storage_path, version, origin)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s)
|
||||
RETURNING *
|
||||
""",
|
||||
(application_id, kind, filename, content_hash, storage_path, version, origin),
|
||||
)
|
||||
if row is None:
|
||||
raise RuntimeError("insert artifact failed")
|
||||
return _normalize_artifact(row)
|
||||
|
||||
|
||||
def list_artifacts(application_id: str) -> list[dict[str, Any]]:
|
||||
rows = fetch_all(
|
||||
"SELECT * FROM artifact WHERE application_id = %s ORDER BY created_at DESC",
|
||||
(application_id,),
|
||||
)
|
||||
return [_normalize_artifact(r) for r in rows]
|
||||
|
||||
|
||||
def get_artifact(artifact_id: str) -> dict[str, Any] | None:
|
||||
row = fetch_one("SELECT * FROM artifact WHERE id = %s", (artifact_id,))
|
||||
if row is None:
|
||||
return None
|
||||
return _normalize_artifact(row)
|
||||
|
||||
|
||||
def _normalize_artifact(row: dict[str, Any]) -> dict[str, Any]:
|
||||
return {
|
||||
"id": str(row["id"]),
|
||||
"application_id": str(row["application_id"]),
|
||||
"kind": row["kind"],
|
||||
"filename": row["filename"],
|
||||
"content_hash": row["content_hash"],
|
||||
"storage_path": row["storage_path"],
|
||||
"version": row["version"],
|
||||
"origin": row["origin"],
|
||||
}
|
||||
|
||||
|
||||
# --- Approval ---
|
||||
|
||||
def create_approval(
|
||||
application_id: str,
|
||||
artifact_id: str,
|
||||
artifact_hash: str,
|
||||
action: str,
|
||||
) -> dict[str, Any]:
|
||||
row = execute(
|
||||
"""
|
||||
INSERT INTO approval (application_id, artifact_id, artifact_hash, action, expires_at)
|
||||
VALUES (%s, %s, %s, %s, now() + interval '24 hours')
|
||||
RETURNING *
|
||||
""",
|
||||
(application_id, artifact_id, artifact_hash, action),
|
||||
)
|
||||
if row is None:
|
||||
raise RuntimeError("insert approval failed")
|
||||
return _normalize_approval(row)
|
||||
|
||||
|
||||
def get_approval(approval_id: str) -> dict[str, Any] | None:
|
||||
row = fetch_one("SELECT * FROM approval WHERE id = %s", (approval_id,))
|
||||
if row is None:
|
||||
return None
|
||||
return _normalize_approval(row)
|
||||
|
||||
|
||||
def confirm_approval(approval_id: str) -> dict[str, Any] | None:
|
||||
row = execute(
|
||||
"""
|
||||
UPDATE approval
|
||||
SET confirmed_by_user = true, confirmed_at = now()
|
||||
WHERE id = %s
|
||||
RETURNING *
|
||||
""",
|
||||
(approval_id,),
|
||||
)
|
||||
if row is None:
|
||||
return None
|
||||
return _normalize_approval(row)
|
||||
|
||||
|
||||
def reject_approval(approval_id: str) -> dict[str, Any] | None:
|
||||
row = execute(
|
||||
"""
|
||||
UPDATE approval
|
||||
SET confirmed_by_user = false, confirmed_at = now()
|
||||
WHERE id = %s
|
||||
RETURNING *
|
||||
""",
|
||||
(approval_id,),
|
||||
)
|
||||
if row is None:
|
||||
return None
|
||||
return _normalize_approval(row)
|
||||
|
||||
|
||||
def _normalize_approval(row: dict[str, Any]) -> dict[str, Any]:
|
||||
return {
|
||||
"id": str(row["id"]),
|
||||
"application_id": str(row["application_id"]),
|
||||
"artifact_id": str(row["artifact_id"]),
|
||||
"artifact_hash": row["artifact_hash"],
|
||||
"action": row["action"],
|
||||
"confirmed_by_user": row["confirmed_by_user"],
|
||||
"confirmed_at": row["confirmed_at"].isoformat() if row.get("confirmed_at") else None,
|
||||
"expires_at": row["expires_at"].isoformat() if row.get("expires_at") else None,
|
||||
"created_at": row["created_at"].isoformat() if row.get("created_at") else None,
|
||||
}
|
||||
|
||||
|
||||
# --- Outbox ---
|
||||
|
||||
def create_outbox(approval_id: str, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
row = execute(
|
||||
"""
|
||||
INSERT INTO outbox (approval_id, payload, status)
|
||||
VALUES (%s, %s, 'pending')
|
||||
RETURNING *
|
||||
""",
|
||||
(approval_id, json.dumps(payload)),
|
||||
)
|
||||
if row is None:
|
||||
raise RuntimeError("insert outbox failed")
|
||||
return _normalize_outbox(row)
|
||||
|
||||
|
||||
def update_outbox_sent(outbox_id: str) -> dict[str, Any] | None:
|
||||
row = execute(
|
||||
"""
|
||||
UPDATE outbox
|
||||
SET status = 'sent', sent_at = now()
|
||||
WHERE id = %s
|
||||
RETURNING *
|
||||
""",
|
||||
(outbox_id,),
|
||||
)
|
||||
if row is None:
|
||||
return None
|
||||
return _normalize_outbox(row)
|
||||
|
||||
|
||||
def _normalize_outbox(row: dict[str, Any]) -> dict[str, Any]:
|
||||
payload = row.get("payload")
|
||||
if payload is not None and not isinstance(payload, dict):
|
||||
payload = json.loads(payload) if isinstance(payload, str) else payload
|
||||
return {
|
||||
"id": str(row["id"]),
|
||||
"approval_id": str(row["approval_id"]),
|
||||
"kind": row.get("kind", "email"),
|
||||
"payload": payload or {},
|
||||
"status": row["status"],
|
||||
"sent_at": row["sent_at"].isoformat() if row.get("sent_at") else None,
|
||||
"error": row.get("error"),
|
||||
}
|
||||
|
||||
|
||||
# --- Task Run (telemetry) ---
|
||||
|
||||
def create_task_run(data: dict[str, Any]) -> dict[str, Any]:
|
||||
row = execute(
|
||||
"""
|
||||
INSERT INTO task_run (task, model, provider, input_tokens, output_tokens, cost_usd, duration_ms, application_id)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
|
||||
RETURNING *
|
||||
""",
|
||||
(
|
||||
data["task"],
|
||||
data["model"],
|
||||
data["provider"],
|
||||
data["input_tokens"],
|
||||
data["output_tokens"],
|
||||
data.get("cost_usd"),
|
||||
data["duration_ms"],
|
||||
data.get("application_id"),
|
||||
),
|
||||
)
|
||||
if row is None:
|
||||
raise RuntimeError("insert task_run failed")
|
||||
return _normalize_task_run(row)
|
||||
|
||||
|
||||
def list_task_runs() -> list[dict[str, Any]]:
|
||||
rows = fetch_all("SELECT * FROM task_run ORDER BY created_at DESC LIMIT 100")
|
||||
return [_normalize_task_run(r) for r in rows]
|
||||
|
||||
|
||||
def _normalize_task_run(row: dict[str, Any]) -> dict[str, Any]:
|
||||
return {
|
||||
"id": str(row["id"]),
|
||||
"task": row["task"],
|
||||
"model": row["model"],
|
||||
"provider": row["provider"],
|
||||
"input_tokens": row["input_tokens"],
|
||||
"output_tokens": row["output_tokens"],
|
||||
"cost_usd": float(row["cost_usd"]) if row.get("cost_usd") is not None else None,
|
||||
"duration_ms": row["duration_ms"],
|
||||
"application_id": str(row["application_id"]) if row.get("application_id") else None,
|
||||
"created_at": row["created_at"].isoformat() if row.get("created_at") else None,
|
||||
}
|
||||
157
apps/api/app/db/repo_profile.py
Normal file
157
apps/api/app/db/repo_profile.py
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
"""Repository functions for profile and cv_section tables."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from app.db import execute, fetch_all, fetch_one
|
||||
|
||||
|
||||
def get_or_create_profile() -> dict[str, Any] | None:
|
||||
"""Get the single profile row, or create a default one if none exists."""
|
||||
row = fetch_one("SELECT * FROM profile LIMIT 1")
|
||||
if row is not None:
|
||||
return _normalize_profile(row)
|
||||
# Create default profile
|
||||
row = execute(
|
||||
"""
|
||||
INSERT INTO profile (full_name, email)
|
||||
VALUES ('', '')
|
||||
RETURNING *
|
||||
"""
|
||||
)
|
||||
if row is None:
|
||||
return None
|
||||
return _normalize_profile(row)
|
||||
|
||||
|
||||
def update_profile(data: dict[str, Any]) -> dict[str, Any] | None:
|
||||
"""Update the single profile row with provided fields."""
|
||||
profile = get_or_create_profile()
|
||||
if profile is None:
|
||||
return None
|
||||
pid = profile["id"]
|
||||
fields = []
|
||||
values: list[Any] = []
|
||||
for key in ("full_name", "email", "phone", "location", "headline", "summary"):
|
||||
if key in data and data[key] is not None:
|
||||
fields.append(f"{key} = %s")
|
||||
values.append(data[key])
|
||||
if "languages" in data and data["languages"] is not None:
|
||||
fields.append("languages = %s")
|
||||
values.append(json.dumps(data["languages"]))
|
||||
if "hard_rules" in data and data["hard_rules"] is not None:
|
||||
fields.append("hard_rules = %s")
|
||||
values.append(json.dumps(data["hard_rules"]))
|
||||
if not fields:
|
||||
return profile
|
||||
fields.append("updated_at = now()")
|
||||
values.append(pid)
|
||||
sql = f"UPDATE profile SET {', '.join(fields)} WHERE id = %s RETURNING *"
|
||||
row = execute(sql, tuple(values))
|
||||
if row is None:
|
||||
return None
|
||||
return _normalize_profile(row)
|
||||
|
||||
|
||||
def _normalize_profile(row: dict[str, Any]) -> dict[str, Any]:
|
||||
return {
|
||||
"id": str(row["id"]),
|
||||
"full_name": row["full_name"],
|
||||
"email": row["email"],
|
||||
"phone": row.get("phone"),
|
||||
"location": row.get("location"),
|
||||
"headline": row.get("headline"),
|
||||
"summary": row.get("summary"),
|
||||
"languages": row.get("languages", []) if isinstance(row.get("languages"), list) else json.loads(row.get("languages", "[]")),
|
||||
"hard_rules": row.get("hard_rules", {}) if isinstance(row.get("hard_rules"), dict) else json.loads(row.get("hard_rules", "{}")),
|
||||
}
|
||||
|
||||
|
||||
# --- CV Sections ---
|
||||
|
||||
def list_sections() -> list[dict[str, Any]]:
|
||||
rows = fetch_all(
|
||||
"SELECT * FROM cv_section ORDER BY kind, sort_order"
|
||||
)
|
||||
return [_normalize_section(r) for r in rows]
|
||||
|
||||
|
||||
def create_section(profile_id: str, data: dict[str, Any]) -> dict[str, Any]:
|
||||
row = execute(
|
||||
"""
|
||||
INSERT INTO cv_section
|
||||
(profile_id, kind, title, org, location, start_date, end_date, bullets, tags, sort_order)
|
||||
VALUES
|
||||
(%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
RETURNING *
|
||||
""",
|
||||
(
|
||||
profile_id,
|
||||
data["kind"],
|
||||
data["title"],
|
||||
data.get("org"),
|
||||
data.get("location"),
|
||||
data.get("start_date"),
|
||||
data.get("end_date"),
|
||||
json.dumps(data.get("bullets", [])),
|
||||
data.get("tags", []),
|
||||
data.get("sort_order", 0),
|
||||
),
|
||||
)
|
||||
if row is None:
|
||||
raise RuntimeError("insert failed")
|
||||
return _normalize_section(row)
|
||||
|
||||
|
||||
def update_section(section_id: str, data: dict[str, Any]) -> dict[str, Any] | None:
|
||||
fields = []
|
||||
values: list[Any] = []
|
||||
for key in ("kind", "title", "org", "location", "start_date", "end_date", "sort_order"):
|
||||
if key in data and data[key] is not None:
|
||||
fields.append(f"{key} = %s")
|
||||
values.append(data[key])
|
||||
if "bullets" in data and data["bullets"] is not None:
|
||||
fields.append("bullets = %s")
|
||||
values.append(json.dumps(data["bullets"]))
|
||||
if "tags" in data and data["tags"] is not None:
|
||||
fields.append("tags = %s")
|
||||
values.append(data["tags"])
|
||||
if not fields:
|
||||
return get_section(section_id)
|
||||
fields.append("updated_at = now()")
|
||||
values.append(section_id)
|
||||
sql = f"UPDATE cv_section SET {', '.join(fields)} WHERE id = %s RETURNING *"
|
||||
row = execute(sql, tuple(values))
|
||||
if row is None:
|
||||
return None
|
||||
return _normalize_section(row)
|
||||
|
||||
|
||||
def get_section(section_id: str) -> dict[str, Any] | None:
|
||||
row = fetch_one("SELECT * FROM cv_section WHERE id = %s", (section_id,))
|
||||
if row is None:
|
||||
return None
|
||||
return _normalize_section(row)
|
||||
|
||||
|
||||
def delete_section(section_id: str) -> bool:
|
||||
row = execute("DELETE FROM cv_section WHERE id = %s RETURNING id", (section_id,))
|
||||
return row is not None
|
||||
|
||||
|
||||
def _normalize_section(row: dict[str, Any]) -> dict[str, Any]:
|
||||
return {
|
||||
"id": str(row["id"]),
|
||||
"profile_id": str(row["profile_id"]),
|
||||
"kind": row["kind"],
|
||||
"title": row["title"],
|
||||
"org": row.get("org"),
|
||||
"location": row.get("location"),
|
||||
"start_date": row.get("start_date").isoformat() if row.get("start_date") else None,
|
||||
"end_date": row.get("end_date").isoformat() if row.get("end_date") else None,
|
||||
"bullets": row.get("bullets", []) if isinstance(row.get("bullets"), list) else json.loads(row.get("bullets", "[]")),
|
||||
"tags": list(row.get("tags", [])) if row.get("tags") else [],
|
||||
"sort_order": row.get("sort_order", 0),
|
||||
}
|
||||
101
apps/api/app/llm.py
Normal file
101
apps/api/app/llm.py
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
"""LLM gateway integration.
|
||||
|
||||
Per the task spec: LLM usage via packages.llm_gateway. Since T2 hasn't built
|
||||
the gateway yet, this module provides a minimal mock-mode interface that the
|
||||
API endpoints call. When packages.llm_gateway is available (importable), it
|
||||
delegates to the real gateway. Otherwise, it returns deterministic canned
|
||||
outputs per task name.
|
||||
|
||||
Task names used by the API:
|
||||
- cv_assist: returns suggestions for a CV section bullet
|
||||
- cl_critique: returns critique comments for a cover letter
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
# Try to import the real llm_gateway package
|
||||
try:
|
||||
from packages.llm_gateway.client import run_task as _real_run_task # type: ignore
|
||||
HAS_REAL_GATEWAY = True
|
||||
except Exception:
|
||||
HAS_REAL_GATEWAY = False
|
||||
|
||||
|
||||
def _has_api_key() -> bool:
|
||||
return bool(
|
||||
os.environ.get("LLM_PRIMARY_KEY")
|
||||
or os.environ.get("OLLAMA_API_KEY")
|
||||
)
|
||||
|
||||
|
||||
# Deterministic mock outputs per task name
|
||||
MOCK_OUTPUTS: dict[str, dict[str, Any]] = {
|
||||
"cv_assist": {
|
||||
"suggestions": [
|
||||
"Improved bullet: Led cross-functional team of 8 to deliver feature X 2 weeks ahead of schedule.",
|
||||
"Alternative: Streamlined process reducing cycle time by 30% via automation.",
|
||||
]
|
||||
},
|
||||
"cl_critique": {
|
||||
"comments": [
|
||||
{
|
||||
"quote": "I am writing to apply",
|
||||
"suggestion": "Consider a stronger opening that references the specific role.",
|
||||
"severity": "medium",
|
||||
},
|
||||
{
|
||||
"quote": "I have experience",
|
||||
"suggestion": "Quantify with a specific achievement rather than a generic claim.",
|
||||
"severity": "low",
|
||||
},
|
||||
]
|
||||
},
|
||||
"score": {
|
||||
"score": 72,
|
||||
"rationale": {
|
||||
"match": 0.72,
|
||||
"factors": {"skills": 0.8, "location": 0.6, "experience": 0.75},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def run_task(
|
||||
task: str,
|
||||
prompt: str,
|
||||
schema: dict[str, Any] | None = None,
|
||||
telemetry_sink: callable | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Run an LLM task. Uses mock mode when no API key is present.
|
||||
|
||||
Returns a dict with the task result. If a telemetry_sink callable is
|
||||
provided, it is called with a dict of token/cost info.
|
||||
"""
|
||||
if HAS_REAL_GATEWAY and _has_api_key():
|
||||
return _real_run_task(task, prompt, schema)
|
||||
|
||||
# Mock mode
|
||||
time.sleep(0.01) # simulate latency
|
||||
result = MOCK_OUTPUTS.get(task, {"result": "mock"})
|
||||
|
||||
# Validate against schema if provided (basic check)
|
||||
# In real gateway this would be jsonschema validation
|
||||
|
||||
if telemetry_sink is not None:
|
||||
telemetry_sink({
|
||||
"task": task,
|
||||
"model": "mock",
|
||||
"provider": "mock",
|
||||
"input_tokens": len(prompt) // 4, # rough estimate
|
||||
"output_tokens": len(json.dumps(result)) // 4,
|
||||
"cost_usd": 0.0,
|
||||
"duration_ms": 10,
|
||||
})
|
||||
|
||||
return result
|
||||
450
apps/api/app/main.py
Normal file
450
apps/api/app/main.py
Normal file
|
|
@ -0,0 +1,450 @@
|
|||
"""FastAPI application — main entry point."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from fastapi import FastAPI, HTTPException, status
|
||||
|
||||
from app import llm
|
||||
from app.db import close_pool, execute, fetch_all, fetch_one, get_pool
|
||||
from app.db import migrate as migrate_mod
|
||||
from app.db import repo_app, repo_profile
|
||||
from app.schemas import (
|
||||
AiAssistRequest,
|
||||
AiAssistResponse,
|
||||
ApplicationOut,
|
||||
ApprovalCreate,
|
||||
ApprovalOut,
|
||||
ArtifactCreate,
|
||||
ArtifactOut,
|
||||
CoverLetterRequest,
|
||||
CoverLetterResponse,
|
||||
CvSectionCreate,
|
||||
CvSectionOut,
|
||||
CvSectionUpdate,
|
||||
ErrorOut,
|
||||
JobPostingCreate,
|
||||
JobPostingOut,
|
||||
OutboxOut,
|
||||
OutboxSendRequest,
|
||||
ProfileOut,
|
||||
ProfileUpdate,
|
||||
ScoreResponse,
|
||||
TaskRunOut,
|
||||
TransitionRequest,
|
||||
)
|
||||
from app.statemachine import TransitionContext, check_transition, InvalidTransition
|
||||
from app.transport import get_transport
|
||||
|
||||
app = FastAPI(title="Jobhunt API", version="0.1.0")
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
def _startup() -> None:
|
||||
"""Ensure pool is initialized and migrations are applied."""
|
||||
get_pool()
|
||||
migrate_mod.run_migrations()
|
||||
|
||||
|
||||
@app.on_event("shutdown")
|
||||
def _shutdown() -> None:
|
||||
close_pool()
|
||||
|
||||
|
||||
# --- Health ---
|
||||
|
||||
@app.get("/api/health")
|
||||
def health() -> dict[str, str]:
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
# --- Profile ---
|
||||
|
||||
@app.get("/api/profile", response_model=ProfileOut)
|
||||
def get_profile() -> Any:
|
||||
p = repo_profile.get_or_create_profile()
|
||||
if p is None:
|
||||
raise HTTPException(status_code=500, detail="Failed to create profile")
|
||||
return p
|
||||
|
||||
|
||||
@app.put("/api/profile", response_model=ProfileOut)
|
||||
def put_profile(body: ProfileUpdate) -> Any:
|
||||
p = repo_profile.update_profile(body.model_dump(exclude_none=True))
|
||||
if p is None:
|
||||
raise HTTPException(status_code=404, detail="Profile not found")
|
||||
return p
|
||||
|
||||
|
||||
# --- CV Sections ---
|
||||
|
||||
@app.get("/api/profile/sections", response_model=list[CvSectionOut])
|
||||
def get_sections() -> Any:
|
||||
return repo_profile.list_sections()
|
||||
|
||||
|
||||
@app.post("/api/profile/sections", response_model=CvSectionOut, status_code=201)
|
||||
def create_section(body: CvSectionCreate) -> Any:
|
||||
p = repo_profile.get_or_create_profile()
|
||||
if p is None:
|
||||
raise HTTPException(status_code=500, detail="No profile")
|
||||
return repo_profile.create_section(p["id"], body.model_dump())
|
||||
|
||||
|
||||
@app.put("/api/profile/sections/{section_id}", response_model=CvSectionOut)
|
||||
def update_section(section_id: str, body: CvSectionUpdate) -> Any:
|
||||
s = repo_profile.update_section(section_id, body.model_dump(exclude_none=True))
|
||||
if s is None:
|
||||
raise HTTPException(status_code=404, detail="Section not found")
|
||||
return s
|
||||
|
||||
|
||||
@app.delete("/api/profile/sections/{section_id}", status_code=204)
|
||||
def delete_section(section_id: str) -> None:
|
||||
if not repo_profile.delete_section(section_id):
|
||||
raise HTTPException(status_code=404, detail="Section not found")
|
||||
|
||||
|
||||
@app.post("/api/profile/sections/{section_id}/ai-assist", response_model=AiAssistResponse)
|
||||
def ai_assist(section_id: str, body: AiAssistRequest) -> Any:
|
||||
section = repo_profile.get_section(section_id)
|
||||
if section is None:
|
||||
raise HTTPException(status_code=404, detail="Section not found")
|
||||
result = llm.run_task(
|
||||
"cv_assist",
|
||||
body.instruction,
|
||||
telemetry_sink=lambda info: repo_app.create_task_run({
|
||||
**info,
|
||||
"application_id": None,
|
||||
}),
|
||||
)
|
||||
return {"suggestions": result.get("suggestions", [])}
|
||||
|
||||
|
||||
# --- Job Postings ---
|
||||
|
||||
@app.post("/api/postings", response_model=ApplicationOut, status_code=201)
|
||||
def create_posting(body: JobPostingCreate) -> Any:
|
||||
"""Create a job posting from a URL. For POC: manual_url source."""
|
||||
url = body.url.strip()
|
||||
if not url:
|
||||
raise HTTPException(status_code=422, detail="URL is required")
|
||||
|
||||
# For POC: manual_url source. In production, connectors package would fetch.
|
||||
# Extract a simple company/title from URL or use placeholder.
|
||||
posting = repo_app.create_job_posting(
|
||||
source="manual_url",
|
||||
url=url,
|
||||
company="Unknown",
|
||||
title="Unknown position",
|
||||
location=None,
|
||||
description="",
|
||||
raw={"url": url},
|
||||
)
|
||||
application = repo_app.create_application(posting["id"])
|
||||
return application
|
||||
|
||||
|
||||
@app.get("/api/postings", response_model=list[JobPostingOut])
|
||||
def get_postings() -> Any:
|
||||
return repo_app.list_postings()
|
||||
|
||||
|
||||
@app.post("/api/postings/{posting_id}/score", response_model=ScoreResponse)
|
||||
def score_posting(posting_id: str) -> Any:
|
||||
"""Score a job posting against the profile."""
|
||||
posting = repo_app.get_job_posting(posting_id)
|
||||
if posting is None:
|
||||
raise HTTPException(status_code=404, detail="Posting not found")
|
||||
|
||||
# Find the application for this posting
|
||||
apps = repo_app.list_applications()
|
||||
app_for_posting = None
|
||||
for a in apps:
|
||||
if a["job_posting_id"] == posting["id"]:
|
||||
app_for_posting = a
|
||||
break
|
||||
if app_for_posting is None:
|
||||
raise HTTPException(status_code=404, detail="No application for posting")
|
||||
|
||||
# Run scoring via LLM (mock mode returns deterministic result)
|
||||
result = llm.run_task(
|
||||
"score",
|
||||
f"Score this posting: {posting['title']} at {posting['company']}",
|
||||
telemetry_sink=lambda info: repo_app.create_task_run({
|
||||
**info,
|
||||
"application_id": app_for_posting["id"],
|
||||
}),
|
||||
)
|
||||
|
||||
score = float(result.get("score", 50))
|
||||
rationale = result.get("rationale", {})
|
||||
|
||||
repo_app.update_application_score(app_for_posting["id"], score, rationale)
|
||||
return {"score": score, "rationale": rationale}
|
||||
|
||||
|
||||
# --- Applications ---
|
||||
|
||||
@app.get("/api/applications", response_model=list[ApplicationOut])
|
||||
def get_applications() -> Any:
|
||||
return repo_app.list_applications()
|
||||
|
||||
|
||||
@app.post("/api/applications/{app_id}/transition")
|
||||
def transition_application(app_id: str, body: TransitionRequest) -> Any:
|
||||
app_row = repo_app.get_application(app_id)
|
||||
if app_row is None:
|
||||
raise HTTPException(status_code=404, detail="Application not found")
|
||||
|
||||
to_state = body.to
|
||||
from_state = app_row["state"]
|
||||
|
||||
# Build context for guard evaluation
|
||||
has_score = app_row.get("score") is not None
|
||||
|
||||
# For drafting -> sent transition, check confirmed approval
|
||||
has_confirmed_approval = False
|
||||
artifact_hash_match = False
|
||||
if from_state == "drafting" and to_state == "sent":
|
||||
approvals = fetch_all(
|
||||
"SELECT * FROM approval WHERE application_id = %s AND confirmed_by_user = true",
|
||||
(app_id,),
|
||||
)
|
||||
for a in approvals:
|
||||
expires = a["expires_at"]
|
||||
if expires is not None:
|
||||
# Check not expired
|
||||
now_utc = datetime.now(timezone.utc)
|
||||
if expires.tzinfo is None:
|
||||
expires = expires.replace(tzinfo=timezone.utc)
|
||||
if expires > now_utc:
|
||||
has_confirmed_approval = True
|
||||
# Check hash match
|
||||
artifact = repo_app.get_artifact(str(a["artifact_id"]))
|
||||
if artifact and artifact["content_hash"] == a["artifact_hash"]:
|
||||
artifact_hash_match = True
|
||||
break
|
||||
|
||||
ctx = TransitionContext(
|
||||
application_id=app_id,
|
||||
from_state=from_state,
|
||||
to_state=to_state,
|
||||
has_score=has_score,
|
||||
has_confirmed_approval=has_confirmed_approval,
|
||||
artifact_hash_match=artifact_hash_match,
|
||||
)
|
||||
|
||||
try:
|
||||
check_transition(ctx)
|
||||
except InvalidTransition as exc:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail={"code": "invalid_transition", "message": str(exc)},
|
||||
)
|
||||
|
||||
updated = repo_app.update_application_state(app_id, to_state)
|
||||
if updated is None:
|
||||
raise HTTPException(status_code=500, detail="Update failed")
|
||||
return updated
|
||||
|
||||
|
||||
# --- Artifacts ---
|
||||
|
||||
@app.post("/api/applications/{app_id}/artifacts", response_model=ArtifactOut, status_code=201)
|
||||
def create_artifact(app_id: str, body: ArtifactCreate) -> Any:
|
||||
app_row = repo_app.get_application(app_id)
|
||||
if app_row is None:
|
||||
raise HTTPException(status_code=404, detail="Application not found")
|
||||
|
||||
content_bytes = body.content.encode("utf-8")
|
||||
# Store in temp dir (POC)
|
||||
storage_dir = os.path.join(tempfile.gettempdir(), "jobhunt_artifacts")
|
||||
os.makedirs(storage_dir, exist_ok=True)
|
||||
filename = f"{body.kind}_{app_id[:8]}.txt"
|
||||
storage_path = os.path.join(storage_dir, filename)
|
||||
with open(storage_path, "wb") as f:
|
||||
f.write(content_bytes)
|
||||
|
||||
return repo_app.create_artifact(
|
||||
application_id=app_id,
|
||||
kind=body.kind,
|
||||
filename=filename,
|
||||
content_bytes=content_bytes,
|
||||
storage_path=storage_path,
|
||||
origin="user_drafted",
|
||||
)
|
||||
|
||||
|
||||
@app.post("/api/applications/{app_id}/artifacts/cover-letter", response_model=CoverLetterResponse)
|
||||
def create_cover_letter(app_id: str, body: CoverLetterRequest) -> Any:
|
||||
app_row = repo_app.get_application(app_id)
|
||||
if app_row is None:
|
||||
raise HTTPException(status_code=404, detail="Application not found")
|
||||
|
||||
content_bytes = body.letter_text.encode("utf-8")
|
||||
storage_dir = os.path.join(tempfile.gettempdir(), "jobhunt_artifacts")
|
||||
os.makedirs(storage_dir, exist_ok=True)
|
||||
filename = f"cover_letter_{app_id[:8]}.txt"
|
||||
storage_path = os.path.join(storage_dir, filename)
|
||||
with open(storage_path, "wb") as f:
|
||||
f.write(content_bytes)
|
||||
|
||||
artifact = repo_app.create_artifact(
|
||||
application_id=app_id,
|
||||
kind="cover_letter",
|
||||
filename=filename,
|
||||
content_bytes=content_bytes,
|
||||
storage_path=storage_path,
|
||||
origin="user_drafted",
|
||||
)
|
||||
|
||||
# Run AI critique via LLM gateway (mock mode)
|
||||
result = llm.run_task(
|
||||
"cl_critique",
|
||||
body.letter_text,
|
||||
telemetry_sink=lambda info: repo_app.create_task_run({
|
||||
**info,
|
||||
"application_id": app_id,
|
||||
}),
|
||||
)
|
||||
|
||||
return {
|
||||
"artifact": artifact,
|
||||
"critique": result.get("comments", []),
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/applications/{app_id}/artifacts", response_model=list[ArtifactOut])
|
||||
def get_artifacts(app_id: str) -> Any:
|
||||
return repo_app.list_artifacts(app_id)
|
||||
|
||||
|
||||
# --- Approval & Outbox ---
|
||||
|
||||
@app.post("/api/applications/{app_id}/approvals", response_model=ApprovalOut, status_code=201)
|
||||
def create_approval_endpoint(app_id: str, body: ApprovalCreate) -> Any:
|
||||
app_row = repo_app.get_application(app_id)
|
||||
if app_row is None:
|
||||
raise HTTPException(status_code=404, detail="Application not found")
|
||||
|
||||
artifact = repo_app.get_artifact(body.artifact_id)
|
||||
if artifact is None:
|
||||
raise HTTPException(status_code=404, detail="Artifact not found")
|
||||
|
||||
return repo_app.create_approval(
|
||||
application_id=app_id,
|
||||
artifact_id=body.artifact_id,
|
||||
artifact_hash=artifact["content_hash"],
|
||||
action=body.action,
|
||||
)
|
||||
|
||||
|
||||
@app.post("/api/approvals/{approval_id}/confirm", response_model=ApprovalOut)
|
||||
def confirm_approval(approval_id: str) -> Any:
|
||||
approval = repo_app.get_approval(approval_id)
|
||||
if approval is None:
|
||||
raise HTTPException(status_code=404, detail="Approval not found")
|
||||
|
||||
# Verify artifact hash matches
|
||||
artifact = repo_app.get_artifact(approval["artifact_id"])
|
||||
if artifact is None:
|
||||
raise HTTPException(status_code=409, detail="Artifact not found")
|
||||
if artifact["content_hash"] != approval["artifact_hash"]:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail={"code": "hash_mismatch", "message": "Artifact hash does not match approval hash"},
|
||||
)
|
||||
|
||||
# Check expiry
|
||||
expires = approval["expires_at"]
|
||||
if expires:
|
||||
now_utc = datetime.now(timezone.utc)
|
||||
expires_dt = datetime.fromisoformat(expires)
|
||||
if expires_dt.tzinfo is None:
|
||||
expires_dt = expires_dt.replace(tzinfo=timezone.utc)
|
||||
if expires_dt < now_utc:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail={"code": "expired", "message": "Approval has expired"},
|
||||
)
|
||||
|
||||
result = repo_app.confirm_approval(approval_id)
|
||||
if result is None:
|
||||
raise HTTPException(status_code=500, detail="Confirm failed")
|
||||
return result
|
||||
|
||||
|
||||
@app.post("/api/approvals/{approval_id}/reject", response_model=ApprovalOut)
|
||||
def reject_approval_endpoint(approval_id: str) -> Any:
|
||||
result = repo_app.reject_approval(approval_id)
|
||||
if result is None:
|
||||
raise HTTPException(status_code=404, detail="Approval not found")
|
||||
return result
|
||||
|
||||
|
||||
@app.post("/api/outbox/send", response_model=OutboxOut)
|
||||
def send_outbox(body: OutboxSendRequest) -> Any:
|
||||
"""Send via outbox. Fails 409 unless approval is confirmed, unexpired, hash match."""
|
||||
approval = repo_app.get_approval(body.approval_id)
|
||||
if approval is None:
|
||||
raise HTTPException(status_code=404, detail="Approval not found")
|
||||
|
||||
# Check confirmed
|
||||
if not approval["confirmed_by_user"]:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail={"code": "not_confirmed", "message": "Approval not confirmed by user"},
|
||||
)
|
||||
|
||||
# Check expiry
|
||||
expires = approval["expires_at"]
|
||||
if expires:
|
||||
now_utc = datetime.now(timezone.utc)
|
||||
expires_dt = datetime.fromisoformat(expires)
|
||||
if expires_dt.tzinfo is None:
|
||||
expires_dt = expires_dt.replace(tzinfo=timezone.utc)
|
||||
if expires_dt < now_utc:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail={"code": "expired", "message": "Approval has expired"},
|
||||
)
|
||||
|
||||
# Check hash match
|
||||
artifact = repo_app.get_artifact(approval["artifact_id"])
|
||||
if artifact is None:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail={"code": "artifact_missing", "message": "Artifact not found"},
|
||||
)
|
||||
if artifact["content_hash"] != approval["artifact_hash"]:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail={"code": "hash_mismatch", "message": "Artifact content has changed"},
|
||||
)
|
||||
|
||||
# Create outbox record
|
||||
outbox = repo_app.create_outbox(body.approval_id, body.payload)
|
||||
|
||||
# Send via transport
|
||||
transport = get_transport()
|
||||
result = transport.send(body.payload)
|
||||
|
||||
if result.get("success"):
|
||||
updated = repo_app.update_outbox_sent(outbox["id"])
|
||||
if updated:
|
||||
return updated
|
||||
return outbox
|
||||
|
||||
|
||||
# --- Telemetry ---
|
||||
|
||||
@app.get("/api/telemetry/tasks", response_model=list[TaskRunOut])
|
||||
def get_telemetry() -> Any:
|
||||
return repo_app.list_task_runs()
|
||||
220
apps/api/app/schemas.py
Normal file
220
apps/api/app/schemas.py
Normal file
|
|
@ -0,0 +1,220 @@
|
|||
"""Pydantic schemas for all API contract payloads."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
# --- Profile ---
|
||||
|
||||
class ProfileBase(BaseModel):
|
||||
full_name: str = ""
|
||||
email: str = ""
|
||||
phone: str | None = None
|
||||
location: str | None = None
|
||||
headline: str | None = None
|
||||
summary: str | None = None
|
||||
languages: list[dict[str, Any]] = Field(default_factory=list)
|
||||
hard_rules: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class ProfileUpdate(BaseModel):
|
||||
full_name: str | None = None
|
||||
email: str | None = None
|
||||
phone: str | None = None
|
||||
location: str | None = None
|
||||
headline: str | None = None
|
||||
summary: str | None = None
|
||||
languages: list[dict[str, Any]] | None = None
|
||||
hard_rules: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class ProfileOut(BaseModel):
|
||||
id: str
|
||||
full_name: str
|
||||
email: str
|
||||
phone: str | None = None
|
||||
location: str | None = None
|
||||
headline: str | None = None
|
||||
summary: str | None = None
|
||||
languages: list[dict[str, Any]] = []
|
||||
hard_rules: dict[str, Any] = {}
|
||||
|
||||
|
||||
# --- CV Section ---
|
||||
|
||||
class CvSectionCreate(BaseModel):
|
||||
kind: str
|
||||
title: str
|
||||
org: str | None = None
|
||||
location: str | None = None
|
||||
start_date: date | None = None
|
||||
end_date: date | None = None
|
||||
bullets: list[str] = Field(default_factory=list)
|
||||
tags: list[str] = Field(default_factory=list)
|
||||
sort_order: int = 0
|
||||
|
||||
|
||||
class CvSectionUpdate(BaseModel):
|
||||
kind: str | None = None
|
||||
title: str | None = None
|
||||
org: str | None = None
|
||||
location: str | None = None
|
||||
start_date: date | None = None
|
||||
end_date: date | None = None
|
||||
bullets: list[str] | None = None
|
||||
tags: list[str] | None = None
|
||||
sort_order: int | None = None
|
||||
|
||||
|
||||
class CvSectionOut(BaseModel):
|
||||
id: str
|
||||
profile_id: str
|
||||
kind: str
|
||||
title: str
|
||||
org: str | None = None
|
||||
location: str | None = None
|
||||
start_date: date | None = None
|
||||
end_date: date | None = None
|
||||
bullets: list[str] = []
|
||||
tags: list[str] = []
|
||||
sort_order: int = 0
|
||||
|
||||
|
||||
class AiAssistRequest(BaseModel):
|
||||
instruction: str
|
||||
|
||||
|
||||
class AiAssistResponse(BaseModel):
|
||||
suggestions: list[str]
|
||||
|
||||
|
||||
# --- Job Posting ---
|
||||
|
||||
class JobPostingCreate(BaseModel):
|
||||
url: str
|
||||
|
||||
|
||||
class JobPostingOut(BaseModel):
|
||||
id: str
|
||||
source: str
|
||||
external_id: str | None = None
|
||||
url: str
|
||||
company: str
|
||||
title: str
|
||||
location: str | None = None
|
||||
description: str = ""
|
||||
fetched_at: str | None = None
|
||||
|
||||
|
||||
class ScoreResponse(BaseModel):
|
||||
score: float
|
||||
rationale: dict[str, Any]
|
||||
|
||||
|
||||
# --- Application ---
|
||||
|
||||
class ApplicationOut(BaseModel):
|
||||
id: str
|
||||
job_posting_id: str
|
||||
state: str
|
||||
score: float | None = None
|
||||
score_rationale: dict[str, Any] | None = None
|
||||
notes: str | None = None
|
||||
state_changed_at: str | None = None
|
||||
created_at: str | None = None
|
||||
# joined posting info
|
||||
company: str | None = None
|
||||
title: str | None = None
|
||||
location: str | None = None
|
||||
|
||||
|
||||
class TransitionRequest(BaseModel):
|
||||
to: str
|
||||
|
||||
|
||||
# --- Artifact ---
|
||||
|
||||
class ArtifactCreate(BaseModel):
|
||||
kind: str
|
||||
content: str
|
||||
|
||||
|
||||
class ArtifactOut(BaseModel):
|
||||
id: str
|
||||
application_id: str
|
||||
kind: str
|
||||
filename: str
|
||||
content_hash: str
|
||||
storage_path: str
|
||||
version: int
|
||||
origin: str
|
||||
|
||||
|
||||
class CoverLetterRequest(BaseModel):
|
||||
letter_text: str
|
||||
|
||||
|
||||
class CoverLetterResponse(BaseModel):
|
||||
artifact: ArtifactOut
|
||||
critique: list[dict[str, Any]]
|
||||
|
||||
|
||||
# --- Approval ---
|
||||
|
||||
class ApprovalCreate(BaseModel):
|
||||
action: str
|
||||
artifact_id: str
|
||||
|
||||
|
||||
class ApprovalOut(BaseModel):
|
||||
id: str
|
||||
application_id: str
|
||||
artifact_id: str
|
||||
artifact_hash: str
|
||||
action: str
|
||||
confirmed_by_user: bool
|
||||
confirmed_at: str | None = None
|
||||
expires_at: str
|
||||
created_at: str | None = None
|
||||
|
||||
|
||||
# --- Outbox ---
|
||||
|
||||
class OutboxSendRequest(BaseModel):
|
||||
approval_id: str
|
||||
payload: dict[str, Any]
|
||||
|
||||
|
||||
class OutboxOut(BaseModel):
|
||||
id: str
|
||||
approval_id: str
|
||||
kind: str
|
||||
payload: dict[str, Any]
|
||||
status: str
|
||||
sent_at: str | None = None
|
||||
error: str | None = None
|
||||
|
||||
|
||||
# --- Telemetry ---
|
||||
|
||||
class TaskRunOut(BaseModel):
|
||||
id: str
|
||||
task: str
|
||||
model: str
|
||||
provider: str
|
||||
input_tokens: int
|
||||
output_tokens: int
|
||||
cost_usd: float | None = None
|
||||
duration_ms: int
|
||||
application_id: str | None = None
|
||||
created_at: str | None = None
|
||||
|
||||
|
||||
# --- Errors ---
|
||||
|
||||
class ErrorOut(BaseModel):
|
||||
error: dict[str, str]
|
||||
105
apps/api/app/statemachine.py
Normal file
105
apps/api/app/statemachine.py
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
"""State machine for application transitions.
|
||||
|
||||
Implements the transition table from docs/data-model.md.
|
||||
Any transition not listed raises InvalidTransition (409 in API).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
class InvalidTransition(Exception):
|
||||
"""Raised when a state transition is not allowed."""
|
||||
|
||||
def __init__(self, from_state: str, to_state: str, reason: str = ""):
|
||||
self.from_state = from_state
|
||||
self.to_state = to_state
|
||||
self.reason = reason
|
||||
super().__init__(
|
||||
f"Invalid transition: {from_state} -> {to_state}"
|
||||
+ (f": {reason}" if reason else "")
|
||||
)
|
||||
|
||||
|
||||
# All valid states
|
||||
STATES = frozenset({
|
||||
"discovered",
|
||||
"scored",
|
||||
"approved",
|
||||
"rejected",
|
||||
"drafting",
|
||||
"sent",
|
||||
"interviewing",
|
||||
"offer",
|
||||
"closed",
|
||||
"expired",
|
||||
})
|
||||
|
||||
|
||||
# Transition table: (from, to) -> guard name (or None for no guard)
|
||||
# Guard names map to guard functions below.
|
||||
TRANSITIONS: dict[tuple[str, str], str | None] = {
|
||||
("discovered", "scored"): "scoring_completed",
|
||||
("discovered", "rejected"): None,
|
||||
("scored", "approved"): None,
|
||||
("scored", "rejected"): None,
|
||||
("approved", "drafting"): None,
|
||||
("drafting", "sent"): "confirmed_approval",
|
||||
("sent", "interviewing"): None,
|
||||
("interviewing", "offer"): None,
|
||||
("interviewing", "closed"): None,
|
||||
("offer", "closed"): None,
|
||||
("scored", "expired"): None,
|
||||
("approved", "expired"): None,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class TransitionContext:
|
||||
"""Context passed to guard functions for validation."""
|
||||
|
||||
application_id: str
|
||||
from_state: str
|
||||
to_state: str
|
||||
has_score: bool = False
|
||||
has_confirmed_approval: bool = False
|
||||
artifact_hash_match: bool = False
|
||||
|
||||
|
||||
# Guard implementations
|
||||
GUARDS: dict[str, callable] = {
|
||||
"scoring_completed": lambda ctx: ctx.has_score,
|
||||
"confirmed_approval": lambda ctx: ctx.has_confirmed_approval and ctx.artifact_hash_match,
|
||||
}
|
||||
|
||||
|
||||
def can_transition(from_state: str, to_state: str) -> bool:
|
||||
"""Check if a transition is in the table (does not evaluate guards)."""
|
||||
return (from_state, to_state) in TRANSITIONS
|
||||
|
||||
|
||||
def check_transition(ctx: TransitionContext) -> None:
|
||||
"""Validate a transition. Raises InvalidTransition if not allowed."""
|
||||
if ctx.from_state not in STATES:
|
||||
raise InvalidTransition(ctx.from_state, ctx.to_state, f"unknown state: {ctx.from_state}")
|
||||
if ctx.to_state not in STATES:
|
||||
raise InvalidTransition(ctx.from_state, ctx.to_state, f"unknown state: {ctx.to_state}")
|
||||
|
||||
key = (ctx.from_state, ctx.to_state)
|
||||
if key not in TRANSITIONS:
|
||||
raise InvalidTransition(
|
||||
ctx.from_state,
|
||||
ctx.to_state,
|
||||
f"transition {ctx.from_state} -> {ctx.to_state} is not in the transition table",
|
||||
)
|
||||
|
||||
guard_name = TRANSITIONS[key]
|
||||
if guard_name is not None:
|
||||
guard_fn = GUARDS[guard_name]
|
||||
if not guard_fn(ctx):
|
||||
raise InvalidTransition(
|
||||
ctx.from_state,
|
||||
ctx.to_state,
|
||||
f"guard failed: {guard_name}",
|
||||
)
|
||||
51
apps/api/app/transport.py
Normal file
51
apps/api/app/transport.py
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
"""Send transport interface.
|
||||
|
||||
Pluggable Transport interface for the outbox send operation.
|
||||
Default EchoTransport records the payload and marks as sent.
|
||||
No real email in POC.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Protocol
|
||||
|
||||
|
||||
class Transport(Protocol):
|
||||
def send(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Send a payload. Returns a result dict with at least 'success' bool."""
|
||||
...
|
||||
|
||||
|
||||
class EchoTransport:
|
||||
"""Default transport: records payload, returns success.
|
||||
|
||||
No real email is sent. Used for POC.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.sent: list[dict[str, Any]] = []
|
||||
|
||||
def send(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
self.sent.append(payload)
|
||||
return {
|
||||
"success": True,
|
||||
"sent_at": datetime.now(timezone.utc).isoformat(),
|
||||
"echo": payload,
|
||||
}
|
||||
|
||||
|
||||
# Singleton instance
|
||||
_default_transport: Transport | None = None
|
||||
|
||||
|
||||
def get_transport() -> Transport:
|
||||
global _default_transport
|
||||
if _default_transport is None:
|
||||
_default_transport = EchoTransport()
|
||||
return _default_transport
|
||||
|
||||
|
||||
def set_transport(t: Transport) -> None:
|
||||
global _default_transport
|
||||
_default_transport = t
|
||||
106
apps/api/migrations/001_initial.sql
Normal file
106
apps/api/migrations/001_initial.sql
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
-- 001_initial.sql — baseline schema
|
||||
-- Mirrors schema.sql for the migration runner.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS profile (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
full_name text NOT NULL,
|
||||
email text NOT NULL,
|
||||
phone text,
|
||||
location text,
|
||||
headline text,
|
||||
summary text,
|
||||
languages jsonb NOT NULL DEFAULT '[]',
|
||||
hard_rules jsonb NOT NULL DEFAULT '{}',
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS cv_section (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
profile_id uuid NOT NULL REFERENCES profile(id) ON DELETE CASCADE,
|
||||
kind text NOT NULL CHECK (kind IN ('experience','education','skills','projects','other')),
|
||||
title text NOT NULL,
|
||||
org text,
|
||||
location text,
|
||||
start_date date,
|
||||
end_date date,
|
||||
bullets jsonb NOT NULL DEFAULT '[]',
|
||||
tags text[] NOT NULL DEFAULT '{}',
|
||||
sort_order int NOT NULL DEFAULT 0,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS job_posting (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
source text NOT NULL,
|
||||
external_id text,
|
||||
url text NOT NULL,
|
||||
company text NOT NULL,
|
||||
title text NOT NULL,
|
||||
location text,
|
||||
description text NOT NULL DEFAULT '',
|
||||
raw jsonb NOT NULL DEFAULT '{}',
|
||||
fetched_at timestamptz NOT NULL DEFAULT now(),
|
||||
UNIQUE (source, url)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS application (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
job_posting_id uuid NOT NULL REFERENCES job_posting(id) ON DELETE CASCADE,
|
||||
state text NOT NULL DEFAULT 'discovered' CHECK (state IN
|
||||
('discovered','scored','approved','rejected','drafting','sent','interviewing','offer','closed','expired')),
|
||||
score numeric,
|
||||
score_rationale jsonb,
|
||||
notes text,
|
||||
state_changed_at timestamptz NOT NULL DEFAULT now(),
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS artifact (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
application_id uuid NOT NULL REFERENCES application(id) ON DELETE CASCADE,
|
||||
kind text NOT NULL CHECK (kind IN ('cv','cover_letter','email','other')),
|
||||
filename text NOT NULL,
|
||||
content_hash text NOT NULL,
|
||||
storage_path text NOT NULL,
|
||||
version int NOT NULL DEFAULT 1,
|
||||
origin text NOT NULL DEFAULT 'ai_reviewed' CHECK (origin IN ('user_drafted','ai_drafted','ai_reviewed')),
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS approval (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
application_id uuid NOT NULL REFERENCES application(id) ON DELETE CASCADE,
|
||||
artifact_id uuid NOT NULL REFERENCES artifact(id),
|
||||
artifact_hash text NOT NULL,
|
||||
action text NOT NULL CHECK (action IN ('send_email','submit_application')),
|
||||
confirmed_by_user boolean NOT NULL DEFAULT false,
|
||||
confirmed_at timestamptz,
|
||||
expires_at timestamptz NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS outbox (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
approval_id uuid NOT NULL REFERENCES approval(id),
|
||||
kind text NOT NULL DEFAULT 'email',
|
||||
payload jsonb NOT NULL,
|
||||
status text NOT NULL DEFAULT 'pending' CHECK (status IN ('pending','sent','failed','cancelled')),
|
||||
sent_at timestamptz,
|
||||
error text,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS task_run (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
task text NOT NULL,
|
||||
model text NOT NULL,
|
||||
provider text NOT NULL,
|
||||
input_tokens int NOT NULL,
|
||||
output_tokens int NOT NULL,
|
||||
cost_usd numeric,
|
||||
duration_ms int NOT NULL,
|
||||
application_id uuid REFERENCES application(id),
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
29
apps/api/pyproject.toml
Normal file
29
apps/api/pyproject.toml
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
[project]
|
||||
name = "jobhunt-api"
|
||||
version = "0.1.0"
|
||||
description = "Jobhunt platform backend API"
|
||||
requires-python = ">=3.13"
|
||||
dependencies = [
|
||||
"fastapi>=0.115",
|
||||
"uvicorn>=0.30",
|
||||
"psycopg[binary,pool]>=3.2",
|
||||
"pydantic>=2.9",
|
||||
"python-multipart>=0.0.9",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"pytest>=8.3",
|
||||
"httpx>=0.27",
|
||||
]
|
||||
|
||||
[build-system]
|
||||
requires = ["setuptools>=64"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[tool.setuptools]
|
||||
packages = ["app", "app.db"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
pythonpath = ["."]
|
||||
106
apps/api/schema.sql
Normal file
106
apps/api/schema.sql
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
-- schema.sql — full DDL for the jobhunt platform POC
|
||||
-- Plain SQL, no ORM. Applied by the migration runner in db/migrate.py.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS profile (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
full_name text NOT NULL,
|
||||
email text NOT NULL,
|
||||
phone text,
|
||||
location text,
|
||||
headline text,
|
||||
summary text,
|
||||
languages jsonb NOT NULL DEFAULT '[]',
|
||||
hard_rules jsonb NOT NULL DEFAULT '{}',
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS cv_section (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
profile_id uuid NOT NULL REFERENCES profile(id) ON DELETE CASCADE,
|
||||
kind text NOT NULL CHECK (kind IN ('experience','education','skills','projects','other')),
|
||||
title text NOT NULL,
|
||||
org text,
|
||||
location text,
|
||||
start_date date,
|
||||
end_date date,
|
||||
bullets jsonb NOT NULL DEFAULT '[]',
|
||||
tags text[] NOT NULL DEFAULT '{}',
|
||||
sort_order int NOT NULL DEFAULT 0,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS job_posting (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
source text NOT NULL,
|
||||
external_id text,
|
||||
url text NOT NULL,
|
||||
company text NOT NULL,
|
||||
title text NOT NULL,
|
||||
location text,
|
||||
description text NOT NULL DEFAULT '',
|
||||
raw jsonb NOT NULL DEFAULT '{}',
|
||||
fetched_at timestamptz NOT NULL DEFAULT now(),
|
||||
UNIQUE (source, url)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS application (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
job_posting_id uuid NOT NULL REFERENCES job_posting(id) ON DELETE CASCADE,
|
||||
state text NOT NULL DEFAULT 'discovered' CHECK (state IN
|
||||
('discovered','scored','approved','rejected','drafting','sent','interviewing','offer','closed','expired')),
|
||||
score numeric,
|
||||
score_rationale jsonb,
|
||||
notes text,
|
||||
state_changed_at timestamptz NOT NULL DEFAULT now(),
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS artifact (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
application_id uuid NOT NULL REFERENCES application(id) ON DELETE CASCADE,
|
||||
kind text NOT NULL CHECK (kind IN ('cv','cover_letter','email','other')),
|
||||
filename text NOT NULL,
|
||||
content_hash text NOT NULL,
|
||||
storage_path text NOT NULL,
|
||||
version int NOT NULL DEFAULT 1,
|
||||
origin text NOT NULL DEFAULT 'ai_reviewed' CHECK (origin IN ('user_drafted','ai_drafted','ai_reviewed')),
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS approval (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
application_id uuid NOT NULL REFERENCES application(id) ON DELETE CASCADE,
|
||||
artifact_id uuid NOT NULL REFERENCES artifact(id),
|
||||
artifact_hash text NOT NULL,
|
||||
action text NOT NULL CHECK (action IN ('send_email','submit_application')),
|
||||
confirmed_by_user boolean NOT NULL DEFAULT false,
|
||||
confirmed_at timestamptz,
|
||||
expires_at timestamptz NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS outbox (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
approval_id uuid NOT NULL REFERENCES approval(id),
|
||||
kind text NOT NULL DEFAULT 'email',
|
||||
payload jsonb NOT NULL,
|
||||
status text NOT NULL DEFAULT 'pending' CHECK (status IN ('pending','sent','failed','cancelled')),
|
||||
sent_at timestamptz,
|
||||
error text,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS task_run (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
task text NOT NULL,
|
||||
model text NOT NULL,
|
||||
provider text NOT NULL,
|
||||
input_tokens int NOT NULL,
|
||||
output_tokens int NOT NULL,
|
||||
cost_usd numeric,
|
||||
duration_ms int NOT NULL,
|
||||
application_id uuid REFERENCES application(id),
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
1
apps/api/tests/__init__.py
Normal file
1
apps/api/tests/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
"""Test package init."""
|
||||
47
apps/api/tests/conftest.py
Normal file
47
apps/api/tests/conftest.py
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
"""Pytest fixtures: database setup/teardown for all tests.
|
||||
|
||||
Uses a real PostgreSQL on port 5433 (jobhunt-test-pg container).
|
||||
Each test module gets a clean database via truncate fixtures.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
# Set DATABASE_URL before importing app modules
|
||||
os.environ.setdefault(
|
||||
"DATABASE_URL",
|
||||
"postgresql://jobhunt:jobhunt@localhost:5433/jobhunt",
|
||||
)
|
||||
|
||||
from app.db import close_pool, get_pool # noqa: E402
|
||||
from app.db import migrate as migrate_mod # noqa: E402
|
||||
from app.db import repo_app, repo_profile # noqa: E402
|
||||
|
||||
|
||||
@pytest.fixture(scope="session", autouse=True)
|
||||
def _setup_database():
|
||||
"""Run migrations once at session start."""
|
||||
migrate_mod.reset_database()
|
||||
yield
|
||||
close_pool()
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _truncate_tables():
|
||||
"""Truncate all tables before each test (except schema_migrations)."""
|
||||
import psycopg
|
||||
from app.config import DATABASE_URL
|
||||
|
||||
with psycopg.connect(DATABASE_URL) as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
TRUNCATE TABLE task_run, outbox, approval, artifact,
|
||||
application, job_posting, cv_section, profile
|
||||
CASCADE
|
||||
"""
|
||||
)
|
||||
conn.commit()
|
||||
yield
|
||||
265
apps/api/tests/test_api.py
Normal file
265
apps/api/tests/test_api.py
Normal file
|
|
@ -0,0 +1,265 @@
|
|||
"""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"
|
||||
216
apps/api/tests/test_approval.py
Normal file
216
apps/api/tests/test_approval.py
Normal file
|
|
@ -0,0 +1,216 @@
|
|||
"""Approval gate tests: confirm, hash-match, expiry."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import time
|
||||
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
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client():
|
||||
"""FastAPI TestClient with real database."""
|
||||
from app.main import app
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def seed_data():
|
||||
"""Seed a profile, posting, application, and artifact for approval tests."""
|
||||
profile = repo_profile.get_or_create_profile()
|
||||
assert profile is not None
|
||||
|
||||
posting = repo_app.create_job_posting(
|
||||
source="manual_url",
|
||||
url="https://example.com/job/123",
|
||||
company="TestCorp",
|
||||
title="Engineer",
|
||||
)
|
||||
application = repo_app.create_application(posting["id"])
|
||||
|
||||
content = b"Hello, I am applying for the position."
|
||||
artifact = repo_app.create_artifact(
|
||||
application_id=application["id"],
|
||||
kind="cover_letter",
|
||||
filename="cover.txt",
|
||||
content_bytes=content,
|
||||
storage_path="/tmp/cover.txt",
|
||||
origin="user_drafted",
|
||||
)
|
||||
|
||||
return {
|
||||
"profile": profile,
|
||||
"posting": posting,
|
||||
"application": application,
|
||||
"artifact": artifact,
|
||||
"content": content,
|
||||
}
|
||||
|
||||
|
||||
class TestApprovalConfirm:
|
||||
def test_confirm_approval_success(self, client, seed_data):
|
||||
"""User confirms approval with matching hash -> success."""
|
||||
app_id = seed_data["application"]["id"]
|
||||
artifact_id = seed_data["artifact"]["id"]
|
||||
|
||||
# Create approval
|
||||
resp = client.post(
|
||||
f"/api/applications/{app_id}/approvals",
|
||||
json={"action": "send_email", "artifact_id": artifact_id},
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
approval = resp.json()
|
||||
|
||||
# Confirm
|
||||
resp = client.post(f"/api/approvals/{approval['id']}/confirm")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["confirmed_by_user"] is True
|
||||
|
||||
def test_confirm_approval_hash_mismatch(self, client, seed_data):
|
||||
"""Confirm with a different artifact hash -> 409."""
|
||||
app_id = seed_data["application"]["id"]
|
||||
artifact_id = seed_data["artifact"]["id"]
|
||||
|
||||
# Create approval (stores correct hash)
|
||||
resp = client.post(
|
||||
f"/api/applications/{app_id}/approvals",
|
||||
json={"action": "send_email", "artifact_id": artifact_id},
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
approval_id = resp.json()["id"]
|
||||
|
||||
# Now modify the artifact content so hash changes
|
||||
# We create a new artifact with different content but same id is not possible.
|
||||
# Instead, directly update the artifact hash in the DB to simulate mutation.
|
||||
new_content = b"Modified content"
|
||||
new_hash = hashlib.sha256(new_content).hexdigest()
|
||||
with psycopg.connect(DATABASE_URL) as conn:
|
||||
conn.execute(
|
||||
"UPDATE artifact SET content_hash = %s WHERE id = %s",
|
||||
(new_hash, artifact_id),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
# Confirm should fail with hash mismatch
|
||||
resp = client.post(f"/api/approvals/{approval_id}/confirm")
|
||||
assert resp.status_code == 409
|
||||
assert "hash_mismatch" in resp.text or "hash" in resp.text.lower()
|
||||
|
||||
|
||||
class TestApprovalExpiry:
|
||||
def test_confirm_expired_approval(self, client, seed_data):
|
||||
"""Confirm an expired approval -> 409."""
|
||||
app_id = seed_data["application"]["id"]
|
||||
artifact_id = seed_data["artifact"]["id"]
|
||||
|
||||
# Create approval
|
||||
resp = client.post(
|
||||
f"/api/applications/{app_id}/approvals",
|
||||
json={"action": "send_email", "artifact_id": artifact_id},
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
approval_id = resp.json()["id"]
|
||||
|
||||
# Set expires_at to the past
|
||||
past_time = (datetime.now(timezone.utc) - timedelta(hours=1)).isoformat()
|
||||
with psycopg.connect(DATABASE_URL) as conn:
|
||||
conn.execute(
|
||||
"UPDATE approval SET expires_at = %s WHERE id = %s",
|
||||
(past_time, approval_id),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
# Confirm should fail with expired
|
||||
resp = client.post(f"/api/approvals/{approval_id}/confirm")
|
||||
assert resp.status_code == 409
|
||||
assert "expired" in resp.text.lower()
|
||||
|
||||
|
||||
class TestOutboxSend:
|
||||
def test_send_without_confirmation(self, client, seed_data):
|
||||
"""Send without user confirmation -> 409."""
|
||||
app_id = seed_data["application"]["id"]
|
||||
artifact_id = seed_data["artifact"]["id"]
|
||||
|
||||
# Create approval
|
||||
resp = client.post(
|
||||
f"/api/applications/{app_id}/approvals",
|
||||
json={"action": "send_email", "artifact_id": artifact_id},
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
approval_id = resp.json()["id"]
|
||||
|
||||
# Try to send without confirming
|
||||
resp = client.post(
|
||||
"/api/outbox/send",
|
||||
json={"approval_id": approval_id, "payload": {"to": "test@example.com"}},
|
||||
)
|
||||
assert resp.status_code == 409
|
||||
assert "not_confirmed" in resp.text
|
||||
|
||||
def test_send_with_confirmation_success(self, client, seed_data):
|
||||
"""Full flow: create approval, confirm, send -> success."""
|
||||
app_id = seed_data["application"]["id"]
|
||||
artifact_id = seed_data["artifact"]["id"]
|
||||
|
||||
# Create approval
|
||||
resp = client.post(
|
||||
f"/api/applications/{app_id}/approvals",
|
||||
json={"action": "send_email", "artifact_id": artifact_id},
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
approval_id = resp.json()["id"]
|
||||
|
||||
# Confirm
|
||||
resp = client.post(f"/api/approvals/{approval_id}/confirm")
|
||||
assert resp.status_code == 200
|
||||
|
||||
# Send
|
||||
resp = client.post(
|
||||
"/api/outbox/send",
|
||||
json={
|
||||
"approval_id": approval_id,
|
||||
"payload": {"to": "test@example.com", "subject": "App", "body": "Hi"},
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["status"] == "sent"
|
||||
|
||||
def test_send_expired_confirmation(self, client, seed_data):
|
||||
"""Send with an expired confirmed approval -> 409."""
|
||||
app_id = seed_data["application"]["id"]
|
||||
artifact_id = seed_data["artifact"]["id"]
|
||||
|
||||
# Create + confirm approval
|
||||
resp = client.post(
|
||||
f"/api/applications/{app_id}/approvals",
|
||||
json={"action": "send_email", "artifact_id": artifact_id},
|
||||
)
|
||||
approval_id = resp.json()["id"]
|
||||
|
||||
resp = client.post(f"/api/approvals/{approval_id}/confirm")
|
||||
assert resp.status_code == 200
|
||||
|
||||
# Expire it
|
||||
past_time = (datetime.now(timezone.utc) - timedelta(hours=1)).isoformat()
|
||||
with psycopg.connect(DATABASE_URL) as conn:
|
||||
conn.execute(
|
||||
"UPDATE approval SET expires_at = %s WHERE id = %s",
|
||||
(past_time, approval_id),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
# Send should fail
|
||||
resp = client.post(
|
||||
"/api/outbox/send",
|
||||
json={"approval_id": approval_id, "payload": {"to": "test@example.com"}},
|
||||
)
|
||||
assert resp.status_code == 409
|
||||
assert "expired" in resp.text.lower()
|
||||
161
apps/api/tests/test_statemachine.py
Normal file
161
apps/api/tests/test_statemachine.py
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
"""State machine transition tests (happy path + 409 invalid transitions)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from app.statemachine import (
|
||||
InvalidTransition,
|
||||
STATES,
|
||||
TransitionContext,
|
||||
check_transition,
|
||||
)
|
||||
|
||||
|
||||
class TestStateMachineTransitions:
|
||||
"""Test all valid transitions (happy path)."""
|
||||
|
||||
def test_discovered_to_scored(self):
|
||||
ctx = TransitionContext(
|
||||
application_id="test",
|
||||
from_state="discovered",
|
||||
to_state="scored",
|
||||
has_score=True,
|
||||
)
|
||||
check_transition(ctx) # should not raise
|
||||
|
||||
def test_discovered_to_rejected(self):
|
||||
ctx = TransitionContext("test", "discovered", "rejected")
|
||||
check_transition(ctx)
|
||||
|
||||
def test_scored_to_approved(self):
|
||||
ctx = TransitionContext("test", "scored", "approved")
|
||||
check_transition(ctx)
|
||||
|
||||
def test_scored_to_rejected(self):
|
||||
ctx = TransitionContext("test", "scored", "rejected")
|
||||
check_transition(ctx)
|
||||
|
||||
def test_approved_to_drafting(self):
|
||||
ctx = TransitionContext("test", "approved", "drafting")
|
||||
check_transition(ctx)
|
||||
|
||||
def test_drafting_to_sent(self):
|
||||
ctx = TransitionContext(
|
||||
"test",
|
||||
"drafting",
|
||||
"sent",
|
||||
has_confirmed_approval=True,
|
||||
artifact_hash_match=True,
|
||||
)
|
||||
check_transition(ctx)
|
||||
|
||||
def test_sent_to_interviewing(self):
|
||||
ctx = TransitionContext("test", "sent", "interviewing")
|
||||
check_transition(ctx)
|
||||
|
||||
def test_interviewing_to_offer(self):
|
||||
ctx = TransitionContext("test", "interviewing", "offer")
|
||||
check_transition(ctx)
|
||||
|
||||
def test_interviewing_to_closed(self):
|
||||
ctx = TransitionContext("test", "interviewing", "closed")
|
||||
check_transition(ctx)
|
||||
|
||||
def test_offer_to_closed(self):
|
||||
ctx = TransitionContext("test", "offer", "closed")
|
||||
check_transition(ctx)
|
||||
|
||||
def test_scored_to_expired(self):
|
||||
ctx = TransitionContext("test", "scored", "expired")
|
||||
check_transition(ctx)
|
||||
|
||||
def test_approved_to_expired(self):
|
||||
ctx = TransitionContext("test", "approved", "expired")
|
||||
check_transition(ctx)
|
||||
|
||||
|
||||
class TestStateMachineInvalidTransitions:
|
||||
"""Test invalid transitions raise InvalidTransition."""
|
||||
|
||||
def test_discovered_to_approved(self):
|
||||
ctx = TransitionContext("test", "discovered", "approved")
|
||||
with pytest.raises(InvalidTransition):
|
||||
check_transition(ctx)
|
||||
|
||||
def test_discovered_to_sent(self):
|
||||
ctx = TransitionContext("test", "discovered", "sent")
|
||||
with pytest.raises(InvalidTransition):
|
||||
check_transition(ctx)
|
||||
|
||||
def test_rejected_to_scored(self):
|
||||
ctx = TransitionContext("test", "rejected", "scored")
|
||||
with pytest.raises(InvalidTransition):
|
||||
check_transition(ctx)
|
||||
|
||||
def test_closed_to_sent(self):
|
||||
ctx = TransitionContext("test", "closed", "sent")
|
||||
with pytest.raises(InvalidTransition):
|
||||
check_transition(ctx)
|
||||
|
||||
def test_unknown_from_state(self):
|
||||
ctx = TransitionContext("test", "nonexistent", "scored")
|
||||
with pytest.raises(InvalidTransition):
|
||||
check_transition(ctx)
|
||||
|
||||
def test_unknown_to_state(self):
|
||||
ctx = TransitionContext("test", "discovered", "nonexistent")
|
||||
with pytest.raises(InvalidTransition):
|
||||
check_transition(ctx)
|
||||
|
||||
def test_drafting_to_sent_no_approval(self):
|
||||
"""Guard: drafting -> sent requires confirmed approval."""
|
||||
ctx = TransitionContext(
|
||||
"test",
|
||||
"drafting",
|
||||
"sent",
|
||||
has_confirmed_approval=False,
|
||||
artifact_hash_match=False,
|
||||
)
|
||||
with pytest.raises(InvalidTransition) as exc_info:
|
||||
check_transition(ctx)
|
||||
assert "guard failed" in str(exc_info.value)
|
||||
|
||||
def test_drafting_to_sent_hash_mismatch(self):
|
||||
"""Guard: drafting -> sent requires hash match."""
|
||||
ctx = TransitionContext(
|
||||
"test",
|
||||
"drafting",
|
||||
"sent",
|
||||
has_confirmed_approval=True,
|
||||
artifact_hash_match=False,
|
||||
)
|
||||
with pytest.raises(InvalidTransition):
|
||||
check_transition(ctx)
|
||||
|
||||
def test_discovered_to_scored_no_score(self):
|
||||
"""Guard: discovered -> scored requires scoring completed (has_score)."""
|
||||
ctx = TransitionContext(
|
||||
"test",
|
||||
"discovered",
|
||||
"scored",
|
||||
has_score=False,
|
||||
)
|
||||
with pytest.raises(InvalidTransition) as exc_info:
|
||||
check_transition(ctx)
|
||||
assert "guard failed" in str(exc_info.value)
|
||||
|
||||
|
||||
class TestStateMachineHelpers:
|
||||
def test_all_states_present(self):
|
||||
assert len(STATES) == 10
|
||||
|
||||
def test_can_transition_valid(self):
|
||||
from app.statemachine import can_transition
|
||||
assert can_transition("discovered", "scored")
|
||||
assert can_transition("discovered", "rejected")
|
||||
|
||||
def test_can_transition_invalid(self):
|
||||
from app.statemachine import can_transition
|
||||
assert not can_transition("discovered", "sent")
|
||||
assert not can_transition("closed", "discovered")
|
||||
|
|
@ -18,6 +18,17 @@ services:
|
|||
timeout: 3s
|
||||
retries: 10
|
||||
|
||||
api-test:
|
||||
build:
|
||||
context: ./apps/api
|
||||
dockerfile: Dockerfile.test
|
||||
environment:
|
||||
DATABASE_URL: postgresql://jobhunt:jobhunt@postgres:5432/jobhunt
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
restart: "no"
|
||||
|
||||
volumes:
|
||||
jobhunt_pgdata:
|
||||
name: jobhunt_pgdata
|
||||
Loading…
Reference in a new issue