221 lines
7.9 KiB
Python
221 lines
7.9 KiB
Python
from datetime import datetime, timedelta
|
|
from typing import Any
|
|
|
|
import pytest
|
|
|
|
from flight_club import main as main_module
|
|
from flight_club.data_manager import DataManagerError
|
|
from flight_club.flight_data import FlightData
|
|
from flight_club.main import run
|
|
|
|
TODAY = datetime(2026, 10, 24, 12, 0)
|
|
|
|
|
|
class FakeDataManager:
|
|
def __init__(self, rows: list[dict[str, Any]]) -> None:
|
|
self.rows = rows
|
|
self.updates: list[tuple[int, float]] = []
|
|
self.customers_error: Exception | None = None
|
|
self.customer_reads = 0
|
|
|
|
def get_destination_data(self) -> list[dict[str, Any]]:
|
|
return self.rows
|
|
|
|
def update_lowest_price(self, row_id: int, new_price: float) -> None:
|
|
self.updates.append((row_id, new_price))
|
|
|
|
def get_customer_emails(self) -> list[dict[str, Any]]:
|
|
if self.customers_error is not None:
|
|
raise self.customers_error
|
|
self.customer_reads += 1
|
|
return [{"email": "ann@example.test"}, {"email": "bo@example.test"}]
|
|
|
|
@staticmethod
|
|
def extract_emails(customers: list[dict[str, Any]]) -> list[str]:
|
|
return [str(c["email"]) for c in customers]
|
|
|
|
|
|
class FakeFlightSearch:
|
|
def __init__(self, price: float | None, indirect_price: float | None = None) -> None:
|
|
self.price = price
|
|
self.indirect_price = indirect_price
|
|
self.calls: list[tuple[str, str, datetime, datetime]] = []
|
|
self.direct_flags: list[bool] = []
|
|
|
|
def check_flights(
|
|
self,
|
|
origin: str,
|
|
destination: str,
|
|
from_time: datetime,
|
|
to_time: datetime,
|
|
is_direct: bool = True,
|
|
) -> dict[str, Any] | None:
|
|
self.calls.append((origin, destination, from_time, to_time))
|
|
self.direct_flags.append(is_direct)
|
|
price = self.price if is_direct else self.indirect_price
|
|
if price is None:
|
|
return None
|
|
leg = {
|
|
"departure_airport": {"id": origin, "time": "2026-10-25 08:00"},
|
|
"arrival_airport": {"id": destination, "time": "2026-10-25 10:00"},
|
|
}
|
|
legs = [leg] if is_direct else [leg, leg]
|
|
return {"best_flights": [{"price": price, "flights": legs}]}
|
|
|
|
|
|
class FakeNotificationManager:
|
|
def __init__(self) -> None:
|
|
self.sent: list[str] = []
|
|
self.emails: list[tuple[list[str], str]] = []
|
|
|
|
def send_emails(self, email_list: list[str], email_body: str) -> int:
|
|
self.emails.append((email_list, email_body))
|
|
return len(email_list)
|
|
|
|
def build_message(self, flight: FlightData) -> str:
|
|
return f"alert {flight.destination_airport} {flight.price}"
|
|
|
|
def send_whatsapp(self, message: str) -> bool:
|
|
self.sent.append(message)
|
|
return True
|
|
|
|
|
|
def _run(
|
|
rows: list[dict[str, Any]], price: float | None, indirect_price: float | None = None
|
|
) -> tuple[list[FlightData], FakeDataManager, FakeFlightSearch, FakeNotificationManager]:
|
|
data, search, notifier = (
|
|
FakeDataManager(rows),
|
|
FakeFlightSearch(price, indirect_price),
|
|
FakeNotificationManager(),
|
|
)
|
|
found = run(data, search, notifier, today=TODAY) # type: ignore[arg-type]
|
|
return found, data, search, notifier
|
|
|
|
|
|
def _row(lowest: Any = 100) -> dict[str, Any]:
|
|
return {"city": "Paris", "iataCode": "CDG", "lowestPrice": lowest, "id": 2}
|
|
|
|
|
|
def test_run_updates_and_notifies_when_price_is_lower() -> None:
|
|
found, data, _, notifier = _run([_row(100)], 54)
|
|
assert [f.price for f in found] == [54]
|
|
assert data.updates == [(2, 54)]
|
|
assert notifier.sent == ["alert CDG 54.0"]
|
|
assert found[0].return_date == (TODAY + timedelta(days=182)).strftime("%Y-%m-%d")
|
|
|
|
|
|
@pytest.mark.parametrize("price", [100, 150])
|
|
def test_run_does_nothing_when_price_is_not_lower(price: float) -> None:
|
|
found, data, _, notifier = _run([_row(100)], price)
|
|
assert found == []
|
|
assert data.updates == []
|
|
assert notifier.sent == []
|
|
|
|
|
|
def test_run_does_nothing_when_price_not_available() -> None:
|
|
found, data, _, notifier = _run([_row(100)], None)
|
|
assert found == []
|
|
assert data.updates == []
|
|
assert notifier.sent == []
|
|
|
|
|
|
@pytest.mark.parametrize("lowest", [None, "", "cheap"])
|
|
def test_run_skips_row_without_numeric_lowest_price(lowest: Any) -> None:
|
|
_, data, search, notifier = _run([_row(lowest)], 1)
|
|
assert search.calls == []
|
|
assert data.updates == []
|
|
assert notifier.sent == []
|
|
|
|
|
|
def test_run_skips_row_without_iata_code() -> None:
|
|
row = {"city": "Nowhere", "iataCode": "", "lowestPrice": 100, "id": 3}
|
|
_, _, search, _ = _run([row], 1)
|
|
assert search.calls == []
|
|
|
|
|
|
def test_run_checks_every_row_with_origin_and_dates() -> None:
|
|
rows = [{**_row(), "iataCode": code, "id": i} for i, code in enumerate(["CDG", "BER", "TYO"])]
|
|
_, _, search, _ = _run(rows, 54)
|
|
assert [c[:2] for c in search.calls] == [("LHR", "CDG"), ("LHR", "BER"), ("LHR", "TYO")]
|
|
for call in search.calls:
|
|
assert call[2] == TODAY + timedelta(days=1)
|
|
assert call[3] == TODAY + timedelta(days=182)
|
|
|
|
|
|
def test_main_exits_with_one_on_data_manager_error(
|
|
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
|
) -> None:
|
|
def fail() -> None:
|
|
raise DataManagerError("Environment variable SHEETY_ENDPOINT is not set.")
|
|
|
|
monkeypatch.setattr(main_module, "load_dotenv", lambda: True)
|
|
monkeypatch.setattr(main_module, "install_flight_cache", lambda: None)
|
|
monkeypatch.setattr(main_module, "DataManager", fail)
|
|
with pytest.raises(SystemExit) as exit_info:
|
|
main_module.main()
|
|
assert exit_info.value.code == 1
|
|
assert "SHEETY_ENDPOINT" in capsys.readouterr().out
|
|
|
|
|
|
def test_run_prints_direct_search_message(capsys: pytest.CaptureFixture[str]) -> None:
|
|
_run([_row(100)], 54)
|
|
assert "Getting direct flights for Paris..." in capsys.readouterr().out
|
|
|
|
|
|
def test_run_searches_only_direct_when_a_direct_flight_exists() -> None:
|
|
_, _, search, _ = _run([_row(100)], 54, 10)
|
|
assert search.direct_flags == [True]
|
|
|
|
|
|
def test_run_falls_back_to_indirect_flights(capsys: pytest.CaptureFixture[str]) -> None:
|
|
found, data, search, notifier = _run([_row(100)], None, 70)
|
|
assert search.direct_flags == [True, False]
|
|
assert "No direct flight to Paris. Looking for indirect flights..." in capsys.readouterr().out
|
|
assert [f.price for f in found] == [70]
|
|
assert found[0].stops == 1
|
|
assert data.updates == [(2, 70)]
|
|
assert notifier.sent == ["alert CDG 70.0"]
|
|
|
|
|
|
def test_run_reports_nothing_when_neither_search_finds_a_flight() -> None:
|
|
found, data, search, _ = _run([_row(100)], None, None)
|
|
assert search.direct_flags == [True, False]
|
|
assert found == []
|
|
assert data.updates == []
|
|
|
|
|
|
def test_run_emails_every_customer_when_a_cheaper_flight_is_found() -> None:
|
|
_, _, _, notifier = _run([_row(100)], 54)
|
|
assert notifier.emails == [(["ann@example.test", "bo@example.test"], "alert CDG 54.0")]
|
|
|
|
|
|
def test_run_sends_no_email_when_nothing_is_cheaper() -> None:
|
|
_, _, _, notifier = _run([_row(100)], 150)
|
|
assert notifier.emails == []
|
|
|
|
|
|
def test_run_reads_the_customers_once_for_several_deals() -> None:
|
|
rows = [{**_row(), "iataCode": code, "id": i} for i, code in enumerate(["CDG", "BER"])]
|
|
data, search, notifier = FakeDataManager(rows), FakeFlightSearch(54), FakeNotificationManager()
|
|
run(data, search, notifier, today=TODAY) # type: ignore[arg-type]
|
|
assert data.customer_reads == 1
|
|
assert len(notifier.emails) == 2
|
|
|
|
|
|
def test_run_still_sends_whatsapp_when_the_customers_cannot_be_read(
|
|
capsys: pytest.CaptureFixture[str],
|
|
) -> None:
|
|
data, search, notifier = (
|
|
FakeDataManager([_row(100)]),
|
|
FakeFlightSearch(54),
|
|
FakeNotificationManager(),
|
|
)
|
|
data.customers_error = DataManagerError(
|
|
"Environment variable SHEETY_USERS_ENDPOINT is not set."
|
|
)
|
|
found = run(data, search, notifier, today=TODAY) # type: ignore[arg-type]
|
|
assert [f.price for f in found] == [54]
|
|
assert notifier.sent == ["alert CDG 54.0"]
|
|
assert notifier.emails == [([], "alert CDG 54.0")]
|
|
assert "SHEETY_USERS_ENDPOINT" in capsys.readouterr().out
|