"""Persistence and migration regression tests; never use the live game files.""" import copy import importlib.util import json import os import sqlite3 import tempfile import threading import unittest from http.client import HTTPConnection from http.server import ThreadingHTTPServer from pathlib import Path from datetime import datetime, timezone from unittest.mock import patch class PersistenceTests(unittest.TestCase): @classmethod def setUpClass(cls): with tempfile.TemporaryDirectory() as directory: with patch.dict(os.environ, { "BUTTON_DATA_FILE": str(Path(directory) / "data.json"), "BUTTON_DATABASE_FILE": str(Path(directory) / "data.sqlite3"), "BUTTON_MAILING_LIST_FILE": str(Path(directory) / "mailing.csv"), }): spec = importlib.util.spec_from_file_location("button_test_server", Path(__file__).with_name("server.py")) cls.game = importlib.util.module_from_spec(spec) spec.loader.exec_module(cls.game) def setUp(self): self.directory = tempfile.TemporaryDirectory() self.addCleanup(self.directory.cleanup) root = Path(self.directory.name) self.game.DATA_FILE = root / "data.json" self.game.DATABASE_FILE = root / "data.sqlite3" self.game.MAILING_LIST_FILE = root / "mailing.csv" self.game.failed_logins.clear() self.game.subscribed_emails.clear() self.game.presence.clear() def sample_state(self): state = self.game.new_game() state["players"] = { "alice": { "name": "Alice '); DROP TABLE players; --", "password_hash": "existing-password-hash", "token_hashes": ["first-session", "second-session"], "created_at": 123, "subscribed": True, }, "bob": { "name": "Bøb", "password_hash": None, "token_hashes": [], "created_at": 456, "subscribed": False, }, } state["presses"] = [ {"player_id": "alice", "at": 100, "remaining_ms": 20, "zone": "white", "points": 32}, {"player_id": "bob", "at": 100, "remaining_ms": 50, "zone": "black", "points": 16}, {"player_id": "alice", "at": 200, "remaining_ms": 90, "zone": "red", "points": 8}, ] return state def write_legacy(self, state): self.game.DATA_FILE.write_text(json.dumps(state), encoding="utf-8") def season_fixture(self): at = int(datetime(2026, 10, 21, tzinfo=timezone.utc).timestamp() * 1000) with patch.object(self.game, "now_ms", return_value=at): self.game.state = self.game.load_state() return at def test_astronomical_calendar_known_usno_dates_and_exact_midnight(self): # USNO API: https://aa.usno.navy.mil/api/seasons?year=2026 (also 2027). for year, dates in ((2026, ("03-20", "06-21", "09-23", "12-21")), (2027, ("03-20", "06-21", "09-23", "12-22"))): boundaries = self.game.season_boundaries(year) self.assertEqual([b["id"] for b in boundaries], [f"{year}-{date}" for date in dates]) for boundary in boundaries: self.assertEqual(boundary["starts_at"] % 86400000, 0) self.assertEqual(self.game.season_for_time(boundary["starts_at"])["id"], boundary["id"]) self.assertNotEqual(self.game.season_for_time(boundary["starts_at"] - 1)["id"], boundary["id"]) january = int(datetime(2027, 1, 1, tzinfo=timezone.utc).timestamp() * 1000) self.assertEqual(self.game.season_for_time(january)["id"], "2026-12-21") with self.assertRaises(ValueError): self.game.season_for_time(0) def test_season_timeout_archives_winner_and_waits_until_boundary(self): at = self.season_fixture() state = self.sample_state() state.update(started_at=at, deadline=at + 10000, ended=False, ended_at=None) self.game.state = state self.game.save_state(state) with patch.object(self.game, "now_ms", return_value=at + 10000): self.game.check_ended() result = self.game.seasonal_leaderboard() self.assertTrue(self.game.state["ended"]) archive = result["seasons"][0] self.assertEqual(archive["duration_ms"], 10000) self.assertEqual(archive["winners"][0]["total_points"], 40) self.assertEqual(archive["reason"], "countdown") with patch.object(self.game, "now_ms", return_value=at + 20000): self.game.check_ended() self.game.state = self.game.load_state() self.game.check_ended() self.assertEqual(self.game.seasonal_leaderboard()["seasons"], result["seasons"]) self.assertTrue(self.game.state["ended"]) def test_season_boundary_resets_scores_keeps_accounts_chat_and_archives_history(self): self.season_fixture() with self.game.database() as connection: season = self.game.current_season(connection) boundary = season["ends_at"] self.game.state = self.sample_state() self.game.state.update(started_at=boundary - 10000, deadline=boundary + 5000) with patch.object(self.game, "now_ms", return_value=boundary - 100): self.game.save_state(self.game.state) players = copy.deepcopy(self.game.state["players"]) original_presses = copy.deepcopy(self.game.state["presses"]) with self.game.database() as connection: connection.execute("INSERT INTO chat_messages (player_id, name, at, message) VALUES (?, ?, ?, ?)", ("alice", "Alice", boundary - 100, "See you next season")) with patch.object(self.game, "now_ms", return_value=boundary - 1): self.game.check_ended() self.assertEqual(self.game.state["deadline"], boundary) with patch.object(self.game, "now_ms", return_value=boundary): self.game.check_ended() self.game.check_ended() self.assertFalse(self.game.state["ended"]) self.assertEqual(self.game.state["started_at"], boundary) self.assertEqual(self.game.state["deadline"], boundary + self.game.DURATION_MS) self.assertEqual(self.game.state["players"], players) self.assertEqual(self.game.state["presses"], []) self.assertEqual(self.game.player_summary("alice")["total_points"], 0) self.assertTrue(any(m["message"] == "See you next season" for m in self.game.chat_messages())) archive = self.game.seasonal_leaderboard()["seasons"][0] self.assertEqual(archive["id"], season["id"]) self.assertEqual(archive["reason"], "boundary") self.assertEqual(archive["duration_ms"], 10000) with self.game.database() as connection: row = connection.execute("SELECT presses FROM season_results WHERE id = ?", (season["id"],)).fetchone() self.assertEqual(json.loads(row["presses"]), original_presses) self.assertEqual(connection.execute("PRAGMA foreign_key_check").fetchall(), []) def test_season_tied_winners_include_zero_score_participants_not_idle_accounts(self): self.season_fixture() self.game.state = self.sample_state() self.game.state["players"]["idle"] = dict(self.game.state["players"]["bob"], name="Idle", token_hashes=[]) for press in self.game.state["presses"]: press["points"] = 0 with self.game.database() as connection: season = self.game.current_season(connection) self.game.archive_season(connection, season, self.game.state, self.game.state["deadline"], "countdown") winners = self.game.seasonal_leaderboard()["seasons"][0]["winners"] self.assertEqual({w["name"] for w in winners}, {self.game.state["players"]["alice"]["name"], "Bøb"}) self.assertTrue(all(w["total_points"] == 0 for w in winners)) def test_seasons_catch_up_downtime_without_fabricating_winners(self): self.season_fixture() at = int(datetime(2027, 7, 1, tzinfo=timezone.utc).timestamp() * 1000) with patch.object(self.game, "now_ms", return_value=at): self.game.check_ended() self.game.state = self.game.load_state() self.game.check_ended() result = self.game.seasonal_leaderboard() self.assertEqual(result["current"]["id"], "2027-06-21") self.assertTrue(result["current"]["ended"]) self.assertEqual(len(result["seasons"]), 4) self.assertTrue(all(row["winners"] == [] for row in result["seasons"])) def test_season_rollover_failure_does_not_change_memory_or_database(self): self.season_fixture() before = copy.deepcopy(self.game.state) with self.game.database() as connection: boundary = self.game.current_season(connection)["ends_at"] connection.execute("""CREATE TRIGGER reject_archive BEFORE INSERT ON season_results BEGIN SELECT RAISE(ABORT, 'test rollback'); END""") with patch.object(self.game, "now_ms", return_value=boundary): with self.assertRaises(sqlite3.IntegrityError): self.game.check_ended() self.assertEqual(self.game.state, before) with self.game.database() as connection: self.assertEqual(self.game.current_season(connection)["ends_at"], boundary) self.assertEqual(connection.execute("SELECT COUNT(*) FROM season_results").fetchone()[0], 0) connection.execute("DROP TRIGGER reject_archive") self.assertEqual(self.game.load_state(), before) def test_season_upgrade_adopts_existing_game_without_losing_data(self): source = self.sample_state() self.write_legacy(source) self.assertEqual(self.game.load_state(), source) with self.game.database() as connection: original = self.game.current_season(connection) connection.execute("DROP TABLE season_clock") connection.execute("DROP TABLE season_results") connection.execute("DROP TABLE season_milestone_messages") self.assertEqual(self.game.load_state(), source) with self.game.database() as connection: self.assertEqual(self.game.current_season(connection)["id"], original["id"]) self.assertEqual(self.game.seasonal_leaderboard()["seasons"], []) def test_seasons_api_is_public_and_contains_no_private_credentials(self): request = self.presence_client() status, result = request(path="/api/seasons") self.assertEqual(status, 200) self.assertEqual(set(result), {"current", "seasons"}) for player in result["current"]["leaderboard"]: self.assertEqual(set(player), {"name", "total_points", "press_count"}) self.assertNotIn("password_hash", json.dumps(result)) self.assertNotIn("token_hashes", json.dumps(result)) def test_press_at_season_boundary_scores_only_in_new_season(self): request = self.presence_client() with self.game.database() as connection: boundary = self.game.current_season(connection)["ends_at"] self.game.state["deadline"] = boundary + 1000 before = copy.deepcopy(self.game.state["presses"]) with patch.object(self.game, "now_ms", return_value=boundary): status, result = request("POST", {}, "alice-token", "/api/press") self.assertEqual(status, 200) self.assertEqual(result["press"]["points"], 0) self.assertEqual(len(self.game.state["presses"]), 1) self.assertEqual(self.game.state["presses"][0]["at"], boundary) self.assertEqual(self.game.state["players"]["alice"]["token_hashes"][0], self.game.hash_token("alice-token")) with self.game.database() as connection: archive = connection.execute("SELECT presses FROM season_results ORDER BY starts_at LIMIT 1").fetchone() self.assertEqual(json.loads(archive["presses"]), before) def test_finished_season_rejects_presses_and_keeps_archive_immutable(self): request = self.presence_client() deadline = self.game.state["deadline"] with patch.object(self.game, "now_ms", return_value=deadline): self.assertEqual(request("POST", {}, "alice-token", "/api/press")[0], 409) first = request(path="/api/seasons")[1] self.assertEqual(request("POST", {}, "alice-token", "/api/press")[0], 409) self.assertEqual(request(path="/api/seasons")[1], first) self.assertEqual(first["seasons"][0]["winners"][0]["total_points"], 40) self.assertEqual(len(self.game.state["presses"]), 3) def test_season_reset_preserves_prior_milestone_announcements(self): self.season_fixture() with self.game.database() as connection: season = self.game.current_season(connection) connection.execute("UPDATE community_milestones SET reached_at = ?, announced_at = ?, message = ? WHERE id = 'ten_players'", (self.game.state["started_at"], self.game.state["started_at"], "Historic milestone")) self.game.state["deadline"] = season["ends_at"] + 100 with patch.object(self.game, "now_ms", return_value=season["ends_at"]): self.game.check_ended() messages = [message for message in self.game.chat_messages() if message["message"] == "Historic milestone"] self.assertEqual(len(messages), 1) self.assertIn(season["id"], messages[0]["id"]) with self.game.database() as connection: self.assertIsNone(connection.execute("SELECT reached_at FROM community_milestones WHERE id = 'ten_players'").fetchone()[0]) def test_season_watcher_keeps_running_after_countdown_ends(self): at = self.season_fixture() self.game.state.update(ended=True, ended_at=at, deadline=at) with patch.object(self.game, "now_ms", return_value=at): with patch.object(self.game.time, "sleep", side_effect=RuntimeError("stop watcher")) as sleep: with self.assertRaisesRegex(RuntimeError, "stop watcher"): self.game.end_watcher() sleep.assert_called_once() self.assertGreater(sleep.call_args.args[0], 0) self.assertLessEqual(sleep.call_args.args[0], 60) def test_season_roman_numerals(self): for number, expected in ((1, "I"), (2, "II"), (3, "III"), (4, "IV"), (9, "IX"), (14, "XIV"), (40, "XL"), (49, "XLIX"), (99, "XCIX"), (400, "CD")): self.assertEqual(self.game.roman_numeral(number), expected) with self.assertRaises(ValueError): self.game.roman_numeral(0) def test_season_numbering_stable_on_rollover_and_restart(self): self.season_fixture() with self.game.database() as connection: first = self.game.current_season(connection) self.assertEqual((first["number"], first["name"]), (1, "Season I")) with patch.object(self.game, "now_ms", return_value=first["ends_at"]): self.game.check_ended() self.game.state = self.game.load_state() result = self.game.seasonal_leaderboard() self.assertEqual((result["current"]["number"], result["current"]["name"]), (2, "Season II")) self.assertEqual(result["current"]["starts_at"], first["ends_at"]) self.assertEqual((result["seasons"][0]["number"], result["seasons"][0]["name"]), (1, "Season I")) with self.game.database() as connection: self.assertEqual(self.game.current_season(connection)["name"], "Season II") def test_season_numbering_upgrade_starts_with_earliest_archive(self): self.season_fixture() with self.game.database() as connection: earlier = self.game.season_for_time(self.game.current_season(connection)["starts_at"] - 1) self.game.archive_season(connection, earlier, self.game.state, self.game.state["deadline"], "countdown") connection.execute("DROP TABLE season_numbering") self.game.state = self.game.load_state() result = self.game.seasonal_leaderboard() self.assertEqual(result["seasons"][0]["name"], "Season I") self.assertEqual(result["current"]["name"], "Season II") with self.game.database() as connection: origin = connection.execute("SELECT origin_at FROM season_numbering").fetchone()[0] self.assertEqual(origin, earlier["starts_at"]) self.game.load_state() self.assertEqual(self.game.seasonal_leaderboard()["current"]["name"], "Season II") def presence_client(self): self.game.load_state() self.game.state = self.sample_state() self.game.state["players"]["alice"]["token_hashes"] = [self.game.hash_token("alice-token"), self.game.hash_token("alice-second")] self.game.state["players"]["bob"]["token_hashes"] = [self.game.hash_token("bob-token")] self.game.save_state(self.game.state) self.game.token_index = { th: pid for pid, player in self.game.state["players"].items() for th in player["token_hashes"] } httpd = ThreadingHTTPServer(("127.0.0.1", 0), self.game.Handler) thread = threading.Thread(target=httpd.serve_forever, daemon=True) thread.start() self.addCleanup(httpd.server_close) self.addCleanup(thread.join) self.addCleanup(httpd.shutdown) def request(method="GET", data=None, token=None, path="/api/presence"): connection = HTTPConnection(*httpd.server_address, timeout=5) try: headers = {"Content-Type": "application/json"} if token: headers["Authorization"] = "Bearer " + token connection.request(method, path, json.dumps(data) if data is not None else None, headers) response = connection.getresponse() return response.status, json.loads(response.read()) finally: connection.close() return request def test_presence_auth_validation_and_default_privacy(self): request = self.presence_client() self.assertEqual(request(), (200, {"online_count": 0, "chat_players": []})) update = {"tab_id": "a" * 32, "active": True, "in_chat": True} self.assertEqual(request("POST", update)[0], 401) self.assertEqual(request("POST", update, "not-a-token")[0], 401) for invalid in ({}, {**update, "active": 1}, {**update, "in_chat": "true"}, {**update, "tab_id": "a" * 33}, {**update, "tab_id": "