diff --git a/.gitignore b/.gitignore index cd76e71..7fe6807 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,8 @@ .env .venv/ __pycache__/ +**/__pycache__/ *.py[cod] +*.pyo +*.pyd +.pytest_cache/ diff --git a/pyproject.toml b/pyproject.toml index b4d9562..f9613e8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,6 +12,7 @@ dependencies = [ "valkey==6.1.0", "pydantic==2.11.7", "pydantic-settings==2.10.1", + "pytest==8.4.1", ] [tool.uv] diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..06e8dc2 --- /dev/null +++ b/tests/conftest.py @@ -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 diff --git a/tests/routes/test_bus.py b/tests/routes/test_bus.py new file mode 100644 index 0000000..bcf567d --- /dev/null +++ b/tests/routes/test_bus.py @@ -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" diff --git a/tests/routes/test_health.py b/tests/routes/test_health.py new file mode 100644 index 0000000..07918a9 --- /dev/null +++ b/tests/routes/test_health.py @@ -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"} diff --git a/uv.lock b/uv.lock index 32aa59d..8bd6487 100644 --- a/uv.lock +++ b/uv.lock @@ -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"