"""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, "is_admin": False, "is_banned": False, "is_deleted": False, }, "bob": { "name": "Bøb", "password_hash": None, "token_hashes": [], "created_at": 456, "subscribed": False, "is_admin": False, "is_banned": False, "is_deleted": 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 admin_client(self): request = self.presence_client() self.game.state["players"]["alice"]["is_admin"] = True self.game.save_state(self.game.state) return request def test_admin_endpoints_deny_guests_and_regular_players(self): request = self.admin_client() for path in ("/api/admin",): self.assertEqual(request(path=path)[0], 401) self.assertEqual(request(token="bob-token", path=path)[0], 403) for action in ("set_admin", "set_banned", "delete_player", "delete_message"): payload = {"action": action, "target_id": "alice", "value": True, "at": 1} self.assertEqual(request("POST", payload, path="/api/admin/action")[0], 401) self.assertEqual(request("POST", payload, "bob-token", "/api/admin/action")[0], 403) self.assertTrue(self.game.state["players"]["alice"]["is_admin"]) def test_admin_overview_does_not_expose_credentials_and_role_is_private(self): request = self.admin_client() status, dashboard = request(token="alice-token", path="/api/admin") self.assertEqual(status, 200) self.assertEqual(dashboard["actor_id"], "alice") self.assertEqual(len(dashboard["players"]), 2) raw = json.dumps(dashboard) for secret in ("password_hash", "token_hash", "existing-password-hash", "first-session"): self.assertNotIn(secret, raw) self.assertTrue(request(token="alice-token", path="/api/me")[1]["is_admin"]) for row in self.game.public_leaderboard() + self.game.global_stats()["leaderboard"]: self.assertNotIn("is_admin", row) def test_admin_role_changes_revoke_sessions_and_survive_save_reload(self): request = self.admin_client() payload = {"action": "set_admin", "target_id": "bob", "value": True} self.assertEqual(request("POST", payload, "alice-token", "/api/admin/action")[0], 200) self.assertTrue(self.game.state["players"]["bob"]["is_admin"]) self.assertEqual(request(token="bob-token", path="/api/admin")[0], 401) self.assertEqual(self.game.state["players"]["bob"]["token_hashes"], []) self.game.save_state(self.game.state) self.game.state = self.game.load_state() self.assertTrue(self.game.state["players"]["bob"]["is_admin"]) # Simulate a new authenticated session, then demote it from a different admin. self.game.token_index[self.game.hash_token("bob-new")] = "bob" self.game.state["players"]["bob"]["token_hashes"] = [self.game.hash_token("bob-new")] self.assertEqual(request(token="bob-new", path="/api/admin")[0], 200) self.assertEqual(request("POST", {**payload, "value": False}, "alice-token", "/api/admin/action")[0], 200) self.assertEqual(request(token="bob-new", path="/api/admin")[0], 401) def test_ban_revokes_all_access_and_unban_requires_new_login(self): request = self.admin_client() self.game.state["players"]["bob"]["password_hash"] = self.game.hash_password("test-password") self.game.save_state(self.game.state) update = {"tab_id": "a" * 32, "active": True, "in_chat": True} self.assertEqual(request("POST", update, "bob-token")[0], 200) payload = {"action": "set_banned", "target_id": "bob", "value": True} self.assertEqual(request("POST", payload, "alice-token", "/api/admin/action")[0], 200) for path, method, data in (("/api/me", "GET", None), ("/api/press", "POST", {}), ("/api/chat", "POST", {"message": "Banned"}), ("/api/presence", "POST", update), ("/api/subscribe", "POST", {})): self.assertEqual(request(method, data, "bob-token", path)[0], 401) self.assertEqual(request()[1]["online_count"], 0) self.assertEqual(request("POST", {"name": "Bøb", "password": "test-password"}, path="/api/login")[0], 401) self.assertEqual(request("POST", {"action": "set_admin", "target_id": "bob", "value": True}, "alice-token", "/api/admin/action")[0], 409) self.assertEqual(request("POST", {**payload, "value": False}, "alice-token", "/api/admin/action")[0], 200) self.assertFalse(self.game.state["players"]["bob"]["is_banned"]) self.assertEqual(request(token="bob-token", path="/api/me")[0], 401) self.assertEqual(request("POST", {"name": "Bøb", "password": "test-password"}, path="/api/login")[0], 200) def test_admin_self_protection_and_last_active_admin(self): request = self.admin_client() for action, value in (("set_admin", False), ("set_banned", True), ("delete_player", None)): payload = {"action": action, "target_id": "alice", "value": value} self.assertEqual(request("POST", payload, "alice-token", "/api/admin/action")[0], 409) self.assertEqual(sum(self.game.active_admin(p) for p in self.game.state["players"].values()), 1) self.assertEqual(request("POST", {"action": "set_admin", "target_id": "bob", "value": "true"}, "alice-token", "/api/admin/action")[0], 400) self.assertEqual(request("POST", {"action": "delete_player", "target_id": "missing"}, "alice-token", "/api/admin/action")[0], 404) self.assertEqual(request("POST", {"action": "execute_sql", "target_id": "bob"}, "alice-token", "/api/admin/action")[0], 400) def test_admin_deletes_chat_without_id_reuse_or_cooldown_bypass(self): request = self.admin_client() with self.game.database() as connection: at = self.game.now_ms() cursor = connection.execute("INSERT INTO chat_messages (player_id, name, at, message) VALUES (?, ?, ?, ?)", ("bob", "Bøb", at, "Remove this")) mid = cursor.lastrowid payload = {"action": "delete_message", "target_id": str(mid), "at": at} self.assertEqual(request("POST", payload, "alice-token", "/api/admin/action")[0], 200) self.assertFalse(any(m["id"] == mid for m in self.game.chat_messages())) self.assertEqual(request("POST", {"message": "Too soon"}, "bob-token", "/api/chat")[0], 429) with self.game.database() as connection: self.assertEqual(connection.execute("SELECT message FROM chat_messages WHERE id = ?", (mid,)).fetchone()[0], "[Message removed]") self.game.load_state() self.assertFalse(any(m["id"] == mid for m in self.game.chat_messages())) self.assertEqual(request("POST", payload, "alice-token", "/api/admin/action")[0], 404) def test_admin_can_remove_system_milestone_without_reannouncing_it(self): request = self.admin_client() at = self.game.now_ms() with self.game.database() as connection: connection.execute("UPDATE community_milestones SET reached_at = ?, announced_at = ?, message = ? WHERE id = 'ten_players'", (at, at, "Milestone to remove")) payload = {"action": "delete_message", "target_id": "milestone:ten_players", "at": at} self.assertEqual(request("POST", payload, "alice-token", "/api/admin/action")[0], 200) self.game.check_ended() self.assertFalse(any(m["message"] == "Milestone to remove" for m in self.game.chat_messages())) with self.game.database() as connection: self.assertIsNotNone(connection.execute("SELECT reached_at FROM community_milestones WHERE id = 'ten_players'").fetchone()[0]) boundary = self.game.current_season(connection)["ends_at"] self.game.check_ended(boundary) self.assertFalse(any(m["at"] == at and m["kind"] == "milestone" for m in self.game.chat_messages())) def test_admin_account_deletion_anonymizes_without_losing_game_history(self): request = self.admin_client() before_presses = copy.deepcopy(self.game.state["presses"]) 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") connection.execute("INSERT INTO chat_messages (player_id, name, at, message) VALUES (?, ?, ?, ?)", ("bob", "Bøb", 123, "My message")) self.assertEqual(request("POST", {"action": "delete_player", "target_id": "bob"}, "alice-token", "/api/admin/action")[0], 200) player = self.game.state["players"]["bob"] self.assertTrue(player["is_deleted"]) self.assertEqual(player["name"], "Deleted player") self.assertIsNone(player["password_hash"]) self.assertEqual(player["token_hashes"], []) self.assertIsNone(self.game.find_player_by_name("Bøb")) self.assertEqual(self.game.state["presses"], before_presses) self.assertEqual(request(token="bob-token", path="/api/me")[0], 401) self.assertNotIn("bob", [p["id"] for p in request(token="alice-token", path="/api/admin")[1]["players"]]) self.assertNotIn("Bøb", json.dumps(self.game.seasonal_leaderboard(), ensure_ascii=False)) self.assertTrue(any(m["message"] == "[Account deleted]" for m in self.game.chat_messages())) self.game.state = self.game.load_state() self.assertTrue(self.game.state["players"]["bob"]["is_deleted"]) with self.game.database() as connection: self.assertEqual(connection.execute("PRAGMA foreign_key_check").fetchall(), []) self.assertEqual(connection.execute("SELECT action, actor_id FROM admin_audit").fetchone()["action"], "delete_player") def test_admin_action_rollback_does_not_revoke_or_mutate(self): request = self.admin_client() before = copy.deepcopy(self.game.state) with self.game.database() as connection: connection.execute("""CREATE TRIGGER fail_admin_audit BEFORE INSERT ON admin_audit BEGIN SELECT RAISE(ABORT, 'test rollback'); END""") with self.assertRaises(sqlite3.IntegrityError): self.game.admin_action("alice", {"action": "set_banned", "target_id": "bob", "value": True}) self.assertEqual(self.game.state, before) self.assertIn(self.game.hash_token("bob-token"), self.game.token_index) self.assertEqual(request(token="bob-token", path="/api/me")[0], 200) with self.game.database() as connection: connection.execute("DROP TRIGGER fail_admin_audit") self.assertEqual(self.game.load_state(), before) def test_public_registration_cannot_grant_admin_or_bypass_ban_flags(self): request = self.admin_client() data = {"name": "Newcomer", "password": "test-password", "is_admin": True, "is_banned": False} status, result = request("POST", data, path="/api/register") self.assertEqual(status, 201) pid = self.game.find_player_by_name("Newcomer") self.assertFalse(self.game.state["players"][pid].get("is_admin", False)) self.assertEqual(request(token=result["token"], path="/api/admin")[0], 403) def test_admin_schema_upgrade_and_database_provisioning(self): self.game.load_state() self.game.state = self.sample_state() self.game.save_state(self.game.state) with self.game.database() as connection: connection.execute("PRAGMA foreign_keys = OFF") connection.execute("""CREATE TABLE old_players ( id TEXT PRIMARY KEY, name TEXT NOT NULL, password_hash TEXT, created_at INTEGER NOT NULL, subscribed INTEGER NOT NULL )""") connection.execute("INSERT INTO old_players SELECT id, name, password_hash, created_at, subscribed FROM players") connection.execute("DROP TABLE players") connection.execute("ALTER TABLE old_players RENAME TO players") loaded = self.game.load_state() self.assertFalse(any(p["is_admin"] for p in loaded["players"].values())) with self.game.database() as connection: connection.execute("UPDATE players SET is_admin = 1 WHERE id = ?", ("alice",)) self.game.state = self.game.load_state() self.assertTrue(self.game.state["players"]["alice"]["is_admin"]) self.game.save_state(self.game.state) self.assertTrue(self.game.load_state()["players"]["alice"]["is_admin"]) with self.game.database() as connection: self.assertEqual(connection.execute("PRAGMA foreign_key_check").fetchall(), []) def test_legacy_json_cannot_provision_administrators(self): legacy = self.sample_state() legacy["players"]["alice"].update(is_admin=True, is_banned=True, is_deleted=True) self.write_legacy(legacy) self.game.state = self.game.load_state() for field in ("is_admin", "is_banned", "is_deleted"): self.assertFalse(self.game.state["players"]["alice"][field]) self.assertFalse(self.game.load_state()["players"]["alice"][field]) def test_admin_malformed_request_and_cross_site_mutation(self): request = self.admin_client() self.assertEqual(request("POST", {}, "alice-token", "/api/admin/unknown")[0], 404) 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) headers = {"Authorization": "Bearer alice-token", "Content-Type": "application/json"} for body, extra, expected in (("{broken", {}, 400), ("[]", {}, 400), (json.dumps({"action": "set_banned", "target_id": "bob", "value": True}), {"Sec-Fetch-Site": "cross-site"}, 403)): connection = HTTPConnection(*httpd.server_address, timeout=5) try: connection.request("POST", "/api/admin/action", body, {**headers, **extra}) response = connection.getresponse() self.assertEqual(response.status, expected) response.read() finally: connection.close() self.assertFalse(self.game.state["players"]["bob"]["is_banned"]) 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": "