jobhunt-platform/apps/api/tests/test_approval.py
hermes 17759ef564 T1: FastAPI backend core (models, state machine, approval gate) + compose test runner
- apps/api: psycopg v3 repositories, schema.sql + migration runner, state machine
  implementing the data-model transition table, approval gate with sha256 hash
  match + 24h expiry enforced at send time, EchoTransport pluggable sender,
  LLM calls routed through packages.llm-gateway with mock mode, 47 pytest tests green
- apps/api/Dockerfile.test: python 3.13-slim test image (DinD-safe: migrations
  copied as directory)
- docker-compose.yml: add api-test service on compose network (host port
  publishing is broken in this sandbox; container-to-container networking used)
- Fix: replace masked placeholder password in config.py/conftest.py defaults
2026-07-30 18:10:59 +00:00

216 lines
No EOL
7.2 KiB
Python

"""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()