diff --git a/apps/api/Dockerfile.test b/apps/api/Dockerfile.test new file mode 100644 index 0000000..09fbb96 --- /dev/null +++ b/apps/api/Dockerfile.test @@ -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"] diff --git a/apps/api/app/__init__.py b/apps/api/app/__init__.py new file mode 100644 index 0000000..aea4409 --- /dev/null +++ b/apps/api/app/__init__.py @@ -0,0 +1 @@ +"""jobhunt API package.""" \ No newline at end of file diff --git a/apps/api/app/config.py b/apps/api/app/config.py new file mode 100644 index 0000000..e1a5115 --- /dev/null +++ b/apps/api/app/config.py @@ -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" \ No newline at end of file diff --git a/apps/api/app/db/__init__.py b/apps/api/app/db/__init__.py new file mode 100644 index 0000000..119f162 --- /dev/null +++ b/apps/api/app/db/__init__.py @@ -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) \ No newline at end of file diff --git a/apps/api/app/db/migrate.py b/apps/api/app/db/migrate.py new file mode 100644 index 0000000..2e06910 --- /dev/null +++ b/apps/api/app/db/migrate.py @@ -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) \ No newline at end of file diff --git a/apps/api/app/db/repo_app.py b/apps/api/app/db/repo_app.py new file mode 100644 index 0000000..420e7ab --- /dev/null +++ b/apps/api/app/db/repo_app.py @@ -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, + } \ No newline at end of file diff --git a/apps/api/app/db/repo_profile.py b/apps/api/app/db/repo_profile.py new file mode 100644 index 0000000..6ed5d4b --- /dev/null +++ b/apps/api/app/db/repo_profile.py @@ -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), + } \ No newline at end of file diff --git a/apps/api/app/llm.py b/apps/api/app/llm.py new file mode 100644 index 0000000..cd1b91e --- /dev/null +++ b/apps/api/app/llm.py @@ -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 \ No newline at end of file diff --git a/apps/api/app/main.py b/apps/api/app/main.py new file mode 100644 index 0000000..e60a0f3 --- /dev/null +++ b/apps/api/app/main.py @@ -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() \ No newline at end of file diff --git a/apps/api/app/schemas.py b/apps/api/app/schemas.py new file mode 100644 index 0000000..f455291 --- /dev/null +++ b/apps/api/app/schemas.py @@ -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] \ No newline at end of file diff --git a/apps/api/app/statemachine.py b/apps/api/app/statemachine.py new file mode 100644 index 0000000..6a5a0d4 --- /dev/null +++ b/apps/api/app/statemachine.py @@ -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}", + ) \ No newline at end of file diff --git a/apps/api/app/transport.py b/apps/api/app/transport.py new file mode 100644 index 0000000..6fe312b --- /dev/null +++ b/apps/api/app/transport.py @@ -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 \ No newline at end of file diff --git a/apps/api/migrations/001_initial.sql b/apps/api/migrations/001_initial.sql new file mode 100644 index 0000000..919faa9 --- /dev/null +++ b/apps/api/migrations/001_initial.sql @@ -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() +); \ No newline at end of file diff --git a/apps/api/pyproject.toml b/apps/api/pyproject.toml new file mode 100644 index 0000000..1ee74fb --- /dev/null +++ b/apps/api/pyproject.toml @@ -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 = ["."] \ No newline at end of file diff --git a/apps/api/schema.sql b/apps/api/schema.sql new file mode 100644 index 0000000..adcbc9e --- /dev/null +++ b/apps/api/schema.sql @@ -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() +); \ No newline at end of file diff --git a/apps/api/tests/__init__.py b/apps/api/tests/__init__.py new file mode 100644 index 0000000..7f30ad4 --- /dev/null +++ b/apps/api/tests/__init__.py @@ -0,0 +1 @@ +"""Test package init.""" \ No newline at end of file diff --git a/apps/api/tests/conftest.py b/apps/api/tests/conftest.py new file mode 100644 index 0000000..54262c9 --- /dev/null +++ b/apps/api/tests/conftest.py @@ -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 \ No newline at end of file diff --git a/apps/api/tests/test_api.py b/apps/api/tests/test_api.py new file mode 100644 index 0000000..c7efae3 --- /dev/null +++ b/apps/api/tests/test_api.py @@ -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" \ No newline at end of file diff --git a/apps/api/tests/test_approval.py b/apps/api/tests/test_approval.py new file mode 100644 index 0000000..d54e798 --- /dev/null +++ b/apps/api/tests/test_approval.py @@ -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() \ No newline at end of file diff --git a/apps/api/tests/test_statemachine.py b/apps/api/tests/test_statemachine.py new file mode 100644 index 0000000..c98edb5 --- /dev/null +++ b/apps/api/tests/test_statemachine.py @@ -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") \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 90a5574..c8f3fac 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 \ No newline at end of file