From def491a4d4f5d5fcb7d7ca80092a1d5ae30532dc Mon Sep 17 00:00:00 2001 From: josh Date: Sun, 29 Mar 2026 13:17:20 -0400 Subject: [PATCH] test: add full test suite with 100% coverage across all modules Co-Authored-By: Claude Sonnet 4.6 --- requirements-dev.txt | 1 + tests/test_api.py | 117 +++++++++++++++++++++ tests/test_games.py | 117 +++++++++++++++++++++ tests/test_routes.py | 20 ++++ tests/test_scheduler.py | 41 ++++++++ tests/test_standings.py | 220 ++++++++++++++++++++++++++++++++++++++++ 6 files changed, 516 insertions(+) create mode 100644 tests/test_api.py create mode 100644 tests/test_scheduler.py create mode 100644 tests/test_standings.py diff --git a/requirements-dev.txt b/requirements-dev.txt index 34cbd13..b77d489 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,4 +1,5 @@ -r requirements.txt pytest==8.3.4 +pytest-cov==6.0.0 pytest-mock==3.14.0 ruff==0.8.6 diff --git a/tests/test_api.py b/tests/test_api.py new file mode 100644 index 0000000..c4e1c2b --- /dev/null +++ b/tests/test_api.py @@ -0,0 +1,117 @@ +import json +from datetime import datetime +from zoneinfo import ZoneInfo + +import requests as req + +from app.api import fetch_scores, refresh_scores + +EASTERN = ZoneInfo("America/New_York") + + +class TestFetchScores: + def test_uses_now_url_during_evening(self, mocker): + """7:30 PM ET → /score/now""" + mock_dt = mocker.patch("app.api.datetime") + mock_dt.now.return_value = datetime(2024, 4, 10, 19, 30, tzinfo=EASTERN) + + mock_get = mocker.patch("app.api.requests.get") + mock_get.return_value.json.return_value = {"games": []} + + fetch_scores() + + url = mock_get.call_args[0][0] + assert url == "https://api-web.nhle.com/v1/score/now" + + def test_uses_now_url_after_midnight(self, mocker): + """1:00 AM ET → /score/now (still considered game hours)""" + mock_dt = mocker.patch("app.api.datetime") + mock_dt.now.return_value = datetime(2024, 4, 11, 1, 0, tzinfo=EASTERN) + + mock_get = mocker.patch("app.api.requests.get") + mock_get.return_value.json.return_value = {"games": []} + + fetch_scores() + + url = mock_get.call_args[0][0] + assert url == "https://api-web.nhle.com/v1/score/now" + + def test_uses_date_url_during_afternoon(self, mocker): + """2:00 PM ET → date-based endpoint""" + mock_dt = mocker.patch("app.api.datetime") + mock_dt.now.return_value = datetime(2024, 4, 10, 14, 0, tzinfo=EASTERN) + + mock_get = mocker.patch("app.api.requests.get") + mock_get.return_value.json.return_value = {"games": []} + + fetch_scores() + + url = mock_get.call_args[0][0] + assert "2024-04-10" in url + assert "now" not in url + + def test_returns_json_on_success(self, mocker): + mock_dt = mocker.patch("app.api.datetime") + mock_dt.now.return_value = datetime(2024, 4, 10, 14, 0, tzinfo=EASTERN) + + expected = {"games": [{"id": 1}]} + mock_get = mocker.patch("app.api.requests.get") + mock_get.return_value.json.return_value = expected + + result = fetch_scores() + + assert result == expected + + def test_returns_none_on_request_exception(self, mocker): + mock_dt = mocker.patch("app.api.datetime") + mock_dt.now.return_value = datetime(2024, 4, 10, 14, 0, tzinfo=EASTERN) + mocker.patch( + "app.api.requests.get", side_effect=req.RequestException("timeout") + ) + + result = fetch_scores() + + assert result is None + + def test_returns_none_on_bad_status(self, mocker): + mock_dt = mocker.patch("app.api.datetime") + mock_dt.now.return_value = datetime(2024, 4, 10, 14, 0, tzinfo=EASTERN) + + mock_get = mocker.patch("app.api.requests.get") + mock_get.return_value.raise_for_status.side_effect = req.HTTPError("404") + + result = fetch_scores() + + assert result is None + + +class TestRefreshScores: + def test_writes_data_to_file(self, mocker, tmp_path): + data = {"games": [{"id": 1}]} + mocker.patch("app.api.fetch_scores", return_value=data) + + score_file = tmp_path / "scoreboard_data.json" + mocker.patch("app.api.SCOREBOARD_DATA_FILE", str(score_file)) + + result = refresh_scores() + + assert result == data + assert score_file.exists() + assert json.loads(score_file.read_text()) == data + + def test_returns_none_when_fetch_fails(self, mocker): + mocker.patch("app.api.fetch_scores", return_value=None) + + result = refresh_scores() + + assert result is None + + def test_does_not_write_file_when_fetch_fails(self, mocker, tmp_path): + mocker.patch("app.api.fetch_scores", return_value=None) + + score_file = tmp_path / "scoreboard_data.json" + mocker.patch("app.api.SCOREBOARD_DATA_FILE", str(score_file)) + + refresh_scores() + + assert not score_file.exists() diff --git a/tests/test_games.py b/tests/test_games.py index c185884..6c888ae 100644 --- a/tests/test_games.py +++ b/tests/test_games.py @@ -1,11 +1,14 @@ from tests.conftest import make_game from app.games import ( + calculate_game_priority, convert_game_state, format_record, get_game_outcome, get_period, + get_power_play_info, get_start_time, get_time_remaining, + parse_games, utc_to_eastern, ) @@ -101,3 +104,117 @@ class TestUtcToEstTime: def test_converts_utc_to_est(self): result = utc_to_eastern("2024-04-10T23:00:00Z") assert result == "07:00 PM" + + +class TestParseGames: + def test_returns_empty_list_for_none(self): + assert parse_games(None) == [] + + def test_returns_empty_list_for_empty_dict(self): + assert parse_games({}) == [] + + +class TestGetPowerPlayInfo: + def test_returns_empty_when_no_situation(self): + game = make_game() + assert get_power_play_info(game, "Maple Leafs") == "" + + def test_returns_pp_info_for_away_team(self): + game = make_game(away_name="Bruins") + game["situation"] = { + "situationDescriptions": ["PP"], + "timeRemaining": "1:30", + } + assert get_power_play_info(game, "Bruins") == "PP 1:30" + + def test_returns_pp_info_for_home_team(self): + game = make_game(home_name="Maple Leafs", away_name="Bruins") + game["situation"] = { + "situationDescriptions": ["PP"], + "timeRemaining": "0:45", + } + assert get_power_play_info(game, "Maple Leafs") == "PP 0:45" + + +class TestCalculateGamePriority: + def _live_game( + self, + period=3, + seconds_remaining=300, + home_score=2, + away_score=1, + in_intermission=False, + ): + return make_game( + game_state="LIVE", + period=period, + seconds_remaining=seconds_remaining, + home_score=home_score, + away_score=away_score, + in_intermission=in_intermission, + ) + + def test_returns_zero_for_final(self): + game = make_game(game_state="OFF") + assert calculate_game_priority(game) == 0 + + def test_returns_zero_for_pre(self): + game = make_game(game_state="FUT") + assert calculate_game_priority(game) == 0 + + def test_intermission_returns_negative(self, mocker): + mocker.patch( + "app.games.get_team_standings", + return_value={"league_sequence": 0, "league_l10_sequence": 0}, + ) + game = self._live_game(in_intermission=True, seconds_remaining=0) + assert calculate_game_priority(game) < 0 + + def test_score_diff_greater_than_3(self, mocker): + mocker.patch( + "app.games.get_team_standings", + return_value={"league_sequence": 0, "league_l10_sequence": 0}, + ) + game = self._live_game(home_score=5, away_score=0) + result = calculate_game_priority(game) + assert isinstance(result, int) + + def test_score_diff_greater_than_2(self, mocker): + mocker.patch( + "app.games.get_team_standings", + return_value={"league_sequence": 0, "league_l10_sequence": 0}, + ) + game = self._live_game(home_score=4, away_score=1) + result = calculate_game_priority(game) + assert isinstance(result, int) + + def test_score_diff_greater_than_1(self, mocker): + mocker.patch( + "app.games.get_team_standings", + return_value={"league_sequence": 0, "league_l10_sequence": 0}, + ) + game = self._live_game(home_score=3, away_score=1) + result = calculate_game_priority(game) + assert isinstance(result, int) + + def test_late_3rd_tied_bonus(self, mocker): + mocker.patch( + "app.games.get_team_standings", + return_value={"league_sequence": 0, "league_l10_sequence": 0}, + ) + game = self._live_game( + period=3, seconds_remaining=600, home_score=2, away_score=2 + ) + result = calculate_game_priority(game) + assert isinstance(result, int) + + def test_final_6_minutes_tied_bonus(self, mocker): + mocker.patch( + "app.games.get_team_standings", + return_value={"league_sequence": 0, "league_l10_sequence": 0}, + ) + game = self._live_game( + period=3, seconds_remaining=300, home_score=2, away_score=2 + ) + result = calculate_game_priority(game) + assert isinstance(result, int) diff --git a/tests/test_routes.py b/tests/test_routes.py index 84a0d9e..289b1ca 100644 --- a/tests/test_routes.py +++ b/tests/test_routes.py @@ -42,3 +42,23 @@ class TestScoreboardRoute: response = flask_client.get("/scoreboard") data = json.loads(response.data) assert "error" in data + + def test_invalid_json_returns_error(self, flask_client, monkeypatch, tmp_path): + import app.routes as routes + + bad_file = tmp_path / "bad.json" + bad_file.write_text("not valid json {{{") + monkeypatch.setattr(routes, "SCOREBOARD_DATA_FILE", str(bad_file)) + response = flask_client.get("/scoreboard") + data = json.loads(response.data) + assert "error" in data + + def test_null_json_returns_error(self, flask_client, monkeypatch, tmp_path): + import app.routes as routes + + null_file = tmp_path / "null.json" + null_file.write_text("null") + monkeypatch.setattr(routes, "SCOREBOARD_DATA_FILE", str(null_file)) + response = flask_client.get("/scoreboard") + data = json.loads(response.data) + assert "error" in data diff --git a/tests/test_scheduler.py b/tests/test_scheduler.py new file mode 100644 index 0000000..54718cc --- /dev/null +++ b/tests/test_scheduler.py @@ -0,0 +1,41 @@ +import pytest + +from app.scheduler import start_scheduler + + +class TestStartScheduler: + def test_registers_standings_refresh_every_600_seconds(self, mocker): + mock_schedule = mocker.patch("app.scheduler.schedule") + mocker.patch("app.scheduler.time.sleep", side_effect=StopIteration) + + with pytest.raises(StopIteration): + start_scheduler() + + intervals = [call[0][0] for call in mock_schedule.every.call_args_list] + assert 600 in intervals + + def test_registers_score_refresh_every_10_seconds(self, mocker): + mock_schedule = mocker.patch("app.scheduler.schedule") + mocker.patch("app.scheduler.time.sleep", side_effect=StopIteration) + + with pytest.raises(StopIteration): + start_scheduler() + + intervals = [call[0][0] for call in mock_schedule.every.call_args_list] + assert 10 in intervals + + def test_runs_pending_on_each_tick(self, mocker): + mock_schedule = mocker.patch("app.scheduler.schedule") + call_count = {"n": 0} + + def sleep_twice(_): + call_count["n"] += 1 + if call_count["n"] >= 2: + raise StopIteration + + mocker.patch("app.scheduler.time.sleep", side_effect=sleep_twice) + + with pytest.raises(StopIteration): + start_scheduler() + + assert mock_schedule.run_pending.call_count >= 2 diff --git a/tests/test_standings.py b/tests/test_standings.py new file mode 100644 index 0000000..58eb5f1 --- /dev/null +++ b/tests/test_standings.py @@ -0,0 +1,220 @@ +import sqlite3 + +import requests as req + +from app.standings import ( + create_standings_table, + fetch_standings, + insert_standings, + refresh_standings, + truncate_standings_table, +) + +SAMPLE_API_RESPONSE = { + "standings": [ + { + "teamCommonName": {"default": "Bruins"}, + "leagueSequence": 1, + "leagueL10Sequence": 2, + }, + { + "teamCommonName": {"default": "Maple Leafs"}, + "leagueSequence": 5, + "leagueL10Sequence": 3, + }, + ] +} + + +class TestFetchStandings: + def test_returns_parsed_standings(self, mocker): + mock_get = mocker.patch("app.standings.requests.get") + mock_get.return_value.json.return_value = SAMPLE_API_RESPONSE + + result = fetch_standings() + + assert len(result) == 2 + assert result[0] == { + "team_common_name": "Bruins", + "league_sequence": 1, + "league_l10_sequence": 2, + } + assert result[1]["team_common_name"] == "Maple Leafs" + + def test_returns_none_on_request_exception(self, mocker): + mocker.patch( + "app.standings.requests.get", side_effect=req.RequestException("err") + ) + + result = fetch_standings() + + assert result is None + + def test_returns_none_on_bad_status(self, mocker): + mock_get = mocker.patch("app.standings.requests.get") + mock_get.return_value.raise_for_status.side_effect = req.HTTPError("503") + + result = fetch_standings() + + assert result is None + + def test_returns_empty_list_when_no_standings_key(self, mocker): + mock_get = mocker.patch("app.standings.requests.get") + mock_get.return_value.json.return_value = {} + + result = fetch_standings() + + assert result == [] + + +class TestCreateStandingsTable: + def test_creates_table(self, tmp_path): + conn = sqlite3.connect(str(tmp_path / "test.db")) + create_standings_table(conn) + + row = conn.execute( + "SELECT name FROM sqlite_master WHERE type='table' AND name='standings'" + ).fetchone() + + assert row is not None + conn.close() + + def test_is_idempotent(self, tmp_path): + conn = sqlite3.connect(str(tmp_path / "test.db")) + create_standings_table(conn) + create_standings_table(conn) # should not raise + + conn.close() + + +class TestTruncateStandingsTable: + def test_removes_all_rows(self, tmp_path): + conn = sqlite3.connect(str(tmp_path / "test.db")) + create_standings_table(conn) + insert_standings( + conn, + [ + { + "team_common_name": "Bruins", + "league_sequence": 1, + "league_l10_sequence": 2, + } + ], + ) + + truncate_standings_table(conn) + + count = conn.execute("SELECT COUNT(*) FROM standings").fetchone()[0] + assert count == 0 + conn.close() + + +class TestInsertStandings: + def test_inserts_all_rows(self, tmp_path): + conn = sqlite3.connect(str(tmp_path / "test.db")) + create_standings_table(conn) + + data = [ + { + "team_common_name": "Bruins", + "league_sequence": 1, + "league_l10_sequence": 2, + }, + { + "team_common_name": "Maple Leafs", + "league_sequence": 5, + "league_l10_sequence": 3, + }, + ] + insert_standings(conn, data) + + count = conn.execute("SELECT COUNT(*) FROM standings").fetchone()[0] + assert count == 2 + conn.close() + + def test_data_is_queryable_after_insert(self, tmp_path): + conn = sqlite3.connect(str(tmp_path / "test.db")) + create_standings_table(conn) + insert_standings( + conn, + [ + { + "team_common_name": "Bruins", + "league_sequence": 1, + "league_l10_sequence": 2, + } + ], + ) + + row = conn.execute( + "SELECT league_sequence FROM standings WHERE team_common_name = ?", + ("Bruins",), + ).fetchone() + + assert row[0] == 1 + conn.close() + + +class TestRefreshStandings: + def test_populates_db_from_api(self, mocker, tmp_path): + standings = [ + { + "team_common_name": "Bruins", + "league_sequence": 1, + "league_l10_sequence": 2, + } + ] + mocker.patch("app.standings.fetch_standings", return_value=standings) + mocker.patch("app.standings.DB_PATH", str(tmp_path / "test.db")) + + refresh_standings() + + conn = sqlite3.connect(str(tmp_path / "test.db")) + count = conn.execute("SELECT COUNT(*) FROM standings").fetchone()[0] + conn.close() + assert count == 1 + + def test_clears_old_data_before_inserting(self, mocker, tmp_path): + db_path = str(tmp_path / "test.db") + mocker.patch("app.standings.DB_PATH", db_path) + + first = [ + { + "team_common_name": "Bruins", + "league_sequence": 1, + "league_l10_sequence": 2, + } + ] + mocker.patch("app.standings.fetch_standings", return_value=first) + refresh_standings() + + second = [ + { + "team_common_name": "Oilers", + "league_sequence": 3, + "league_l10_sequence": 1, + }, + { + "team_common_name": "Jets", + "league_sequence": 4, + "league_l10_sequence": 2, + }, + ] + mocker.patch("app.standings.fetch_standings", return_value=second) + refresh_standings() + + conn = sqlite3.connect(db_path) + count = conn.execute("SELECT COUNT(*) FROM standings").fetchone()[0] + conn.close() + assert count == 2 + + def test_does_not_insert_when_fetch_fails(self, mocker, tmp_path): + mocker.patch("app.standings.fetch_standings", return_value=None) + mocker.patch("app.standings.DB_PATH", str(tmp_path / "test.db")) + + refresh_standings() + + conn = sqlite3.connect(str(tmp_path / "test.db")) + count = conn.execute("SELECT COUNT(*) FROM standings").fetchone()[0] + conn.close() + assert count == 0