apoorvrajdev commited on
Commit
785dbd5
·
1 Parent(s): 8e0a706

test(backend): add FastAPI route tests with fake predictor service

Browse files

Adds backend/app/tests/ covering the GET /healthz and POST /v1/captions contracts end-to-end without loading TensorFlow or any real model. A duck-typed FakePredictorService stands in on app.state, bypassing the lifespan. Covers 200, 400, 413, 415, 422, 503 plus request-id propagation through x-request-id.

pyproject.toml: pythonpath = ["backend"] under [tool.pytest.ini_options] so 'from app.* import ...' resolves under pytest from the repo root.

12 new tests, full suite 90 passing in ~30s.

CLAUDE.md ADDED
@@ -0,0 +1,73 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Project Conventions for Claude Code
2
+
3
+ ## CRITICAL: Commit & Attribution Rules
4
+
5
+ **Claude Code MUST follow these rules without exception:**
6
+
7
+ 1. **NEVER add `Co-Authored-By: Claude` or any AI co-author trailer to commit messages.**
8
+ 2. **NEVER add `🤖 Generated with Claude Code` footers or any AI attribution.**
9
+ 3. **NEVER mention Claude, Anthropic, OpenAI, Copilot, AI, LLMs, or any model/assistant name in commit messages, code comments, file headers, documentation, PR descriptions, or changelogs.**
10
+ 4. **All commits must be authored solely by:**
11
+ - Name: `apoorvrajdev`
12
+ - Email: `apoorvrajmgr@gmail.com`
13
+ 5. **NEVER stage or commit changes on your own.** Only suggest commit messages — the user runs `git commit` themselves.
14
+ 6. **NEVER push to remote.** Only the user pushes.
15
+ 7. **NEVER create branches, tags, or releases on your own.**
16
+
17
+ ## Commit Message Format
18
+
19
+ Use Conventional Commits. Examples:
20
+ - `chore: initial repo scaffolding`
21
+ - `feat(backend): add /caption endpoint for image upload`
22
+ - `feat(inference): add beam search decoder`
23
+ - `fix(data): correct COCO split deduplication`
24
+ - `fix(training): stabilize loss scaling for mixed precision`
25
+ - `docs: update stabilized training runbook`
26
+ - `test(evaluation): add BLEU/CIDEr metric tests`
27
+ - `refactor(models): extract encoder CNN factory`
28
+ - `perf(inference): cache image features for batched predict`
29
+
30
+ Keep subject under 72 characters. Body optional but explains *why*, not *what*.
31
+
32
+ ## Project Stack
33
+
34
+ - **Core ML:** Python 3.10+, TensorFlow / Keras, NumPy, Pillow
35
+ - **Model:** InceptionV3 encoder + Transformer decoder for image captioning
36
+ - **Backend:** FastAPI app under `backend/app/` (routes, services, schemas, utils)
37
+ - **Frontend:** React 18 + Vite (JSX) under `frontend/`, ESLint configured
38
+ - **Config:** YAML configs under `configs/` loaded via `src/captioning/config/`
39
+ - **Data:** MS COCO pipeline under `src/captioning/data/`
40
+ - **Evaluation:** BLEU, CIDEr, METEOR, ROUGE under `src/captioning/evaluation/`
41
+ - **Tooling:** `pyproject.toml`, `Makefile`, `pytest`, packaging as `captioning`
42
+
43
+ ## Repository Layout (authoritative)
44
+
45
+ - `src/captioning/` — installable library (`config`, `data`, `models`, `preprocessing`, `training`, `inference`, `evaluation`, `utils`)
46
+ - `backend/app/` — FastAPI service (`api/routes.py`, `services/predictor_service.py`, `schemas/`, `core/`, `utils/`)
47
+ - `frontend/src/` — React UI (`components/`, `services/api.js`)
48
+ - `scripts/` — CLI entrypoints (`train.py`, `evaluate.py`, `predict.py`, etc.)
49
+ - `configs/` — YAML training/eval configs
50
+ - `models/vX.Y.Z/` — versioned model artifacts (`model.h5`, `vocab.json`)
51
+ - `tests/unit/` — pytest unit tests
52
+ - `notebooks/` — exploratory notebooks (not part of runtime)
53
+ - `docs/` — phase notes and runbooks
54
+
55
+ ## Code Standards
56
+
57
+ - **Python:** type hints on all new/edited public functions; prefer `pathlib.Path` over string paths
58
+ - **Imports:** absolute imports from `captioning.*`; no relative imports across top-level packages
59
+ - **Determinism:** seed NumPy / TF / Python `random` whenever introducing stochastic code paths in training or evaluation
60
+ - **Configs:** never hardcode hyperparameters in scripts — extend `src/captioning/config/schema.py` and update the relevant YAML in `configs/`
61
+ - **Models / vocab:** never modify files under `models/vX.Y.Z/` in place; bump the version directory instead
62
+ - **Backend layering:** `api/routes.py` only orchestrates; inference logic stays in `backend/app/services/` and `src/captioning/inference/`
63
+ - **Schemas:** all FastAPI request/response bodies go through Pydantic schemas in `backend/app/schemas/`
64
+ - **Frontend:** functional components + hooks; keep API calls inside `frontend/src/services/api.js`
65
+ - **Tests:** new behavior gets a unit test under `tests/unit/`; keep tests CPU-only and offline (no network, no real model downloads)
66
+
67
+ ## Working Style
68
+
69
+ - Plan before implementing for any non-trivial change (training loop, decoder, data pipeline, API contract)
70
+ - One module at a time, with tests
71
+ - Run `pytest` for touched areas before declaring a change done
72
+ - After making changes, summarize what you did so the user can review and commit
73
+ - If a change spans library + backend + frontend, list the affected files grouped by layer in the summary
backend/app/tests/__init__.py ADDED
File without changes
backend/app/tests/conftest.py ADDED
@@ -0,0 +1,92 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Shared fixtures for the backend test suite.
2
+
3
+ These tests deliberately avoid loading TensorFlow or any real model.
4
+ The route layer depends on ``PredictorService`` only through duck-typed
5
+ attributes (``model_version``, ``decode_strategy``, ``max_upload_bytes``,
6
+ ``caption_image_bytes``), so a small fake stands in cleanly and keeps the
7
+ whole suite under one second.
8
+
9
+ We also bypass the FastAPI lifespan entirely. The lifespan builds a real
10
+ ``CaptionPredictor`` from disk, which requires weights, a tokenizer, and a
11
+ TF graph build. Tests build a fresh ``FastAPI`` instance, wire the same
12
+ router and middleware, and stash the fake service directly on
13
+ ``app.state.predictor_service`` — the exact slot the lifespan would have
14
+ populated in production.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from collections.abc import Callable, Iterator
20
+
21
+ import pytest
22
+ from fastapi import FastAPI
23
+ from fastapi.testclient import TestClient
24
+
25
+ from app.api.routes import router
26
+ from app.core.config import BackendSettings
27
+ from app.core.logging import RequestContextMiddleware, configure_app_logging
28
+ from app.utils.image import ImageDecodeError
29
+
30
+ configure_app_logging()
31
+
32
+
33
+ class FakePredictorService:
34
+ """Duck-typed stand-in for ``PredictorService``."""
35
+
36
+ def __init__(
37
+ self,
38
+ *,
39
+ caption: str = "a test caption",
40
+ latency_ms: float = 1.23,
41
+ decode_strategy: str = "greedy",
42
+ model_version: str = "test-v0",
43
+ max_upload_bytes: int = 1024,
44
+ raise_decode_error: bool = False,
45
+ ) -> None:
46
+ self.model_version = model_version
47
+ self.decode_strategy = decode_strategy
48
+ self.max_upload_bytes = max_upload_bytes
49
+ self._caption = caption
50
+ self._latency_ms = latency_ms
51
+ self._raise = raise_decode_error
52
+ self.calls: list[bytes] = []
53
+
54
+ async def caption_image_bytes(self, image_bytes: bytes) -> tuple[str, float]:
55
+ self.calls.append(image_bytes)
56
+ if self._raise:
57
+ raise ImageDecodeError("synthetic decode failure")
58
+ return self._caption, self._latency_ms
59
+
60
+
61
+ def _build_app(service: FakePredictorService | None) -> FastAPI:
62
+ app = FastAPI()
63
+ app.state.backend_settings = BackendSettings()
64
+ app.state.predictor_service = service
65
+ app.add_middleware(RequestContextMiddleware)
66
+ app.include_router(router)
67
+ return app
68
+
69
+
70
+ @pytest.fixture
71
+ def fake_service() -> FakePredictorService:
72
+ return FakePredictorService()
73
+
74
+
75
+ @pytest.fixture
76
+ def client(fake_service: FakePredictorService) -> Iterator[TestClient]:
77
+ with TestClient(_build_app(fake_service)) as test_client:
78
+ yield test_client
79
+
80
+
81
+ @pytest.fixture
82
+ def client_without_service() -> Iterator[TestClient]:
83
+ with TestClient(_build_app(None)) as test_client:
84
+ yield test_client
85
+
86
+
87
+ @pytest.fixture
88
+ def build_client() -> Callable[[FakePredictorService | None], TestClient]:
89
+ def _make(service: FakePredictorService | None) -> TestClient:
90
+ return TestClient(_build_app(service))
91
+
92
+ return _make
backend/app/tests/test_captions.py ADDED
@@ -0,0 +1,87 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for ``POST /v1/captions``.
2
+
3
+ Covers the route's status-code contract end-to-end:
4
+
5
+ * 200 — happy path, typed ``CaptionResponse`` body
6
+ * 400 — empty file upload
7
+ * 413 — payload above ``max_upload_bytes``
8
+ * 415 — disallowed content type
9
+ * 422 — bytes that the predictor cannot decode (synthetic)
10
+ * 503 — predictor not yet loaded
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from fastapi.testclient import TestClient
16
+
17
+ from app.tests.conftest import FakePredictorService
18
+
19
+
20
+ def _image_field(payload: bytes, content_type: str = "image/jpeg", name: str = "a.jpg"):
21
+ return {"image": (name, payload, content_type)}
22
+
23
+
24
+ def test_captions_happy_path_returns_typed_response(
25
+ client: TestClient, fake_service: FakePredictorService
26
+ ) -> None:
27
+ response = client.post("/v1/captions", files=_image_field(b"\xff\xd8stub"))
28
+ assert response.status_code == 200
29
+
30
+ body = response.json()
31
+ assert body["caption"] == "a test caption"
32
+ assert body["model_version"] == "test-v0"
33
+ assert body["decode_strategy"] == "greedy"
34
+ assert body["latency_ms"] == 1.23
35
+ assert body["request_id"]
36
+
37
+ # Service actually received the upload payload.
38
+ assert fake_service.calls == [b"\xff\xd8stub"]
39
+
40
+
41
+ def test_captions_request_id_matches_response_header(client: TestClient) -> None:
42
+ response = client.post(
43
+ "/v1/captions",
44
+ files=_image_field(b"\xff\xd8stub"),
45
+ headers={"x-request-id": "trace-123"},
46
+ )
47
+ assert response.status_code == 200
48
+ assert response.headers.get("x-request-id") == "trace-123"
49
+ assert response.json()["request_id"] == "trace-123"
50
+
51
+
52
+ def test_captions_rejects_unsupported_content_type(client: TestClient) -> None:
53
+ response = client.post(
54
+ "/v1/captions",
55
+ files=_image_field(b"hello", content_type="text/plain", name="a.txt"),
56
+ )
57
+ assert response.status_code == 415
58
+ assert "Unsupported content type" in response.json()["detail"]
59
+
60
+
61
+ def test_captions_rejects_empty_upload(client: TestClient) -> None:
62
+ response = client.post("/v1/captions", files=_image_field(b""))
63
+ assert response.status_code == 400
64
+ assert "Empty" in response.json()["detail"]
65
+
66
+
67
+ def test_captions_rejects_oversize_upload(client: TestClient) -> None:
68
+ # Fake service.max_upload_bytes = 1024
69
+ response = client.post("/v1/captions", files=_image_field(b"x" * 2048))
70
+ assert response.status_code == 413
71
+ assert "limit" in response.json()["detail"].lower()
72
+
73
+
74
+ def test_captions_returns_422_on_decode_failure(build_client) -> None:
75
+ bad_service = FakePredictorService(raise_decode_error=True)
76
+ with build_client(bad_service) as test_client:
77
+ response = test_client.post("/v1/captions", files=_image_field(b"\xff\xd8junk"))
78
+ assert response.status_code == 422
79
+ assert "synthetic decode failure" in response.json()["detail"]
80
+
81
+
82
+ def test_captions_returns_503_when_predictor_not_loaded(
83
+ client_without_service: TestClient,
84
+ ) -> None:
85
+ response = client_without_service.post("/v1/captions", files=_image_field(b"\xff\xd8stub"))
86
+ assert response.status_code == 503
87
+ assert "not ready" in response.json()["detail"].lower()
backend/app/tests/test_health.py ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for ``GET /healthz``.
2
+
3
+ The route reports liveness + readiness in the response body and always
4
+ returns 200; readiness is conveyed by ``model_loaded``.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from fastapi.testclient import TestClient
10
+
11
+ from app.tests.conftest import FakePredictorService
12
+
13
+
14
+ def test_healthz_reports_ready_when_service_present(client: TestClient) -> None:
15
+ response = client.get("/healthz")
16
+ assert response.status_code == 200
17
+
18
+ body = response.json()
19
+ assert body["status"] == "ok"
20
+ assert body["model_loaded"] is True
21
+ assert body["model_version"] == "test-v0"
22
+ assert body["api_version"]
23
+ assert "timestamp" in body
24
+
25
+
26
+ def test_healthz_reports_loading_when_service_missing(
27
+ client_without_service: TestClient,
28
+ ) -> None:
29
+ response = client_without_service.get("/healthz")
30
+ assert response.status_code == 200
31
+
32
+ body = response.json()
33
+ assert body["status"] == "loading"
34
+ assert body["model_loaded"] is False
35
+
36
+
37
+ def test_healthz_echoes_request_id_header(client: TestClient) -> None:
38
+ response = client.get("/healthz", headers={"x-request-id": "deadbeef"})
39
+ assert response.status_code == 200
40
+ assert response.headers.get("x-request-id") == "deadbeef"
41
+
42
+
43
+ def test_healthz_generates_request_id_when_absent(client: TestClient) -> None:
44
+ response = client.get("/healthz")
45
+ assert response.status_code == 200
46
+ rid = response.headers.get("x-request-id")
47
+ assert rid and len(rid) >= 16
48
+
49
+
50
+ def test_healthz_uses_overridden_model_version(
51
+ build_client,
52
+ ) -> None:
53
+ service = FakePredictorService(model_version="v9.9.9")
54
+ with build_client(service) as test_client:
55
+ body = test_client.get("/healthz").json()
56
+ assert body["model_version"] == "v9.9.9"
pyproject.toml CHANGED
@@ -211,6 +211,7 @@ ignore_missing_imports = true
211
  [tool.pytest.ini_options]
212
  minversion = "8.0"
213
  testpaths = ["tests", "backend/app/tests"]
 
214
  addopts = [
215
  "-ra", # Show short summary for non-passing tests
216
  "--strict-markers",
 
211
  [tool.pytest.ini_options]
212
  minversion = "8.0"
213
  testpaths = ["tests", "backend/app/tests"]
214
+ pythonpath = ["backend"] # Lets `from app.* import ...` resolve in tests
215
  addopts = [
216
  "-ra", # Show short summary for non-passing tests
217
  "--strict-markers",