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
This commit is contained in:
parent
1a7298f755
commit
c8a82e264c
@ -11,6 +11,15 @@ AIRBNB_LISTING_ID=
|
|||||||
AIRBNB_BASE_PRICE=150
|
AIRBNB_BASE_PRICE=150
|
||||||
PRICE_INCREASE_PCT=20
|
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 ===
|
# === General ===
|
||||||
LOOKAHEAD_DAYS=30
|
LOOKAHEAD_DAYS=30
|
||||||
LOG_LEVEL=INFO
|
LOG_LEVEL=INFO
|
||||||
|
|||||||
3
pyproject.toml
Normal file
3
pyproject.toml
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
[tool.pytest.ini_options]
|
||||||
|
testpaths = ["tests"]
|
||||||
|
pythonpath = ["."]
|
||||||
@ -3,3 +3,7 @@ pydantic>=2.0,<3
|
|||||||
pydantic-settings>=2.0,<3
|
pydantic-settings>=2.0,<3
|
||||||
playwright>=1.40,<2
|
playwright>=1.40,<2
|
||||||
python-dotenv>=1.0,<2
|
python-dotenv>=1.0,<2
|
||||||
|
|
||||||
|
# Testing
|
||||||
|
pytest>=8.0,<9
|
||||||
|
pytest-httpx>=0.30,<1
|
||||||
|
|||||||
@ -17,6 +17,14 @@ class Settings(BaseSettings):
|
|||||||
airbnb_base_price: int = 150
|
airbnb_base_price: int = 150
|
||||||
price_increase_pct: int = 20
|
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
|
# General
|
||||||
lookahead_days: int = 30
|
lookahead_days: int = 30
|
||||||
log_level: str = "INFO"
|
log_level: str = "INFO"
|
||||||
|
|||||||
53
src/dedup.py
53
src/dedup.py
@ -15,6 +15,27 @@ _NAME_SIMILARITY_MIN = 0.78
|
|||||||
# Venue strings vary (suffixes, punctuation); stricter than names.
|
# Venue strings vary (suffixes, punctuation); stricter than names.
|
||||||
_VENUE_SIMILARITY_MIN = 0.88
|
_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+")
|
_WS_RE = re.compile(r"\s+")
|
||||||
|
|
||||||
|
|
||||||
@ -41,19 +62,41 @@ def _is_same_event(a: NormalizedEvent, b: NormalizedEvent) -> bool:
|
|||||||
return True
|
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:
|
def _pick_representative(cluster: list[NormalizedEvent]) -> NormalizedEvent:
|
||||||
"""Prefer richer records when merging duplicates (pre-scoring)."""
|
"""Prefer richer records when merging duplicates (pre-scoring)."""
|
||||||
source_rank = {"ticketmaster": 2, "seatgeek": 1}
|
source_rank = {"ticketmaster": 2, "seatgeek": 1}
|
||||||
|
|
||||||
def key(e: NormalizedEvent) -> tuple:
|
def key(e: NormalizedEvent) -> tuple:
|
||||||
return (
|
return (
|
||||||
bool(e.url),
|
_promo_variant_penalty(e.name),
|
||||||
source_rank.get(e.source, 0),
|
not bool(e.url),
|
||||||
len(e.name),
|
-source_rank.get(e.source, 0),
|
||||||
|
-len(e.name),
|
||||||
e.name,
|
e.name,
|
||||||
)
|
)
|
||||||
|
|
||||||
return max(cluster, key=key)
|
return min(cluster, key=key)
|
||||||
|
|
||||||
|
|
||||||
def deduplicate(events: list[NormalizedEvent]) -> list[NormalizedEvent]:
|
def deduplicate(events: list[NormalizedEvent]) -> list[NormalizedEvent]:
|
||||||
@ -68,7 +111,7 @@ def deduplicate(events: list[NormalizedEvent]) -> list[NormalizedEvent]:
|
|||||||
clusters: list[list[NormalizedEvent]] = []
|
clusters: list[list[NormalizedEvent]] = []
|
||||||
for e in events:
|
for e in events:
|
||||||
for cluster in clusters:
|
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)
|
cluster.append(e)
|
||||||
break
|
break
|
||||||
else:
|
else:
|
||||||
|
|||||||
63
src/main.py
63
src/main.py
@ -24,6 +24,30 @@ from src.scoring.impact import score_events
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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:
|
def parse_args() -> argparse.Namespace:
|
||||||
parser = argparse.ArgumentParser(description="EventRate — Toronto event pricing assistant")
|
parser = argparse.ArgumentParser(description="EventRate — Toronto event pricing assistant")
|
||||||
@ -38,10 +62,16 @@ def fetch_all_events(settings) -> list[NormalizedEvent]:
|
|||||||
TicketmasterProvider(
|
TicketmasterProvider(
|
||||||
api_key=settings.ticketmaster_key,
|
api_key=settings.ticketmaster_key,
|
||||||
lookahead_days=settings.lookahead_days,
|
lookahead_days=settings.lookahead_days,
|
||||||
|
lat=settings.search_lat,
|
||||||
|
lon=settings.search_lon,
|
||||||
|
radius_km=settings.search_radius_km,
|
||||||
),
|
),
|
||||||
SeatGeekProvider(
|
SeatGeekProvider(
|
||||||
client_id=settings.seatgeek_client_id,
|
client_id=settings.seatgeek_client_id,
|
||||||
lookahead_days=settings.lookahead_days,
|
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]
|
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:
|
def print_summary(events: list[NormalizedEvent]) -> None:
|
||||||
"""Print a human-readable summary to stdout."""
|
"""Print a human-readable summary to stdout."""
|
||||||
if not events:
|
if not events:
|
||||||
@ -149,23 +190,29 @@ def main() -> None:
|
|||||||
send_alert([], settings.telegram_bot_token, settings.telegram_chat_id)
|
send_alert([], settings.telegram_bot_token, settings.telegram_chat_id)
|
||||||
sys.exit(0)
|
sys.exit(0)
|
||||||
|
|
||||||
# 2. Deduplicate
|
# 2. Remove noise (tours, meeting spaces, fan add-ons)
|
||||||
unique_events = deduplicate(raw_events)
|
clean_events = filter_noise(raw_events)
|
||||||
|
|
||||||
# 3. Score
|
# 3. Deduplicate
|
||||||
|
unique_events = deduplicate(clean_events)
|
||||||
|
|
||||||
|
# 4. Score
|
||||||
scored_events = score_events(unique_events)
|
scored_events = score_events(unique_events)
|
||||||
|
|
||||||
# 4. Filter
|
# 5. Filter by date window
|
||||||
upcoming = filter_by_window(scored_events, settings.lookahead_days)
|
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)
|
print_summary(upcoming)
|
||||||
|
|
||||||
if args.dry_run:
|
if args.dry_run:
|
||||||
logger.info("Dry run complete, no alerts or updates sent")
|
logger.info("Dry run complete, no alerts or updates sent")
|
||||||
return
|
return
|
||||||
|
|
||||||
# 6. Alert
|
# 8. Alert
|
||||||
alert_ok = send_alert(
|
alert_ok = send_alert(
|
||||||
upcoming,
|
upcoming,
|
||||||
settings.telegram_bot_token,
|
settings.telegram_bot_token,
|
||||||
@ -175,7 +222,7 @@ def main() -> None:
|
|||||||
if not alert_ok:
|
if not alert_ok:
|
||||||
logger.error("Telegram alert failed")
|
logger.error("Telegram alert failed")
|
||||||
|
|
||||||
# 7. Optionally update Airbnb
|
# 9. Optionally update Airbnb
|
||||||
if not args.alerts_only and upcoming:
|
if not args.alerts_only and upcoming:
|
||||||
update_airbnb_prices(upcoming, settings)
|
update_airbnb_prices(upcoming, settings)
|
||||||
|
|
||||||
|
|||||||
@ -52,11 +52,22 @@ def send_alert(
|
|||||||
return False
|
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:
|
def _format_message(events: list[NormalizedEvent]) -> str:
|
||||||
"""Group events by date and format as MarkdownV2."""
|
"""Group events by date and format as MarkdownV2."""
|
||||||
sorted_events = sorted(events, key=lambda e: e.event_date)
|
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):
|
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'))}*")
|
lines.append(f"*{_escape_md(event_date.strftime('%a %b %d, %Y'))}*")
|
||||||
for event in group:
|
for event in group:
|
||||||
|
|||||||
@ -27,9 +27,19 @@ MIN_SCORE_THRESHOLD = 0.5
|
|||||||
|
|
||||||
|
|
||||||
class SeatGeekProvider(EventProvider):
|
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._client_id = client_id
|
||||||
self._lookahead_days = lookahead_days
|
self._lookahead_days = lookahead_days
|
||||||
|
self._lat = lat
|
||||||
|
self._lon = lon
|
||||||
|
self._radius_km = radius_km
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def name(self) -> str:
|
def name(self) -> str:
|
||||||
@ -52,9 +62,12 @@ class SeatGeekProvider(EventProvider):
|
|||||||
"%Y-%m-%dT%H:%M:%S"
|
"%Y-%m-%dT%H:%M:%S"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
range_mi = max(1, int(self._radius_km * 0.621371))
|
||||||
params = {
|
params = {
|
||||||
"client_id": self._client_id,
|
"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.gte": start,
|
||||||
"datetime_utc.lte": end,
|
"datetime_utc.lte": end,
|
||||||
"per_page": 100,
|
"per_page": 100,
|
||||||
|
|||||||
@ -35,9 +35,19 @@ MAJOR_VENUES = {
|
|||||||
|
|
||||||
|
|
||||||
class TicketmasterProvider(EventProvider):
|
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._api_key = api_key
|
||||||
self._lookahead_days = lookahead_days
|
self._lookahead_days = lookahead_days
|
||||||
|
self._lat = lat
|
||||||
|
self._lon = lon
|
||||||
|
self._radius_km = radius_km
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def name(self) -> str:
|
def name(self) -> str:
|
||||||
@ -62,7 +72,9 @@ class TicketmasterProvider(EventProvider):
|
|||||||
|
|
||||||
params = {
|
params = {
|
||||||
"apikey": self._api_key,
|
"apikey": self._api_key,
|
||||||
"city": "Toronto",
|
"latlong": f"{self._lat},{self._lon}",
|
||||||
|
"radius": str(self._radius_km),
|
||||||
|
"unit": "km",
|
||||||
"countryCode": "CA",
|
"countryCode": "CA",
|
||||||
"startDateTime": start,
|
"startDateTime": start,
|
||||||
"endDateTime": end,
|
"endDateTime": end,
|
||||||
|
|||||||
@ -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.
|
Assigns a 0.0–1.0 score based on venue capacity, event type, and
|
||||||
This is intentionally naive — a starting point, not a pricing model.
|
whether the event involves a major local sports team.
|
||||||
|
|
||||||
ASSUMPTION: Venue capacities are approximate and hardcoded.
|
ASSUMPTION: Venue capacities are approximate and hardcoded.
|
||||||
Real capacity depends on event configuration (e.g., concert vs hockey
|
Real capacity depends on event configuration (e.g., concert vs hockey
|
||||||
@ -15,7 +15,6 @@ from src.models import NormalizedEvent
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# TODO: Validate these capacities and expand as needed
|
|
||||||
VENUE_CAPACITY: dict[str, int] = {
|
VENUE_CAPACITY: dict[str, int] = {
|
||||||
"rogers centre": 49000,
|
"rogers centre": 49000,
|
||||||
"scotiabank arena": 19800,
|
"scotiabank arena": 19800,
|
||||||
@ -28,6 +27,30 @@ VENUE_CAPACITY: dict[str, int] = {
|
|||||||
|
|
||||||
MAX_CAPACITY = max(VENUE_CAPACITY.values())
|
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:
|
def score_event(event: NormalizedEvent) -> NormalizedEvent:
|
||||||
"""Return a copy of the event with an impact score assigned."""
|
"""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)
|
capacity = VENUE_CAPACITY.get(venue_key, 0)
|
||||||
|
|
||||||
if capacity == 0:
|
if capacity == 0:
|
||||||
# Unknown venue — assign a moderate default so it still surfaces
|
base_score = 0.3
|
||||||
score = 0.3
|
|
||||||
else:
|
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(
|
return NormalizedEvent(
|
||||||
name=event.name,
|
name=event.name,
|
||||||
|
|||||||
0
tests/airbnb/__init__.py
Normal file
0
tests/airbnb/__init__.py
Normal file
35
tests/airbnb/test_auth.py
Normal file
35
tests/airbnb/test_auth.py
Normal file
@ -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")
|
||||||
99
tests/airbnb/test_calendar.py
Normal file
99
tests/airbnb/test_calendar.py
Normal file
@ -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
|
||||||
105
tests/conftest.py
Normal file
105
tests/conftest.py
Normal file
@ -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,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
0
tests/notifications/__init__.py
Normal file
0
tests/notifications/__init__.py
Normal file
131
tests/notifications/test_telegram.py
Normal file
131
tests/notifications/test_telegram.py
Normal file
@ -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
|
||||||
0
tests/providers/__init__.py
Normal file
0
tests/providers/__init__.py
Normal file
80
tests/providers/test_seatgeek.py
Normal file
80
tests/providers/test_seatgeek.py
Normal file
@ -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
|
||||||
105
tests/providers/test_ticketmaster.py
Normal file
105
tests/providers/test_ticketmaster.py
Normal file
@ -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
|
||||||
0
tests/scoring/__init__.py
Normal file
0
tests/scoring/__init__.py
Normal file
129
tests/scoring/test_impact.py
Normal file
129
tests/scoring/test_impact.py
Normal file
@ -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"
|
||||||
64
tests/test_config.py
Normal file
64
tests/test_config.py
Normal file
@ -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)
|
||||||
136
tests/test_dedup.py
Normal file
136
tests/test_dedup.py
Normal file
@ -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
|
||||||
73
tests/test_filter.py
Normal file
73
tests/test_filter.py
Normal file
@ -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 == []
|
||||||
31
tests/test_log.py
Normal file
31
tests/test_log.py
Normal file
@ -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
|
||||||
318
tests/test_main.py
Normal file
318
tests/test_main.py
Normal file
@ -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")
|
||||||
64
tests/test_models.py
Normal file
64
tests/test_models.py
Normal file
@ -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
|
||||||
Loading…
x
Reference in New Issue
Block a user