From c8a82e264c6ec85c1ab08691cc5b538865ca3c40 Mon Sep 17 00:00:00 2001 From: ilia Date: Sat, 4 Apr 2026 15:25:35 -0400 Subject: [PATCH] Add tests, geo search, noise filtering, sports scoring, and dedup improvements. 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 --- .env.example | 9 + pyproject.toml | 3 + requirements.txt | 4 + src/config.py | 8 + src/dedup.py | 53 ++++- src/main.py | 63 +++++- src/notifications/telegram.py | 13 +- src/providers/seatgeek.py | 17 +- src/providers/ticketmaster.py | 16 +- src/scoring/impact.py | 39 +++- tests/airbnb/__init__.py | 0 tests/airbnb/test_auth.py | 35 +++ tests/airbnb/test_calendar.py | 99 +++++++++ tests/conftest.py | 105 +++++++++ tests/notifications/__init__.py | 0 tests/notifications/test_telegram.py | 131 +++++++++++ tests/providers/__init__.py | 0 tests/providers/test_seatgeek.py | 80 +++++++ tests/providers/test_ticketmaster.py | 105 +++++++++ tests/scoring/__init__.py | 0 tests/scoring/test_impact.py | 129 +++++++++++ tests/test_config.py | 64 ++++++ tests/test_dedup.py | 136 ++++++++++++ tests/test_filter.py | 73 ++++++ tests/test_log.py | 31 +++ tests/test_main.py | 318 +++++++++++++++++++++++++++ tests/test_models.py | 64 ++++++ 27 files changed, 1570 insertions(+), 25 deletions(-) create mode 100644 pyproject.toml create mode 100644 tests/airbnb/__init__.py create mode 100644 tests/airbnb/test_auth.py create mode 100644 tests/airbnb/test_calendar.py create mode 100644 tests/conftest.py create mode 100644 tests/notifications/__init__.py create mode 100644 tests/notifications/test_telegram.py create mode 100644 tests/providers/__init__.py create mode 100644 tests/providers/test_seatgeek.py create mode 100644 tests/providers/test_ticketmaster.py create mode 100644 tests/scoring/__init__.py create mode 100644 tests/scoring/test_impact.py create mode 100644 tests/test_config.py create mode 100644 tests/test_dedup.py create mode 100644 tests/test_filter.py create mode 100644 tests/test_log.py create mode 100644 tests/test_main.py create mode 100644 tests/test_models.py diff --git a/.env.example b/.env.example index b8d3599..41321f5 100644 --- a/.env.example +++ b/.env.example @@ -11,6 +11,15 @@ AIRBNB_LISTING_ID= AIRBNB_BASE_PRICE=150 PRICE_INCREASE_PCT=20 +# === Search location (default: Thornhill, ON — 30km covers Toronto + GTA) === +SEARCH_LAT=43.8083 +SEARCH_LON=-79.4220 +SEARCH_RADIUS_KM=30 + +# === Alerts (optional) === +# Hide low-impact events from Telegram and Airbnb date list (0 = show all) +MIN_ALERT_SCORE=0 + # === General === LOOKAHEAD_DAYS=30 LOG_LEVEL=INFO diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..a498f38 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,3 @@ +[tool.pytest.ini_options] +testpaths = ["tests"] +pythonpath = ["."] diff --git a/requirements.txt b/requirements.txt index 74f4cb3..0c8834c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,3 +3,7 @@ pydantic>=2.0,<3 pydantic-settings>=2.0,<3 playwright>=1.40,<2 python-dotenv>=1.0,<2 + +# Testing +pytest>=8.0,<9 +pytest-httpx>=0.30,<1 diff --git a/src/config.py b/src/config.py index 0e47948..4df10cc 100644 --- a/src/config.py +++ b/src/config.py @@ -17,6 +17,14 @@ class Settings(BaseSettings): airbnb_base_price: int = 150 price_increase_pct: int = 20 + # Search location (default: Thornhill, ON — covers Toronto + GTA) + search_lat: float = 43.8083 + search_lon: float = -79.4220 + search_radius_km: int = 30 + + # Alerts: drop events below this impact score (0 = show all) + min_alert_score: float = 0.0 + # General lookahead_days: int = 30 log_level: str = "INFO" diff --git a/src/dedup.py b/src/dedup.py index ddeb4d3..61dda72 100644 --- a/src/dedup.py +++ b/src/dedup.py @@ -15,6 +15,27 @@ _NAME_SIMILARITY_MIN = 0.78 # Venue strings vary (suffixes, punctuation); stricter than names. _VENUE_SIMILARITY_MIN = 0.88 +# Same calendar slot at the same venue for the same pro team (e.g. two Ticketmaster +# listings for one Jays game: full title vs promo night). +_TEAM_SLOT_KEYS: tuple[str, ...] = ( + "blue jays", + "raptors", + "maple leafs", + "toronto marlies", + "marlies", + "toronto fc", + "argonauts", +) + +# Prefer the cleaner listing when merging promo variants of the same game. +_PROMO_VARIANT_HINTS: tuple[str, ...] = ( + "loonie", + "theme night", + "special event", + "bobblehead", + "giveaway", +) + _WS_RE = re.compile(r"\s+") @@ -41,19 +62,41 @@ def _is_same_event(a: NormalizedEvent, b: NormalizedEvent) -> bool: return True +def _team_keys_in(name: str) -> frozenset[str]: + n = name.lower() + return frozenset(k for k in _TEAM_SLOT_KEYS if k in n) + + +def _is_same_game_slot(a: NormalizedEvent, b: NormalizedEvent) -> bool: + if a.event_date != b.event_date: + return False + if _similarity(a.venue, b.venue) < _VENUE_SIMILARITY_MIN: + return False + ka, kb = _team_keys_in(a.name), _team_keys_in(b.name) + if not ka or not kb: + return False + return bool(ka & kb) + + +def _promo_variant_penalty(name: str) -> int: + n = name.lower() + return sum(1 for h in _PROMO_VARIANT_HINTS if h in n) + + def _pick_representative(cluster: list[NormalizedEvent]) -> NormalizedEvent: """Prefer richer records when merging duplicates (pre-scoring).""" source_rank = {"ticketmaster": 2, "seatgeek": 1} def key(e: NormalizedEvent) -> tuple: return ( - bool(e.url), - source_rank.get(e.source, 0), - len(e.name), + _promo_variant_penalty(e.name), + not bool(e.url), + -source_rank.get(e.source, 0), + -len(e.name), e.name, ) - return max(cluster, key=key) + return min(cluster, key=key) def deduplicate(events: list[NormalizedEvent]) -> list[NormalizedEvent]: @@ -68,7 +111,7 @@ def deduplicate(events: list[NormalizedEvent]) -> list[NormalizedEvent]: clusters: list[list[NormalizedEvent]] = [] for e in events: for cluster in clusters: - if any(_is_same_event(x, e) for x in cluster): + if any(_is_same_event(x, e) or _is_same_game_slot(x, e) for x in cluster): cluster.append(e) break else: diff --git a/src/main.py b/src/main.py index fa19299..ca78e93 100644 --- a/src/main.py +++ b/src/main.py @@ -24,6 +24,30 @@ from src.scoring.impact import score_events logger = logging.getLogger(__name__) +NOISE_KEYWORDS = [ + "meeting spaces", + "ballpark tours", + "guided tours", + "premium guided tours", + "fan access plus ups", + "parking pass", + "parking:", +] + + +def filter_noise(events: list[NormalizedEvent]) -> list[NormalizedEvent]: + """Remove non-event listings (tours, meeting rooms, fan add-ons, etc.).""" + cleaned = [] + for e in events: + name_lower = e.name.lower() + if any(kw in name_lower for kw in NOISE_KEYWORDS): + continue + cleaned.append(e) + removed = len(events) - len(cleaned) + if removed: + logger.info("Noise filter removed %d non-event listing(s)", removed) + return cleaned + def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser(description="EventRate — Toronto event pricing assistant") @@ -38,10 +62,16 @@ def fetch_all_events(settings) -> list[NormalizedEvent]: TicketmasterProvider( api_key=settings.ticketmaster_key, lookahead_days=settings.lookahead_days, + lat=settings.search_lat, + lon=settings.search_lon, + radius_km=settings.search_radius_km, ), SeatGeekProvider( client_id=settings.seatgeek_client_id, lookahead_days=settings.lookahead_days, + lat=settings.search_lat, + lon=settings.search_lon, + radius_km=settings.search_radius_km, ), ] @@ -70,6 +100,17 @@ def filter_by_window(events: list[NormalizedEvent], lookahead_days: int) -> list return [e for e in events if today <= e.event_date <= cutoff] +def filter_by_min_score(events: list[NormalizedEvent], min_score: float) -> list[NormalizedEvent]: + """Drop low-impact events from alerts and price updates.""" + if min_score <= 0: + return events + kept = [e for e in events if e.score >= min_score] + dropped = len(events) - len(kept) + if dropped: + logger.info("Min score filter (%.2f) removed %d event(s)", min_score, dropped) + return kept + + def print_summary(events: list[NormalizedEvent]) -> None: """Print a human-readable summary to stdout.""" if not events: @@ -149,23 +190,29 @@ def main() -> None: send_alert([], settings.telegram_bot_token, settings.telegram_chat_id) sys.exit(0) - # 2. Deduplicate - unique_events = deduplicate(raw_events) + # 2. Remove noise (tours, meeting spaces, fan add-ons) + clean_events = filter_noise(raw_events) - # 3. Score + # 3. Deduplicate + unique_events = deduplicate(clean_events) + + # 4. Score scored_events = score_events(unique_events) - # 4. Filter - upcoming = filter_by_window(scored_events, settings.lookahead_days) + # 5. Filter by date window + in_window = filter_by_window(scored_events, settings.lookahead_days) - # 5. Output + # 6. Drop low-impact events (optional) + upcoming = filter_by_min_score(in_window, settings.min_alert_score) + + # 7. Output print_summary(upcoming) if args.dry_run: logger.info("Dry run complete, no alerts or updates sent") return - # 6. Alert + # 8. Alert alert_ok = send_alert( upcoming, settings.telegram_bot_token, @@ -175,7 +222,7 @@ def main() -> None: if not alert_ok: logger.error("Telegram alert failed") - # 7. Optionally update Airbnb + # 9. Optionally update Airbnb if not args.alerts_only and upcoming: update_airbnb_prices(upcoming, settings) diff --git a/src/notifications/telegram.py b/src/notifications/telegram.py index 4447b57..93492c2 100644 --- a/src/notifications/telegram.py +++ b/src/notifications/telegram.py @@ -52,11 +52,22 @@ def send_alert( return False +def _severity_summary(events: list[NormalizedEvent]) -> str: + """One-line counts for Telegram (plain text, no MarkdownV2 specials).""" + n = len(events) + high = sum(1 for e in events if e.score >= 0.8) + med = sum(1 for e in events if 0.5 <= e.score < 0.8) + low = n - high - med + ev_word = "event" if n == 1 else "events" + return f"{n} {ev_word} · {high} high · {med} medium · {low} lower" + + def _format_message(events: list[NormalizedEvent]) -> str: """Group events by date and format as MarkdownV2.""" sorted_events = sorted(events, key=lambda e: e.event_date) - lines = ["*EventRate Alert* 🏟️\n"] + summary = _escape_md(_severity_summary(events)) + lines = ["*EventRate Alert* 🏟️", summary, ""] for event_date, group in groupby(sorted_events, key=lambda e: e.event_date): lines.append(f"*{_escape_md(event_date.strftime('%a %b %d, %Y'))}*") for event in group: diff --git a/src/providers/seatgeek.py b/src/providers/seatgeek.py index e6ce9cf..dc2b330 100644 --- a/src/providers/seatgeek.py +++ b/src/providers/seatgeek.py @@ -27,9 +27,19 @@ MIN_SCORE_THRESHOLD = 0.5 class SeatGeekProvider(EventProvider): - def __init__(self, client_id: str, lookahead_days: int = 30) -> None: + def __init__( + self, + client_id: str, + lookahead_days: int = 30, + lat: float = 43.8083, + lon: float = -79.4220, + radius_km: int = 30, + ) -> None: self._client_id = client_id self._lookahead_days = lookahead_days + self._lat = lat + self._lon = lon + self._radius_km = radius_km @property def name(self) -> str: @@ -52,9 +62,12 @@ class SeatGeekProvider(EventProvider): "%Y-%m-%dT%H:%M:%S" ) + range_mi = max(1, int(self._radius_km * 0.621371)) params = { "client_id": self._client_id, - "venue.city": "Toronto", + "lat": str(self._lat), + "lon": str(self._lon), + "range": f"{range_mi}mi", "datetime_utc.gte": start, "datetime_utc.lte": end, "per_page": 100, diff --git a/src/providers/ticketmaster.py b/src/providers/ticketmaster.py index ba8b6ec..4e6110b 100644 --- a/src/providers/ticketmaster.py +++ b/src/providers/ticketmaster.py @@ -35,9 +35,19 @@ MAJOR_VENUES = { class TicketmasterProvider(EventProvider): - def __init__(self, api_key: str, lookahead_days: int = 30) -> None: + def __init__( + self, + api_key: str, + lookahead_days: int = 30, + lat: float = 43.8083, + lon: float = -79.4220, + radius_km: int = 30, + ) -> None: self._api_key = api_key self._lookahead_days = lookahead_days + self._lat = lat + self._lon = lon + self._radius_km = radius_km @property def name(self) -> str: @@ -62,7 +72,9 @@ class TicketmasterProvider(EventProvider): params = { "apikey": self._api_key, - "city": "Toronto", + "latlong": f"{self._lat},{self._lon}", + "radius": str(self._radius_km), + "unit": "km", "countryCode": "CA", "startDateTime": start, "endDateTime": end, diff --git a/src/scoring/impact.py b/src/scoring/impact.py index 6d59877..ed10c96 100644 --- a/src/scoring/impact.py +++ b/src/scoring/impact.py @@ -1,7 +1,7 @@ -"""Simple rule-based impact scoring for events. +"""Rule-based impact scoring for events. -Assigns a 0.0–1.0 score based on venue capacity and event type. -This is intentionally naive — a starting point, not a pricing model. +Assigns a 0.0–1.0 score based on venue capacity, event type, and +whether the event involves a major local sports team. ASSUMPTION: Venue capacities are approximate and hardcoded. Real capacity depends on event configuration (e.g., concert vs hockey @@ -15,7 +15,6 @@ from src.models import NormalizedEvent logger = logging.getLogger(__name__) -# TODO: Validate these capacities and expand as needed VENUE_CAPACITY: dict[str, int] = { "rogers centre": 49000, "scotiabank arena": 19800, @@ -28,6 +27,30 @@ VENUE_CAPACITY: dict[str, int] = { MAX_CAPACITY = max(VENUE_CAPACITY.values()) +# Major Toronto sports teams get a scoring boost because they reliably +# drive short-term rental demand regardless of raw venue-capacity ratio. +TEAM_BOOST: dict[str, float] = { + # Large enough that NBA/NHL at Scotiabank read as high impact (≈0.8+ with venue base). + "raptors": 0.42, + "blue jays": 0.20, + "maple leafs": 0.42, + "leafs": 0.42, + "toronto fc": 0.15, + "tfc": 0.15, + "argonauts": 0.10, + "argos": 0.10, + "marlies": 0.05, +} + + +def _team_boost(event_name: str) -> float: + """Return the highest applicable team boost for an event name.""" + name_lower = event_name.lower() + return max( + (boost for keyword, boost in TEAM_BOOST.items() if keyword in name_lower), + default=0.0, + ) + def score_event(event: NormalizedEvent) -> NormalizedEvent: """Return a copy of the event with an impact score assigned.""" @@ -35,10 +58,12 @@ def score_event(event: NormalizedEvent) -> NormalizedEvent: capacity = VENUE_CAPACITY.get(venue_key, 0) if capacity == 0: - # Unknown venue — assign a moderate default so it still surfaces - score = 0.3 + base_score = 0.3 else: - score = round(capacity / MAX_CAPACITY, 2) + base_score = round(capacity / MAX_CAPACITY, 2) + + boost = _team_boost(event.name) + score = min(base_score + boost, 1.0) return NormalizedEvent( name=event.name, diff --git a/tests/airbnb/__init__.py b/tests/airbnb/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/airbnb/test_auth.py b/tests/airbnb/test_auth.py new file mode 100644 index 0000000..51701ef --- /dev/null +++ b/tests/airbnb/test_auth.py @@ -0,0 +1,35 @@ +"""Tests for Airbnb authentication / storage state management.""" + +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +from src.airbnb.auth import load_authenticated_context, DEFAULT_STATE_PATH + + +class TestLoadAuthenticatedContext: + def test_raises_when_state_file_missing(self, tmp_path): + browser = MagicMock() + missing_path = tmp_path / "nonexistent.json" + + with pytest.raises(FileNotFoundError, match="No saved session"): + load_authenticated_context(browser, state_path=missing_path) + + def test_loads_context_when_state_exists(self, tmp_path): + state_path = tmp_path / "state.json" + state_path.write_text("{}") + + mock_context = MagicMock() + mock_browser = MagicMock() + mock_browser.new_context.return_value = mock_context + + ctx = load_authenticated_context(mock_browser, state_path=state_path) + + mock_browser.new_context.assert_called_once_with( + storage_state=str(state_path) + ) + assert ctx is mock_context + + def test_default_state_path(self): + assert DEFAULT_STATE_PATH == Path("state.json") diff --git a/tests/airbnb/test_calendar.py b/tests/airbnb/test_calendar.py new file mode 100644 index 0000000..ef66e08 --- /dev/null +++ b/tests/airbnb/test_calendar.py @@ -0,0 +1,99 @@ +"""Tests for the Airbnb calendar price automation module.""" + +from datetime import date +from unittest.mock import MagicMock, call, patch + +import pytest + +from src.airbnb.calendar import ( + update_price, + _navigate_to_month, + CALENDAR_URL, + SELECTORS, + _MAX_UPDATE_ATTEMPTS, +) + + +class TestUpdatePrice: + def _make_page(self, *, fail_on_click: bool = False) -> MagicMock: + page = MagicMock() + if fail_on_click: + from playwright.sync_api import TimeoutError as PlaywrightTimeout + page.click.side_effect = PlaywrightTimeout("timed out") + return page + + def test_successful_price_update(self): + page = self._make_page() + result = update_price(page, date(2026, 5, 10), 180) + + assert result is True + page.goto.assert_called_once() + assert "2026-05-10" in str(page.click.call_args_list[0]) + page.fill.assert_called_once() + + def test_navigates_to_calendar_url(self): + page = self._make_page() + update_price(page, date(2026, 5, 10), 180) + + page.goto.assert_called_once_with( + CALENDAR_URL, wait_until="networkidle", timeout=30_000 + ) + + def test_fills_correct_price(self): + page = self._make_page() + update_price(page, date(2026, 5, 10), 200) + + page.fill.assert_called_once_with( + SELECTORS["price_input"], "200", timeout=5_000 + ) + + def test_retries_on_timeout(self): + from playwright.sync_api import TimeoutError as PlaywrightTimeout + + page = MagicMock() + page.goto.side_effect = PlaywrightTimeout("timed out") + + with patch("src.airbnb.calendar.time.sleep"): + result = update_price(page, date(2026, 5, 10), 180) + + assert result is False + assert page.goto.call_count == _MAX_UPDATE_ATTEMPTS + + def test_retries_on_generic_exception(self): + page = MagicMock() + page.goto.side_effect = RuntimeError("unexpected") + + with patch("src.airbnb.calendar.time.sleep"): + result = update_price(page, date(2026, 5, 10), 180) + + assert result is False + assert page.goto.call_count == _MAX_UPDATE_ATTEMPTS + + def test_succeeds_on_second_attempt(self): + from playwright.sync_api import TimeoutError as PlaywrightTimeout + + page = MagicMock() + page.goto.side_effect = [PlaywrightTimeout("first fail"), None] + + with patch("src.airbnb.calendar.time.sleep"): + result = update_price(page, date(2026, 5, 10), 180) + + assert result is True + assert page.goto.call_count == 2 + + +class TestNavigateToMonth: + def test_stub_does_not_crash(self): + page = MagicMock() + _navigate_to_month(page, date(2026, 5, 10)) + + +class TestSelectors: + def test_date_cell_selector_uses_date_format(self): + sel = SELECTORS["date_cell"].format(date_str="2026-05-10") + assert "2026-05-10" in sel + + def test_all_selectors_defined(self): + assert "date_cell" in SELECTORS + assert "price_input" in SELECTORS + assert "save_button" in SELECTORS diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..427c0d8 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,105 @@ +"""Shared fixtures for all tests.""" + +from __future__ import annotations + +from datetime import date + +import pytest + +from src.models import NormalizedEvent + + +@pytest.fixture() +def sample_event() -> NormalizedEvent: + return NormalizedEvent( + name="Toronto Raptors vs Boston Celtics", + event_date=date(2026, 5, 10), + venue="Scotiabank Arena", + source="ticketmaster", + url="https://example.com/raptors", + ) + + +@pytest.fixture() +def sample_events() -> list[NormalizedEvent]: + return [ + NormalizedEvent( + name="Toronto Raptors vs Boston Celtics", + event_date=date(2026, 5, 10), + venue="Scotiabank Arena", + source="ticketmaster", + url="https://example.com/raptors", + ), + NormalizedEvent( + name="Blue Jays vs Yankees", + event_date=date(2026, 5, 12), + venue="Rogers Centre", + source="seatgeek", + url="https://example.com/jays", + ), + NormalizedEvent( + name="Drake Concert", + event_date=date(2026, 5, 15), + venue="Budweiser Stage", + source="ticketmaster", + url="https://example.com/drake", + ), + ] + + +TICKETMASTER_RESPONSE = { + "_embedded": { + "events": [ + { + "name": "Toronto Raptors vs Boston Celtics", + "dates": {"start": {"localDate": "2026-05-10"}}, + "url": "https://www.ticketmaster.ca/raptors", + "_embedded": { + "venues": [{"name": "Scotiabank Arena"}] + }, + }, + { + "name": "Blue Jays vs Yankees", + "dates": {"start": {"localDate": "2026-05-12"}}, + "url": "https://www.ticketmaster.ca/jays", + "_embedded": { + "venues": [{"name": "Rogers Centre"}] + }, + }, + { + "name": "Indie Band at Horseshoe", + "dates": {"start": {"localDate": "2026-05-11"}}, + "url": "https://www.ticketmaster.ca/indie", + "_embedded": { + "venues": [{"name": "Horseshoe Tavern"}] + }, + }, + ] + } +} + +SEATGEEK_RESPONSE = { + "events": [ + { + "title": "Raptors vs Celtics", + "datetime_local": "2026-05-10T19:30:00", + "venue": {"name": "Scotiabank Arena"}, + "url": "https://seatgeek.com/raptors", + "score": 0.85, + }, + { + "title": "Drake Concert", + "datetime_local": "2026-05-15T20:00:00", + "venue": {"name": "Budweiser Stage"}, + "url": "https://seatgeek.com/drake", + "score": 0.72, + }, + { + "title": "Local Open Mic", + "datetime_local": "2026-05-14T21:00:00", + "venue": {"name": "The Rex"}, + "url": "https://seatgeek.com/openmic", + "score": 0.1, + }, + ] +} diff --git a/tests/notifications/__init__.py b/tests/notifications/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/notifications/test_telegram.py b/tests/notifications/test_telegram.py new file mode 100644 index 0000000..3a68227 --- /dev/null +++ b/tests/notifications/test_telegram.py @@ -0,0 +1,131 @@ +"""Tests for the Telegram notification module.""" + +from datetime import date + +import pytest + +from src.models import NormalizedEvent +from src.notifications.telegram import ( + send_alert, + _format_message, + _escape_md, + _score_indicator, + _severity_summary, +) + + +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="", score=score, + ) + + +class TestEscapeMd: + def test_escapes_special_characters(self): + assert _escape_md("hello_world") == "hello\\_world" + assert _escape_md("*bold*") == "\\*bold\\*" + assert _escape_md("a.b") == "a\\.b" + + def test_plain_text_unchanged(self): + assert _escape_md("hello") == "hello" + + def test_multiple_special_chars(self): + result = _escape_md("[link](url)") + assert "\\[" in result + assert "\\]" in result + assert "\\(" in result + assert "\\)" in result + + +class TestScoreIndicator: + def test_high_score(self): + assert _score_indicator(0.8) == "\U0001f534" + assert _score_indicator(1.0) == "\U0001f534" + + def test_medium_score(self): + assert _score_indicator(0.5) == "\U0001f7e1" + assert _score_indicator(0.7) == "\U0001f7e1" + + def test_low_score(self): + assert _score_indicator(0.3) == "\U0001f7e2" + assert _score_indicator(0.0) == "\U0001f7e2" + + +class TestSeveritySummary: + def test_counts_buckets(self): + events = [ + _make_event("A", date(2026, 5, 10), "V", 0.9), + _make_event("B", date(2026, 5, 10), "V", 0.6), + _make_event("C", date(2026, 5, 11), "V", 0.2), + ] + s = _severity_summary(events) + assert "3 events" in s + assert "1 high" in s + assert "1 medium" in s + assert "1 lower" in s + + +class TestFormatMessage: + def test_groups_events_by_date(self): + events = [ + _make_event("Show A", date(2026, 5, 10), "Venue A"), + _make_event("Show B", date(2026, 5, 10), "Venue B"), + _make_event("Show C", date(2026, 5, 12), "Venue C"), + ] + message = _format_message(events) + assert "Show A" in message + assert "Show B" in message + assert "Show C" in message + + def test_sorts_by_date(self): + events = [ + _make_event("Late", date(2026, 5, 15), "V1"), + _make_event("Early", date(2026, 5, 10), "V2"), + ] + message = _format_message(events) + early_pos = message.index("Early") + late_pos = message.index("Late") + assert early_pos < late_pos + + def test_contains_header(self): + events = [_make_event("Show", date(2026, 5, 10), "Venue")] + message = _format_message(events) + assert "EventRate Alert" in message + assert "1 event ·" in message + + def test_includes_venue(self): + events = [_make_event("Show", date(2026, 5, 10), "Scotiabank Arena")] + message = _format_message(events) + assert "Scotiabank Arena" in message + + +class TestSendAlert: + def test_skips_when_no_credentials(self): + assert send_alert([], "", "chat123") is False + assert send_alert([], "token", "") is False + + def test_returns_true_when_no_events(self): + assert send_alert([], "token", "chat123") is True + + def test_sends_message_on_success(self, httpx_mock): + httpx_mock.add_response(json={"ok": True}) + events = [_make_event("Raptors", date(2026, 5, 10), "Scotiabank Arena")] + result = send_alert(events, "fake-token", "123456") + assert result is True + + request = httpx_mock.get_request() + assert request is not None + assert "fake-token" in str(request.url) + + def test_returns_false_on_api_error(self, httpx_mock): + httpx_mock.add_response(status_code=400) + events = [_make_event("Raptors", date(2026, 5, 10), "Scotiabank Arena")] + result = send_alert(events, "fake-token", "123456") + assert result is False + + def test_returns_false_on_network_error(self, httpx_mock): + httpx_mock.add_exception(ConnectionError("network down")) + events = [_make_event("Raptors", date(2026, 5, 10), "Scotiabank Arena")] + result = send_alert(events, "fake-token", "123456") + assert result is False diff --git a/tests/providers/__init__.py b/tests/providers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/providers/test_seatgeek.py b/tests/providers/test_seatgeek.py new file mode 100644 index 0000000..978fcd9 --- /dev/null +++ b/tests/providers/test_seatgeek.py @@ -0,0 +1,80 @@ +"""Tests for the SeatGeek provider.""" + +from datetime import date + +import pytest + +from src.providers.seatgeek import SeatGeekProvider, MIN_SCORE_THRESHOLD +from tests.conftest import SEATGEEK_RESPONSE + + +class TestSeatGeekProvider: + def test_name(self): + provider = SeatGeekProvider(client_id="test-id") + assert provider.name == "seatgeek" + + def test_fetch_skips_when_no_client_id(self): + provider = SeatGeekProvider(client_id="") + assert provider.fetch() == [] + + def test_fetch_filters_by_score_threshold(self, httpx_mock): + httpx_mock.add_response(json=SEATGEEK_RESPONSE) + provider = SeatGeekProvider(client_id="test-id") + events = provider.fetch() + + assert len(events) == 2 + names = {e.name for e in events} + assert "Local Open Mic" not in names + + def test_fetch_normalizes_fields(self, httpx_mock): + httpx_mock.add_response(json=SEATGEEK_RESPONSE) + provider = SeatGeekProvider(client_id="test-id") + events = provider.fetch() + + drake = next(e for e in events if "Drake" in e.name) + assert drake.event_date == date(2026, 5, 15) + assert drake.venue == "Budweiser Stage" + assert drake.source == "seatgeek" + assert "seatgeek.com" in drake.url + + def test_fetch_handles_api_error(self, httpx_mock): + httpx_mock.add_response(status_code=500) + provider = SeatGeekProvider(client_id="test-id") + assert provider.fetch() == [] + + def test_fetch_handles_empty_events(self, httpx_mock): + httpx_mock.add_response(json={"events": []}) + provider = SeatGeekProvider(client_id="test-id") + assert provider.fetch() == [] + + def test_fetch_handles_null_score(self, httpx_mock): + response = { + "events": [ + { + "title": "Event with null score", + "datetime_local": "2026-05-10T19:00:00", + "venue": {"name": "Some Venue"}, + "url": "https://example.com", + "score": None, + }, + ] + } + httpx_mock.add_response(json=response) + provider = SeatGeekProvider(client_id="test-id") + events = provider.fetch() + assert events == [] + + def test_extract_date_iso_format(self): + item = {"datetime_local": "2026-05-10T19:30:00"} + assert SeatGeekProvider._extract_date(item) == date(2026, 5, 10) + + def test_extract_date_utc_fallback(self): + item = {"datetime_utc": "2026-05-10T23:30:00"} + assert SeatGeekProvider._extract_date(item) == date(2026, 5, 10) + + def test_extract_date_missing(self): + assert SeatGeekProvider._extract_date({}) is None + + def test_extract_date_invalid(self): + item = {"datetime_local": "not-a-date"} + assert SeatGeekProvider._extract_date(item) is None diff --git a/tests/providers/test_ticketmaster.py b/tests/providers/test_ticketmaster.py new file mode 100644 index 0000000..730148a --- /dev/null +++ b/tests/providers/test_ticketmaster.py @@ -0,0 +1,105 @@ +"""Tests for the Ticketmaster provider.""" + +from datetime import date + +import pytest + +from src.providers.ticketmaster import TicketmasterProvider, MAJOR_VENUES +from tests.conftest import TICKETMASTER_RESPONSE + + +class TestTicketmasterProvider: + def test_name(self): + provider = TicketmasterProvider(api_key="test-key") + assert provider.name == "ticketmaster" + + def test_fetch_skips_when_no_api_key(self): + provider = TicketmasterProvider(api_key="") + assert provider.fetch() == [] + + def test_fetch_returns_only_major_venue_events(self, httpx_mock): + httpx_mock.add_response(json=TICKETMASTER_RESPONSE) + provider = TicketmasterProvider(api_key="test-key", lookahead_days=30) + events = provider.fetch() + + venue_names = {e.venue.lower().strip() for e in events} + assert venue_names <= MAJOR_VENUES + assert len(events) == 2 + + def test_fetch_normalizes_fields(self, httpx_mock): + httpx_mock.add_response(json=TICKETMASTER_RESPONSE) + provider = TicketmasterProvider(api_key="test-key") + events = provider.fetch() + + raptors = next(e for e in events if "Raptors" in e.name) + assert raptors.event_date == date(2026, 5, 10) + assert raptors.venue == "Scotiabank Arena" + assert raptors.source == "ticketmaster" + assert "ticketmaster.ca" in raptors.url + + def test_fetch_handles_missing_venue(self, httpx_mock): + response = { + "_embedded": { + "events": [ + { + "name": "Mystery Event", + "dates": {"start": {"localDate": "2026-05-10"}}, + "url": "https://example.com", + "_embedded": {"venues": []}, + } + ] + } + } + httpx_mock.add_response(json=response) + provider = TicketmasterProvider(api_key="test-key") + events = provider.fetch() + assert events == [] + + def test_fetch_handles_missing_date(self, httpx_mock): + response = { + "_embedded": { + "events": [ + { + "name": "Raptors Game", + "dates": {"start": {}}, + "_embedded": { + "venues": [{"name": "Scotiabank Arena"}] + }, + } + ] + } + } + httpx_mock.add_response(json=response) + provider = TicketmasterProvider(api_key="test-key") + events = provider.fetch() + assert events == [] + + def test_fetch_handles_api_error(self, httpx_mock): + httpx_mock.add_response(status_code=500) + provider = TicketmasterProvider(api_key="test-key") + events = provider.fetch() + assert events == [] + + def test_fetch_handles_empty_response(self, httpx_mock): + httpx_mock.add_response(json={}) + provider = TicketmasterProvider(api_key="test-key") + events = provider.fetch() + assert events == [] + + def test_is_major_venue_case_insensitive(self): + assert TicketmasterProvider._is_major_venue("SCOTIABANK ARENA") + assert TicketmasterProvider._is_major_venue("scotiabank arena") + assert TicketmasterProvider._is_major_venue(" Rogers Centre ") + assert not TicketmasterProvider._is_major_venue("Horseshoe Tavern") + + def test_extract_date_valid(self): + item = {"dates": {"start": {"localDate": "2026-05-10"}}} + assert TicketmasterProvider._extract_date(item) == date(2026, 5, 10) + + def test_extract_date_invalid_format(self): + item = {"dates": {"start": {"localDate": "not-a-date"}}} + assert TicketmasterProvider._extract_date(item) is None + + def test_extract_date_missing(self): + assert TicketmasterProvider._extract_date({}) is None + assert TicketmasterProvider._extract_date({"dates": {}}) is None diff --git a/tests/scoring/__init__.py b/tests/scoring/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/scoring/test_impact.py b/tests/scoring/test_impact.py new file mode 100644 index 0000000..f33b212 --- /dev/null +++ b/tests/scoring/test_impact.py @@ -0,0 +1,129 @@ +"""Tests for the impact scoring module.""" + +from datetime import date + +from src.models import NormalizedEvent +from src.scoring.impact import score_event, score_events, VENUE_CAPACITY, MAX_CAPACITY, _team_boost + + +def _make_event( + venue: str, event_date: date = date(2026, 5, 10), name: str = "Test Event", +) -> NormalizedEvent: + return NormalizedEvent( + name=name, event_date=event_date, + venue=venue, source="test", + ) + + +class TestScoreEvent: + def test_rogers_centre_gets_highest_score(self): + event = _make_event("Rogers Centre") + scored = score_event(event) + assert scored.score == 1.0 + + def test_scotiabank_arena_proportion(self): + event = _make_event("Scotiabank Arena") + scored = score_event(event) + expected = round(19800 / MAX_CAPACITY, 2) + assert scored.score == expected + + def test_unknown_venue_gets_default(self): + event = _make_event("Random Bar") + scored = score_event(event) + assert scored.score == 0.3 + + def test_case_insensitive_venue_matching(self): + event = _make_event("SCOTIABANK ARENA") + scored = score_event(event) + expected = round(19800 / MAX_CAPACITY, 2) + assert scored.score == expected + + def test_preserves_other_fields(self): + original = NormalizedEvent( + name="Some Concert", event_date=date(2026, 5, 10), + venue="Scotiabank Arena", source="ticketmaster", + url="https://example.com", raw={"key": "value"}, + ) + scored = score_event(original) + assert scored.name == original.name + assert scored.event_date == original.event_date + assert scored.venue == original.venue + assert scored.source == original.source + assert scored.url == original.url + assert scored.raw == original.raw + + def test_all_known_venues_score_above_zero(self): + for venue_name in VENUE_CAPACITY: + event = _make_event(venue_name) + scored = score_event(event) + assert scored.score > 0, f"{venue_name} scored 0" + + +class TestScoreEvents: + def test_empty_list(self): + assert score_events([]) == [] + + def test_sorts_by_score_descending(self): + events = [ + _make_event("Massey Hall"), + _make_event("Rogers Centre"), + _make_event("Scotiabank Arena"), + ] + scored = score_events(events) + scores = [e.score for e in scored] + assert scores == sorted(scores, reverse=True) + + def test_tiebreaker_is_date(self): + events = [ + _make_event("Scotiabank Arena", date(2026, 5, 15)), + _make_event("Scotiabank Arena", date(2026, 5, 10)), + ] + scored = score_events(events) + assert scored[0].event_date == date(2026, 5, 10) + assert scored[1].event_date == date(2026, 5, 15) + + def test_scores_all_events(self): + events = [_make_event("Venue A"), _make_event("Venue B")] + scored = score_events(events) + assert len(scored) == 2 + assert all(e.score > 0 for e in scored) + + +class TestTeamBoost: + def test_raptors_get_boost(self): + assert _team_boost("Toronto Raptors vs. Miami Heat") == 0.42 + + def test_blue_jays_get_boost(self): + assert _team_boost("Toronto Blue Jays vs. Yankees") == 0.20 + + def test_leafs_get_boost(self): + assert _team_boost("Maple Leafs vs Bruins") == 0.42 + + def test_no_team_no_boost(self): + assert _team_boost("Drake Concert") == 0.0 + + def test_case_insensitive(self): + assert _team_boost("RAPTORS vs celtics") == 0.42 + + def test_boost_added_to_venue_score(self): + event = _make_event("Scotiabank Arena", name="Toronto Raptors vs Heat") + scored = score_event(event) + base = round(19800 / MAX_CAPACITY, 2) + assert scored.score == min(base + 0.42, 1.0) + + def test_boost_capped_at_one(self): + event = _make_event("Rogers Centre", name="Blue Jays vs Yankees") + scored = score_event(event) + assert scored.score == 1.0 + + def test_marlies_smaller_boost(self): + assert _team_boost("Toronto Marlies v Charlotte") == 0.05 + + +class TestVenueCapacity: + def test_max_capacity_is_rogers_centre(self): + assert MAX_CAPACITY == 49000 + + def test_all_capacities_positive(self): + for venue, cap in VENUE_CAPACITY.items(): + assert cap > 0, f"{venue} has non-positive capacity" diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..fd2d7ef --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,64 @@ +"""Tests for the configuration module.""" + +import os +from unittest.mock import patch + +from src.config import Settings, load_settings + + +class TestSettings: + def test_defaults(self): + with patch.dict(os.environ, {}, clear=True): + settings = Settings( + _env_file=None, # type: ignore[call-arg] + ) + assert settings.ticketmaster_key == "" + assert settings.seatgeek_client_id == "" + assert settings.telegram_bot_token == "" + assert settings.telegram_chat_id == "" + assert settings.airbnb_listing_id == "" + assert settings.airbnb_base_price == 150 + assert settings.price_increase_pct == 20 + assert settings.search_lat == 43.8083 + assert settings.search_lon == -79.4220 + assert settings.search_radius_km == 30 + assert settings.min_alert_score == 0.0 + assert settings.lookahead_days == 30 + assert settings.log_level == "INFO" + + def test_loads_from_env(self): + env = { + "TICKETMASTER_KEY": "tm-key-123", + "SEATGEEK_CLIENT_ID": "sg-id-456", + "TELEGRAM_BOT_TOKEN": "bot-token", + "TELEGRAM_CHAT_ID": "12345", + "AIRBNB_LISTING_ID": "listing-789", + "AIRBNB_BASE_PRICE": "200", + "PRICE_INCREASE_PCT": "30", + "SEARCH_LAT": "44.0", + "SEARCH_LON": "-80.0", + "SEARCH_RADIUS_KM": "50", + "MIN_ALERT_SCORE": "0.45", + "LOOKAHEAD_DAYS": "14", + "LOG_LEVEL": "DEBUG", + } + with patch.dict(os.environ, env, clear=True): + settings = Settings(_env_file=None) # type: ignore[call-arg] + + assert settings.ticketmaster_key == "tm-key-123" + assert settings.seatgeek_client_id == "sg-id-456" + assert settings.telegram_bot_token == "bot-token" + assert settings.telegram_chat_id == "12345" + assert settings.airbnb_listing_id == "listing-789" + assert settings.airbnb_base_price == 200 + assert settings.price_increase_pct == 30 + assert settings.search_lat == 44.0 + assert settings.search_lon == -80.0 + assert settings.search_radius_km == 50 + assert settings.min_alert_score == 0.45 + assert settings.lookahead_days == 14 + assert settings.log_level == "DEBUG" + + def test_load_settings_returns_settings_instance(self): + settings = load_settings() + assert isinstance(settings, Settings) diff --git a/tests/test_dedup.py b/tests/test_dedup.py new file mode 100644 index 0000000..e16cd3c --- /dev/null +++ b/tests/test_dedup.py @@ -0,0 +1,136 @@ +"""Tests for event deduplication.""" + +from datetime import date + +from src.dedup import deduplicate, _similarity, _is_same_event +from src.models import NormalizedEvent + + +def _make_event(name: str, event_date: date, venue: str, source: str = "test") -> NormalizedEvent: + return NormalizedEvent(name=name, event_date=event_date, venue=venue, source=source) + + +class TestSimilarity: + def test_identical_strings(self): + assert _similarity("hello", "hello") == 1.0 + + def test_empty_strings(self): + assert _similarity("", "hello") == 0.0 + assert _similarity("hello", "") == 0.0 + assert _similarity("", "") == 0.0 + + def test_similar_strings(self): + score = _similarity("Scotiabank Arena", "Scotiabank arena") + assert score == 1.0 # lowercased, identical + + def test_different_strings(self): + score = _similarity("Rogers Centre", "Budweiser Stage") + assert score < 0.5 + + def test_whitespace_collapse(self): + score = _similarity(" Scotiabank Arena ", "scotiabank arena") + assert score == 1.0 + + +class TestIsSameEvent: + def test_same_event_different_sources(self): + a = _make_event("Raptors vs Celtics", date(2026, 5, 10), "Scotiabank Arena", "ticketmaster") + b = _make_event("Raptors vs. Celtics", date(2026, 5, 10), "Scotiabank Arena", "seatgeek") + assert _is_same_event(a, b) + + def test_different_dates(self): + a = _make_event("Raptors", date(2026, 5, 10), "Scotiabank Arena") + b = _make_event("Raptors", date(2026, 5, 11), "Scotiabank Arena") + assert not _is_same_event(a, b) + + def test_different_venues(self): + a = _make_event("Concert", date(2026, 5, 10), "Scotiabank Arena") + b = _make_event("Concert", date(2026, 5, 10), "Rogers Centre") + assert not _is_same_event(a, b) + + def test_very_different_names_same_venue_date(self): + a = _make_event("Raptors Game", date(2026, 5, 10), "Scotiabank Arena") + b = _make_event("Drake Concert", date(2026, 5, 10), "Scotiabank Arena") + assert not _is_same_event(a, b) + + +class TestDeduplicate: + def test_empty_list(self): + assert deduplicate([]) == [] + + def test_no_duplicates(self): + events = [ + _make_event("Event A", date(2026, 5, 10), "Scotiabank Arena"), + _make_event("Event B", date(2026, 5, 11), "Rogers Centre"), + ] + result = deduplicate(events) + assert len(result) == 2 + + def test_removes_cross_provider_duplicates(self): + events = [ + _make_event("Raptors vs Celtics", date(2026, 5, 10), "Scotiabank Arena", "ticketmaster"), + _make_event("Raptors vs. Celtics", date(2026, 5, 10), "Scotiabank Arena", "seatgeek"), + ] + result = deduplicate(events) + assert len(result) == 1 + + def test_prefers_ticketmaster_with_url(self): + tm = NormalizedEvent( + name="Raptors vs Celtics", + event_date=date(2026, 5, 10), + venue="Scotiabank Arena", + source="ticketmaster", + url="https://ticketmaster.ca/event", + ) + sg = NormalizedEvent( + name="Raptors vs. Celtics", + event_date=date(2026, 5, 10), + venue="Scotiabank Arena", + source="seatgeek", + url="https://seatgeek.com/event", + ) + result = deduplicate([tm, sg]) + assert len(result) == 1 + assert result[0].source == "ticketmaster" + + def test_keeps_different_events_same_date(self): + events = [ + _make_event("Raptors Game", date(2026, 5, 10), "Scotiabank Arena"), + _make_event("Blue Jays Game", date(2026, 5, 10), "Rogers Centre"), + ] + result = deduplicate(events) + assert len(result) == 2 + + def test_three_duplicates_become_one(self): + events = [ + _make_event("Big Show", date(2026, 5, 10), "Scotiabank Arena", "ticketmaster"), + _make_event("Big Show", date(2026, 5, 10), "Scotiabank Arena", "seatgeek"), + _make_event("The Big Show", date(2026, 5, 10), "Scotiabank Arena", "other"), + ] + result = deduplicate(events) + assert len(result) == 1 + + def test_merges_jays_promo_variant_same_slot(self): + events = [ + _make_event( + "Toronto Blue Jays vs. Dodgers (Loonie Dogs Night)", + date(2026, 5, 10), + "Rogers Centre", + ), + _make_event( + "Toronto Blue Jays vs. Los Angeles Dodgers", + date(2026, 5, 10), + "Rogers Centre", + ), + ] + result = deduplicate(events) + assert len(result) == 1 + assert "Loonie" not in result[0].name + + def test_does_not_merge_jays_on_different_days(self): + events = [ + _make_event("Toronto Blue Jays vs. Yankees", date(2026, 5, 10), "Rogers Centre"), + _make_event("Toronto Blue Jays vs. Red Sox", date(2026, 5, 11), "Rogers Centre"), + ] + result = deduplicate(events) + assert len(result) == 2 diff --git a/tests/test_filter.py b/tests/test_filter.py new file mode 100644 index 0000000..b1d6dcb --- /dev/null +++ b/tests/test_filter.py @@ -0,0 +1,73 @@ +"""Tests for the date-window filter in main.py.""" + +from datetime import date, timedelta +from unittest.mock import patch + +from src.main import filter_by_window +from src.models import NormalizedEvent + + +def _make_event(event_date: date) -> NormalizedEvent: + return NormalizedEvent( + name="Test", event_date=event_date, + venue="Venue", source="test", + ) + + +class TestFilterByWindow: + @patch("src.main.date") + def test_keeps_events_in_window(self, mock_date): + mock_date.today.return_value = date(2026, 5, 1) + mock_date.side_effect = lambda *args, **kwargs: date(*args, **kwargs) + + events = [ + _make_event(date(2026, 5, 5)), + _make_event(date(2026, 5, 15)), + _make_event(date(2026, 5, 30)), + ] + result = filter_by_window(events, lookahead_days=30) + assert len(result) == 3 + + @patch("src.main.date") + def test_removes_past_events(self, mock_date): + mock_date.today.return_value = date(2026, 5, 10) + mock_date.side_effect = lambda *args, **kwargs: date(*args, **kwargs) + + events = [ + _make_event(date(2026, 5, 1)), + _make_event(date(2026, 5, 15)), + ] + result = filter_by_window(events, lookahead_days=30) + assert len(result) == 1 + assert result[0].event_date == date(2026, 5, 15) + + @patch("src.main.date") + def test_removes_events_beyond_window(self, mock_date): + mock_date.today.return_value = date(2026, 5, 1) + mock_date.side_effect = lambda *args, **kwargs: date(*args, **kwargs) + + events = [ + _make_event(date(2026, 5, 10)), + _make_event(date(2026, 8, 1)), + ] + result = filter_by_window(events, lookahead_days=30) + assert len(result) == 1 + assert result[0].event_date == date(2026, 5, 10) + + @patch("src.main.date") + def test_includes_boundary_dates(self, mock_date): + mock_date.today.return_value = date(2026, 5, 1) + mock_date.side_effect = lambda *args, **kwargs: date(*args, **kwargs) + + today = date(2026, 5, 1) + cutoff = date(2026, 5, 31) + events = [ + _make_event(today), + _make_event(cutoff), + ] + result = filter_by_window(events, lookahead_days=30) + assert len(result) == 2 + + def test_empty_input(self): + result = filter_by_window([], lookahead_days=30) + assert result == [] diff --git a/tests/test_log.py b/tests/test_log.py new file mode 100644 index 0000000..c2c7c53 --- /dev/null +++ b/tests/test_log.py @@ -0,0 +1,31 @@ +"""Tests for the logging setup module.""" + +import logging + +from src.log import setup_logging + + +class TestSetupLogging: + def test_sets_root_level(self): + setup_logging("DEBUG") + root = logging.getLogger() + assert root.level == logging.DEBUG + + def test_adds_handler(self): + root = logging.getLogger() + root.handlers.clear() + setup_logging("INFO") + assert len(root.handlers) >= 1 + + def test_no_duplicate_handlers(self): + root = logging.getLogger() + root.handlers.clear() + setup_logging("INFO") + count = len(root.handlers) + setup_logging("INFO") + assert len(root.handlers) == count + + def test_invalid_level_defaults_to_info(self): + setup_logging("NONEXISTENT") + root = logging.getLogger() + assert root.level == logging.INFO diff --git a/tests/test_main.py b/tests/test_main.py new file mode 100644 index 0000000..6854dbe --- /dev/null +++ b/tests/test_main.py @@ -0,0 +1,318 @@ +"""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") diff --git a/tests/test_models.py b/tests/test_models.py new file mode 100644 index 0000000..50fb39b --- /dev/null +++ b/tests/test_models.py @@ -0,0 +1,64 @@ +"""Tests for the NormalizedEvent model.""" + +from datetime import date + +from src.models import NormalizedEvent + + +class TestNormalizedEvent: + def test_create_event(self): + event = NormalizedEvent( + name="Test Event", + event_date=date(2026, 6, 1), + venue="Test Venue", + source="test", + ) + assert event.name == "Test Event" + assert event.event_date == date(2026, 6, 1) + assert event.venue == "Test Venue" + assert event.source == "test" + assert event.url == "" + assert event.score == 0.0 + assert event.raw == {} + + def test_dedup_key_combines_date_and_lowered_venue(self): + event = NormalizedEvent( + name="Show", + event_date=date(2026, 6, 1), + venue="Scotiabank Arena", + source="ticketmaster", + ) + assert event.dedup_key == "2026-06-01|scotiabank arena" + + def test_dedup_key_strips_whitespace(self): + event = NormalizedEvent( + name="Show", + event_date=date(2026, 6, 1), + venue=" Scotiabank Arena ", + source="ticketmaster", + ) + assert event.dedup_key == "2026-06-01|scotiabank arena" + + def test_frozen_dataclass_prevents_mutation(self): + event = NormalizedEvent( + name="Show", + event_date=date(2026, 6, 1), + venue="Scotiabank Arena", + source="ticketmaster", + ) + try: + event.name = "Changed" # type: ignore[misc] + assert False, "Should have raised FrozenInstanceError" + except AttributeError: + pass + + def test_equality_ignores_raw(self): + e1 = NormalizedEvent( + name="Show", event_date=date(2026, 6, 1), + venue="Venue", source="a", raw={"x": 1}, + ) + e2 = NormalizedEvent( + name="Show", event_date=date(2026, 6, 1), + venue="Venue", source="a", raw={"y": 2}, + ) + assert e1 == e2