Initial commit
This commit is contained in:
commit
d71d560d29
8 changed files with 1325 additions and 0 deletions
298
tests/test_client.py
Normal file
298
tests/test_client.py
Normal file
|
|
@ -0,0 +1,298 @@
|
|||
"""Tests for the ODMS client, against a real HTTP server on localhost.
|
||||
|
||||
No mocking library and no monkeypatched sockets: the client's whole job is to
|
||||
speak HTTP correctly, and a fake that intercepts above the socket cannot catch a
|
||||
malformed query string, a mishandled 429, or a retry that silently repeats a
|
||||
POST. ``http.server`` is in the standard library and costs milliseconds.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
import threading
|
||||
from datetime import datetime, timezone
|
||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||
from pathlib import Path
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from odms import NotFound, OdmsClient, RateLimited, Unauthorized # noqa: E402
|
||||
from odms.errors import HTTPError, ServiceUnavailable # noqa: E402
|
||||
|
||||
|
||||
class Recorder:
|
||||
"""The scripted responses, and what the client actually asked for."""
|
||||
|
||||
def __init__(self):
|
||||
self.requests: list[dict] = []
|
||||
self.responses: list[tuple] = []
|
||||
|
||||
def reply(self, status: int, body="", headers=None):
|
||||
"""Queue one response. The last queued one repeats once used up."""
|
||||
if isinstance(body, (dict, list)):
|
||||
body = json.dumps(body)
|
||||
headers = {"Content-Type": "application/json", **(headers or {})}
|
||||
self.responses.append((status, body, headers or {}))
|
||||
return self
|
||||
|
||||
def next_response(self):
|
||||
if len(self.responses) > 1:
|
||||
return self.responses.pop(0)
|
||||
return self.responses[0] if self.responses else (200, "{}", {})
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def _server():
|
||||
"""One server for the module. Binding a socket and starting a thread costs
|
||||
most of a second here, and 22 of them is a suite nobody runs."""
|
||||
recorder = Recorder()
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
def log_message(self, *_args):
|
||||
pass # the test output is not a web server log
|
||||
|
||||
def _handle(self):
|
||||
parsed = urlparse(self.path)
|
||||
length = int(self.headers.get("Content-Length") or 0)
|
||||
recorder.requests.append({
|
||||
"method": self.command,
|
||||
"path": parsed.path,
|
||||
"query": parse_qs(parsed.query),
|
||||
"body": self.rfile.read(length).decode() if length else "",
|
||||
"headers": dict(self.headers),
|
||||
})
|
||||
status, body, headers = recorder.next_response()
|
||||
payload = body.encode()
|
||||
self.send_response(status)
|
||||
for key, value in headers.items():
|
||||
self.send_header(key, value)
|
||||
self.send_header("Content-Length", str(len(payload)))
|
||||
self.end_headers()
|
||||
self.wfile.write(payload)
|
||||
|
||||
do_GET = do_POST = _handle
|
||||
|
||||
class FastServer(HTTPServer):
|
||||
def server_bind(self):
|
||||
# HTTPServer.server_bind resolves its own FQDN for the Server header,
|
||||
# which costs ~half a second per bind on Windows. Nothing under test
|
||||
# reads it.
|
||||
super(HTTPServer, self).server_bind()
|
||||
self.server_name = "127.0.0.1"
|
||||
self.server_port = self.socket.getsockname()[1]
|
||||
|
||||
httpd = FastServer(("127.0.0.1", 0), Handler)
|
||||
thread = threading.Thread(target=httpd.serve_forever, kwargs={"poll_interval": 0.02})
|
||||
thread.daemon = True
|
||||
thread.start()
|
||||
recorder.url = f"http://127.0.0.1:{httpd.server_port}"
|
||||
try:
|
||||
yield recorder
|
||||
finally:
|
||||
httpd.shutdown()
|
||||
httpd.server_close()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def server(_server):
|
||||
_server.requests.clear()
|
||||
_server.responses.clear()
|
||||
return _server
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(server):
|
||||
# Retries with no backoff: the retry *policy* is what is under test, and
|
||||
# sleeping through it would only make the suite slow.
|
||||
with OdmsClient(server.url, token="t0ken", max_retries=2) as odms:
|
||||
odms._transport.backoff_s = 0.0
|
||||
# urllib is the floor every consumer gets; httpx, if installed here, is
|
||||
# an optimisation the tests should not accidentally become dependent on.
|
||||
odms._transport._session = None
|
||||
yield odms
|
||||
|
||||
|
||||
# --- request shape ----------------------------------------------------------
|
||||
|
||||
|
||||
def test_the_token_is_sent_as_a_bearer_header(server, client):
|
||||
server.reply(200, {"results": []})
|
||||
client.satellites()
|
||||
assert server.requests[0]["headers"]["Authorization"] == "Bearer t0ken"
|
||||
|
||||
|
||||
def test_unset_filters_are_omitted_rather_than_sent_as_none(server, client):
|
||||
"""A `?source=None` filter matches no source and returns nothing, silently."""
|
||||
server.reply(200, {"count": 0, "results": []})
|
||||
client.query(internal_id="iss", source=None, name=None)
|
||||
|
||||
query = server.requests[0]["query"]
|
||||
assert query == {"internal_id": ["iss"], "format": ["json"]}
|
||||
|
||||
|
||||
def test_datetimes_are_sent_as_iso_8601(server, client):
|
||||
server.reply(200, {"count": 0, "results": []})
|
||||
client.at("iss", datetime(2026, 4, 1, tzinfo=timezone.utc))
|
||||
|
||||
assert server.requests[0]["query"]["datetime"] == ["2026-04-01T00:00:00+00:00"]
|
||||
|
||||
|
||||
def test_booleans_are_sent_the_way_django_reads_them(server, client):
|
||||
"""Python's str(True) is "True"; str(False) is "False" -- but a lowercased
|
||||
"false" is truthy to a naive parser, which is the bug this pins."""
|
||||
server.reply(201, {"id": 1})
|
||||
client.push_message("CCSDS_OPM_VERS = 3.0", key="iss", is_public=False)
|
||||
|
||||
assert json.loads(server.requests[0]["body"])["is_public"] is False
|
||||
|
||||
|
||||
def test_an_unknown_filter_raises_instead_of_being_dropped(client):
|
||||
"""A typo'd filter that quietly returns the whole catalogue is worse than
|
||||
an exception -- the caller gets plausible, wrong data."""
|
||||
with pytest.raises(TypeError, match="norrad"):
|
||||
client.query(norrad="25544")
|
||||
|
||||
|
||||
def test_an_unknown_format_raises_before_the_request(server, client):
|
||||
with pytest.raises(ValueError, match="format"):
|
||||
client.query(format="parquet")
|
||||
assert server.requests == []
|
||||
|
||||
|
||||
def test_transform_requires_exactly_one_source(client):
|
||||
with pytest.raises(TypeError):
|
||||
client.transform("ITRF")
|
||||
with pytest.raises(TypeError):
|
||||
client.transform("ITRF", key="iss", message_id=4)
|
||||
|
||||
|
||||
# --- responses --------------------------------------------------------------
|
||||
|
||||
|
||||
def test_rendered_formats_come_back_as_text_not_json(server, client):
|
||||
lines = "ISS (ZARYA)\n1 25544U ...\n2 25544 ..."
|
||||
server.reply(200, lines, {"Content-Type": "text/plain"})
|
||||
|
||||
assert client.download(internal_id="iss", format="tle") == lines
|
||||
|
||||
|
||||
def test_latest_returns_none_when_the_satellite_has_no_elements(server, client):
|
||||
server.reply(200, {"count": 0, "results": []})
|
||||
assert client.latest("iss") is None
|
||||
|
||||
|
||||
def test_decay_latest_returns_none_when_there_is_no_forecast(server, client):
|
||||
"""No forecast is a normal state: an object with no fittable history is
|
||||
skipped deliberately rather than predicted from a coefficient nobody can
|
||||
justify. Callers must not have to catch an exception for the normal case."""
|
||||
server.reply(404, {"detail": "no forecast"})
|
||||
assert client.decay_latest("iss") is None
|
||||
|
||||
|
||||
def test_find_returns_none_where_satellite_raises(server, client):
|
||||
server.reply(404, {"detail": "not found"})
|
||||
assert client.find("nope") is None
|
||||
with pytest.raises(NotFound):
|
||||
client.satellite("nope")
|
||||
|
||||
|
||||
# --- errors -----------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize("status,expected", [
|
||||
(401, Unauthorized),
|
||||
(403, Unauthorized),
|
||||
(404, NotFound),
|
||||
(400, HTTPError),
|
||||
])
|
||||
def test_statuses_map_onto_actionable_exceptions(server, client, status, expected):
|
||||
server.reply(status, {"detail": "nope"})
|
||||
with pytest.raises(expected):
|
||||
client.satellite("iss")
|
||||
|
||||
|
||||
def test_the_error_detail_is_lifted_out_of_a_json_body(server, client):
|
||||
server.reply(400, {"detail": "end must be >= start"})
|
||||
with pytest.raises(HTTPError) as caught:
|
||||
client.satellite("iss")
|
||||
assert caught.value.detail == "end must be >= start"
|
||||
assert caught.value.status == 400
|
||||
|
||||
|
||||
def test_a_non_json_error_body_still_produces_a_usable_message(server, client):
|
||||
server.reply(502, "<html>Bad Gateway</html>", {"Content-Type": "text/html"})
|
||||
with pytest.raises(ServiceUnavailable) as caught:
|
||||
client.satellite("iss")
|
||||
assert "Bad Gateway" in str(caught.value)
|
||||
|
||||
|
||||
# --- retries ----------------------------------------------------------------
|
||||
|
||||
|
||||
def test_a_429_is_retried_and_then_succeeds(server, client):
|
||||
server.reply(429, "slow down", {"Retry-After": "0"})
|
||||
server.reply(200, {"results": [{"internal_id": "iss"}]})
|
||||
|
||||
assert client.satellites() == [{"internal_id": "iss"}]
|
||||
assert len(server.requests) == 2
|
||||
|
||||
|
||||
def test_a_persistent_429_raises_with_the_servers_advice(server, client):
|
||||
server.reply(429, "slow down", {"Retry-After": "0"})
|
||||
|
||||
with pytest.raises(RateLimited) as caught:
|
||||
client.satellites()
|
||||
assert caught.value.retry_after == 0
|
||||
assert len(server.requests) == 3 # the original plus max_retries
|
||||
|
||||
|
||||
def test_a_500_is_not_retried(server, client):
|
||||
"""It means ODMS took the request and broke on it. Repeating it breaks it
|
||||
again, and turns one alert into three."""
|
||||
server.reply(500, "boom")
|
||||
|
||||
with pytest.raises(HTTPError):
|
||||
client.satellites()
|
||||
assert len(server.requests) == 1
|
||||
|
||||
|
||||
def test_a_503_is_retried_even_for_a_post(server, client):
|
||||
"""503 proves the request was not acted on, so repeating it cannot
|
||||
duplicate anything -- unlike a transport failure."""
|
||||
server.reply(503, "busy")
|
||||
server.reply(201, {"id": 7})
|
||||
|
||||
assert client.push_message("CCSDS_OPM_VERS = 3.0", key="iss") == {"id": 7}
|
||||
assert len(server.requests) == 2
|
||||
|
||||
|
||||
def test_a_transport_failure_never_repeats_a_post(server):
|
||||
"""A push that died in flight may already have stored the message. Retrying
|
||||
would store a second copy, and nothing downstream would notice."""
|
||||
from odms.errors import TransportError
|
||||
|
||||
# Nothing is listening on this port, so every attempt fails in transport.
|
||||
with OdmsClient("http://127.0.0.1:9", max_retries=3, timeout_s=0.5) as odms:
|
||||
odms._transport.backoff_s = 0.0
|
||||
odms._transport._session = None
|
||||
attempts = []
|
||||
original = odms._transport._send_urllib
|
||||
|
||||
def counting(*args, **kwargs):
|
||||
attempts.append(1)
|
||||
return original(*args, **kwargs)
|
||||
|
||||
odms._transport._send_urllib = counting
|
||||
|
||||
with pytest.raises(TransportError):
|
||||
odms.push_message("CCSDS_OPM_VERS = 3.0", key="iss")
|
||||
assert len(attempts) == 1
|
||||
|
||||
attempts.clear()
|
||||
with pytest.raises(TransportError):
|
||||
odms.satellites()
|
||||
assert len(attempts) == 4 # a read may be repeated: 1 + max_retries
|
||||
Loading…
Add table
Add a link
Reference in a new issue