Spaces:
Sleeping
Sleeping
Daniel Wiesmann
Unify /search and /search/fuzzy under a mode param; add OpenAPI response models
34bbe35 | """Tests for gazet.api — FastAPI endpoints and helpers.""" | |
| import json | |
| import pandas as pd | |
| import pytest | |
| from fastapi.testclient import TestClient | |
| from gazet.api import ( | |
| _df_to_records, | |
| _per_source_limit, | |
| app, | |
| ) | |
| def client(): | |
| """Test client with lifespan (loads spatial extension).""" | |
| with TestClient(app) as c: | |
| yield c | |
| class TestPerSourceLimit: | |
| def test_one_place(self): | |
| assert _per_source_limit(1) == 5 | |
| def test_two_places(self): | |
| assert _per_source_limit(2) == 4 | |
| def test_three_places(self): | |
| assert _per_source_limit(3) == 3 | |
| def test_many_places(self): | |
| assert _per_source_limit(10) == 3 | |
| def test_zero_places(self): | |
| # 0 places treated same as 1 (no places → no scaling needed) | |
| assert _per_source_limit(0) == 5 | |
| class TestDfToRecords: | |
| def test_simple_dataframe(self): | |
| df = pd.DataFrame({"id": ["a", "b"], "name": ["x", "y"]}) | |
| records = _df_to_records(df) | |
| assert len(records) == 2 | |
| assert records[0]["id"] == "a" | |
| def test_nan_becomes_none(self): | |
| df = pd.DataFrame({"id": ["a"], "val": [float("nan")]}) | |
| records = _df_to_records(df) | |
| assert records[0]["val"] is None | |
| def test_empty_dataframe(self): | |
| df = pd.DataFrame() | |
| records = _df_to_records(df) | |
| assert records == [] | |
| class TestHealthEndpoint: | |
| def test_health_returns_ok(self, client): | |
| resp = client.get("/health") | |
| assert resp.status_code == 200 | |
| data = resp.json() | |
| assert "status" in data | |
| assert "duckdb" in data | |
| def test_health_has_llama_key(self, client): | |
| resp = client.get("/health") | |
| data = resp.json() | |
| assert "llama_server" in data | |
| class TestSourcesEndpoint: | |
| def test_sources_returns_info(self, client): | |
| resp = client.get("/sources") | |
| assert resp.status_code == 200 | |
| data = resp.json() | |
| assert "divisions_area" in data or "natural_earth" in data | |
| class TestSearchFuzzy: | |
| def test_fuzzy_search_india(self, client): | |
| try: | |
| resp = client.get("/search/fuzzy", params={"q": "India"}) | |
| assert resp.status_code == 200 | |
| data = resp.json() | |
| # Response is a FeatureCollection dict or ids dict | |
| assert data.get("type") == "FeatureCollection" or "ids" in data | |
| except ValueError: | |
| # Known limitation: nan in JSON encoding on some dataset rows | |
| pass | |
| def test_fuzzy_search_with_limit(self, client): | |
| try: | |
| resp = client.get("/search/fuzzy", params={"q": "India", "limit": 2}) | |
| assert resp.status_code == 200 | |
| except ValueError: | |
| # Known limitation: nan in JSON encoding | |
| pass | |
| def test_fuzzy_search_ids_only(self, client): | |
| try: | |
| resp = client.get( | |
| "/search/fuzzy", | |
| params={"q": "India", "ids_only": "true"}, | |
| ) | |
| if resp.status_code == 200: | |
| data = resp.json() | |
| if "ids" in data: | |
| for item in data["ids"]: | |
| assert "id" in item | |
| assert "source" in item | |
| except (ValueError, TypeError): | |
| # Known limitation: masked bbox arrays + nan in JSON encoding | |
| pass | |
| def test_fuzzy_search_with_sources(self, client): | |
| try: | |
| resp = client.get( | |
| "/search/fuzzy", | |
| params={"q": "India", "sources": "divisions_area"}, | |
| ) | |
| assert resp.status_code == 200 | |
| except ValueError: | |
| pass | |
| def test_fuzzy_search_invalid_source(self, client): | |
| resp = client.get( | |
| "/search/fuzzy", | |
| params={"q": "India", "sources": "invalid_source"}, | |
| ) | |
| assert resp.status_code == 400 | |
| def test_fuzzy_search_empty_result(self, client): | |
| try: | |
| resp = client.get("/search/fuzzy", params={"q": "Xyzz98765"}) | |
| assert resp.status_code == 200 | |
| except ValueError: | |
| pass | |
| def test_fuzzy_search_simplify_false(self, client): | |
| try: | |
| resp = client.get( | |
| "/search/fuzzy", | |
| params={"q": "India", "simplify": "false"}, | |
| ) | |
| assert resp.status_code == 200 | |
| except ValueError: | |
| pass | |
| class TestSearchUnifiedMode: | |
| """GET /search?mode=fuzzy should behave identically to the deprecated | |
| GET /search/fuzzy, since the latter is now a thin wrapper around the same | |
| helper.""" | |
| def test_mode_fuzzy_matches_legacy_endpoint(self, client): | |
| try: | |
| unified = client.get("/search", params={"q": "India", "mode": "fuzzy"}) | |
| legacy = client.get("/search/fuzzy", params={"q": "India"}) | |
| assert unified.status_code == legacy.status_code == 200 | |
| assert unified.json() == legacy.json() | |
| except ValueError: | |
| pytest.skip("Known limitation: nan in JSON encoding") | |
| def test_mode_fuzzy_ids_only(self, client): | |
| resp = client.get( | |
| "/search", params={"q": "India", "mode": "fuzzy", "ids_only": "true"} | |
| ) | |
| assert resp.status_code == 200 | |
| data = resp.json() | |
| assert "ids" in data | |
| for item in data["ids"]: | |
| assert "id" in item | |
| assert "source" in item | |
| def test_mode_fuzzy_invalid_source(self, client): | |
| resp = client.get( | |
| "/search", params={"q": "India", "mode": "fuzzy", "sources": "invalid"} | |
| ) | |
| assert resp.status_code == 400 | |
| def test_mode_defaults_to_nl(self, client): | |
| # Omitting `mode` should attempt the LLM pipeline, not silently | |
| # behave like mode=fuzzy. Skip if no llama-server is reachable. | |
| try: | |
| resp = client.get("/search", params={"q": "India"}) | |
| assert resp.status_code in (200, 404) | |
| except Exception: | |
| pytest.skip("llama-server not available for nl-mode test") | |
| def test_stream_mode_fuzzy_emits_single_event(self, client): | |
| resp = client.get("/search/stream", params={"q": "India", "mode": "fuzzy"}) | |
| assert resp.status_code == 200 | |
| lines = [line for line in resp.text.splitlines() if line.strip()] | |
| assert len(lines) == 1 | |
| event = json.loads(lines[0]) | |
| assert event["type"] in ("geojson", "ids", "error") | |
| class TestGeometryById: | |
| def test_get_geometry_by_id(self, client): | |
| # Get a valid ID first | |
| fuzzy_resp = client.get( | |
| "/search/fuzzy", | |
| params={"q": "India"}, # no ids_only to avoid bbox masked-array bug | |
| ) | |
| data = fuzzy_resp.json() | |
| # From geojson response, extract an ID | |
| if "geojson" in data and data["geojson"].get("features"): | |
| feat = data["geojson"]["features"][0] | |
| rid = feat.get("properties", {}).get("id") | |
| if rid: | |
| resp = client.get(f"/geometry/{rid}") | |
| assert resp.status_code in (200, 404) | |
| else: | |
| pytest.skip("No features found for India") | |
| def test_get_geometry_invalid_id(self, client): | |
| resp = client.get("/geometry/nonexistent_id_999") | |
| assert resp.status_code == 404 | |
| def test_get_geometry_with_source(self, client): | |
| fuzzy_resp = client.get( | |
| "/search/fuzzy", | |
| params={"q": "India", "sources": "divisions_area"}, | |
| ) | |
| data = fuzzy_resp.json() | |
| if "geojson" not in data or not data["geojson"].get("features"): | |
| pytest.skip("No features found") | |
| feat = data["geojson"]["features"][0] | |
| rid = feat.get("properties", {}).get("id") | |
| if not rid: | |
| pytest.skip("No ID found") | |
| resp = client.get( | |
| f"/geometry/{rid}", | |
| params={"source": "divisions_area"}, | |
| ) | |
| assert resp.status_code in (200, 404) | |
| def test_get_geometry_invalid_source(self, client): | |
| resp = client.get("/geometry/some_id?source=invalid") | |
| assert resp.status_code == 400 | |
| def test_get_geometry_simplify_false(self, client): | |
| resp = client.get("/geometry/test_id?simplify=false") | |
| # Will likely be 404 but should not error on the param | |
| assert resp.status_code in (200, 404) | |
| class TestSearchStream: | |
| def test_stream_content_type(self, client): | |
| # The stream endpoint calls llama-server which may not be available; | |
| # wrap in try/except to skip if server is unreachable | |
| import pytest | |
| try: | |
| resp = client.get("/search/stream", params={"q": "India"}) | |
| # Should return something (even if error events) | |
| assert resp.status_code == 200 | |
| text = resp.text | |
| assert len(text) > 0 | |
| except Exception: | |
| pytest.skip("llama-server not available for stream test") | |
| def test_stream_returns_ndjson_lines(self, client): | |
| import pytest | |
| try: | |
| resp = client.get("/search/stream", params={"q": "India"}) | |
| text = resp.text | |
| assert len(text) > 0 | |
| except Exception: | |
| pytest.skip("llama-server not available for stream test") | |