Tests cover providers, dedup, Telegram, scoring, main runner, and Airbnb stubs. Ticketmaster and SeatGeek use configurable lat/lon/radius (Thornhill default). Pipeline filters noise listings, merges same-day sports duplicates, optional MIN_ALERT_SCORE, and Telegram severity summary. Made-with: Cursor
319 lines
11 KiB
Python
319 lines
11 KiB
Python
"""Tests for the main orchestration runner."""
|
|
|
|
from datetime import date
|
|
from unittest.mock import patch, MagicMock
|
|
|
|
import pytest
|
|
|
|
from src.main import (
|
|
parse_args,
|
|
fetch_all_events,
|
|
filter_noise,
|
|
filter_by_min_score,
|
|
print_summary,
|
|
update_airbnb_prices,
|
|
main,
|
|
)
|
|
from src.models import NormalizedEvent
|
|
|
|
|
|
def _make_event(name: str, event_date: date, venue: str, score: float = 0.5) -> NormalizedEvent:
|
|
return NormalizedEvent(
|
|
name=name, event_date=event_date, venue=venue,
|
|
source="test", url="https://example.com", score=score,
|
|
)
|
|
|
|
|
|
class TestParseArgs:
|
|
def test_default_mode(self):
|
|
with patch("sys.argv", ["main"]):
|
|
args = parse_args()
|
|
assert args.dry_run is False
|
|
assert args.alerts_only is False
|
|
|
|
def test_dry_run(self):
|
|
with patch("sys.argv", ["main", "--dry-run"]):
|
|
args = parse_args()
|
|
assert args.dry_run is True
|
|
|
|
def test_alerts_only(self):
|
|
with patch("sys.argv", ["main", "--alerts-only"]):
|
|
args = parse_args()
|
|
assert args.alerts_only is True
|
|
|
|
|
|
class TestFetchAllEvents:
|
|
def test_collects_from_all_providers(self):
|
|
mock_settings = MagicMock()
|
|
mock_settings.ticketmaster_key = "key"
|
|
mock_settings.seatgeek_client_id = "id"
|
|
mock_settings.lookahead_days = 30
|
|
|
|
events = [_make_event("A", date(2026, 5, 10), "V")]
|
|
|
|
with patch("src.main.TicketmasterProvider") as MockTM, \
|
|
patch("src.main.SeatGeekProvider") as MockSG:
|
|
MockTM.return_value.name = "ticketmaster"
|
|
MockTM.return_value.fetch.return_value = events
|
|
MockSG.return_value.name = "seatgeek"
|
|
MockSG.return_value.fetch.return_value = events
|
|
|
|
result = fetch_all_events(mock_settings)
|
|
|
|
assert len(result) == 2
|
|
|
|
def test_handles_provider_returning_empty(self):
|
|
mock_settings = MagicMock()
|
|
mock_settings.ticketmaster_key = "key"
|
|
mock_settings.seatgeek_client_id = "id"
|
|
mock_settings.lookahead_days = 30
|
|
|
|
with patch("src.main.TicketmasterProvider") as MockTM, \
|
|
patch("src.main.SeatGeekProvider") as MockSG:
|
|
MockTM.return_value.name = "ticketmaster"
|
|
MockTM.return_value.fetch.return_value = [
|
|
_make_event("A", date(2026, 5, 10), "V")
|
|
]
|
|
MockSG.return_value.name = "seatgeek"
|
|
MockSG.return_value.fetch.return_value = []
|
|
|
|
result = fetch_all_events(mock_settings)
|
|
|
|
assert len(result) == 1
|
|
|
|
|
|
class TestFilterNoise:
|
|
def test_removes_meeting_spaces(self):
|
|
events = [
|
|
_make_event("Meeting Spaces", date(2026, 5, 10), "Rogers Centre"),
|
|
_make_event("Blue Jays vs Yankees", date(2026, 5, 10), "Rogers Centre"),
|
|
]
|
|
result = filter_noise(events)
|
|
assert len(result) == 1
|
|
assert result[0].name == "Blue Jays vs Yankees"
|
|
|
|
def test_removes_ballpark_tours(self):
|
|
events = [
|
|
_make_event("Rogers Centre Ballpark Tours", date(2026, 5, 10), "Rogers Centre"),
|
|
_make_event("Raptors vs Heat", date(2026, 5, 10), "Scotiabank Arena"),
|
|
]
|
|
result = filter_noise(events)
|
|
assert len(result) == 1
|
|
|
|
def test_removes_guided_tours(self):
|
|
events = [
|
|
_make_event("Premium Guided Tours of Scotiabank Arena", date(2026, 5, 10), "Scotiabank Arena"),
|
|
]
|
|
result = filter_noise(events)
|
|
assert len(result) == 0
|
|
|
|
def test_removes_fan_access_addons(self):
|
|
events = [
|
|
_make_event("Raptors Fan Access Plus Ups: Courtside Access", date(2026, 5, 10), "Scotiabank Arena"),
|
|
_make_event("Raptors Fan Access Plus Ups: On-Court Flag Bearer", date(2026, 5, 10), "Scotiabank Arena"),
|
|
]
|
|
result = filter_noise(events)
|
|
assert len(result) == 0
|
|
|
|
def test_keeps_real_events(self):
|
|
events = [
|
|
_make_event("Toronto Raptors vs. Miami Heat", date(2026, 5, 10), "Scotiabank Arena"),
|
|
_make_event("Drake Concert", date(2026, 5, 15), "Budweiser Stage"),
|
|
]
|
|
result = filter_noise(events)
|
|
assert len(result) == 2
|
|
|
|
def test_case_insensitive(self):
|
|
events = [
|
|
_make_event("MEETING SPACES", date(2026, 5, 10), "Rogers Centre"),
|
|
]
|
|
result = filter_noise(events)
|
|
assert len(result) == 0
|
|
|
|
def test_empty_list(self):
|
|
assert filter_noise([]) == []
|
|
|
|
|
|
class TestFilterByMinScore:
|
|
def test_zero_passes_all(self):
|
|
events = [_make_event("A", date(2026, 5, 10), "V", 0.1)]
|
|
assert filter_by_min_score(events, 0.0) == events
|
|
|
|
def test_filters_low_scores(self):
|
|
events = [
|
|
_make_event("Big", date(2026, 5, 10), "V", 0.9),
|
|
_make_event("Small", date(2026, 5, 11), "V", 0.2),
|
|
]
|
|
result = filter_by_min_score(events, 0.5)
|
|
assert len(result) == 1
|
|
assert result[0].name == "Big"
|
|
|
|
|
|
class TestPrintSummary:
|
|
def test_prints_no_events_message(self, capsys):
|
|
print_summary([])
|
|
output = capsys.readouterr().out
|
|
assert "No upcoming events" in output
|
|
|
|
def test_prints_event_details(self, capsys):
|
|
events = [_make_event("Raptors Game", date(2026, 5, 10), "Scotiabank Arena", 0.8)]
|
|
print_summary(events)
|
|
output = capsys.readouterr().out
|
|
assert "Raptors Game" in output
|
|
assert "Scotiabank Arena" in output
|
|
assert "0.80" in output
|
|
|
|
def test_prints_event_count(self, capsys):
|
|
events = [
|
|
_make_event("A", date(2026, 5, 10), "V1"),
|
|
_make_event("B", date(2026, 5, 11), "V2"),
|
|
]
|
|
print_summary(events)
|
|
output = capsys.readouterr().out
|
|
assert "2 events" in output
|
|
|
|
|
|
class TestUpdateAirbnbPrices:
|
|
def test_skips_when_no_listing_configured(self):
|
|
settings = MagicMock()
|
|
settings.airbnb_listing_id = ""
|
|
events = [_make_event("A", date(2026, 5, 10), "V")]
|
|
update_airbnb_prices(events, settings)
|
|
|
|
def test_calculates_price_correctly(self):
|
|
settings = MagicMock()
|
|
settings.airbnb_listing_id = "12345"
|
|
settings.airbnb_base_price = 100
|
|
settings.price_increase_pct = 25
|
|
|
|
events = [_make_event("A", date(2026, 5, 10), "V")]
|
|
|
|
with patch("playwright.sync_api.sync_playwright") as mock_pw, \
|
|
patch("src.airbnb.auth.load_authenticated_context") as mock_auth, \
|
|
patch("src.airbnb.calendar.update_price") as mock_update:
|
|
mock_page = MagicMock()
|
|
mock_context = MagicMock()
|
|
mock_context.new_page.return_value = mock_page
|
|
mock_browser = MagicMock()
|
|
mock_auth.return_value = mock_context
|
|
mock_pw.return_value.__enter__ = MagicMock(
|
|
return_value=MagicMock(chromium=MagicMock(launch=MagicMock(return_value=mock_browser)))
|
|
)
|
|
mock_update.return_value = True
|
|
|
|
update_airbnb_prices(events, settings)
|
|
mock_update.assert_called_once_with(mock_page, date(2026, 5, 10), 125)
|
|
|
|
def test_handles_missing_state_file(self):
|
|
settings = MagicMock()
|
|
settings.airbnb_listing_id = "12345"
|
|
settings.airbnb_base_price = 100
|
|
settings.price_increase_pct = 20
|
|
|
|
events = [_make_event("A", date(2026, 5, 10), "V")]
|
|
|
|
with patch("playwright.sync_api.sync_playwright") as mock_pw:
|
|
mock_browser = MagicMock()
|
|
mock_p = MagicMock()
|
|
mock_p.chromium.launch.return_value = mock_browser
|
|
mock_pw.return_value.__enter__ = MagicMock(return_value=mock_p)
|
|
mock_browser.close = MagicMock()
|
|
|
|
with patch("src.airbnb.auth.load_authenticated_context", side_effect=FileNotFoundError("no state")):
|
|
update_airbnb_prices(events, settings)
|
|
|
|
|
|
class TestMainRunner:
|
|
@patch("src.main.parse_args")
|
|
@patch("src.main.load_settings")
|
|
@patch("src.main.setup_logging")
|
|
@patch("src.main.fetch_all_events")
|
|
@patch("src.main.filter_noise")
|
|
@patch("src.main.deduplicate")
|
|
@patch("src.main.score_events")
|
|
@patch("src.main.filter_by_window")
|
|
@patch("src.main.print_summary")
|
|
@patch("src.main.send_alert")
|
|
def test_dry_run_skips_alerts_and_updates(
|
|
self, mock_alert, mock_print, mock_filter,
|
|
mock_score, mock_dedup, mock_noise, mock_fetch, mock_log,
|
|
mock_settings, mock_args,
|
|
):
|
|
mock_args.return_value.dry_run = True
|
|
mock_args.return_value.alerts_only = False
|
|
mock_settings.return_value.log_level = "INFO"
|
|
mock_settings.return_value.lookahead_days = 30
|
|
mock_settings.return_value.min_alert_score = 0.0
|
|
|
|
events = [_make_event("A", date(2026, 5, 10), "V")]
|
|
mock_fetch.return_value = events
|
|
mock_noise.return_value = events
|
|
mock_dedup.return_value = events
|
|
mock_score.return_value = events
|
|
mock_filter.return_value = events
|
|
|
|
main()
|
|
|
|
mock_print.assert_called_once()
|
|
mock_alert.assert_not_called()
|
|
|
|
@patch("src.main.parse_args")
|
|
@patch("src.main.load_settings")
|
|
@patch("src.main.setup_logging")
|
|
@patch("src.main.fetch_all_events")
|
|
@patch("src.main.filter_noise")
|
|
@patch("src.main.deduplicate")
|
|
@patch("src.main.score_events")
|
|
@patch("src.main.filter_by_window")
|
|
@patch("src.main.print_summary")
|
|
@patch("src.main.send_alert")
|
|
@patch("src.main.update_airbnb_prices")
|
|
def test_alerts_only_sends_telegram_no_airbnb(
|
|
self, mock_airbnb, mock_alert, mock_print, mock_filter,
|
|
mock_score, mock_dedup, mock_noise, mock_fetch, mock_log,
|
|
mock_settings, mock_args,
|
|
):
|
|
mock_args.return_value.dry_run = False
|
|
mock_args.return_value.alerts_only = True
|
|
mock_settings.return_value.log_level = "INFO"
|
|
mock_settings.return_value.telegram_bot_token = "token"
|
|
mock_settings.return_value.telegram_chat_id = "123"
|
|
mock_settings.return_value.lookahead_days = 30
|
|
mock_settings.return_value.min_alert_score = 0.0
|
|
|
|
events = [_make_event("A", date(2026, 5, 10), "V")]
|
|
mock_fetch.return_value = events
|
|
mock_noise.return_value = events
|
|
mock_dedup.return_value = events
|
|
mock_score.return_value = events
|
|
mock_filter.return_value = events
|
|
mock_alert.return_value = True
|
|
|
|
main()
|
|
|
|
mock_alert.assert_called_once()
|
|
mock_airbnb.assert_not_called()
|
|
|
|
@patch("src.main.parse_args")
|
|
@patch("src.main.load_settings")
|
|
@patch("src.main.setup_logging")
|
|
@patch("src.main.fetch_all_events")
|
|
@patch("src.main.send_alert")
|
|
def test_no_events_sends_empty_alert(
|
|
self, mock_alert, mock_fetch, mock_log,
|
|
mock_settings, mock_args,
|
|
):
|
|
mock_args.return_value.dry_run = False
|
|
mock_args.return_value.alerts_only = False
|
|
mock_settings.return_value.log_level = "INFO"
|
|
mock_settings.return_value.telegram_bot_token = "token"
|
|
mock_settings.return_value.telegram_chat_id = "123"
|
|
|
|
mock_fetch.return_value = []
|
|
|
|
with pytest.raises(SystemExit) as exc_info:
|
|
main()
|
|
assert exc_info.value.code == 0
|
|
|
|
mock_alert.assert_called_once_with([], "token", "123")
|