HR-ATS-Portal/tests/integration/test_api.py

250 lines
9.0 KiB
Python

from __future__ import annotations
from collections.abc import Callable
from typing import Any
from fastapi.testclient import TestClient
from app.core.errors import ErrorCode
from tests.conftest import FakeScorer, make_encrypted_pdf, make_pdf, resume_pdf
JD = "Backend engineer. Required: Python, FastAPI, Docker. Preferred: AWS, Kubernetes."
def upload(name: str, data: bytes, content_type: str = "application/pdf") -> tuple[str, Any]:
return ("resumes", (name, data, content_type))
def post(client: TestClient, files: list[Any], job_description: str = JD) -> Any:
return client.post(
"/api/v1/score",
data={"job_description": job_description},
files=files,
)
class TestHappyPath:
def test_returns_a_score_sorted_leaderboard(self, client: TestClient) -> None:
response = post(
client,
[
upload("mid.pdf", resume_pdf("Mid", 55)),
upload("top.pdf", resume_pdf("Top", 92)),
upload("low.pdf", resume_pdf("Low", 20)),
],
)
assert response.status_code == 200
body = response.json()
assert body["total"] == 3
assert body["succeeded"] == 3
assert body["failed"] == 0
assert [item["filename"] for item in body["results"]] == [
"top.pdf",
"mid.pdf",
"low.pdf",
]
assert [item["match_score"] for item in body["results"]] == [92, 55, 20]
def test_completed_results_match_the_documented_schema(self, client: TestClient) -> None:
body = post(client, [upload("a.pdf", resume_pdf("Ada", 70))]).json()
assert set(body) == {"request_id", "total", "succeeded", "failed", "results"}
result = body["results"][0]
assert set(result) == {
"filename",
"status",
"match_score",
"matched_keywords",
"missing_keywords",
"summary_critique",
}
assert result["status"] == "completed"
def test_response_carries_a_request_id_header(self, client: TestClient) -> None:
response = post(client, [upload("a.pdf", resume_pdf("Ada", 70))])
assert response.headers["X-Request-ID"]
assert response.json()["request_id"] == response.headers["X-Request-ID"]
def test_inbound_request_id_is_honoured(self, client: TestClient) -> None:
response = client.post(
"/api/v1/score",
data={"job_description": JD},
files=[upload("a.pdf", resume_pdf("Ada", 70))],
headers={"X-Request-ID": "trace-123"},
)
assert response.headers["X-Request-ID"] == "trace-123"
def test_health_endpoint(self, client: TestClient) -> None:
assert client.get("/api/v1/health").json() == {"status": "ok"}
class TestPartialFailure:
def test_one_unreadable_resume_does_not_fail_the_batch(self, client: TestClient) -> None:
response = post(
client,
[
upload("good.pdf", resume_pdf("Good", 88)),
upload("scanned.pdf", make_pdf([[]])),
upload("locked.pdf", make_encrypted_pdf()),
upload("broken.pdf", b"%PDF-1.4\nnot really a pdf"),
],
)
assert response.status_code == 200
body = response.json()
assert body["total"] == 4
assert body["succeeded"] == 1
assert body["failed"] == 3
results = body["results"]
assert results[0]["filename"] == "good.pdf"
# Failures come after completions, in upload order.
assert [item["filename"] for item in results[1:]] == [
"scanned.pdf",
"locked.pdf",
"broken.pdf",
]
assert [item["error_code"] for item in results[1:]] == [
ErrorCode.PDF_TEXT_UNAVAILABLE,
ErrorCode.PDF_ENCRYPTED,
ErrorCode.INVALID_PDF,
]
def test_failed_results_match_the_documented_schema(self, client: TestClient) -> None:
body = post(client, [upload("scanned.pdf", make_pdf([[]]))]).json()
result = body["results"][0]
assert set(result) == {"filename", "status", "error_code", "error_message"}
assert result["status"] == "failed"
def test_a_provider_failure_is_isolated_to_its_candidate(
self, make_client: Callable[..., TestClient]
) -> None:
def handler(text: str) -> Any:
if "Boom" in text:
raise RuntimeError("upstream detail that must not leak")
from tests.conftest import default_score
return default_score(text)
client = make_client(scorer=FakeScorer(handler))
body = post(
client,
[
upload("ok.pdf", resume_pdf("Fine", 65)),
upload("bad.pdf", resume_pdf("Boom", 10)),
],
).json()
assert body["succeeded"] == 1
failure = body["results"][1]
assert failure["error_code"] == ErrorCode.INTERNAL_ERROR
assert "upstream detail" not in failure["error_message"]
def test_a_batch_of_only_failures_still_returns_200(self, client: TestClient) -> None:
response = post(client, [upload("scanned.pdf", make_pdf([[]]))])
assert response.status_code == 200
assert response.json()["succeeded"] == 0
class TestRequestValidation:
def test_blank_job_description_is_400(self, client: TestClient) -> None:
response = post(client, [upload("a.pdf", resume_pdf("Ada"))], job_description=" ")
assert response.status_code == 400
assert response.json()["error_code"] == ErrorCode.INVALID_REQUEST
def test_missing_job_description_is_400(self, client: TestClient) -> None:
response = client.post("/api/v1/score", files=[upload("a.pdf", resume_pdf("Ada"))])
assert response.status_code == 400
assert response.json()["error_code"] == ErrorCode.INVALID_REQUEST
def test_missing_resumes_is_400(self, client: TestClient) -> None:
response = client.post("/api/v1/score", data={"job_description": JD})
assert response.status_code == 400
def test_over_length_job_description_is_422(
self, make_client: Callable[..., TestClient]
) -> None:
client = make_client(max_jd_chars=100)
response = post(client, [upload("a.pdf", resume_pdf("Ada"))], job_description="x" * 200)
assert response.status_code == 422
assert response.json()["error_code"] == ErrorCode.UNPROCESSABLE_FIELD
def test_too_many_files_is_413(self, make_client: Callable[..., TestClient]) -> None:
client = make_client(max_resumes_per_request=2)
response = post(
client,
[upload(f"c{i}.pdf", resume_pdf(f"C{i}", 50)) for i in range(3)],
)
assert response.status_code == 413
assert response.json()["error_code"] == ErrorCode.PAYLOAD_TOO_LARGE
def test_oversized_file_is_413(self, make_client: Callable[..., TestClient]) -> None:
client = make_client(max_pdf_size_mb=1)
big = make_pdf(
[[f"Padding line number {i} for the oversized document." for i in range(30_000)]]
)
assert len(big) > 1024 * 1024
response = post(client, [upload("big.pdf", big)])
assert response.status_code == 413
def test_non_pdf_extension_is_415(self, client: TestClient) -> None:
response = post(client, [upload("resume.docx", b"whatever", "application/msword")])
assert response.status_code == 415
assert response.json()["error_code"] == ErrorCode.UNSUPPORTED_FILE_TYPE
def test_disallowed_content_type_is_415(self, client: TestClient) -> None:
response = post(client, [upload("resume.pdf", resume_pdf("Ada"), "text/html")])
assert response.status_code == 415
def test_octet_stream_is_accepted_when_the_extension_is_pdf(self, client: TestClient) -> None:
"""Clients disagree on the PDF MIME type; the %PDF- signature is the real gate."""
response = post(
client,
[upload("a.pdf", resume_pdf("Ada", 60), "application/octet-stream")],
)
assert response.status_code == 200
def test_error_envelope_shape(self, client: TestClient) -> None:
body = post(client, [upload("a.pdf", resume_pdf("Ada"))], job_description="").json()
assert set(body) == {"request_id", "error_code", "error_message"}
class TestFilenameHandling:
def test_traversal_in_the_upload_name_is_stripped(self, client: TestClient) -> None:
body = post(
client,
[upload(r"..\..\windows\system32\evil.pdf", resume_pdf("Ada", 60))],
).json()
assert body["results"][0]["filename"] == "evil.pdf"
class TestConcurrency:
def test_batch_respects_the_configured_bound(
self, make_client: Callable[..., TestClient]
) -> None:
scorer = FakeScorer(delay=0.01)
client = make_client(scorer=scorer, scoring_concurrency=2, max_resumes_per_request=10)
post(client, [upload(f"c{i}.pdf", resume_pdf(f"C{i}", 50)) for i in range(6)])
assert scorer.max_concurrent <= 2