agenda/tests/test_flight_diagnostics.py

148 lines
6 KiB
Python

"""Offline failure recording, replay and retention coverage."""
import gzip
import json
import os
import stat
import time
import typing
from datetime import date
from pathlib import Path
from types import SimpleNamespace
import pytest
from playwright.sync_api import Page
from agenda import flight_diagnostics, flight_search_cache, google_flights
def read_snapshot(path: Path) -> dict[str, typing.Any]:
"""Read the same artifact an administrator can use for analysis."""
with gzip.open(path, "rt", encoding="utf-8") as source:
return typing.cast(dict[str, typing.Any], json.load(source))
def test_failed_page_saved_for_replay(tmp_path: Path, monkeypatch: typing.Any) -> None:
"""A parser failure saves the route, raw script and loaded response content."""
script = "AF_initDataCallback({data:[]});"
page_text = "Something went wrong"
html = "<html><body>Something went wrong</body></html>"
url = google_flights.search_url("AKJ", "LON", date(2099, 9, 12), 1)
page = SimpleNamespace(
url=url,
goto=lambda *args, **kwargs: SimpleNamespace(status=200),
content=lambda: html,
locator=lambda selector: SimpleNamespace(
wait_for=lambda **kwargs: None,
inner_text=lambda **kwargs: page_text if selector == "body" else script,
),
)
browser = google_flights.BrowserSearch(tmp_path)
browser.page = typing.cast(Page, page)
monkeypatch.setattr(browser, "start", lambda: browser.page)
with pytest.raises(ValueError, match="unusable search data"):
browser.search("AKJ", "LON", date(2099, 9, 12), 1)
path = next((tmp_path / "errors").glob("*.json.gz"))
snapshot = read_snapshot(path)
assert snapshot["origin"] == "AKJ"
assert snapshot["destination"] == "LON"
assert snapshot["departure_date"] == "2099-09-12"
assert snapshot["http_status"] == 200
assert snapshot["requested_url"] == snapshot["page_url"] == url
assert snapshot["flight_script"] == script
assert snapshot["page_html"] == html
assert snapshot["page_text"] == page_text
assert snapshot["truncated_fields"] == []
assert stat.S_IMODE(path.stat().st_mode) == 0o640
with pytest.raises(ValueError, match="Unrecognized"):
google_flights.parse_results(
snapshot["flight_script"], snapshot["requested_url"]
)
def test_recording_failure_preserves_lookup_error(
tmp_path: Path, monkeypatch: typing.Any
) -> None:
"""Unavailable diagnostic storage cannot hide the real lookup error."""
browser = google_flights.BrowserSearch(tmp_path)
def fail(*args: typing.Any) -> typing.Any:
raise ValueError("Original flight failure")
def cannot_record(*args: typing.Any) -> None:
raise PermissionError("No access to diagnostics")
monkeypatch.setattr(browser, "perform_search", fail)
monkeypatch.setattr(browser, "record_failure", cannot_record)
with pytest.raises(ValueError, match="Original flight failure"):
browser.search("AKJ", "LON", date(2099, 9, 12), 1)
def test_snapshot_retention_and_truncation(
tmp_path: Path, monkeypatch: typing.Any
) -> None:
"""Old and excess snapshots are removed; large raw captures have marked limits."""
monkeypatch.setattr(flight_diagnostics, "MAX_RECORDS", 2)
first = flight_diagnostics.write_failure(tmp_path, {"error": "old"})
assert first is not None
old = time.time() - flight_diagnostics.RETENTION_SECONDS - 1
os.utime(first, (old, old))
second = flight_diagnostics.write_failure(tmp_path, {"error": "recent"})
assert second is not None and not first.exists()
third = flight_diagnostics.write_failure(tmp_path, {"error": "recent"})
fourth = flight_diagnostics.write_failure(
tmp_path, {"flight_script": "x" * (1024 * 1024 + 1)}
)
assert third is not None and fourth is not None
assert not second.exists()
assert len(list((tmp_path / "errors").glob("*.json.gz"))) == 2
snapshot = read_snapshot(fourth)
assert snapshot["truncated_fields"] == ["flight_script"]
assert len(snapshot["flight_script"].encode()) == 1024 * 1024
def test_storage_error_is_best_effort(tmp_path: Path) -> None:
"""An unwritable destination logs a warning and returns without raising."""
(tmp_path / "errors").write_text("not a directory")
assert flight_diagnostics.write_failure(tmp_path, {"error": "original"}) is None
def test_http_error_captured_once_during_cooldown(
tmp_path: Path, monkeypatch: typing.Any
) -> None:
"""Capture a received 429 body, but don't save duplicate local cooldown refusals."""
calls: list[str] = []
url = "https://www.google.com/travel/flights/search"
body = "Too Many Requests"
def goto(requested_url: str, **kwargs: typing.Any) -> typing.Any:
calls.append(requested_url)
return SimpleNamespace(status=429, url=url, text=lambda: body)
page = SimpleNamespace(
url=url,
goto=goto,
content=lambda: body,
locator=lambda selector: SimpleNamespace(inner_text=lambda **kwargs: body),
)
browser = google_flights.BrowserSearch(tmp_path)
browser.page = typing.cast(Page, page)
monkeypatch.setattr(browser, "start", lambda: browser.page)
for _ in range(2):
with pytest.raises(flight_search_cache.RateLimitCooldownError):
browser.search("AKJ", "LON", date(2099, 9, 12), 1)
records = list((tmp_path / "errors").glob("*.json.gz"))
assert len(records) == len(calls) == 1
snapshot = read_snapshot(records[0])
assert snapshot["http_status"] == 429
assert snapshot["http_errors"] == [{"status": 429, "url": url, "body": body}]
def test_success_does_not_create_diagnostics(
tmp_path: Path, monkeypatch: typing.Any
) -> None:
"""Successful searches, including empty results, do not accumulate snapshots."""
browser = google_flights.BrowserSearch(tmp_path)
monkeypatch.setattr(browser, "perform_search", lambda *args: [])
assert browser.search("AKJ", "LON", date(2099, 9, 12), 1) == []
assert not (tmp_path / "errors").exists()