Spaces:
Running
Running
| """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 | |