"""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 = "Something went wrong" 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()