148 lines
6 KiB
Python
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()
|