Files
040-personal-flight-club/tests/test_flight_search.py
Tirsvad 52afb369a7 Search indirect flights when no direct flight exists
Add FlightData.stops, check_flights(is_direct) and the indirect fallback in main.
Record ADR-0001 and the code review RC-009.

Task: MIL-002#1
Task: MIL-002#2
Task: MIL-002#3
Task: MIL-002#4
2026-10-24 23:45:43 +08:00

233 lines
7.6 KiB
Python

from datetime import datetime
from typing import Any
from unittest.mock import MagicMock
import pytest
import requests
from flight_club.constants import (
CURRENCY,
DEFAULT_MOCK_FLIGHTS_URL,
REQUEST_TIMEOUT_SECONDS,
SERPAPI_URL,
)
from flight_club.flight_search import FlightSearch, FlightSearchError
API_KEY = "serp-secret-key"
APP_ID = "mock-app-id-value"
APP_KEY = "mock-app-key-secret"
FROM_TIME = datetime(2026, 6, 15)
TO_TIME = datetime(2026, 6, 22)
BODY: dict[str, Any] = {"best_flights": [{"flights": [], "price": 99}]}
@pytest.fixture(autouse=True)
def _env(monkeypatch: pytest.MonkeyPatch) -> None:
for name in (
"FLIGHT_PROVIDER",
"SERPAPI_API_KEY",
"MOCK_FLIGHTS_APP_ID",
"MOCK_FLIGHTS_APP_KEY",
"MOCK_FLIGHTS_URL",
):
monkeypatch.delenv(name, raising=False)
monkeypatch.setenv("SERPAPI_API_KEY", API_KEY)
def _use_mock(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("FLIGHT_PROVIDER", "mock")
monkeypatch.setenv("MOCK_FLIGHTS_APP_ID", APP_ID)
monkeypatch.setenv("MOCK_FLIGHTS_APP_KEY", APP_KEY)
def _session(status: int = 200, body: Any = BODY, invalid_json: bool = False) -> MagicMock:
response = MagicMock()
response.status_code = status
if invalid_json:
response.json.side_effect = ValueError("bad json")
else:
response.json.return_value = body
session = MagicMock()
session.get.return_value = response
return session
def _search(session: MagicMock) -> dict[str, Any] | None:
return FlightSearch(session).check_flights("LHR", "CDG", FROM_TIME, TO_TIME)
def test_check_flights_sends_serpapi_params() -> None:
session = _session()
assert _search(session) == BODY
args, kwargs = session.get.call_args
assert args == (SERPAPI_URL,)
assert kwargs["params"] == {
"engine": "google_flights",
"departure_id": "LHR",
"arrival_id": "CDG",
"outbound_date": "2026-06-15",
"return_date": "2026-06-22",
"type": "1",
"adults": "1",
"currency": CURRENCY,
"stops": "1",
"api_key": API_KEY,
}
assert kwargs["timeout"] == REQUEST_TIMEOUT_SECONDS
def test_check_flights_uses_mock_headers_and_default_url(
monkeypatch: pytest.MonkeyPatch,
) -> None:
_use_mock(monkeypatch)
session = _session()
assert _search(session) == BODY
args, kwargs = session.get.call_args
assert args == (DEFAULT_MOCK_FLIGHTS_URL,)
assert kwargs["headers"] == {"x-app-id": APP_ID, "x-app-key": APP_KEY}
assert kwargs["params"]["api_key"] == APP_KEY
assert kwargs["params"]["departure_id"] == "LHR"
def test_default_mock_url_targets_flights_search_when_env_unset(
monkeypatch: pytest.MonkeyPatch,
) -> None:
assert DEFAULT_MOCK_FLIGHTS_URL.endswith("/v1/flights/search")
_use_mock(monkeypatch)
session = _session()
_search(session)
assert session.get.call_args.args[0].endswith("/v1/flights/search")
def test_check_flights_uses_mock_url_from_env(monkeypatch: pytest.MonkeyPatch) -> None:
_use_mock(monkeypatch)
monkeypatch.setenv("MOCK_FLIGHTS_URL", "https://example.test/flights")
session = _session()
_search(session)
assert session.get.call_args.args == ("https://example.test/flights",)
def test_init_raises_for_unknown_provider(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("FLIGHT_PROVIDER", "carrier-pigeon")
with pytest.raises(FlightSearchError, match="FLIGHT_PROVIDER"):
FlightSearch()
def test_init_raises_without_serpapi_key(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv("SERPAPI_API_KEY")
with pytest.raises(FlightSearchError, match="SERPAPI_API_KEY"):
FlightSearch()
@pytest.mark.parametrize("missing", ["MOCK_FLIGHTS_APP_ID", "MOCK_FLIGHTS_APP_KEY"])
def test_init_raises_without_mock_credentials(
monkeypatch: pytest.MonkeyPatch, missing: str
) -> None:
_use_mock(monkeypatch)
monkeypatch.delenv(missing)
with pytest.raises(FlightSearchError, match=missing):
FlightSearch()
def test_check_flights_returns_none_on_http_error(
capsys: pytest.CaptureFixture[str],
) -> None:
assert _search(_session(status=500)) is None
assert "500" in capsys.readouterr().out
def test_check_flights_prints_service_error_text_on_http_error(
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
_use_mock(monkeypatch)
text = "Invalid input: expected string, received undefined"
assert _search(_session(status=400, body={"error": text})) is None
captured = capsys.readouterr()
assert f"Flight search failed with HTTP 400: {text}." in captured.out
for secret in (API_KEY, APP_ID, APP_KEY):
assert secret not in captured.out + captured.err
def test_check_flights_truncates_long_service_error_text(
capsys: pytest.CaptureFixture[str],
) -> None:
assert _search(_session(status=400, body={"error": "x" * 500})) is None
out = capsys.readouterr().out
assert "x" * 200 in out
assert "x" * 201 not in out
@pytest.mark.parametrize("body", [["error"], {"error": 5}, {"other": "x"}])
def test_check_flights_falls_back_when_error_body_is_not_text(
capsys: pytest.CaptureFixture[str], body: Any
) -> None:
assert _search(_session(status=500, body=body)) is None
assert capsys.readouterr().out == "Flight search failed with HTTP 500.\n"
def test_check_flights_falls_back_when_error_body_is_not_json(
capsys: pytest.CaptureFixture[str],
) -> None:
assert _search(_session(status=502, invalid_json=True)) is None
assert capsys.readouterr().out == "Flight search failed with HTTP 502.\n"
def test_check_flights_returns_none_on_invalid_json(
capsys: pytest.CaptureFixture[str],
) -> None:
assert _search(_session(invalid_json=True)) is None
assert "invalid JSON" in capsys.readouterr().out
def test_check_flights_returns_none_on_error_body(
capsys: pytest.CaptureFixture[str],
) -> None:
assert _search(_session(body={"error": "Invalid API key"})) is None
assert "Invalid API key" in capsys.readouterr().out
def test_check_flights_returns_none_on_connection_error(
capsys: pytest.CaptureFixture[str],
) -> None:
session = MagicMock()
session.get.side_effect = requests.ConnectionError(f"failed for key {API_KEY}")
assert _search(session) is None
assert "ConnectionError" in capsys.readouterr().out
def test_check_flights_never_prints_serpapi_key(
capsys: pytest.CaptureFixture[str],
) -> None:
session = MagicMock()
session.get.side_effect = requests.ConnectionError(f"failed for key {API_KEY}")
_search(session)
_search(_session(status=500))
_search(_session(body={"error": "bad"}))
captured = capsys.readouterr()
assert API_KEY not in captured.out + captured.err
def test_check_flights_never_prints_mock_secrets(
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
_use_mock(monkeypatch)
session = MagicMock()
session.get.side_effect = requests.ConnectionError(f"{APP_ID} {APP_KEY}")
_search(session)
_search(_session(status=500))
captured = capsys.readouterr()
assert APP_ID not in captured.out + captured.err
assert APP_KEY not in captured.out + captured.err
def test_check_flights_asks_for_up_to_two_stops_when_not_direct() -> None:
session = _session()
FlightSearch(session).check_flights("LHR", "DPS", FROM_TIME, TO_TIME, is_direct=False)
assert session.get.call_args.kwargs["params"]["stops"] == "3"
def test_check_flights_is_direct_by_default() -> None:
session = _session()
_search(session)
assert session.get.call_args.kwargs["params"]["stops"] == "1"