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