mirror of
https://github.com/furyhawk/lta_datamall_api.git
synced 2026-07-21 02:06:50 +00:00
Add unit tests for CacheClient and LTAClient with mock implementations
This commit is contained in:
@@ -0,0 +1,122 @@
|
||||
import asyncio
|
||||
import json
|
||||
|
||||
from valkey.exceptions import ValkeyError
|
||||
|
||||
from app.core.config import Settings
|
||||
from app.services.cache import CacheClient
|
||||
|
||||
|
||||
def make_settings(**overrides):
|
||||
return Settings(datamall_api_key="test-account-key", **overrides)
|
||||
|
||||
|
||||
def run(coro):
|
||||
return asyncio.run(coro)
|
||||
|
||||
|
||||
class FakeValkeyClient:
|
||||
def __init__(self, get_value=None, get_error=None, set_error=None):
|
||||
self.get_value = get_value
|
||||
self.get_error = get_error
|
||||
self.set_error = set_error
|
||||
self.get_calls = []
|
||||
self.set_calls = []
|
||||
self.ping_calls = 0
|
||||
self.closed = False
|
||||
|
||||
async def ping(self):
|
||||
self.ping_calls += 1
|
||||
|
||||
async def get(self, key):
|
||||
self.get_calls.append(key)
|
||||
if self.get_error is not None:
|
||||
raise self.get_error
|
||||
return self.get_value
|
||||
|
||||
async def set(self, key, value, ex=None):
|
||||
self.set_calls.append((key, value, ex))
|
||||
if self.set_error is not None:
|
||||
raise self.set_error
|
||||
|
||||
async def aclose(self):
|
||||
self.closed = True
|
||||
|
||||
|
||||
def test_build_cache_key_is_deterministic():
|
||||
first = CacheClient.build_cache_key("/v3/BusArrival", {"BusStopCode": "12345", "ServiceNo": "10"})
|
||||
second = CacheClient.build_cache_key("/v3/BusArrival", {"ServiceNo": "10", "BusStopCode": "12345"})
|
||||
|
||||
assert first == second
|
||||
|
||||
|
||||
def test_get_json_returns_parsed_payload():
|
||||
cache = CacheClient(make_settings(valkey_enabled=True))
|
||||
cache._client = FakeValkeyClient(get_value=json.dumps({"value": [1, 2, 3]}))
|
||||
|
||||
result = run(cache.get_json("lta:/v3/BusArrival:test"))
|
||||
|
||||
assert result == {"value": [1, 2, 3]}
|
||||
|
||||
|
||||
def test_get_json_returns_none_for_invalid_payload():
|
||||
cache = CacheClient(make_settings(valkey_enabled=True))
|
||||
cache._client = FakeValkeyClient(get_value="not-json")
|
||||
|
||||
result = run(cache.get_json("lta:/v3/BusArrival:test"))
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
def test_get_json_returns_none_when_cache_is_unavailable():
|
||||
cache = CacheClient(make_settings(valkey_enabled=True))
|
||||
cache._client = FakeValkeyClient(get_error=ValkeyError("boom"))
|
||||
|
||||
result = run(cache.get_json("lta:/v3/BusArrival:test"))
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
def test_set_json_uses_default_ttl_when_not_provided():
|
||||
cache = CacheClient(make_settings(valkey_default_ttl_seconds=321))
|
||||
cache._client = FakeValkeyClient()
|
||||
|
||||
run(cache.set_json("lta:/v3/BusArrival:test", {"value": True}))
|
||||
|
||||
assert cache._client.set_calls == [("lta:/v3/BusArrival:test", '{"value":true}', 321)]
|
||||
|
||||
|
||||
def test_set_json_uses_explicit_ttl():
|
||||
cache = CacheClient(make_settings(valkey_default_ttl_seconds=321))
|
||||
cache._client = FakeValkeyClient()
|
||||
|
||||
run(cache.set_json("lta:/v3/BusArrival:test", {"value": True}, ttl_seconds=12))
|
||||
|
||||
assert cache._client.set_calls == [("lta:/v3/BusArrival:test", '{"value":true}', 12)]
|
||||
|
||||
|
||||
def test_startup_disables_cache_when_ping_fails(monkeypatch):
|
||||
fake_client = FakeValkeyClient()
|
||||
|
||||
class FakeFactory:
|
||||
def __init__(self):
|
||||
self.calls = []
|
||||
|
||||
def __call__(self, url, decode_responses, socket_connect_timeout):
|
||||
self.calls.append((url, decode_responses, socket_connect_timeout))
|
||||
return fake_client
|
||||
|
||||
factory = FakeFactory()
|
||||
|
||||
async def failing_ping():
|
||||
raise ValkeyError("boom")
|
||||
|
||||
fake_client.ping = failing_ping
|
||||
monkeypatch.setattr("app.services.cache.valkey.from_url", factory)
|
||||
|
||||
cache = CacheClient(make_settings(valkey_enabled=True))
|
||||
run(cache.startup())
|
||||
|
||||
assert factory.calls == [("redis://valkey:6379/0", True, 1.0)]
|
||||
assert fake_client.closed is True
|
||||
assert cache._client is None
|
||||
@@ -0,0 +1,104 @@
|
||||
import asyncio
|
||||
|
||||
import httpx
|
||||
|
||||
from app.core.config import Settings
|
||||
from app.services import lta_client as lta_client_module
|
||||
from app.services.lta_client import LTAClient
|
||||
|
||||
|
||||
def make_settings(**overrides):
|
||||
return Settings(datamall_api_key="test-account-key", **overrides)
|
||||
|
||||
|
||||
def run(coro):
|
||||
return asyncio.run(coro)
|
||||
|
||||
|
||||
class FakeResponse:
|
||||
def __init__(self, payload=None, status_code=200):
|
||||
self._payload = payload if payload is not None else {"value": []}
|
||||
self.status_code = status_code
|
||||
|
||||
def raise_for_status(self):
|
||||
if self.status_code >= 400:
|
||||
request = httpx.Request("GET", "https://example.test")
|
||||
response = httpx.Response(self.status_code, request=request)
|
||||
raise httpx.HTTPStatusError("boom", request=request, response=response)
|
||||
|
||||
def json(self):
|
||||
return self._payload
|
||||
|
||||
|
||||
class FakeAsyncClient:
|
||||
instances = []
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
self.kwargs = kwargs
|
||||
self.calls = []
|
||||
self.closed = False
|
||||
FakeAsyncClient.instances.append(self)
|
||||
|
||||
async def get(self, path, params=None):
|
||||
self.calls.append((path, params))
|
||||
return FakeResponse({"path": path, "params": params})
|
||||
|
||||
async def aclose(self):
|
||||
self.closed = True
|
||||
|
||||
|
||||
def test_startup_configures_httpx_client(monkeypatch):
|
||||
FakeAsyncClient.instances = []
|
||||
monkeypatch.setattr(lta_client_module.httpx, "AsyncClient", FakeAsyncClient)
|
||||
|
||||
client = LTAClient(make_settings(request_timeout_seconds=7.5, max_connections=12, max_keepalive_connections=6))
|
||||
run(client.startup())
|
||||
|
||||
created = FakeAsyncClient.instances[0]
|
||||
assert created.kwargs["base_url"] == "https://datamall2.mytransport.sg/ltaodataservice"
|
||||
assert created.kwargs["headers"] == {"AccountKey": "test-account-key", "accept": "application/json"}
|
||||
assert created.kwargs["timeout"].connect == 7.5
|
||||
assert created.kwargs["timeout"].read == 7.5
|
||||
assert created.kwargs["timeout"].write == 7.5
|
||||
assert created.kwargs["timeout"].pool == 7.5
|
||||
assert created.kwargs["limits"].max_connections == 12
|
||||
assert created.kwargs["limits"].max_keepalive_connections == 6
|
||||
|
||||
|
||||
def test_get_raises_before_startup():
|
||||
client = LTAClient(make_settings())
|
||||
|
||||
try:
|
||||
run(client.get("/v3/BusArrival"))
|
||||
raised = False
|
||||
except RuntimeError as err:
|
||||
raised = True
|
||||
assert str(err) == "LTA client not initialized"
|
||||
|
||||
assert raised is True
|
||||
|
||||
|
||||
def test_get_returns_json_payload(monkeypatch):
|
||||
FakeAsyncClient.instances = []
|
||||
monkeypatch.setattr(lta_client_module.httpx, "AsyncClient", FakeAsyncClient)
|
||||
|
||||
client = LTAClient(make_settings())
|
||||
run(client.startup())
|
||||
|
||||
payload = run(client.get("/v3/BusArrival", params={"BusStopCode": "12345"}))
|
||||
|
||||
assert payload == {"path": "/v3/BusArrival", "params": {"BusStopCode": "12345"}}
|
||||
assert FakeAsyncClient.instances[0].calls == [("/v3/BusArrival", {"BusStopCode": "12345"})]
|
||||
|
||||
|
||||
def test_shutdown_closes_client(monkeypatch):
|
||||
FakeAsyncClient.instances = []
|
||||
monkeypatch.setattr(lta_client_module.httpx, "AsyncClient", FakeAsyncClient)
|
||||
|
||||
client = LTAClient(make_settings())
|
||||
run(client.startup())
|
||||
created = FakeAsyncClient.instances[0]
|
||||
|
||||
run(client.shutdown())
|
||||
|
||||
assert created.closed is True
|
||||
Reference in New Issue
Block a user