diff --git a/app/wizard/tiers.py b/app/wizard/tiers.py index 2b04ab9..fa86d9a 100644 --- a/app/wizard/tiers.py +++ b/app/wizard/tiers.py @@ -49,6 +49,7 @@ FEATURES: dict[str, str] = { "company_research": "paid", "interview_prep": "paid", "survey_assistant": "paid", + "llm_reply_draft": "paid", # Orchestration / infrastructure — stays gated "email_classifier": "paid", @@ -81,6 +82,7 @@ BYOK_UNLOCKABLE: frozenset[str] = frozenset({ "company_research", "interview_prep", "survey_assistant", + "llm_reply_draft", }) # Demo mode flag — read from environment at module load time. diff --git a/dev-api.py b/dev-api.py index a639b33..c1deaa8 100644 --- a/dev-api.py +++ b/dev-api.py @@ -26,7 +26,7 @@ import yaml from bs4 import BeautifulSoup from contextlib import asynccontextmanager -from fastapi import FastAPI, HTTPException, Request, Response, UploadFile +from fastapi import FastAPI, HTTPException, Query, Request, Response, UploadFile from fastapi.middleware.cors import CORSMiddleware from pydantic import BaseModel @@ -4178,3 +4178,183 @@ def wizard_complete(): return {"ok": True} except Exception as e: raise HTTPException(status_code=500, detail=str(e)) + + +# ── Messaging models ────────────────────────────────────────────────────────── + +class MessageCreateBody(BaseModel): + job_id: Optional[int] = None + job_contact_id: Optional[int] = None + type: str = "email" + direction: Optional[str] = None + subject: Optional[str] = None + body: Optional[str] = None + from_addr: Optional[str] = None + to_addr: Optional[str] = None + template_id: Optional[int] = None + logged_at: Optional[str] = None + + +class MessageUpdateBody(BaseModel): + body: str + + +class TemplateCreateBody(BaseModel): + title: str + category: str = "custom" + subject_template: Optional[str] = None + body_template: str + + +class TemplateUpdateBody(BaseModel): + title: Optional[str] = None + category: Optional[str] = None + subject_template: Optional[str] = None + body_template: Optional[str] = None + + +# ── Messaging (MIT) ─────────────────────────────────────────────────────────── + +@app.get("/api/messages") +def get_messages( + job_id: Optional[int] = None, + type: Optional[str] = None, + direction: Optional[str] = None, + limit: int = Query(default=100, ge=1, le=1000), +): + from scripts.messaging import list_messages + return list_messages( + Path(_request_db.get() or DB_PATH), + job_id=job_id, type=type, direction=direction, limit=limit, + ) + + +@app.post("/api/messages") +def post_message(body: MessageCreateBody): + from scripts.messaging import create_message + return create_message(Path(_request_db.get() or DB_PATH), **body.model_dump()) + + +@app.delete("/api/messages/{message_id}") +def del_message(message_id: int): + from scripts.messaging import delete_message + try: + delete_message(Path(_request_db.get() or DB_PATH), message_id) + return {"ok": True} + except KeyError: + raise HTTPException(404, "message not found") + + +@app.put("/api/messages/{message_id}") +def put_message(message_id: int, body: MessageUpdateBody): + from scripts.messaging import update_message_body + try: + return update_message_body(Path(_request_db.get() or DB_PATH), message_id, body.body) + except KeyError: + raise HTTPException(404, "message not found") + + +@app.get("/api/message-templates") +def get_templates(): + from scripts.messaging import list_templates + return list_templates(Path(_request_db.get() or DB_PATH)) + + +@app.post("/api/message-templates") +def post_template(body: TemplateCreateBody): + from scripts.messaging import create_template + return create_template(Path(_request_db.get() or DB_PATH), **body.model_dump()) + + +@app.put("/api/message-templates/{template_id}") +def put_template(template_id: int, body: TemplateUpdateBody): + from scripts.messaging import update_template + try: + return update_template( + Path(_request_db.get() or DB_PATH), + template_id, + **body.model_dump(exclude_none=True), + ) + except PermissionError: + raise HTTPException(403, "cannot modify built-in templates") + except KeyError: + raise HTTPException(404, "template not found") + + +@app.delete("/api/message-templates/{template_id}") +def del_template(template_id: int): + from scripts.messaging import delete_template + try: + delete_template(Path(_request_db.get() or DB_PATH), template_id) + return {"ok": True} + except PermissionError: + raise HTTPException(403, "cannot delete built-in templates") + except KeyError: + raise HTTPException(404, "template not found") + + +# ── LLM Reply Draft (BSL 1.1) ───────────────────────────────────────────────── + +def _get_effective_tier() -> str: + """Resolve effective tier: Heimdall in cloud mode, APP_TIER env var in single-tenant.""" + if _CLOUD_MODE: + return _resolve_cloud_tier() + from app.wizard.tiers import effective_tier + return effective_tier() + + +@app.post("/api/contacts/{contact_id}/draft-reply") +def draft_reply(contact_id: int): + """Generate an LLM draft reply for an inbound job_contacts row. Tier-gated.""" + from app.wizard.tiers import can_use, has_configured_llm + from scripts.messaging import create_message + from scripts.llm_reply_draft import generate_draft_reply + + db_path = Path(_request_db.get() or DB_PATH) + tier = _get_effective_tier() + if not can_use(tier, "llm_reply_draft", has_byok=has_configured_llm()): + raise HTTPException(402, detail={"error": "tier_required", "min_tier": "free+byok"}) + + con = _get_db() + row = con.execute("SELECT * FROM job_contacts WHERE id=?", (contact_id,)).fetchone() + con.close() + if not row: + raise HTTPException(404, "contact not found") + + profile = _imitate_load_profile() + user_name = getattr(profile, "name", "") or "" + target_role = getattr(profile, "target_role", "") or "" + + cfg_path = db_path.parent / "config" / "llm.yaml" + draft_body = generate_draft_reply( + subject=row["subject"] or "", + from_addr=row["from_addr"] or "", + body=row["body"] or "", + user_name=user_name, + target_role=target_role, + config_path=cfg_path if cfg_path.exists() else None, + ) + msg = create_message( + db_path, + job_id=row["job_id"], + job_contact_id=contact_id, + type="draft", + direction="outbound", + subject=f"Re: {row['subject'] or ''}".strip(), + body=draft_body, + to_addr=row["from_addr"], + template_id=None, + from_addr=None, + ) + return {"message_id": msg["id"]} + + +@app.post("/api/messages/{message_id}/approve") +def approve_message_endpoint(message_id: int): + """Set approved_at=now(). Returns approved body for copy-to-clipboard.""" + from scripts.messaging import approve_message + try: + msg = approve_message(Path(_request_db.get() or DB_PATH), message_id) + return {"body": msg["body"], "approved_at": msg["approved_at"]} + except KeyError: + raise HTTPException(404, "message not found") diff --git a/migrations/008_messaging.sql b/migrations/008_messaging.sql new file mode 100644 index 0000000..74a86ea --- /dev/null +++ b/migrations/008_messaging.sql @@ -0,0 +1,97 @@ +-- messages: manual log entries and LLM drafts +CREATE TABLE IF NOT EXISTS messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + job_id INTEGER REFERENCES jobs(id) ON DELETE SET NULL, + job_contact_id INTEGER REFERENCES job_contacts(id) ON DELETE SET NULL, + type TEXT NOT NULL DEFAULT 'email', + direction TEXT, + subject TEXT, + body TEXT, + from_addr TEXT, + to_addr TEXT, + logged_at TEXT NOT NULL DEFAULT (datetime('now')), + approved_at TEXT, + template_id INTEGER REFERENCES message_templates(id) ON DELETE SET NULL, + osprey_call_id TEXT +); + +-- message_templates: built-in seeds and user-created templates +CREATE TABLE IF NOT EXISTS message_templates ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + key TEXT UNIQUE, + title TEXT NOT NULL, + category TEXT NOT NULL DEFAULT 'custom', + subject_template TEXT, + body_template TEXT NOT NULL, + is_builtin INTEGER NOT NULL DEFAULT 0, + is_community INTEGER NOT NULL DEFAULT 0, + community_source TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +INSERT OR IGNORE INTO message_templates + (key, title, category, subject_template, body_template, is_builtin) +VALUES + ( + 'follow_up', + 'Following up on my application', + 'follow_up', + 'Following up — {{role}} application', + 'Hi {{recruiter_name}}, + +I wanted to follow up on my application for the {{role}} position at {{company}}. I remain very interested in the opportunity and would welcome the chance to discuss my background further. + +Please let me know if there is anything else you need from me. + +Best regards, +{{name}}', + 1 + ), + ( + 'thank_you', + 'Thank you for the interview', + 'thank_you', + 'Thank you — {{role}} interview', + 'Hi {{recruiter_name}}, + +Thank you for taking the time to speak with me about the {{role}} role at {{company}}. I enjoyed learning more about the team and the work you are doing. + +I am very excited about this opportunity and look forward to hearing about the next steps. + +Best regards, +{{name}}', + 1 + ), + ( + 'accommodation_request', + 'Accommodation request', + 'accommodation', + 'Accommodation request — {{role}} interview', + 'Hi {{recruiter_name}}, + +I am writing to request a reasonable accommodation for my upcoming interview for the {{role}} position. Specifically, I would appreciate: + +{{accommodation_details}} + +Please let me know if you need any additional information. I am happy to discuss this further. + +Thank you, +{{name}}', + 1 + ), + ( + 'withdrawal', + 'Withdrawing my application', + 'withdrawal', + 'Application withdrawal — {{role}}', + 'Hi {{recruiter_name}}, + +I am writing to let you know that I would like to withdraw my application for the {{role}} position at {{company}}. + +Thank you for your time and consideration. I wish you and the team all the best. + +Best regards, +{{name}}', + 1 + ) diff --git a/scripts/llm_reply_draft.py b/scripts/llm_reply_draft.py new file mode 100644 index 0000000..305635f --- /dev/null +++ b/scripts/llm_reply_draft.py @@ -0,0 +1,42 @@ +# BSL 1.1 — see LICENSE-BSL +"""LLM-assisted reply draft generation for inbound job contacts (BSL 1.1).""" +from __future__ import annotations + +from pathlib import Path +from typing import Optional + +_SYSTEM = ( + "You are drafting a professional email reply on behalf of a job seeker. " + "Be concise and professional. Do not fabricate facts. If you are uncertain " + "about a detail, leave a [TODO: fill in] placeholder. " + "Output the reply body only — no subject line, no salutation preamble." +) + + +def _build_prompt(subject: str, from_addr: str, body: str, user_name: str, target_role: str) -> str: + return ( + f"ORIGINAL EMAIL:\n" + f"Subject: {subject}\n" + f"From: {from_addr}\n" + f"Body:\n{body}\n\n" + f"USER PROFILE CONTEXT:\n" + f"Name: {user_name}\n" + f"Target role: {target_role}\n\n" + "Write a concise, professional reply to this email." + ) + + +def generate_draft_reply( + subject: str, + from_addr: str, + body: str, + user_name: str, + target_role: str, + config_path: Optional[Path] = None, +) -> str: + """Return a draft reply body string.""" + from scripts.llm_router import LLMRouter + + router = LLMRouter(config_path=config_path) + prompt = _build_prompt(subject, from_addr, body, user_name, target_role) + return router.complete(system=_SYSTEM, user=prompt).strip() diff --git a/scripts/messaging.py b/scripts/messaging.py new file mode 100644 index 0000000..4cccefc --- /dev/null +++ b/scripts/messaging.py @@ -0,0 +1,285 @@ +""" +DB helpers for the messaging feature. + +Messages table: manual log entries and LLM drafts (one row per message). +Message templates table: built-in seeds and user-created templates. + +Conventions (match scripts/db.py): +- All functions take db_path: Path as first argument. +- sqlite3.connect(db_path), row_factory = sqlite3.Row +- Return plain dicts (dict(row)) +- Always close connection in finally +""" +import sqlite3 +from datetime import datetime, timezone +from pathlib import Path +from typing import Optional + + +# --------------------------------------------------------------------------- +# Internal helpers +# --------------------------------------------------------------------------- + +def _connect(db_path: Path) -> sqlite3.Connection: + con = sqlite3.connect(db_path) + con.row_factory = sqlite3.Row + return con + + +def _now_utc() -> str: + """Return current UTC time as ISO 8601 string.""" + return datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S") + + +# --------------------------------------------------------------------------- +# Messages +# --------------------------------------------------------------------------- + +def create_message( + db_path: Path, + *, + job_id: Optional[int], + job_contact_id: Optional[int], + type: str, + direction: str, + subject: Optional[str], + body: Optional[str], + from_addr: Optional[str], + to_addr: Optional[str], + template_id: Optional[int], + logged_at: Optional[str] = None, +) -> dict: + """Insert a new message row and return it as a dict.""" + con = _connect(db_path) + try: + cur = con.execute( + """ + INSERT INTO messages + (job_id, job_contact_id, type, direction, subject, body, + from_addr, to_addr, logged_at, template_id) + VALUES + (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + (job_id, job_contact_id, type, direction, subject, body, + from_addr, to_addr, logged_at or _now_utc(), template_id), + ) + con.commit() + row = con.execute( + "SELECT * FROM messages WHERE id = ?", (cur.lastrowid,) + ).fetchone() + return dict(row) + finally: + con.close() + + +def list_messages( + db_path: Path, + *, + job_id: Optional[int] = None, + type: Optional[str] = None, + direction: Optional[str] = None, + limit: int = 100, +) -> list[dict]: + """Return messages, optionally filtered. Ordered by logged_at DESC.""" + conditions: list[str] = [] + params: list = [] + + if job_id is not None: + conditions.append("job_id = ?") + params.append(job_id) + if type is not None: + conditions.append("type = ?") + params.append(type) + if direction is not None: + conditions.append("direction = ?") + params.append(direction) + + where = ("WHERE " + " AND ".join(conditions)) if conditions else "" + params.append(limit) + + con = _connect(db_path) + try: + rows = con.execute( + f"SELECT * FROM messages {where} ORDER BY logged_at DESC LIMIT ?", + params, + ).fetchall() + return [dict(r) for r in rows] + finally: + con.close() + + +def delete_message(db_path: Path, message_id: int) -> None: + """Delete a message by id. Raises KeyError if not found.""" + con = _connect(db_path) + try: + row = con.execute( + "SELECT id FROM messages WHERE id = ?", (message_id,) + ).fetchone() + if row is None: + raise KeyError(f"Message {message_id} not found") + con.execute("DELETE FROM messages WHERE id = ?", (message_id,)) + con.commit() + finally: + con.close() + + +def approve_message(db_path: Path, message_id: int) -> dict: + """Set approved_at to now for the given message. Raises KeyError if not found.""" + con = _connect(db_path) + try: + row = con.execute( + "SELECT id FROM messages WHERE id = ?", (message_id,) + ).fetchone() + if row is None: + raise KeyError(f"Message {message_id} not found") + con.execute( + "UPDATE messages SET approved_at = ? WHERE id = ?", + (_now_utc(), message_id), + ) + con.commit() + updated = con.execute( + "SELECT * FROM messages WHERE id = ?", (message_id,) + ).fetchone() + return dict(updated) + finally: + con.close() + + +# --------------------------------------------------------------------------- +# Templates +# --------------------------------------------------------------------------- + +def list_templates(db_path: Path) -> list[dict]: + """Return all templates ordered by is_builtin DESC, then title ASC.""" + con = _connect(db_path) + try: + rows = con.execute( + "SELECT * FROM message_templates ORDER BY is_builtin DESC, title ASC" + ).fetchall() + return [dict(r) for r in rows] + finally: + con.close() + + +def create_template( + db_path: Path, + *, + title: str, + category: str = "custom", + subject_template: Optional[str] = None, + body_template: str, +) -> dict: + """Insert a new user-defined template and return it as a dict.""" + con = _connect(db_path) + try: + cur = con.execute( + """ + INSERT INTO message_templates + (title, category, subject_template, body_template, is_builtin) + VALUES + (?, ?, ?, ?, 0) + """, + (title, category, subject_template, body_template), + ) + con.commit() + row = con.execute( + "SELECT * FROM message_templates WHERE id = ?", (cur.lastrowid,) + ).fetchone() + return dict(row) + finally: + con.close() + + +def update_template(db_path: Path, template_id: int, **fields) -> dict: + """ + Update allowed fields on a user-defined template. + + Raises PermissionError if the template is a built-in (is_builtin=1). + Raises KeyError if the template is not found. + """ + if not fields: + # Nothing to update — just return current state + con = _connect(db_path) + try: + row = con.execute( + "SELECT * FROM message_templates WHERE id = ?", (template_id,) + ).fetchone() + if row is None: + raise KeyError(f"Template {template_id} not found") + return dict(row) + finally: + con.close() + + _ALLOWED_FIELDS = { + "title", "category", "subject_template", "body_template", + } + invalid = set(fields) - _ALLOWED_FIELDS + if invalid: + raise ValueError(f"Cannot update field(s): {invalid}") + + con = _connect(db_path) + try: + row = con.execute( + "SELECT id, is_builtin FROM message_templates WHERE id = ?", + (template_id,), + ).fetchone() + if row is None: + raise KeyError(f"Template {template_id} not found") + if row["is_builtin"]: + raise PermissionError( + f"Template {template_id} is a built-in and cannot be modified" + ) + + set_clause = ", ".join(f"{col} = ?" for col in fields) + values = list(fields.values()) + [_now_utc(), template_id] + con.execute( + f"UPDATE message_templates SET {set_clause}, updated_at = ? WHERE id = ?", + values, + ) + con.commit() + updated = con.execute( + "SELECT * FROM message_templates WHERE id = ?", (template_id,) + ).fetchone() + return dict(updated) + finally: + con.close() + + +def delete_template(db_path: Path, template_id: int) -> None: + """ + Delete a user-defined template. + + Raises PermissionError if the template is a built-in (is_builtin=1). + Raises KeyError if the template is not found. + """ + con = _connect(db_path) + try: + row = con.execute( + "SELECT id, is_builtin FROM message_templates WHERE id = ?", + (template_id,), + ).fetchone() + if row is None: + raise KeyError(f"Template {template_id} not found") + if row["is_builtin"]: + raise PermissionError( + f"Template {template_id} is a built-in and cannot be deleted" + ) + con.execute("DELETE FROM message_templates WHERE id = ?", (template_id,)) + con.commit() + finally: + con.close() + + +def update_message_body(db_path: Path, message_id: int, body: str) -> dict: + """Update the body text of a draft message before approval. Returns updated row.""" + con = _connect(db_path) + try: + row = con.execute("SELECT id FROM messages WHERE id=?", (message_id,)).fetchone() + if not row: + raise KeyError(f"message {message_id} not found") + con.execute("UPDATE messages SET body=? WHERE id=?", (body, message_id)) + con.commit() + updated = con.execute("SELECT * FROM messages WHERE id=?", (message_id,)).fetchone() + return dict(updated) + finally: + con.close() diff --git a/tests/test_dev_api_interviews.py b/tests/test_dev_api_interviews.py index 1a3aa64..eeb1eb5 100644 --- a/tests/test_dev_api_interviews.py +++ b/tests/test_dev_api_interviews.py @@ -19,7 +19,8 @@ def tmp_db(tmp_path): match_score REAL, keyword_gaps TEXT, status TEXT, interview_date TEXT, rejection_stage TEXT, applied_at TEXT, phone_screen_at TEXT, interviewing_at TEXT, - offer_at TEXT, hired_at TEXT, survey_at TEXT + offer_at TEXT, hired_at TEXT, survey_at TEXT, + hired_feedback TEXT ); CREATE TABLE job_contacts ( id INTEGER PRIMARY KEY, diff --git a/tests/test_dev_api_survey.py b/tests/test_dev_api_survey.py index 4a03336..4c201ae 100644 --- a/tests/test_dev_api_survey.py +++ b/tests/test_dev_api_survey.py @@ -1,18 +1,36 @@ -"""Tests for survey endpoints: vision health, analyze, save response, get history.""" +"""Tests for survey endpoints: vision health, async analyze task queue, save response, history.""" +import json +import sqlite3 import pytest from unittest.mock import patch, MagicMock from fastapi.testclient import TestClient +from scripts.db_migrate import migrate_db + @pytest.fixture -def client(): - import sys - sys.path.insert(0, "/Library/Development/CircuitForge/peregrine/.worktrees/feature-vue-spa") - from dev_api import app - return TestClient(app) +def fresh_db(tmp_path, monkeypatch): + """Isolated DB + dev_api wired to it via _request_db and DB_PATH.""" + db = tmp_path / "test.db" + migrate_db(db) + monkeypatch.setenv("STAGING_DB", str(db)) + import dev_api + monkeypatch.setattr(dev_api, "DB_PATH", str(db)) + monkeypatch.setattr( + dev_api, + "_request_db", + type("CV", (), {"get": lambda self: str(db), "set": lambda *a: None})(), + ) + return db -# ── GET /api/vision/health ─────────────────────────────────────────────────── +@pytest.fixture +def client(fresh_db): + import dev_api + return TestClient(dev_api.app) + + +# ── GET /api/vision/health ──────────────────────────────────────────────────── def test_vision_health_available(client): """Returns available=true when vision service responds 200.""" @@ -32,133 +50,182 @@ def test_vision_health_unavailable(client): assert resp.json() == {"available": False} -# ── POST /api/jobs/{id}/survey/analyze ────────────────────────────────────── +# ── POST /api/jobs/{id}/survey/analyze ─────────────────────────────────────── -def test_analyze_text_quick(client): - """Text mode quick analysis returns output and source=text_paste.""" - mock_router = MagicMock() - mock_router.complete.return_value = "1. B — best option" - mock_router.config.get.return_value = ["claude_code", "vllm"] - with patch("dev_api.LLMRouter", return_value=mock_router): +def test_analyze_queues_task_and_returns_task_id(client): + """POST analyze queues a background task and returns task_id + is_new.""" + with patch("scripts.task_runner.submit_task", return_value=(42, True)) as mock_submit: resp = client.post("/api/jobs/1/survey/analyze", json={ "text": "Q1: Do you prefer teamwork?\nA. Solo B. Together", "mode": "quick", }) assert resp.status_code == 200 data = resp.json() - assert data["source"] == "text_paste" - assert "B" in data["output"] - # System prompt must be passed for text path - call_kwargs = mock_router.complete.call_args[1] - assert "system" in call_kwargs - assert "culture-fit survey" in call_kwargs["system"] + assert data["task_id"] == 42 + assert data["is_new"] is True + # submit_task called with survey_analyze type + call_kwargs = mock_submit.call_args + assert call_kwargs.kwargs["task_type"] == "survey_analyze" + assert call_kwargs.kwargs["job_id"] == 1 + params = json.loads(call_kwargs.kwargs["params"]) + assert params["mode"] == "quick" + assert params["text"] == "Q1: Do you prefer teamwork?\nA. Solo B. Together" -def test_analyze_text_detailed(client): - """Text mode detailed analysis passes correct prompt.""" - mock_router = MagicMock() - mock_router.complete.return_value = "Option A: good for... Option B: better because..." - mock_router.config.get.return_value = [] - with patch("dev_api.LLMRouter", return_value=mock_router): +def test_analyze_silently_attaches_to_existing_task(client): + """is_new=False when task already running for same input.""" + with patch("scripts.task_runner.submit_task", return_value=(7, False)): resp = client.post("/api/jobs/1/survey/analyze", json={ - "text": "Q1: Describe your work style.", - "mode": "detailed", + "text": "Q1: test", "mode": "quick", }) assert resp.status_code == 200 - assert resp.json()["source"] == "text_paste" + assert resp.json()["is_new"] is False -def test_analyze_image(client): - """Image mode routes through vision path with NO system prompt.""" - mock_router = MagicMock() - mock_router.complete.return_value = "1. C — collaborative choice" - mock_router.config.get.return_value = ["vision_service", "claude_code"] - with patch("dev_api.LLMRouter", return_value=mock_router): +def test_analyze_invalid_mode_returns_400(client): + resp = client.post("/api/jobs/1/survey/analyze", json={"text": "Q1: test", "mode": "wrong"}) + assert resp.status_code == 400 + + +def test_analyze_image_mode_passes_image_in_params(client): + """Image payload is forwarded in task params.""" + with patch("scripts.task_runner.submit_task", return_value=(1, True)) as mock_submit: resp = client.post("/api/jobs/1/survey/analyze", json={ "image_b64": "aGVsbG8=", "mode": "quick", }) assert resp.status_code == 200 + params = json.loads(mock_submit.call_args.kwargs["params"]) + assert params["image_b64"] == "aGVsbG8=" + assert params["text"] is None + + +# ── GET /api/jobs/{id}/survey/analyze/task ──────────────────────────────────── + +def test_task_poll_completed_text(client, fresh_db): + """Completed task with text result returns parsed source + output.""" + result_json = json.dumps({"output": "1. B — best option", "source": "text_paste"}) + con = sqlite3.connect(fresh_db) + con.execute( + "INSERT INTO background_tasks (task_type, job_id, status, error) VALUES (?,?,?,?)", + ("survey_analyze", 1, "completed", result_json), + ) + task_id = con.execute("SELECT last_insert_rowid()").fetchone()[0] + con.commit(); con.close() + + resp = client.get(f"/api/jobs/1/survey/analyze/task?task_id={task_id}") + assert resp.status_code == 200 data = resp.json() - assert data["source"] == "screenshot" - # No system prompt on vision path - call_kwargs = mock_router.complete.call_args[1] - assert "system" not in call_kwargs + assert data["status"] == "completed" + assert data["result"]["source"] == "text_paste" + assert "B" in data["result"]["output"] + assert data["message"] is None -def test_analyze_llm_failure(client): - """Returns 500 when LLM raises an exception.""" - mock_router = MagicMock() - mock_router.complete.side_effect = Exception("LLM unavailable") - mock_router.config.get.return_value = [] - with patch("dev_api.LLMRouter", return_value=mock_router): - resp = client.post("/api/jobs/1/survey/analyze", json={ - "text": "Q1: test", - "mode": "quick", - }) - assert resp.status_code == 500 +def test_task_poll_completed_screenshot(client, fresh_db): + """Completed task with image result returns source=screenshot.""" + result_json = json.dumps({"output": "1. C — collaborative", "source": "screenshot"}) + con = sqlite3.connect(fresh_db) + con.execute( + "INSERT INTO background_tasks (task_type, job_id, status, error) VALUES (?,?,?,?)", + ("survey_analyze", 1, "completed", result_json), + ) + task_id = con.execute("SELECT last_insert_rowid()").fetchone()[0] + con.commit(); con.close() + + resp = client.get(f"/api/jobs/1/survey/analyze/task?task_id={task_id}") + assert resp.status_code == 200 + assert resp.json()["result"]["source"] == "screenshot" -# ── POST /api/jobs/{id}/survey/responses ──────────────────────────────────── +def test_task_poll_failed_returns_message(client, fresh_db): + """Failed task returns status=failed with error message.""" + con = sqlite3.connect(fresh_db) + con.execute( + "INSERT INTO background_tasks (task_type, job_id, status, error) VALUES (?,?,?,?)", + ("survey_analyze", 1, "failed", "LLM unavailable"), + ) + task_id = con.execute("SELECT last_insert_rowid()").fetchone()[0] + con.commit(); con.close() + + resp = client.get(f"/api/jobs/1/survey/analyze/task?task_id={task_id}") + assert resp.status_code == 200 + data = resp.json() + assert data["status"] == "failed" + assert data["message"] == "LLM unavailable" + assert data["result"] is None + + +def test_task_poll_running_returns_stage(client, fresh_db): + """Running task returns status=running with current stage.""" + con = sqlite3.connect(fresh_db) + con.execute( + "INSERT INTO background_tasks (task_type, job_id, status, stage) VALUES (?,?,?,?)", + ("survey_analyze", 1, "running", "analyzing survey"), + ) + task_id = con.execute("SELECT last_insert_rowid()").fetchone()[0] + con.commit(); con.close() + + resp = client.get(f"/api/jobs/1/survey/analyze/task?task_id={task_id}") + assert resp.status_code == 200 + data = resp.json() + assert data["status"] == "running" + assert data["stage"] == "analyzing survey" + + +def test_task_poll_none_when_no_task(client): + """Returns status=none when no task exists for the job.""" + resp = client.get("/api/jobs/999/survey/analyze/task") + assert resp.status_code == 200 + assert resp.json()["status"] == "none" + + +# ── POST /api/jobs/{id}/survey/responses ───────────────────────────────────── def test_save_response_text(client): - """Save text response writes to DB and returns id.""" - mock_db = MagicMock() - with patch("dev_api._get_db", return_value=mock_db): - with patch("dev_api.insert_survey_response", return_value=42) as mock_insert: - resp = client.post("/api/jobs/1/survey/responses", json={ - "mode": "quick", - "source": "text_paste", - "raw_input": "Q1: test question", - "llm_output": "1. B — good reason", - }) + """Save a text-mode survey response returns an id.""" + resp = client.post("/api/jobs/1/survey/responses", json={ + "survey_name": "Culture Fit", + "mode": "quick", + "source": "text_paste", + "raw_input": "Q1: Teamwork?", + "llm_output": "1. B is best", + "reported_score": "85", + }) assert resp.status_code == 200 - assert resp.json()["id"] == 42 - # received_at generated by backend — not None - call_args = mock_insert.call_args - assert call_args[1]["received_at"] is not None or call_args[0][3] is not None + assert "id" in resp.json() -def test_save_response_with_image(client, tmp_path, monkeypatch): - """Save image response writes PNG file and stores path in DB.""" - monkeypatch.setenv("STAGING_DB", str(tmp_path / "test.db")) - with patch("dev_api.insert_survey_response", return_value=7) as mock_insert: - with patch("dev_api.Path") as mock_path_cls: - mock_path_cls.return_value.__truediv__ = lambda s, o: tmp_path / o - resp = client.post("/api/jobs/1/survey/responses", json={ - "mode": "quick", - "source": "screenshot", - "image_b64": "aGVsbG8=", # valid base64 - "llm_output": "1. B — reason", - }) +def test_save_response_with_image(client): + """Save a screenshot-mode survey response returns an id.""" + resp = client.post("/api/jobs/1/survey/responses", json={ + "survey_name": None, + "mode": "quick", + "source": "screenshot", + "image_b64": "aGVsbG8=", + "llm_output": "1. C collaborative", + "reported_score": None, + }) assert resp.status_code == 200 - assert resp.json()["id"] == 7 + assert "id" in resp.json() -# ── GET /api/jobs/{id}/survey/responses ───────────────────────────────────── - def test_get_history_empty(client): - """Returns empty list when no history exists.""" - with patch("dev_api.get_survey_responses", return_value=[]): - resp = client.get("/api/jobs/1/survey/responses") + """History is empty for a fresh job.""" + resp = client.get("/api/jobs/1/survey/responses") assert resp.status_code == 200 assert resp.json() == [] def test_get_history_populated(client): - """Returns history rows newest first.""" - rows = [ - {"id": 2, "survey_name": "Round 2", "mode": "detailed", "source": "text_paste", - "raw_input": None, "image_path": None, "llm_output": "Option A is best", - "reported_score": "90%", "received_at": "2026-03-21T14:00:00", "created_at": "2026-03-21T14:00:01"}, - {"id": 1, "survey_name": "Round 1", "mode": "quick", "source": "text_paste", - "raw_input": "Q1: test", "image_path": None, "llm_output": "1. B", - "reported_score": None, "received_at": "2026-03-21T12:00:00", "created_at": "2026-03-21T12:00:01"}, - ] - with patch("dev_api.get_survey_responses", return_value=rows): - resp = client.get("/api/jobs/1/survey/responses") + """History returns all saved responses for a job in reverse order.""" + for i in range(2): + client.post("/api/jobs/1/survey/responses", json={ + "survey_name": f"Survey {i}", + "mode": "quick", + "source": "text_paste", + "llm_output": f"Output {i}", + }) + resp = client.get("/api/jobs/1/survey/responses") assert resp.status_code == 200 - data = resp.json() - assert len(data) == 2 - assert data[0]["id"] == 2 - assert data[0]["survey_name"] == "Round 2" + assert len(resp.json()) == 2 diff --git a/tests/test_messaging.py b/tests/test_messaging.py new file mode 100644 index 0000000..9ae3207 --- /dev/null +++ b/tests/test_messaging.py @@ -0,0 +1,399 @@ +""" +Unit tests for scripts/messaging.py — DB helpers for messages and message_templates. + +TDD approach: tests written before implementation. +""" +import sqlite3 +from pathlib import Path + +import pytest + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + +def _apply_migration_008(db_path: Path) -> None: + """Apply migration 008 directly so tests run without the full migrate_db stack.""" + migration = ( + Path(__file__).parent.parent / "migrations" / "008_messaging.sql" + ) + sql = migration.read_text(encoding="utf-8") + con = sqlite3.connect(db_path) + try: + # Create jobs table stub so FK references don't break + con.execute(""" + CREATE TABLE IF NOT EXISTS jobs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + title TEXT + ) + """) + con.execute(""" + CREATE TABLE IF NOT EXISTS job_contacts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + job_id INTEGER + ) + """) + # Execute migration statements + statements = [s.strip() for s in sql.split(";") if s.strip()] + for stmt in statements: + stripped = "\n".join( + ln for ln in stmt.splitlines() if not ln.strip().startswith("--") + ).strip() + if stripped: + con.execute(stripped) + con.commit() + finally: + con.close() + + +@pytest.fixture() +def db_path(tmp_path: Path) -> Path: + """Temporary SQLite DB with migration 008 applied.""" + path = tmp_path / "test.db" + _apply_migration_008(path) + return path + + +@pytest.fixture() +def job_id(db_path: Path) -> int: + """Insert a dummy job and return its id.""" + con = sqlite3.connect(db_path) + try: + cur = con.execute("INSERT INTO jobs (title) VALUES ('Test Job')") + con.commit() + return cur.lastrowid + finally: + con.close() + + +# --------------------------------------------------------------------------- +# Message tests +# --------------------------------------------------------------------------- + +class TestCreateMessage: + def test_create_returns_dict(self, db_path: Path, job_id: int) -> None: + from scripts.messaging import create_message + + msg = create_message( + db_path, + job_id=job_id, + job_contact_id=None, + type="email", + direction="outbound", + subject="Hello", + body="Body text", + from_addr="me@example.com", + to_addr="them@example.com", + template_id=None, + ) + + assert isinstance(msg, dict) + assert msg["subject"] == "Hello" + assert msg["body"] == "Body text" + assert msg["direction"] == "outbound" + assert msg["type"] == "email" + assert "id" in msg + assert msg["id"] > 0 + + def test_create_persists_to_db(self, db_path: Path, job_id: int) -> None: + from scripts.messaging import create_message + + create_message( + db_path, + job_id=job_id, + job_contact_id=None, + type="email", + direction="outbound", + subject="Persisted", + body="Stored body", + from_addr="a@b.com", + to_addr="c@d.com", + template_id=None, + ) + + con = sqlite3.connect(db_path) + try: + row = con.execute( + "SELECT subject FROM messages WHERE subject='Persisted'" + ).fetchone() + assert row is not None + finally: + con.close() + + +class TestListMessages: + def _make_message( + self, + db_path: Path, + job_id: int, + *, + type: str = "email", + direction: str = "outbound", + subject: str = "Subject", + ) -> dict: + from scripts.messaging import create_message + return create_message( + db_path, + job_id=job_id, + job_contact_id=None, + type=type, + direction=direction, + subject=subject, + body="body", + from_addr="a@b.com", + to_addr="c@d.com", + template_id=None, + ) + + def test_list_returns_all_messages(self, db_path: Path, job_id: int) -> None: + from scripts.messaging import list_messages + + self._make_message(db_path, job_id, subject="First") + self._make_message(db_path, job_id, subject="Second") + + result = list_messages(db_path) + assert len(result) == 2 + + def test_list_filtered_by_job_id(self, db_path: Path, job_id: int) -> None: + from scripts.messaging import list_messages + + # Create a second job + con = sqlite3.connect(db_path) + try: + cur = con.execute("INSERT INTO jobs (title) VALUES ('Other Job')") + con.commit() + other_job_id = cur.lastrowid + finally: + con.close() + + self._make_message(db_path, job_id, subject="For job 1") + self._make_message(db_path, other_job_id, subject="For job 2") + + result = list_messages(db_path, job_id=job_id) + assert len(result) == 1 + assert result[0]["subject"] == "For job 1" + + def test_list_filtered_by_type(self, db_path: Path, job_id: int) -> None: + from scripts.messaging import list_messages + + self._make_message(db_path, job_id, type="email", subject="Email msg") + self._make_message(db_path, job_id, type="sms", subject="SMS msg") + + emails = list_messages(db_path, type="email") + assert len(emails) == 1 + assert emails[0]["type"] == "email" + + def test_list_filtered_by_direction(self, db_path: Path, job_id: int) -> None: + from scripts.messaging import list_messages + + self._make_message(db_path, job_id, direction="outbound") + self._make_message(db_path, job_id, direction="inbound") + + outbound = list_messages(db_path, direction="outbound") + assert len(outbound) == 1 + assert outbound[0]["direction"] == "outbound" + + def test_list_respects_limit(self, db_path: Path, job_id: int) -> None: + from scripts.messaging import list_messages + + for i in range(5): + self._make_message(db_path, job_id, subject=f"Msg {i}") + + result = list_messages(db_path, limit=3) + assert len(result) == 3 + + +class TestDeleteMessage: + def test_delete_removes_message(self, db_path: Path, job_id: int) -> None: + from scripts.messaging import create_message, delete_message, list_messages + + msg = create_message( + db_path, + job_id=job_id, + job_contact_id=None, + type="email", + direction="outbound", + subject="To delete", + body="bye", + from_addr="a@b.com", + to_addr="c@d.com", + template_id=None, + ) + + delete_message(db_path, msg["id"]) + assert list_messages(db_path) == [] + + def test_delete_raises_key_error_when_not_found(self, db_path: Path) -> None: + from scripts.messaging import delete_message + + with pytest.raises(KeyError): + delete_message(db_path, 99999) + + +class TestApproveMessage: + def test_approve_sets_approved_at(self, db_path: Path, job_id: int) -> None: + from scripts.messaging import approve_message, create_message + + msg = create_message( + db_path, + job_id=job_id, + job_contact_id=None, + type="email", + direction="outbound", + subject="Draft", + body="Draft body", + from_addr="a@b.com", + to_addr="c@d.com", + template_id=None, + ) + assert msg.get("approved_at") is None + + updated = approve_message(db_path, msg["id"]) + assert updated["approved_at"] is not None + assert updated["id"] == msg["id"] + + def test_approve_returns_full_dict(self, db_path: Path, job_id: int) -> None: + from scripts.messaging import approve_message, create_message + + msg = create_message( + db_path, + job_id=job_id, + job_contact_id=None, + type="email", + direction="outbound", + subject="Draft", + body="Body here", + from_addr="a@b.com", + to_addr="c@d.com", + template_id=None, + ) + + updated = approve_message(db_path, msg["id"]) + assert updated["body"] == "Body here" + assert updated["subject"] == "Draft" + + def test_approve_raises_key_error_when_not_found(self, db_path: Path) -> None: + from scripts.messaging import approve_message + + with pytest.raises(KeyError): + approve_message(db_path, 99999) + + +# --------------------------------------------------------------------------- +# Template tests +# --------------------------------------------------------------------------- + +class TestListTemplates: + def test_includes_four_builtins(self, db_path: Path) -> None: + from scripts.messaging import list_templates + + templates = list_templates(db_path) + builtin_keys = {t["key"] for t in templates if t["is_builtin"]} + assert builtin_keys == { + "follow_up", + "thank_you", + "accommodation_request", + "withdrawal", + } + + def test_returns_list_of_dicts(self, db_path: Path) -> None: + from scripts.messaging import list_templates + + templates = list_templates(db_path) + assert isinstance(templates, list) + assert all(isinstance(t, dict) for t in templates) + + +class TestCreateTemplate: + def test_create_returns_dict(self, db_path: Path) -> None: + from scripts.messaging import create_template + + tmpl = create_template( + db_path, + title="My Template", + category="custom", + subject_template="Hello {{name}}", + body_template="Dear {{name}}, ...", + ) + + assert isinstance(tmpl, dict) + assert tmpl["title"] == "My Template" + assert tmpl["category"] == "custom" + assert tmpl["is_builtin"] == 0 + assert "id" in tmpl + + def test_create_default_category(self, db_path: Path) -> None: + from scripts.messaging import create_template + + tmpl = create_template( + db_path, + title="No Category", + body_template="Body", + ) + assert tmpl["category"] == "custom" + + def test_create_appears_in_list(self, db_path: Path) -> None: + from scripts.messaging import create_template, list_templates + + create_template(db_path, title="Listed", body_template="Body") + titles = [t["title"] for t in list_templates(db_path)] + assert "Listed" in titles + + +class TestUpdateTemplate: + def test_update_user_template(self, db_path: Path) -> None: + from scripts.messaging import create_template, update_template + + tmpl = create_template(db_path, title="Original", body_template="Old body") + updated = update_template(db_path, tmpl["id"], title="Updated", body_template="New body") + + assert updated["title"] == "Updated" + assert updated["body_template"] == "New body" + + def test_update_returns_persisted_values(self, db_path: Path) -> None: + from scripts.messaging import create_template, list_templates, update_template + + tmpl = create_template(db_path, title="Before", body_template="x") + update_template(db_path, tmpl["id"], title="After") + + templates = list_templates(db_path) + titles = [t["title"] for t in templates] + assert "After" in titles + assert "Before" not in titles + + def test_update_builtin_raises_permission_error(self, db_path: Path) -> None: + from scripts.messaging import list_templates, update_template + + builtin = next(t for t in list_templates(db_path) if t["is_builtin"]) + with pytest.raises(PermissionError): + update_template(db_path, builtin["id"], title="Hacked") + + def test_update_missing_raises_key_error(self, db_path): + from scripts.messaging import update_template + + with pytest.raises(KeyError): + update_template(db_path, 9999, title="Ghost") + + +class TestDeleteTemplate: + def test_delete_user_template(self, db_path: Path) -> None: + from scripts.messaging import create_template, delete_template, list_templates + + tmpl = create_template(db_path, title="To Delete", body_template="bye") + initial_count = len(list_templates(db_path)) + delete_template(db_path, tmpl["id"]) + assert len(list_templates(db_path)) == initial_count - 1 + + def test_delete_builtin_raises_permission_error(self, db_path: Path) -> None: + from scripts.messaging import delete_template, list_templates + + builtin = next(t for t in list_templates(db_path) if t["is_builtin"]) + with pytest.raises(PermissionError): + delete_template(db_path, builtin["id"]) + + def test_delete_missing_raises_key_error(self, db_path: Path) -> None: + from scripts.messaging import delete_template + + with pytest.raises(KeyError): + delete_template(db_path, 99999) diff --git a/tests/test_messaging_integration.py b/tests/test_messaging_integration.py new file mode 100644 index 0000000..c25c4bb --- /dev/null +++ b/tests/test_messaging_integration.py @@ -0,0 +1,195 @@ +"""Integration tests for messaging endpoints.""" +import os +from pathlib import Path + +import pytest +from fastapi.testclient import TestClient + +from scripts.db_migrate import migrate_db + + +@pytest.fixture +def fresh_db(tmp_path, monkeypatch): + """Set up a fresh isolated DB wired to dev_api._request_db.""" + db = tmp_path / "test.db" + monkeypatch.setenv("STAGING_DB", str(db)) + migrate_db(db) + import dev_api + monkeypatch.setattr( + dev_api, + "_request_db", + type("CV", (), {"get": lambda self: str(db), "set": lambda *a: None})(), + ) + monkeypatch.setattr(dev_api, "DB_PATH", str(db)) + return db + + +@pytest.fixture +def client(fresh_db): + import dev_api + return TestClient(dev_api.app) + + +# --------------------------------------------------------------------------- +# Messages +# --------------------------------------------------------------------------- + +def test_create_and_list_message(client): + """POST /api/messages creates a row; GET /api/messages?job_id= returns it.""" + payload = { + "job_id": 1, + "type": "email", + "direction": "outbound", + "subject": "Hello recruiter", + "body": "I am very interested in this role.", + "to_addr": "recruiter@example.com", + } + resp = client.post("/api/messages", json=payload) + assert resp.status_code == 200, resp.text + created = resp.json() + assert created["subject"] == "Hello recruiter" + assert created["job_id"] == 1 + + resp = client.get("/api/messages", params={"job_id": 1}) + assert resp.status_code == 200 + messages = resp.json() + assert any(m["id"] == created["id"] for m in messages) + + +def test_delete_message(client): + """DELETE removes the message; subsequent GET no longer returns it.""" + resp = client.post("/api/messages", json={"type": "email", "direction": "outbound", "body": "bye"}) + assert resp.status_code == 200 + msg_id = resp.json()["id"] + + resp = client.delete(f"/api/messages/{msg_id}") + assert resp.status_code == 200 + assert resp.json()["ok"] is True + + resp = client.get("/api/messages") + assert resp.status_code == 200 + ids = [m["id"] for m in resp.json()] + assert msg_id not in ids + + +def test_delete_message_not_found(client): + """DELETE /api/messages/9999 returns 404.""" + resp = client.delete("/api/messages/9999") + assert resp.status_code == 404 + + +# --------------------------------------------------------------------------- +# Templates +# --------------------------------------------------------------------------- + +def test_list_templates_has_builtins(client): + """GET /api/message-templates includes the seeded built-in keys.""" + resp = client.get("/api/message-templates") + assert resp.status_code == 200 + templates = resp.json() + keys = {t["key"] for t in templates} + assert "follow_up" in keys + assert "thank_you" in keys + + +def test_template_create_update_delete(client): + """Full lifecycle: create → update title → delete a user-defined template.""" + # Create + resp = client.post("/api/message-templates", json={ + "title": "My Template", + "category": "custom", + "body_template": "Hello {{name}}", + }) + assert resp.status_code == 200 + tmpl = resp.json() + assert tmpl["title"] == "My Template" + assert tmpl["is_builtin"] == 0 + tmpl_id = tmpl["id"] + + # Update title + resp = client.put(f"/api/message-templates/{tmpl_id}", json={"title": "Updated Title"}) + assert resp.status_code == 200 + assert resp.json()["title"] == "Updated Title" + + # Delete + resp = client.delete(f"/api/message-templates/{tmpl_id}") + assert resp.status_code == 200 + assert resp.json()["ok"] is True + + # Confirm gone + resp = client.get("/api/message-templates") + ids = [t["id"] for t in resp.json()] + assert tmpl_id not in ids + + +def test_builtin_template_put_returns_403(client): + """PUT on a built-in template returns 403.""" + resp = client.get("/api/message-templates") + builtin = next(t for t in resp.json() if t["is_builtin"] == 1) + resp = client.put(f"/api/message-templates/{builtin['id']}", json={"title": "Hacked"}) + assert resp.status_code == 403 + + +def test_builtin_template_delete_returns_403(client): + """DELETE on a built-in template returns 403.""" + resp = client.get("/api/message-templates") + builtin = next(t for t in resp.json() if t["is_builtin"] == 1) + resp = client.delete(f"/api/message-templates/{builtin['id']}") + assert resp.status_code == 403 + + +# --------------------------------------------------------------------------- +# Draft reply (tier gate) +# --------------------------------------------------------------------------- + +def test_draft_without_llm_returns_402(fresh_db, monkeypatch): + """POST /api/contacts/{id}/draft-reply with free tier + no LLM configured returns 402.""" + import dev_api + from scripts.db import add_contact + + # Insert a job_contacts row via the db helper so schema changes stay in sync + contact_id = add_contact( + fresh_db, + job_id=None, + direction="inbound", + subject="Test subject", + from_addr="hr@example.com", + body="We would like to schedule...", + ) + + # Ensure has_configured_llm returns False at both import locations + monkeypatch.setattr("app.wizard.tiers.has_configured_llm", lambda *a, **kw: False) + # Force free tier via the tiers module (not via header — header is no longer trusted) + monkeypatch.setattr("app.wizard.tiers.effective_tier", lambda: "free") + + client = TestClient(dev_api.app) + resp = client.post(f"/api/contacts/{contact_id}/draft-reply") + assert resp.status_code == 402 + + +# --------------------------------------------------------------------------- +# Approve +# --------------------------------------------------------------------------- + +def test_approve_message(client): + """POST /api/messages then POST /api/messages/{id}/approve returns body + approved_at.""" + resp = client.post("/api/messages", json={ + "type": "draft", + "direction": "outbound", + "body": "This is my draft reply.", + }) + assert resp.status_code == 200 + msg_id = resp.json()["id"] + assert resp.json()["approved_at"] is None + + resp = client.post(f"/api/messages/{msg_id}/approve") + assert resp.status_code == 200 + data = resp.json() + assert data["body"] == "This is my draft reply." + assert data["approved_at"] is not None + + +def test_approve_message_not_found(client): + """POST /api/messages/9999/approve returns 404.""" + resp = client.post("/api/messages/9999/approve") + assert resp.status_code == 404 diff --git a/web/src/components/AppNav.vue b/web/src/components/AppNav.vue index ae602f4..59f7265 100644 --- a/web/src/components/AppNav.vue +++ b/web/src/components/AppNav.vue @@ -157,7 +157,7 @@ const navLinks = computed(() => [ { to: '/apply', icon: PencilSquareIcon, label: 'Apply' }, { to: '/resumes', icon: DocumentTextIcon, label: 'Resumes' }, { to: '/interviews', icon: CalendarDaysIcon, label: 'Interviews' }, - { to: '/contacts', icon: UsersIcon, label: 'Contacts' }, + { to: '/messages', icon: UsersIcon, label: 'Messages' }, { to: '/references', icon: IdentificationIcon, label: 'References' }, { to: '/digest', icon: NewspaperIcon, label: 'Digest', badge: digestStore.entries.length || undefined }, diff --git a/web/src/components/MessageLogModal.vue b/web/src/components/MessageLogModal.vue new file mode 100644 index 0000000..7b5ecee --- /dev/null +++ b/web/src/components/MessageLogModal.vue @@ -0,0 +1,200 @@ + +