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:
ilia 2026-04-04 15:25:35 -04:00
parent 1a7298f755
commit c8a82e264c
27 changed files with 1570 additions and 25 deletions

View File

@ -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

3
pyproject.toml Normal file
View File

@ -0,0 +1,3 @@
[tool.pytest.ini_options]
testpaths = ["tests"]
pythonpath = ["."]

View File

@ -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

View File

@ -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"

View File

@ -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:

View File

@ -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)

View File

@ -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:

View File

@ -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,

View File

@ -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,

View File

@ -1,7 +1,7 @@
"""Simple rule-based impact scoring for events.
"""Rule-based impact scoring for events.
Assigns a 0.01.0 score based on venue capacity and event type.
This is intentionally naive a starting point, not a pricing model.
Assigns a 0.01.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,

0
tests/airbnb/__init__.py Normal file
View File

35
tests/airbnb/test_auth.py Normal file
View 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")

View 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
View 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,
},
]
}

View File

View 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

View File

View 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

View 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

View File

View 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
View 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
View 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
View 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
View 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
View 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
View 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