cv-buddy-backend / tests /integration /test_api_endpoints.py
Momal's picture
Deploy cv-buddy backend
366c43e
"""Integration tests for every API endpoint a real user hits.
All tests use mocked Redis and mocked LLM β€” no external services required.
"""
from __future__ import annotations
import json
from unittest.mock import patch, AsyncMock, MagicMock
import pytest
from tests.conftest import (
MINIMAL_PDF_BYTES,
SAMPLE_RESUME,
SAMPLE_RESUME_JSON,
make_resume_data,
make_result_data,
)
# =========================================================================
# Health
# =========================================================================
class TestHealth:
def test_health_returns_ok(self, client):
response = client.get("/health")
assert response.status_code == 200
assert response.json() == {"status": "ok"}
# =========================================================================
# Upload (POST /api/upload)
# =========================================================================
class TestUpload:
def test_valid_pdf_upload(self, client, mock_redis):
"""Valid PDF β†’ 200 with session_id and profile."""
mock_parser = MagicMock()
mock_parser.is_supported.return_value = True
mock_parser.parse = AsyncMock(return_value=SAMPLE_RESUME)
mock_scanner = MagicMock()
mock_scanner.scan.return_value = MagicMock(has_issues=False, warnings=[])
with patch("app.api.routes.upload.ResumeParser", return_value=mock_parser), \
patch("app.api.routes.upload.LayoutScanner", return_value=mock_scanner):
response = client.post(
"/api/upload",
files={"file": ("resume.pdf", MINIMAL_PDF_BYTES, "application/pdf")},
)
assert response.status_code == 200
body = response.json()
assert "session_id" in body
assert body["profile"]["name"] == "Jane Doe"
assert len(body["profile"]["skills"]) <= 10
assert "experience_count" in body["profile"]
def test_valid_docx_upload(self, client, mock_redis):
"""DOCX files are accepted."""
mock_parser = MagicMock()
mock_parser.is_supported.return_value = True
mock_parser.parse = AsyncMock(return_value=SAMPLE_RESUME)
mock_scanner = MagicMock()
mock_scanner.scan.return_value = MagicMock(has_issues=False, warnings=[])
with patch("app.api.routes.upload.ResumeParser", return_value=mock_parser), \
patch("app.api.routes.upload.LayoutScanner", return_value=mock_scanner):
response = client.post(
"/api/upload",
files={
"file": (
"resume.docx",
b"fake-docx-bytes",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
)
},
)
assert response.status_code == 200
assert "session_id" in response.json()
def test_invalid_file_type_txt(self, client):
"""Plain text file β†’ 400."""
response = client.post(
"/api/upload",
files={"file": ("notes.txt", b"hello world", "text/plain")},
)
assert response.status_code == 400
assert "Invalid file type" in response.json()["detail"]
def test_invalid_file_type_jpg(self, client):
"""Image file β†’ 400."""
response = client.post(
"/api/upload",
files={"file": ("photo.jpg", b"\xff\xd8\xff", "image/jpeg")},
)
assert response.status_code == 400
def test_file_too_large(self, client):
"""File > 5 MB β†’ 400."""
mock_parser = MagicMock()
mock_parser.is_supported.return_value = True
with patch("app.api.routes.upload.ResumeParser", return_value=mock_parser):
big_content = b"x" * (5 * 1024 * 1024 + 1)
response = client.post(
"/api/upload",
files={"file": ("big.pdf", big_content, "application/pdf")},
)
assert response.status_code == 400
assert "too large" in response.json()["detail"].lower()
def test_no_cookie_in_response(self, client, mock_redis):
"""Upload response must NOT set cookies (we use X-Session-ID header)."""
mock_parser = MagicMock()
mock_parser.is_supported.return_value = True
mock_parser.parse = AsyncMock(return_value=SAMPLE_RESUME)
mock_scanner = MagicMock()
mock_scanner.scan.return_value = MagicMock(has_issues=False, warnings=[])
with patch("app.api.routes.upload.ResumeParser", return_value=mock_parser), \
patch("app.api.routes.upload.LayoutScanner", return_value=mock_scanner):
response = client.post(
"/api/upload",
files={"file": ("resume.pdf", MINIMAL_PDF_BYTES, "application/pdf")},
)
assert "set-cookie" not in response.headers
# =========================================================================
# Session via X-Session-ID header
# =========================================================================
class TestSessionHeader:
def test_valid_session_header(self, client, sample_session):
"""Request with valid X-Session-ID + existing resume β†’ authorized."""
# Use preview-score as a session-gated endpoint
with patch("app.api.routes.analyze.JobScraper") as MockScraper, \
patch("app.api.routes.analyze.ATSScorer") as MockScorer:
mock_scraper = AsyncMock()
mock_scraper.parse_text.return_value = MagicMock()
MockScraper.return_value = mock_scraper
mock_scorer = AsyncMock()
mock_scorer.calculate.return_value = MagicMock(
total=65, matched_keywords=["Python"], missing_keywords=["Go"],
)
MockScorer.return_value = mock_scorer
response = client.post(
"/api/preview-score",
headers={"X-Session-ID": sample_session},
json={"job_text": "Looking for a Python developer"},
)
assert response.status_code == 200
def test_missing_session_header(self, client):
"""No X-Session-ID header β†’ 401."""
response = client.post(
"/api/preview-score",
json={"job_text": "some job"},
)
assert response.status_code == 401
assert "No session" in response.json()["detail"]
def test_expired_session_header(self, client, mock_redis):
"""X-Session-ID with no matching Redis data β†’ 401."""
response = client.post(
"/api/preview-score",
headers={"X-Session-ID": "nonexistent-session-id"},
json={"job_text": "some job"},
)
assert response.status_code == 401
assert "expired" in response.json()["detail"].lower()
# =========================================================================
# Preview Score (POST /api/preview-score)
# =========================================================================
class TestPreviewScore:
def test_preview_score_with_job_text(self, client, sample_session):
"""Preview score with job text β†’ returns score and keywords."""
with patch("app.api.routes.analyze.JobScraper") as MockScraper, \
patch("app.api.routes.analyze.ATSScorer") as MockScorer:
mock_scraper = AsyncMock()
mock_scraper.parse_text.return_value = MagicMock()
MockScraper.return_value = mock_scraper
mock_scorer = AsyncMock()
mock_scorer.calculate.return_value = MagicMock(
total=72,
matched_keywords=["Python", "FastAPI"],
missing_keywords=["Kubernetes"],
)
MockScorer.return_value = mock_scorer
response = client.post(
"/api/preview-score",
headers={"X-Session-ID": sample_session},
json={"job_text": "We need a Python developer with FastAPI experience."},
)
assert response.status_code == 200
body = response.json()
assert "score" in body
assert body["score"] == 72
assert "matched_keywords" in body
assert "missing_keywords" in body
def test_preview_score_without_session(self, client):
"""Preview score without session β†’ 401."""
response = client.post(
"/api/preview-score",
json={"job_text": "some job"},
)
assert response.status_code == 401
def test_preview_score_low_needs_confirmation(self, client, sample_session):
"""Score below 30 β†’ needs_confirmation = True."""
with patch("app.api.routes.analyze.JobScraper") as MockScraper, \
patch("app.api.routes.analyze.ATSScorer") as MockScorer:
mock_scraper = AsyncMock()
mock_scraper.parse_text.return_value = MagicMock()
MockScraper.return_value = mock_scraper
mock_scorer = AsyncMock()
mock_scorer.calculate.return_value = MagicMock(
total=15,
matched_keywords=[],
missing_keywords=["Java", "Spring"],
)
MockScorer.return_value = mock_scorer
response = client.post(
"/api/preview-score",
headers={"X-Session-ID": sample_session},
json={"job_text": "Java Spring Boot developer needed."},
)
assert response.status_code == 200
body = response.json()
assert body["needs_confirmation"] is True
# =========================================================================
# Analyze Job (POST /api/analyze-job)
# =========================================================================
class TestAnalyzeJob:
def test_analyze_job_returns_task_id(self, client, sample_session):
"""Analyze with job text + intensity β†’ returns task_id."""
mock_task = MagicMock()
mock_task.id = "celery-task-id-123"
with patch("app.api.routes.analyze.JobScraper") as MockScraper, \
patch("app.api.routes.analyze.ATSScorer") as MockScorer, \
patch("app.api.routes.analyze.analyze_and_customize") as mock_celery:
# Score check passes (above threshold)
mock_scraper = AsyncMock()
mock_scraper.parse_text.return_value = MagicMock()
MockScraper.return_value = mock_scraper
mock_scorer = AsyncMock()
mock_scorer.calculate.return_value = MagicMock(total=65)
MockScorer.return_value = mock_scorer
mock_celery.delay.return_value = mock_task
response = client.post(
"/api/analyze-job",
headers={"X-Session-ID": sample_session},
json={
"job_text": "Python developer with FastAPI experience",
"intensity": "moderate",
},
)
assert response.status_code == 200
body = response.json()
assert body["task_id"] == "celery-task-id-123"
def test_analyze_job_missing_session(self, client):
"""No session β†’ 401."""
response = client.post(
"/api/analyze-job",
json={"job_text": "some job", "intensity": "moderate"},
)
assert response.status_code == 401
def test_analyze_job_low_score_triggers_confirmation(self, client, sample_session):
"""Score below threshold β†’ needs_confirmation response instead of task."""
with patch("app.api.routes.analyze.JobScraper") as MockScraper, \
patch("app.api.routes.analyze.ATSScorer") as MockScorer:
mock_scraper = AsyncMock()
mock_scraper.parse_text.return_value = MagicMock()
MockScraper.return_value = mock_scraper
mock_scorer = AsyncMock()
mock_scorer.calculate.return_value = MagicMock(total=20)
MockScorer.return_value = mock_scorer
response = client.post(
"/api/analyze-job",
headers={"X-Session-ID": sample_session},
json={"job_text": "Java developer", "intensity": "moderate"},
)
assert response.status_code == 200
body = response.json()
assert body["needs_confirmation"] is True
assert "task_id" not in body
def test_analyze_job_missing_job_input(self, client, sample_session):
"""No job_url or job_text β†’ 400."""
response = client.post(
"/api/analyze-job",
headers={"X-Session-ID": sample_session},
json={"intensity": "moderate"},
)
assert response.status_code == 400
# =========================================================================
# Result (GET /api/result/{id})
# =========================================================================
class TestResult:
def test_valid_result_id(self, client, sample_result):
"""Valid result_id β†’ returns full result."""
response = client.get(f"/api/result/{sample_result}")
assert response.status_code == 200
body = response.json()
assert "original" in body
assert "customized" in body
assert "original_score" in body
assert "customized_score" in body
def test_invalid_result_id(self, client, mock_redis):
"""Non-existent result_id β†’ 404."""
response = client.get("/api/result/does-not-exist")
assert response.status_code == 404
assert "not found" in response.json()["detail"].lower()
# =========================================================================
# Export (GET /api/export/{id})
# =========================================================================
class TestExport:
def test_pdf_export(self, client, sample_result):
"""PDF export β†’ returns PDF bytes."""
with patch("app.api.routes.export.ResumeGenerator") as MockGen:
mock_gen = MagicMock()
mock_gen.to_pdf.return_value = b"%PDF-1.4 fake content"
MockGen.return_value = mock_gen
response = client.get(f"/api/export/{sample_result}?format=pdf")
assert response.status_code == 200
assert response.headers["content-type"] == "application/pdf"
assert b"PDF" in response.content
def test_docx_export(self, client, sample_result):
"""DOCX export β†’ returns DOCX bytes."""
with patch("app.api.routes.export.ResumeGenerator") as MockGen:
mock_gen = MagicMock()
mock_gen.to_docx.return_value = b"PK\x03\x04 fake docx"
MockGen.return_value = mock_gen
response = client.get(f"/api/export/{sample_result}?format=docx")
assert response.status_code == 200
assert "wordprocessingml" in response.headers["content-type"]
def test_export_invalid_result_id(self, client, mock_redis):
"""Non-existent result_id β†’ 404."""
response = client.get("/api/export/does-not-exist?format=pdf")
assert response.status_code == 404
# =========================================================================
# Global Exception Handler
# =========================================================================
class TestGlobalExceptionHandler:
def test_rate_limit_error_returns_429(self, client):
"""Unhandled rate-limit exception β†’ 429 JSON."""
import asyncio
from starlette.testclient import TestClient as _TC
from app.main import global_exception_handler
# Call the handler directly with a mock request
from unittest.mock import MagicMock as _MagicMock
mock_request = _MagicMock()
exc = Exception("rate limit exceeded on quota")
loop = asyncio.new_event_loop()
try:
resp = loop.run_until_complete(global_exception_handler(mock_request, exc))
finally:
loop.close()
assert resp.status_code == 429
import json as _json
body = _json.loads(resp.body)
assert body["error_type"] == "rate_limit"
def test_exhausted_error_returns_503(self, client):
"""Unhandled 'exhausted' exception β†’ 503 JSON."""
import asyncio
from app.main import global_exception_handler
from unittest.mock import MagicMock as _MagicMock
mock_request = _MagicMock()
exc = Exception("All providers exhausted")
loop = asyncio.new_event_loop()
try:
resp = loop.run_until_complete(global_exception_handler(mock_request, exc))
finally:
loop.close()
assert resp.status_code == 503
def test_generic_error_returns_500(self, client):
"""Generic unhandled exception β†’ 500 JSON."""
import asyncio
from app.main import global_exception_handler
from unittest.mock import MagicMock as _MagicMock
mock_request = _MagicMock()
exc = Exception("Something unexpected happened")
loop = asyncio.new_event_loop()
try:
resp = loop.run_until_complete(global_exception_handler(mock_request, exc))
finally:
loop.close()
assert resp.status_code == 500
def test_health_always_returns_json(self, client):
"""Health endpoint always returns well-formed JSON."""
response = client.get("/health")
assert response.headers["content-type"] == "application/json"
response.json() # Should not raise