| """ |
| Functional tests β POST /generate/pdf endpoint |
| ================================================ |
| Tests for the synchronous PDF generation endpoint. |
| |
| Key behaviours exercised: |
| β’ Schema validation (422) for missing / bad fields |
| β’ 404 when request_id not in Supabase |
| β’ Response headers contract (Content-Type, Content-Disposition) |
| β’ num_solutions validation bounds (1β5) |
| β’ Swagger-default token sanitisation ("string" β None) |
| β’ seed_images validator (empty list β 422) |
| """ |
| import json |
| import pytest |
| import requests |
| from tests.conftest import ( |
| BASE_URL, TIMEOUT, SEED_IMAGE_URL, |
| NONEXISTENT_REQUEST_ID, MINIMAL_GENERATE_PAYLOAD, |
| ) |
|
|
| ENDPOINT = f"{BASE_URL}/generate/pdf" |
|
|
|
|
| |
| |
| |
|
|
| def make_payload(**overrides): |
| import copy |
| payload = copy.deepcopy(MINIMAL_GENERATE_PAYLOAD) |
| payload.update(overrides) |
| return payload |
|
|
|
|
| def make_prompt_override(**kw): |
| import copy |
| pp = copy.deepcopy(MINIMAL_GENERATE_PAYLOAD["prompt_params"]) |
| pp.update(kw) |
| return pp |
|
|
|
|
| |
| |
| |
|
|
| class TestGeneratePdfInputValidation: |
| """FastAPI must reject malformed requests with 422 Unprocessable Entity.""" |
|
|
| def test_missing_request_id_returns_422(self, http): |
| payload = { |
| "seed_images": [SEED_IMAGE_URL], |
| "prompt_params": MINIMAL_GENERATE_PAYLOAD["prompt_params"], |
| } |
| r = http.post(ENDPOINT, json=payload, timeout=TIMEOUT) |
| assert r.status_code == 422, ( |
| f"Expected 422 for missing request_id, got {r.status_code}" |
| ) |
|
|
| def test_empty_seed_images_returns_422(self, http): |
| payload = make_payload(seed_images=[]) |
| r = http.post(ENDPOINT, json=payload, timeout=TIMEOUT) |
| assert r.status_code == 422, ( |
| f"Expected 422 for empty seed_images, got {r.status_code}" |
| ) |
|
|
| def test_too_many_seed_images_returns_422(self, http): |
| payload = make_payload(seed_images=[SEED_IMAGE_URL] * 11) |
| r = http.post(ENDPOINT, json=payload, timeout=TIMEOUT) |
| assert r.status_code == 422, ( |
| f"Expected 422 for 11 seed_images (max 10), got {r.status_code}" |
| ) |
|
|
| def test_invalid_seed_image_url_returns_422(self, http): |
| payload = make_payload(seed_images=["not-a-url"]) |
| r = http.post(ENDPOINT, json=payload, timeout=TIMEOUT) |
| assert r.status_code == 422, ( |
| f"Expected 422 for non-URL seed image, got {r.status_code}" |
| ) |
|
|
| def test_num_solutions_below_min_returns_422(self, http): |
| pp = make_prompt_override(num_solutions=0) |
| payload = make_payload(prompt_params=pp) |
| r = http.post(ENDPOINT, json=payload, timeout=TIMEOUT) |
| assert r.status_code == 422, ( |
| f"Expected 422 for num_solutions=0, got {r.status_code}" |
| ) |
|
|
| def test_num_solutions_above_max_returns_422(self, http): |
| pp = make_prompt_override(num_solutions=6) |
| payload = make_payload(prompt_params=pp) |
| r = http.post(ENDPOINT, json=payload, timeout=TIMEOUT) |
| assert r.status_code == 422, ( |
| f"Expected 422 for num_solutions=6, got {r.status_code}" |
| ) |
|
|
| def test_handwriting_ratio_out_of_range_returns_422(self, http): |
| pp = make_prompt_override(handwriting_ratio=1.5) |
| payload = make_payload(prompt_params=pp) |
| r = http.post(ENDPOINT, json=payload, timeout=TIMEOUT) |
| assert r.status_code == 422, ( |
| f"Expected 422 for handwriting_ratio=1.5, got {r.status_code}" |
| ) |
|
|
| def test_non_json_body_returns_422(self, http): |
| r = http.post(ENDPOINT, data="this is not json", timeout=TIMEOUT, |
| headers={"Content-Type": "application/json"}) |
| assert r.status_code == 422, ( |
| f"Expected 422 for non-JSON body, got {r.status_code}" |
| ) |
|
|
| def test_empty_body_returns_422(self, http): |
| r = http.post(ENDPOINT, json={}, timeout=TIMEOUT) |
| assert r.status_code == 422, ( |
| f"Expected 422 for empty body, got {r.status_code}" |
| ) |
|
|
|
|
| |
| |
| |
|
|
| class TestGeneratePdfBusinessLogic: |
| """Valid schema, but request_id not in Supabase β 404.""" |
|
|
| def test_nonexistent_request_id_returns_404(self, http): |
| r = http.post(ENDPOINT, json=MINIMAL_GENERATE_PAYLOAD, timeout=TIMEOUT) |
| assert r.status_code == 404, ( |
| f"Expected 404 for unknown request_id, got {r.status_code}: {r.text}" |
| ) |
|
|
| def test_nonexistent_request_id_error_is_json(self, http): |
| r = http.post(ENDPOINT, json=MINIMAL_GENERATE_PAYLOAD, timeout=TIMEOUT) |
| assert "application/json" in r.headers.get("Content-Type", ""), ( |
| "Error response must be JSON" |
| ) |
|
|
| def test_nonexistent_request_id_has_detail(self, http): |
| r = http.post(ENDPOINT, json=MINIMAL_GENERATE_PAYLOAD, timeout=TIMEOUT) |
| body = r.json() |
| assert "detail" in body, f"Error body must have 'detail' field. Got: {body}" |
|
|
| def test_swagger_string_token_is_sanitised(self, http): |
| """ |
| The frontend Swagger UI sends literal "string" as token defaults. |
| The API should accept the request (not 422) and treat "string" as None. |
| The result is still 404 because the request_id doesn't exist β but NOT 422. |
| """ |
| payload = make_payload( |
| google_drive_token="string", |
| google_drive_refresh_token="string", |
| ) |
| r = http.post(ENDPOINT, json=payload, timeout=TIMEOUT) |
| assert r.status_code != 422, ( |
| "API should NOT reject 'string' tokens with 422 β it should sanitise them." |
| ) |
|
|
| def test_none_google_tokens_are_accepted(self, http): |
| """Explicitly null tokens are valid (Google Drive is optional).""" |
| payload = make_payload( |
| google_drive_token=None, |
| google_drive_refresh_token=None, |
| ) |
| r = http.post(ENDPOINT, json=payload, timeout=TIMEOUT) |
| |
| assert r.status_code != 422 |
|
|
| def test_valid_num_solutions_boundary_values_accepted(self, http): |
| """num_solutions=1 and num_solutions=5 should pass schema validation.""" |
| for n in [1, 5]: |
| pp = make_prompt_override(num_solutions=n) |
| payload = make_payload(prompt_params=pp) |
| r = http.post(ENDPOINT, json=payload, timeout=TIMEOUT) |
| assert r.status_code != 422, ( |
| f"num_solutions={n} should be schema-valid, got {r.status_code}" |
| ) |
|
|
| def test_missing_prompt_params_uses_defaults(self, http): |
| """prompt_params is optional (has defaults); omitting it must not raise 422.""" |
| payload = {"request_id": NONEXISTENT_REQUEST_ID} |
| r = http.post(ENDPOINT, json=payload, timeout=TIMEOUT) |
| assert r.status_code != 422, ( |
| f"Missing prompt_params should use defaults, got {r.status_code}" |
| ) |
|
|
| def test_request_id_with_user_prefix_is_accepted(self, http): |
| """request_id supports 'user_id/request_id' format.""" |
| payload = make_payload(request_id=f"user_42/{NONEXISTENT_REQUEST_ID}") |
| r = http.post(ENDPOINT, json=payload, timeout=TIMEOUT) |
| |
| assert r.status_code in (404, 500), ( |
| f"Prefixed request_id should parse fine, got {r.status_code}" |
| ) |
|
|