inference-server / tests /test_api.py
Aditya Kulkarni
feat: landing server-status panel and embed ttft/ttfr
5e96f99
Raw History Blame Contribute Delete
5.13 kB
"""Endpoint tests using FastAPI's TestClient with the model stubbed out.
The `client` fixture patches `app.main.load_model` so the lifespan loads the FakeModel
instead of downloading MiniLM, while still exercising the real batcher and endpoint code.
"""
import pytest
from fastapi.testclient import TestClient
import app.main as main_module
from app.batching import QueueFullError, RequestTimeoutError
from app.config import settings
from app.main import app
@pytest.fixture
def client(monkeypatch, make_model):
"""A TestClient whose lifespan loads a FakeModel instead of downloading MiniLM."""
monkeypatch.setattr(main_module, "load_model", lambda *args, **kwargs: make_model())
with TestClient(app) as test_client:
yield test_client
def test_landing(client):
"""GET / is an HTML landing that lists the public endpoints."""
resp = client.get("/")
assert resp.status_code == 200
assert "text/html" in resp.headers["content-type"]
body = resp.text
for path in ("/health", "/metrics", "/embed", "/docs", "/redoc", "/openapi.json"):
assert path in body
assert 'id="runtime"' in body
assert "Server status" in body
assert "queue_depth" in body
assert "ttft_ms" in body
def test_health(client):
resp = client.get("/health")
assert resp.status_code == 200
body = resp.json()
assert body["status"] == "ready"
assert body["model_loaded"] is True
assert body["worker_alive"] is True
assert body["queue_depth"] == 0
assert body["inflight"] == 0
assert body["max_queue_size"] == settings.max_queue_size
assert body["max_batch_size"] == settings.max_batch_size
assert "model" in body
assert "device" in body
assert "backend" in body
def test_health_not_ready_returns_503(client, monkeypatch):
"""/health returns 503 when the batch worker is not alive."""
monkeypatch.setattr(app.state.batcher, "is_running", lambda: False)
resp = client.get("/health")
assert resp.status_code == 503
assert resp.json()["status"] == "not ready"
def test_metrics(client):
"""/metrics reuses the /health snapshot and stays 200 when ready."""
resp = client.get("/metrics")
assert resp.status_code == 200
body = resp.json()
assert body["status"] == "ready"
assert body["queue_depth"] == 0
assert body["inflight"] == 0
assert body["max_queue_size"] == settings.max_queue_size
assert body["max_batch_size"] == settings.max_batch_size
assert "backend" in body
assert "device" in body
def test_metrics_stays_200_when_not_ready(client, monkeypatch):
"""/metrics is a scrape endpoint, not a readiness probe."""
monkeypatch.setattr(app.state.batcher, "is_running", lambda: False)
resp = client.get("/metrics")
assert resp.status_code == 200
assert resp.json()["status"] == "not ready"
def test_request_id_generated(client):
"""Every response carries an X-Request-ID header."""
resp = client.get("/health")
assert resp.headers.get("X-Request-ID")
def test_request_id_echoed(client):
"""An inbound X-Request-ID is echoed back on the response."""
resp = client.get("/health", headers={"X-Request-ID": "abc123"})
assert resp.headers.get("X-Request-ID") == "abc123"
def test_embed(client, make_model):
resp = client.post("/embed", json={"texts": ["hello", "world"]})
assert resp.status_code == 200
body = resp.json()
assert body["num_texts"] == 2
assert body["dim"] == 4
assert len(body["embeddings"]) == 2
assert body["embeddings"] == make_model().encode(["hello", "world"]).tolist()
assert body["ttfr_ms"] >= body["ttft_ms"] >= 0
def test_embed_rejects_empty_texts(client):
"""An empty texts list fails validation (422) before reaching the model."""
assert client.post("/embed", json={"texts": []}).status_code == 422
def test_embed_rejects_too_many_texts(client, monkeypatch):
"""More than max_texts_per_request texts is rejected with 422."""
monkeypatch.setattr(settings, "max_texts_per_request", 2)
assert client.post("/embed", json={"texts": ["a", "b", "c"]}).status_code == 422
def test_embed_rejects_long_text(client, monkeypatch):
"""A text longer than max_chars_per_text is rejected with 422."""
monkeypatch.setattr(settings, "max_chars_per_text", 5)
assert client.post("/embed", json={"texts": ["this is too long"]}).status_code == 422
def test_embed_queue_full_returns_503(client, monkeypatch):
"""A QueueFullError from the batcher is mapped to 503 by the endpoint."""
async def reject(texts):
raise QueueFullError("full")
monkeypatch.setattr(app.state.batcher, "submit", reject)
assert client.post("/embed", json={"texts": ["x"]}).status_code == 503
def test_embed_timeout_returns_504(client, monkeypatch):
"""A RequestTimeoutError from the batcher is mapped to 504 by the endpoint."""
async def time_out(texts):
raise RequestTimeoutError("timeout")
monkeypatch.setattr(app.state.batcher, "submit", time_out)
assert client.post("/embed", json={"texts": ["x"]}).status_code == 504