From a08839961bddf5e1ee2f7b740d26e731ac7c16cf Mon Sep 17 00:00:00 2001 From: ilia Date: Sun, 26 Jul 2026 15:42:40 -0400 Subject: [PATCH 1/2] Make CI ruff/pytest gates hard, fix stale tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removes || true from the ruff and pytest steps (job names unchanged so branch protection contexts still match), adds [tool.ruff] config (E4/E7/E9/F/I, line-length 120), applies ruff autofixes, and rewrites tests that had drifted from the current calendar/auth/main APIs — failures the soft gates had been hiding. --- .gitea/workflows/ci.yml | 14 +-- pyproject.toml | 6 ++ src/airbnb/calendar.py | 3 +- src/notifications/telegram.py | 1 - src/providers/__init__.py | 2 +- src/providers/base.py | 1 + src/scoring/impact.py | 1 + tests/airbnb/test_auth.py | 8 +- tests/airbnb/test_browser.py | 1 - tests/airbnb/test_calendar.py | 132 +++++++++++++-------------- tests/notifications/test_telegram.py | 6 +- tests/providers/test_seatgeek.py | 4 +- tests/providers/test_ticketmaster.py | 4 +- tests/scoring/test_impact.py | 2 +- tests/test_dedup.py | 2 +- tests/test_filter.py | 2 +- tests/test_main.py | 33 ++++--- 17 files changed, 106 insertions(+), 116 deletions(-) diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 6cd836d..d0aea9e 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -48,8 +48,8 @@ jobs: if [ -f requirements-dev.txt ]; then pip install -r requirements-dev.txt --break-system-packages; fi pip install bandit pip-audit ruff --break-system-packages - - name: Ruff lint - run: ruff check . || true + - name: Ruff lint (hard gate) + run: ruff check . - name: Bandit (advisory) run: bandit -r . -q || true @@ -57,14 +57,10 @@ jobs: - name: pip-audit (advisory) run: pip-audit -r requirements.txt 2>/dev/null || pip-audit 2>/dev/null || true - - name: Pytest + - name: Pytest (hard gate) run: | - if [ -d tests ] || ls test_*.py *_test.py 2>/dev/null; then - pip install pytest --break-system-packages - pytest -q || true - else - echo "No tests found — skip" - fi + pip install pytest --break-system-packages + pytest -q secret-scan: needs: skip-ci-check diff --git a/pyproject.toml b/pyproject.toml index a498f38..5bbf694 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,3 +1,9 @@ [tool.pytest.ini_options] testpaths = ["tests"] pythonpath = ["."] + +[tool.ruff] +line-length = 120 + +[tool.ruff.lint] +select = ["E4", "E7", "E9", "F", "I"] diff --git a/src/airbnb/calendar.py b/src/airbnb/calendar.py index 8c3c1d1..c6b4691 100644 --- a/src/airbnb/calendar.py +++ b/src/airbnb/calendar.py @@ -16,7 +16,8 @@ import re import time from datetime import date -from playwright.sync_api import Locator, Page, TimeoutError as PlaywrightTimeout +from playwright.sync_api import Locator, Page +from playwright.sync_api import TimeoutError as PlaywrightTimeout logger = logging.getLogger(__name__) diff --git a/src/notifications/telegram.py b/src/notifications/telegram.py index 93492c2..2dec532 100644 --- a/src/notifications/telegram.py +++ b/src/notifications/telegram.py @@ -6,7 +6,6 @@ Uses the Bot API sendMessage endpoint with MarkdownV2 formatting. from __future__ import annotations import logging -from datetime import date from itertools import groupby import httpx diff --git a/src/providers/__init__.py b/src/providers/__init__.py index 4cfc5b9..399636a 100644 --- a/src/providers/__init__.py +++ b/src/providers/__init__.py @@ -1,5 +1,5 @@ from src.providers.base import EventProvider -from src.providers.ticketmaster import TicketmasterProvider from src.providers.seatgeek import SeatGeekProvider +from src.providers.ticketmaster import TicketmasterProvider __all__ = ["EventProvider", "TicketmasterProvider", "SeatGeekProvider"] diff --git a/src/providers/base.py b/src/providers/base.py index fa94bd4..43136f1 100644 --- a/src/providers/base.py +++ b/src/providers/base.py @@ -3,6 +3,7 @@ from __future__ import annotations from abc import ABC, abstractmethod + from src.models import NormalizedEvent diff --git a/src/scoring/impact.py b/src/scoring/impact.py index ed10c96..8bef7dc 100644 --- a/src/scoring/impact.py +++ b/src/scoring/impact.py @@ -11,6 +11,7 @@ layout at Scotiabank Arena). from __future__ import annotations import logging + from src.models import NormalizedEvent logger = logging.getLogger(__name__) diff --git a/tests/airbnb/test_auth.py b/tests/airbnb/test_auth.py index 51701ef..8fab935 100644 --- a/tests/airbnb/test_auth.py +++ b/tests/airbnb/test_auth.py @@ -1,11 +1,11 @@ """Tests for Airbnb authentication / storage state management.""" from pathlib import Path -from unittest.mock import MagicMock, patch +from unittest.mock import MagicMock import pytest -from src.airbnb.auth import load_authenticated_context, DEFAULT_STATE_PATH +from src.airbnb.auth import DEFAULT_STATE_PATH, load_authenticated_context class TestLoadAuthenticatedContext: @@ -27,7 +27,9 @@ class TestLoadAuthenticatedContext: ctx = load_authenticated_context(mock_browser, state_path=state_path) mock_browser.new_context.assert_called_once_with( - storage_state=str(state_path) + storage_state=str(state_path), + viewport={"width": 1440, "height": 900}, + locale="en-CA", ) assert ctx is mock_context diff --git a/tests/airbnb/test_browser.py b/tests/airbnb/test_browser.py index a508d6b..7f7c550 100644 --- a/tests/airbnb/test_browser.py +++ b/tests/airbnb/test_browser.py @@ -1,6 +1,5 @@ """Tests for optional stealth browser launcher.""" -import os from unittest.mock import MagicMock, patch import pytest diff --git a/tests/airbnb/test_calendar.py b/tests/airbnb/test_calendar.py index ef66e08..a240f78 100644 --- a/tests/airbnb/test_calendar.py +++ b/tests/airbnb/test_calendar.py @@ -1,99 +1,91 @@ """Tests for the Airbnb calendar price automation module.""" from datetime import date -from unittest.mock import MagicMock, call, patch +from unittest.mock import MagicMock, patch -import pytest +from playwright.sync_api import TimeoutError as PlaywrightTimeout from src.airbnb.calendar import ( - update_price, - _navigate_to_month, - CALENDAR_URL, - SELECTORS, _MAX_UPDATE_ATTEMPTS, + DEFAULT_HOST_ORIGIN, + parse_month_heading_text, + resolve_calendar_url, + update_price, ) +CALENDAR_URL = f"{DEFAULT_HOST_ORIGIN}/multicalendar/12345" + + +class TestResolveCalendarUrl: + def test_override_wins(self): + url = resolve_calendar_url("12345", "https://www.airbnb.ca/multicalendar/999/") + assert url == "https://www.airbnb.ca/multicalendar/999" + + def test_listing_id_builds_multicalendar_url(self): + assert resolve_calendar_url("12345", "") == f"{DEFAULT_HOST_ORIGIN}/multicalendar/12345" + + def test_no_listing_id_falls_back_to_hosting_calendar(self): + assert resolve_calendar_url("", "") == f"{DEFAULT_HOST_ORIGIN}/hosting/calendar" + + +class TestParseMonthHeadingText: + def test_parses_month_and_year(self): + assert parse_month_heading_text("April 2026") == (2026, 4) + + def test_parses_with_surrounding_text(self): + assert parse_month_heading_text(" December 2025 ") == (2025, 12) + + def test_rejects_garbage(self): + assert parse_month_heading_text("not a month heading") is None + 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) + page = MagicMock() + with patch("src.airbnb.calendar._run_price_update_attempt") as attempt: + result = update_price(page, date(2026, 5, 10), 180, CALENDAR_URL) 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 - ) + attempt.assert_called_once_with(page, date(2026, 5, 10), 180, CALENDAR_URL) 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) + with ( + patch( + "src.airbnb.calendar._run_price_update_attempt", + side_effect=PlaywrightTimeout("timed out"), + ) as attempt, + patch("src.airbnb.calendar.time.sleep"), + ): + result = update_price(page, date(2026, 5, 10), 180, CALENDAR_URL) assert result is False - assert page.goto.call_count == _MAX_UPDATE_ATTEMPTS + assert attempt.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) + with ( + patch( + "src.airbnb.calendar._run_price_update_attempt", + side_effect=RuntimeError("unexpected"), + ) as attempt, + patch("src.airbnb.calendar.time.sleep"), + ): + result = update_price(page, date(2026, 5, 10), 180, CALENDAR_URL) assert result is False - assert page.goto.call_count == _MAX_UPDATE_ATTEMPTS + assert attempt.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) + with ( + patch( + "src.airbnb.calendar._run_price_update_attempt", + side_effect=[PlaywrightTimeout("first fail"), None], + ) as attempt, + patch("src.airbnb.calendar.time.sleep"), + ): + result = update_price(page, date(2026, 5, 10), 180, CALENDAR_URL) 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 + assert attempt.call_count == 2 diff --git a/tests/notifications/test_telegram.py b/tests/notifications/test_telegram.py index 3a68227..fd6591b 100644 --- a/tests/notifications/test_telegram.py +++ b/tests/notifications/test_telegram.py @@ -2,15 +2,13 @@ from datetime import date -import pytest - from src.models import NormalizedEvent from src.notifications.telegram import ( - send_alert, - _format_message, _escape_md, + _format_message, _score_indicator, _severity_summary, + send_alert, ) diff --git a/tests/providers/test_seatgeek.py b/tests/providers/test_seatgeek.py index 978fcd9..c6cb425 100644 --- a/tests/providers/test_seatgeek.py +++ b/tests/providers/test_seatgeek.py @@ -2,9 +2,7 @@ from datetime import date -import pytest - -from src.providers.seatgeek import SeatGeekProvider, MIN_SCORE_THRESHOLD +from src.providers.seatgeek import SeatGeekProvider from tests.conftest import SEATGEEK_RESPONSE diff --git a/tests/providers/test_ticketmaster.py b/tests/providers/test_ticketmaster.py index 730148a..62f40d4 100644 --- a/tests/providers/test_ticketmaster.py +++ b/tests/providers/test_ticketmaster.py @@ -2,9 +2,7 @@ from datetime import date -import pytest - -from src.providers.ticketmaster import TicketmasterProvider, MAJOR_VENUES +from src.providers.ticketmaster import MAJOR_VENUES, TicketmasterProvider from tests.conftest import TICKETMASTER_RESPONSE diff --git a/tests/scoring/test_impact.py b/tests/scoring/test_impact.py index f33b212..472bfd4 100644 --- a/tests/scoring/test_impact.py +++ b/tests/scoring/test_impact.py @@ -3,7 +3,7 @@ from datetime import date from src.models import NormalizedEvent -from src.scoring.impact import score_event, score_events, VENUE_CAPACITY, MAX_CAPACITY, _team_boost +from src.scoring.impact import MAX_CAPACITY, VENUE_CAPACITY, _team_boost, score_event, score_events def _make_event( diff --git a/tests/test_dedup.py b/tests/test_dedup.py index e16cd3c..a40a9a7 100644 --- a/tests/test_dedup.py +++ b/tests/test_dedup.py @@ -2,7 +2,7 @@ from datetime import date -from src.dedup import deduplicate, _similarity, _is_same_event +from src.dedup import _is_same_event, _similarity, deduplicate from src.models import NormalizedEvent diff --git a/tests/test_filter.py b/tests/test_filter.py index b1d6dcb..fcb5578 100644 --- a/tests/test_filter.py +++ b/tests/test_filter.py @@ -1,6 +1,6 @@ """Tests for the date-window filter in main.py.""" -from datetime import date, timedelta +from datetime import date from unittest.mock import patch from src.main import filter_by_window diff --git a/tests/test_main.py b/tests/test_main.py index 6854dbe..ba3875d 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -1,18 +1,18 @@ """Tests for the main orchestration runner.""" from datetime import date -from unittest.mock import patch, MagicMock +from unittest.mock import MagicMock, patch import pytest from src.main import ( - parse_args, fetch_all_events, - filter_noise, filter_by_min_score, + filter_noise, + main, + parse_args, print_summary, update_airbnb_prices, - main, ) from src.models import NormalizedEvent @@ -185,24 +185,23 @@ class TestUpdateAirbnbPrices: settings.airbnb_listing_id = "12345" settings.airbnb_base_price = 100 settings.price_increase_pct = 25 + settings.airbnb_calendar_url = "" + settings.airbnb_headed = False 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 + mock_page = MagicMock() + mock_context = MagicMock() + mock_context.new_page.return_value = mock_page + with patch("src.airbnb.browser.open_browser"), \ + patch("src.airbnb.auth.load_authenticated_context", return_value=mock_context), \ + patch("src.airbnb.calendar.update_price", return_value=True) as mock_update: update_airbnb_prices(events, settings) - mock_update.assert_called_once_with(mock_page, date(2026, 5, 10), 125) + + mock_update.assert_called_once_with( + mock_page, date(2026, 5, 10), 125, "https://www.airbnb.ca/multicalendar/12345" + ) def test_handles_missing_state_file(self): settings = MagicMock() -- 2.49.1 From be4048b3685b286a56257188297e57380206489f Mon Sep 17 00:00:00 2001 From: ilia Date: Sun, 26 Jul 2026 15:42:40 -0400 Subject: [PATCH 2/2] Scrub homelab IP from docs and login script Replaces the automationlab IP with an ATANYRATE_HOST env var in command examples and a placeholder in prose. Also carries the ruff import-order fix in airbnb_login.py. --- README.md | 10 +++++----- docs/HANDOFF.md | 8 ++++---- scripts/airbnb_login.py | 4 ++-- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index ccfb4e3..3a7afd6 100644 --- a/README.md +++ b/README.md @@ -53,13 +53,13 @@ python scripts/airbnb_login.py Chromium opens. Log in, complete any 2FA, then press Enter in the terminal. Session cookies are saved to `state.json`. -### Production (automationlab @ 10.0.10.45) +### Production (automationlab LXC) -Recommended: login on your Mac, then copy: +Recommended: login on your Mac, then copy (set `ATANYRATE_HOST` to the automationlab host): ```bash -scp state.json root@10.0.10.45:/opt/atanyrate/state.json -ssh root@10.0.10.45 'chmod 600 /opt/atanyrate/state.json' +scp state.json "root@${ATANYRATE_HOST}:/opt/atanyrate/state.json" +ssh "root@${ATANYRATE_HOST}" 'chmod 600 /opt/atanyrate/state.json' ``` Or use the ansible deploy script: `ATANYRATE_STATE=~/path/to/state.json make deploy-atanyrate` (see `docs/guides/atanyrate-deploy.md` in the ansible repo). @@ -98,7 +98,7 @@ docker run --rm --env-file .env -v $(pwd)/state.json:/app/state.json eventrate ## Production deploy -Deployed on **automationlab** (`10.0.10.45`) at `/opt/atanyrate`. Full guide: ansible repo `docs/guides/atanyrate-deploy.md`. +Deployed on **automationlab** (``) at `/opt/atanyrate`. Full guide: ansible repo `docs/guides/atanyrate-deploy.md`. ```bash # From ~/Documents/code/ansible diff --git a/docs/HANDOFF.md b/docs/HANDOFF.md index 72bdbda..1ac273a 100644 --- a/docs/HANDOFF.md +++ b/docs/HANDOFF.md @@ -1,7 +1,7 @@ # AtAnyRate — handoff **Repo:** `gitea@git.levkin.ca:ilia/AtAnyRate.git` · local `~/Documents/code/AtAnyRate` -**Deploy:** pve10 LXC **automationlab** @ `10.0.10.45` — `make deploy-atanyrate` (ansible) +**Deploy:** pve10 LXC **automationlab** @ `` — `make deploy-atanyrate` (ansible) **Vikunja:** [todo.levkin.ca → Business → AtAnyRate](https://todo.levkin.ca) (`AAR`) **Epic backlog:** [../BACKLOG.md](../BACKLOG.md) @@ -22,7 +22,7 @@ ## Done (reference) - Ticketmaster + SeatGeek providers, Telegram alerter, Playwright calendar automation -- Deploy on automationlab (`10.0.10.45`); ansible vault `vault_atanyrate_*` +- Deploy on automationlab (``); ansible vault `vault_atanyrate_*` - SeatGeek client ID on server (both providers 200 OK) --- @@ -36,8 +36,8 @@ python -m src.main --alerts-only --dry-run # on Mac: refresh session (display required) python scripts/airbnb_login.py -scp state.json root@10.0.10.45:/opt/atanyrate/state.json -ssh root@10.0.10.45 'chmod 600 /opt/atanyrate/state.json' +scp state.json "root@${ATANYRATE_HOST}:/opt/atanyrate/state.json" +ssh "root@${ATANYRATE_HOST}" 'chmod 600 /opt/atanyrate/state.json' ``` --- diff --git a/scripts/airbnb_login.py b/scripts/airbnb_login.py index d80c292..abe67c9 100644 --- a/scripts/airbnb_login.py +++ b/scripts/airbnb_login.py @@ -16,11 +16,11 @@ Optional stealth Firefox (if Airbnb blocks Chromium — same mode for login + ru Then copy to automationlab: - scp state.json root@10.0.10.45:/opt/atanyrate/state.json + scp state.json "root@${ATANYRATE_HOST}:/opt/atanyrate/state.json" """ -from pathlib import Path import sys +from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) -- 2.49.1