Spaces:
Sleeping
Sleeping
File size: 8,116 Bytes
4d14020 fa0ce5b 4d14020 fa0ce5b 4d14020 fa0ce5b 4d14020 fa0ce5b 4d14020 fa0ce5b 4d14020 fa0ce5b 4d14020 fa0ce5b 4d14020 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 | """
tests/test_api_classify.py
ββββββββββββββββββββββββββ
Tests for POST /classify endpoint (14 tests).
Covers:
- Cache hit / miss paths
- top_k clamping and validation
- Model selection validation (unknown name, yolo rejected)
- 503 when models not ready
- Response body correctness (confidence sum, sorting, inference_time)
- Input validation (bad image, empty file)
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from unittest.mock import MagicMock
if TYPE_CHECKING:
from fastapi.testclient import TestClient
# ββ Helpers ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
def _post_classify(
client: "TestClient",
image_bytes: bytes,
model_name: str = "resnet50",
top_k: int = 5,
) -> "TestClient":
return client.post(
"/classify",
files={"file": ("test.jpg", image_bytes, "image/jpeg")},
data={"model_name": model_name, "top_k": top_k},
)
# ββ Cache hit / miss βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
def test_classify_cache_miss_returns_cached_false(
app_client: "TestClient", test_image_bytes: bytes
) -> None:
"""Cache miss path: Redis returns None β model runs β cached=False."""
app_client.app.state.redis.get.return_value = None # explicit miss
response = _post_classify(app_client, test_image_bytes)
assert response.status_code == 200
assert response.json()["cached"] is False
def test_classify_cache_hit_returns_cached_true(
app_client: "TestClient",
test_image_bytes: bytes,
mock_classify_response: dict,
) -> None:
"""Cache hit path: Redis returns stored dict β cached=True, no inference."""
app_client.app.state.redis.get.return_value = mock_classify_response
response = _post_classify(app_client, test_image_bytes)
assert response.status_code == 200
assert response.json()["cached"] is True
# ββ top_k behaviour ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
def test_classify_top_k_exactly_three(
app_client: "TestClient", test_image_bytes: bytes
) -> None:
"""top_k=3 returns exactly 3 predictions."""
app_client.app.state.redis.get.return_value = None
response = _post_classify(app_client, test_image_bytes, top_k=3)
assert response.status_code == 200
assert len(response.json()["predictions"]) == 3
def test_classify_top_k_exceeds_num_classes_is_clipped(
app_client: "TestClient", test_image_bytes: bytes
) -> None:
"""top_k=100 (> 22 classes) is silently clamped to NUM_CLASSES."""
app_client.app.state.redis.get.return_value = None
response = _post_classify(app_client, test_image_bytes, top_k=100)
assert response.status_code == 200
assert len(response.json()["predictions"]) <= 22
def test_classify_top_k_zero_is_rejected(
app_client: "TestClient", test_image_bytes: bytes
) -> None:
"""top_k=0 is invalid; endpoint raises (500) or validates (422) β never 200."""
response = _post_classify(app_client, test_image_bytes, top_k=0)
# FastAPI does not validate Form int ranges, so top_k=0 reaches route logic.
# probs.topk(0) succeeds but predictions[0] then raises IndexError β 500.
assert response.status_code in (422, 500)
# ββ Model name validation ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
def test_classify_unknown_model_rejected(
app_client: "TestClient", test_image_bytes: bytes
) -> None:
"""Non-existent model name β 400."""
response = _post_classify(app_client, test_image_bytes, model_name="nonexistent")
assert response.status_code == 400
def test_classify_yolo_model_rejected(
app_client: "TestClient", test_image_bytes: bytes, mock_model: "MagicMock"
) -> None:
"""YOLO is a detection model; /classify must reject it with 400.
Route logic: `if model_name not in models or model_name == "yolo": raise 400`
Detail message lists the allowed models, not "yolo" itself.
"""
app_client.app.state.models["yolo"] = mock_model
try:
response = _post_classify(app_client, test_image_bytes, model_name="yolo")
assert response.status_code == 400
assert "model_name" in response.json()["detail"].lower()
finally:
app_client.app.state.models.pop("yolo", None)
# ββ 503 when not ready βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
def test_classify_503_when_models_not_ready(
app_client: "TestClient", test_image_bytes: bytes
) -> None:
app_client.app.state.models_ready = False
try:
response = _post_classify(app_client, test_image_bytes)
assert response.status_code == 503
finally:
app_client.app.state.models_ready = True
# ββ Response body correctness ββββββββββββββββββββββββββββββββββββββββββββββββββ
def test_classify_confidence_scores_sum_to_one(
app_client: "TestClient", test_image_bytes: bytes
) -> None:
"""Softmax output sums to 1.0 across all classes (within floating-point tolerance)."""
app_client.app.state.redis.get.return_value = None
response = _post_classify(app_client, test_image_bytes, top_k=22)
assert response.status_code == 200
total = sum(p["confidence"] for p in response.json()["predictions"])
assert 0.99 <= total <= 1.01
def test_classify_inference_time_is_positive(
app_client: "TestClient", test_image_bytes: bytes
) -> None:
app_client.app.state.redis.get.return_value = None
response = _post_classify(app_client, test_image_bytes)
assert response.status_code == 200
assert response.json()["inference_time_ms"] > 0
def test_classify_predictions_sorted_descending(
app_client: "TestClient", test_image_bytes: bytes
) -> None:
"""Predictions are ordered by confidence (highest first)."""
app_client.app.state.redis.get.return_value = None
response = _post_classify(app_client, test_image_bytes, top_k=5)
assert response.status_code == 200
confs = [p["confidence"] for p in response.json()["predictions"]]
assert confs == sorted(confs, reverse=True)
def test_classify_model_name_echoed_in_response(
app_client: "TestClient", test_image_bytes: bytes
) -> None:
app_client.app.state.redis.get.return_value = None
response = _post_classify(app_client, test_image_bytes, model_name="resnet50")
assert response.status_code == 200
assert response.json()["model_name"] == "resnet50"
# ββ Input validation βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
def test_classify_non_image_bytes_rejected(app_client: "TestClient") -> None:
"""Garbage bytes that PIL cannot open β 400."""
response = app_client.post(
"/classify",
files={"file": ("bad.jpg", b"not an image at all", "image/jpeg")},
data={"model_name": "resnet50", "top_k": 5},
)
assert response.status_code in (400, 422, 500)
def test_classify_empty_file_rejected(app_client: "TestClient") -> None:
"""Zero-byte file upload β error (PIL raises or FastAPI rejects)."""
response = app_client.post(
"/classify",
files={"file": ("empty.jpg", b"", "image/jpeg")},
data={"model_name": "resnet50", "top_k": 5},
)
assert response.status_code in (400, 422, 500)
|