mirror of
https://github.com/furyhawk/lta_datamall_api.git
synced 2026-07-20 09:48:00 +00:00
Update .gitignore and add pytest dependencies; implement test suite for bus and health routes
This commit is contained in:
@@ -1,4 +1,8 @@
|
||||
.env
|
||||
.venv/
|
||||
__pycache__/
|
||||
**/__pycache__/
|
||||
*.py[cod]
|
||||
*.pyo
|
||||
*.pyd
|
||||
.pytest_cache/
|
||||
|
||||
@@ -12,6 +12,7 @@ dependencies = [
|
||||
"valkey==6.1.0",
|
||||
"pydantic==2.11.7",
|
||||
"pydantic-settings==2.10.1",
|
||||
"pytest==8.4.1",
|
||||
]
|
||||
|
||||
[tool.uv]
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(PROJECT_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.main import create_app
|
||||
|
||||
os.environ.setdefault("DATAMALL_API_KEY", "test-account-key")
|
||||
os.environ.setdefault("VALKEY_ENABLED", "false")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def app() -> FastAPI:
|
||||
get_settings.cache_clear()
|
||||
return create_app()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(app: FastAPI):
|
||||
with TestClient(app) as test_client:
|
||||
yield test_client
|
||||
@@ -0,0 +1,106 @@
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from app.api.routes.bus import get_cache_client, get_lta_client
|
||||
from app.services.cache import CacheClient
|
||||
|
||||
|
||||
class StubLTAClient:
|
||||
def __init__(self, payload: dict[str, Any] | None = None, error: Exception | None = None):
|
||||
self.payload = payload if payload is not None else {"value": []}
|
||||
self.error = error
|
||||
self.calls: list[tuple[str, dict[str, Any] | None]] = []
|
||||
|
||||
async def get(self, path: str, params: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
self.calls.append((path, params))
|
||||
if self.error is not None:
|
||||
raise self.error
|
||||
return self.payload
|
||||
|
||||
|
||||
class StubCacheClient:
|
||||
def __init__(self, initial: dict[str, Any] | None = None):
|
||||
self._store = initial if initial is not None else {}
|
||||
self.set_calls: list[tuple[str, dict[str, Any], int | None]] = []
|
||||
|
||||
@staticmethod
|
||||
def build_cache_key(path: str, params: dict[str, Any] | None = None) -> str:
|
||||
return CacheClient.build_cache_key(path, params)
|
||||
|
||||
async def get_json(self, key: str) -> dict[str, Any] | None:
|
||||
return self._store.get(key)
|
||||
|
||||
async def set_json(self, key: str, value: dict[str, Any], ttl_seconds: int | None = None) -> None:
|
||||
self._store[key] = value
|
||||
self.set_calls.append((key, value, ttl_seconds))
|
||||
|
||||
|
||||
def _override_clients(app, lta_client: StubLTAClient, cache_client: StubCacheClient) -> None:
|
||||
async def _lta_override():
|
||||
return lta_client
|
||||
|
||||
async def _cache_override():
|
||||
return cache_client
|
||||
|
||||
app.dependency_overrides[get_lta_client] = _lta_override
|
||||
app.dependency_overrides[get_cache_client] = _cache_override
|
||||
|
||||
|
||||
def test_bus_arrival_returns_upstream_payload(app, client):
|
||||
upstream_payload = {"value": [{"ServiceNo": "10"}]}
|
||||
lta_client = StubLTAClient(payload=upstream_payload)
|
||||
cache_client = StubCacheClient()
|
||||
_override_clients(app, lta_client, cache_client)
|
||||
|
||||
response = client.get("/api/v1/bus-arrival", params={"BusStopCode": "12345", "ServiceNo": "10"})
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == upstream_payload
|
||||
assert lta_client.calls == [("/v3/BusArrival", {"BusStopCode": "12345", "ServiceNo": "10"})]
|
||||
assert len(cache_client.set_calls) == 1
|
||||
assert cache_client.set_calls[0][2] == 15
|
||||
|
||||
|
||||
def test_bus_arrival_rejects_invalid_bus_stop_code(app, client):
|
||||
lta_client = StubLTAClient(payload={"value": []})
|
||||
cache_client = StubCacheClient()
|
||||
_override_clients(app, lta_client, cache_client)
|
||||
|
||||
response = client.get("/api/v1/bus-arrival", params={"BusStopCode": "1234"})
|
||||
|
||||
assert response.status_code == 422
|
||||
assert lta_client.calls == []
|
||||
|
||||
|
||||
def test_bus_arrival_uses_cache_and_skips_upstream_call(app, client):
|
||||
params = {"BusStopCode": "54321"}
|
||||
cached_payload = {"value": [{"ServiceNo": "42"}]}
|
||||
cache_key = CacheClient.build_cache_key("/v3/BusArrival", params)
|
||||
cache_client = StubCacheClient(initial={cache_key: cached_payload})
|
||||
lta_client = StubLTAClient(payload={"value": [{"ServiceNo": "10"}]})
|
||||
_override_clients(app, lta_client, cache_client)
|
||||
|
||||
response = client.get("/api/v1/bus-arrival", params=params)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == cached_payload
|
||||
assert lta_client.calls == []
|
||||
assert cache_client.set_calls == []
|
||||
|
||||
|
||||
def test_bus_services_maps_upstream_http_error(app, client):
|
||||
request = httpx.Request("GET", "https://example.test/BusServices")
|
||||
response = httpx.Response(503, request=request, text="service unavailable")
|
||||
upstream_error = httpx.HTTPStatusError("upstream failure", request=request, response=response)
|
||||
|
||||
lta_client = StubLTAClient(error=upstream_error)
|
||||
cache_client = StubCacheClient()
|
||||
_override_clients(app, lta_client, cache_client)
|
||||
|
||||
api_response = client.get("/api/v1/bus-services", params={"$skip": 10})
|
||||
|
||||
assert api_response.status_code == 503
|
||||
assert api_response.json()["detail"]["message"] == "Upstream DataMall request failed"
|
||||
assert api_response.json()["detail"]["upstream_status"] == 503
|
||||
assert api_response.json()["detail"]["upstream_body"] == "service unavailable"
|
||||
@@ -0,0 +1,12 @@
|
||||
def test_healthz_returns_ok(client):
|
||||
response = client.get("/healthz")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"status": "ok"}
|
||||
|
||||
|
||||
def test_readyz_returns_ready(client):
|
||||
response = client.get("/readyz")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"status": "ready"}
|
||||
@@ -162,6 +162,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/de/a7/f76514cc40ad6234098ecdebda08732d75964776c51a42845b7da10649e2/idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c", size = 65316, upload-time = "2026-05-28T14:32:37.035Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "iniconfig"
|
||||
version = "2.3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lta-datamall-bus-backend"
|
||||
version = "0.1.0"
|
||||
@@ -172,6 +181,7 @@ dependencies = [
|
||||
{ name = "httpx" },
|
||||
{ name = "pydantic" },
|
||||
{ name = "pydantic-settings" },
|
||||
{ name = "pytest" },
|
||||
{ name = "uvicorn", extra = ["standard"] },
|
||||
{ name = "valkey" },
|
||||
]
|
||||
@@ -183,6 +193,7 @@ requires-dist = [
|
||||
{ name = "httpx", specifier = "==0.28.1" },
|
||||
{ name = "pydantic", specifier = "==2.11.7" },
|
||||
{ name = "pydantic-settings", specifier = "==2.10.1" },
|
||||
{ name = "pytest", specifier = "==8.4.1" },
|
||||
{ name = "uvicorn", extras = ["standard"], specifier = "==0.35.0" },
|
||||
{ name = "valkey", specifier = "==6.1.0" },
|
||||
]
|
||||
@@ -196,6 +207,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pluggy"
|
||||
version = "1.6.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pydantic"
|
||||
version = "2.11.7"
|
||||
@@ -267,6 +287,31 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/58/f0/427018098906416f580e3cf1366d3b1abfb408a0652e9f31600c24a1903c/pydantic_settings-2.10.1-py3-none-any.whl", hash = "sha256:a60952460b99cf661dc25c29c0ef171721f98bfcb52ef8d9ea4c943d7c8cc796", size = 45235, upload-time = "2025-06-24T13:26:45.485Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pygments"
|
||||
version = "2.20.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest"
|
||||
version = "8.4.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
{ name = "iniconfig" },
|
||||
{ name = "packaging" },
|
||||
{ name = "pluggy" },
|
||||
{ name = "pygments" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/08/ba/45911d754e8eba3d5a841a5ce61a65a685ff1798421ac054f85aa8747dfb/pytest-8.4.1.tar.gz", hash = "sha256:7c67fd69174877359ed9371ec3af8a3d2b04741818c51e5e99cc1742251fa93c", size = 1517714, upload-time = "2025-06-18T05:48:06.109Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/29/16/c8a903f4c4dffe7a12843191437d7cd8e32751d5de349d45d3fe69544e87/pytest-8.4.1-py3-none-any.whl", hash = "sha256:539c70ba6fcead8e78eebbf1115e8b589e7565830d7d006a8723f19ac8a0afb7", size = 365474, upload-time = "2025-06-18T05:48:03.955Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "python-dotenv"
|
||||
version = "1.2.2"
|
||||
|
||||
Reference in New Issue
Block a user