diff --git a/pyproject.toml b/pyproject.toml index c410960..40ba154 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,6 +23,17 @@ dependencies = [ [project.scripts] schiphol = "src.main:main" +[project.optional-dependencies] +dev = [ + "pytest>=8.0", + "pytest-asyncio>=0.24", +] + +[tool.pytest.ini_options] +asyncio_mode = "auto" +testpaths = ["tests"] +pythonpath = ["src"] + [build-system] requires = ["setuptools>=61.0"] build-backend = "setuptools.build_meta" diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_poll.py b/tests/test_poll.py new file mode 100644 index 0000000..1c73be4 --- /dev/null +++ b/tests/test_poll.py @@ -0,0 +1,121 @@ +"""Tests for poll() — the Schiphol API fetch-and-publish sweep.""" + +import pytest +import requests + +from main import poll + +# --------------------------------------------------------------------------- +# Test doubles +# --------------------------------------------------------------------------- + + +class _NoOpSpan: + def __enter__(self): + return self + + def __exit__(self, *args): + pass + + +class StubTracer: + def start_as_current_span(self, name, attributes=None): + return _NoOpSpan() + + +class StubResponse: + """Minimal requests.Response lookalike.""" + + def __init__(self, flights, *, next_url=None, raise_on_status=False): + self._flights = flights + self._raise = raise_on_status + self.headers = {} + if next_url: + self.headers["link"] = f'<{next_url}>; rel="next"' + + def raise_for_status(self): + if self._raise: + raise requests.HTTPError("500 Server Error") + + def json(self): + return {"flights": self._flights} + + +class StubSession: + """Returns pre-configured StubResponse objects keyed by URL.""" + + def __init__(self, responses_by_url: dict): + self._responses = responses_by_url + + def get(self, url): + return self._responses[url] + + +class MockEngine: + """Captures every send() call so tests can assert on Kafka output.""" + + def __init__(self): + self.sent: list[dict] = [] + + async def send(self, topic, *, value, key): + self.sent.append({"topic": topic, "value": value, "key": key}) + + +class FakeApiSettings: + base_url = "https://api.example.com/flights" + + +class FakeKafkaSettings: + topic = "flights-test" + + +# --------------------------------------------------------------------------- +# Tests +# --------------------------------------------------------------------------- + + +async def test_single_page_publishes_all_flights_and_returns_count(): + """poll() publishes every Flight on a single-page response and returns the count.""" + flights = [{"id": "1", "flightName": "KL100"}, {"id": "2", "flightName": "KL200"}] + session = StubSession({FakeApiSettings.base_url: StubResponse(flights)}) + engine = MockEngine() + + count = await poll(session, engine, FakeApiSettings(), FakeKafkaSettings(), StubTracer()) + + assert count == 2 + assert len(engine.sent) == 2 + published_keys = {msg["key"] for msg in engine.sent} + assert published_keys == {"1", "2"} + + +async def test_pagination_publishes_flights_across_all_pages(): + """poll() follows link: rel="next" headers and publishes Flights from every page.""" + page1_url = FakeApiSettings.base_url + page2_url = "https://api.example.com/flights?page=2" + + page1_flights = [{"id": "A"}, {"id": "B"}] + page2_flights = [{"id": "C"}, {"id": "D"}, {"id": "E"}] + + session = StubSession( + { + page1_url: StubResponse(page1_flights, next_url=page2_url), + page2_url: StubResponse(page2_flights), + } + ) + engine = MockEngine() + + count = await poll(session, engine, FakeApiSettings(), FakeKafkaSettings(), StubTracer()) + + assert count == 5 + assert len(engine.sent) == 5 + + +async def test_http_error_propagates_out_of_poll(): + """poll() lets HTTP errors from raise_for_status() propagate to the caller.""" + session = StubSession( + {FakeApiSettings.base_url: StubResponse([], raise_on_status=True)} + ) + engine = MockEngine() + + with pytest.raises(requests.HTTPError): + await poll(session, engine, FakeApiSettings(), FakeKafkaSettings(), StubTracer())