"""Testy persistentnej vrstvy (db/ + api/history.py + api/auth.py). Bezia na docasnom SQLite subore. Engine sa nepouziva priamo -- pre zapisovu logiku staci lahky stub, ktory zrkadli rozhranie bridzik.Bridzik (series -> rounds -> guesses/get_points_summary). Cisty engine ma vlastne testy v tests.py. """ import asyncio import os import tempfile import unittest import uuid from datetime import datetime, timedelta from types import SimpleNamespace # Nastav DB/ENCRYPTION_KEY PRED importom db/api modulov -- engine sa vytvara pri importe. _DB_FILE = os.path.join(tempfile.gettempdir(), f"bridzik_test_{uuid.uuid4().hex}.db") os.environ["DATABASE_URL"] = "sqlite+aiosqlite:///" + _DB_FILE.replace("\\", "/") from cryptography.fernet import Fernet # noqa: E402 os.environ.setdefault("ENCRYPTION_KEY", Fernet.generate_key().decode()) import pyotp # noqa: E402 from sqlalchemy import select # noqa: E402 from api import auth, history, leaderboard # noqa: E402 from api import bots as bots_module # noqa: E402 from bridzik import ROUNDS_PER_SERIES # noqa: E402 from db import crypto # noqa: E402 from db.db import async_session, init_db # noqa: E402 from db.models import Game, Guess, Player # noqa: E402 def run(coro): return asyncio.run(coro) def make_core(completed=True): """Stub jednej hry s jednym dohratym kolom (seria 0, kolo 0).""" rnd = SimpleNamespace( round_number=0, guesses={0: 2, 1: 1, 2: 0, 3: 1}, is_completed=lambda: True, get_points_summary=lambda: [12, 0, 10, 11], ) series = SimpleNamespace( series_number=0, rounds=[rnd], get_last_round=lambda: rnd ) return SimpleNamespace(series=[series], is_completed=lambda: completed) def make_players(n=4): """Zalozi `n` potvrdenych uctov, vrati ich player_id.""" ids = [] for _ in range(n): username = "u_" + uuid.uuid4().hex[:8] data = run(auth.register_account(username)) ident = run(auth.confirm_account(username, pyotp.TOTP(data["secret"]).now())) ids.append(ident["player_id"]) return ids class HistoryCase(unittest.TestCase): @classmethod def setUpClass(cls): run(init_db()) def _make_players(self, n=4): return make_players(n) @staticmethod def _next_step_code(secret): """Kod pre NASLEDUJUCI casovy krok -- confirm uz spotreboval aktualny.""" return pyotp.TOTP(secret).at((auth._current_step() + 1) * auth.TOTP_PERIOD) def test_register_login_token(self): username = "alice_" + uuid.uuid4().hex[:6] data = run(auth.register_account(username)) self.assertIn("otpauth_uri", data) # Nedokoncena registracia: opakovany register vyda NOVY secret data = run(auth.register_account(username)) self.assertIn("otpauth_uri", data) # Login pred potvrdenim -> RegistrationIncomplete (novy QR namiesto chyby) with self.assertRaises(auth.RegistrationIncomplete): run(auth.login(username, "000000")) code = pyotp.TOTP(data["secret"]).now() ident = run(auth.confirm_account(username, code)) self.assertEqual(ident["username"], username) self.assertTrue(ident["token"]) # Po potvrdeni je uz meno obsadene with self.assertRaises(auth.AuthError): run(auth.register_account(username)) # Token sa da spatne rozlustit na identitu resolved = run(auth.player_by_token(ident["token"])) self.assertEqual(resolved["player_id"], ident["player_id"]) # Zly kod neprejde with self.assertRaises(auth.AuthError): run(auth.login(username, "000000")) def test_reissued_secret_invalidates_old_qr(self): username = "fred_" + uuid.uuid4().hex[:6] old = run(auth.register_account(username)) new = run(auth.register_account(username)) self.assertNotEqual(old["secret"], new["secret"]) # Kod zo stareho QR uz neplati, z noveho ano with self.assertRaises(auth.AuthError): run(auth.confirm_account(username, pyotp.TOTP(old["secret"]).now())) ident = run(auth.confirm_account(username, pyotp.TOTP(new["secret"]).now())) self.assertEqual(ident["username"], username) def test_login_lockout_after_repeated_failures(self): username = "bob_" + uuid.uuid4().hex[:6] data = run(auth.register_account(username)) run(auth.confirm_account(username, pyotp.TOTP(data["secret"]).now())) for _ in range(auth._LOGIN_ATTEMPT_LIMIT): with self.assertRaises(auth.AuthError): run(auth.login(username, "000000")) # Lockout odmietne aj spravny kod, kym neubehne okno with self.assertRaises(auth.AuthError): run(auth.login(username, self._next_step_code(data["secret"]))) def test_successful_login_clears_failed_attempts(self): username = "carol_" + uuid.uuid4().hex[:6] data = run(auth.register_account(username)) run(auth.confirm_account(username, pyotp.TOTP(data["secret"]).now())) for _ in range(auth._LOGIN_ATTEMPT_LIMIT - 1): with self.assertRaises(auth.AuthError): run(auth.login(username, "000000")) ident = run(auth.login(username, self._next_step_code(data["secret"]))) self.assertEqual(ident["username"], username) self.assertNotIn(username, auth._failed_attempts) def test_record_rounds_and_idempotency(self): ids = self._make_players() gid = str(uuid.uuid4()) core = make_core() run(history.record_game_started(gid, "Test", ids)) run(history.record_completed_rounds(gid, core)) # Opakovany zapis nesmie zalozit duplikaty. run(history.record_completed_rounds(gid, core)) detail = run(history.get_game_detail(gid)) self.assertIsNotNone(detail) self.assertEqual(len(detail["rounds"]), 4) # 4 hraci x 1 kolo by_seat = {r["player_id"]: r for r in detail["rounds"]} # Body podla get_points_summary; won = points > 0. self.assertEqual(by_seat[ids[0]]["points"], 12) self.assertTrue(by_seat[ids[0]]["won"]) self.assertEqual(by_seat[ids[1]]["points"], 0) self.assertFalse(by_seat[ids[1]]["won"]) self.assertIsNotNone(detail["ended_at"]) # core.is_completed() -> ended def test_rebuild_core_position(self): # Z dvoch cisel sa obnovi spravna pozicia a karty sa rozdaju nanovo. core = history.rebuild_core(2, 3) self.assertEqual(core.series[-1].series_number, 2) last_round = core.series[-1].get_last_round() self.assertEqual(last_round.round_number, 3) # V kole 3 ma kazdy hrac 8 - 3 = 5 kariet. self.assertEqual(len(core.get_player_cards(0)), 5) def test_restore_game_from_db(self): ids = self._make_players() gid = str(uuid.uuid4()) run(history.record_game_started(gid, "Test", ids)) run(history.record_completed_rounds(gid, make_core())) # zapise poziciu restored = run(history.restore_game_core(gid)) self.assertIsNotNone(restored) # make_core() je seria 0, kolo 0 -> pozicia 0/0 self.assertEqual(restored.series[-1].series_number, 0) self.assertEqual(restored.series[-1].get_last_round().round_number, 0) def test_unfinished_games_listed(self): ids = self._make_players() gid = str(uuid.uuid4()) run(history.record_game_started(gid, "Nedohrata", ids)) run(history.record_completed_rounds(gid, make_core(completed=False))) rows = run(history.get_unfinished_games()) mine = next((g for g in rows if g["gid"] == gid), None) self.assertIsNotNone(mine) self.assertEqual(mine["name"], "Nedohrata") self.assertEqual(len(mine["seats"]), 4) self.assertEqual(mine["seats"][0][0], ids[0]) # player_id na sedadle 0 self.assertIsNotNone(mine["core"]) # uz postaveny Bridzik def test_mark_game_ended_removes_from_unfinished(self): ids = self._make_players() gid = str(uuid.uuid4()) run(history.record_game_started(gid, "Vzdana", ids)) run(history.record_completed_rounds(gid, make_core(completed=False))) self.assertTrue(any(g["gid"] == gid for g in run(history.get_unfinished_games()))) run(history.mark_game_ended(gid)) self.assertFalse(any(g["gid"] == gid for g in run(history.get_unfinished_games()))) def test_reopen_prematurely_ended_game(self): ids = self._make_players() gid = str(uuid.uuid4()) run(history.record_game_started(gid, "Vzdana", ids)) run(history.record_completed_rounds(gid, make_core(completed=False))) run(history.mark_game_ended(gid)) # V historii sa ukazuje ako predcasne ukoncena (nie naplno dohrana). rows = run(history.get_player_history(ids[0])) mine = next(g for g in rows if g["gid"] == gid) self.assertFalse(mine["completed"]) # Cudzi hrac ju obnovit nemoze. outsider = self._make_players(1)[0] self.assertIsNone(run(history.reopen_game(gid, outsider))) # Clen ju obnovi -> ended_at sa zmaze a hra je zas medzi nedohratymi. info = run(history.reopen_game(gid, ids[0])) self.assertIsNotNone(info) self.assertEqual(len(info["seats"]), 4) self.assertTrue(any(g["gid"] == gid for g in run(history.get_unfinished_games()))) # A teda uz nie je v historii (zobrazuju sa iba ukoncene hry). self.assertFalse(any(g["gid"] == gid for g in run(history.get_player_history(ids[0])))) def test_standings_from_db(self): ids = self._make_players() gid = str(uuid.uuid4()) run(history.record_game_started(gid, "Test", ids)) run(history.record_completed_rounds(gid, make_core())) standings, guesses = run(history.get_standings(gid)) # 1 seria, 1 kolo. Body podla sedadiel zo stubu [12, 0, 10, 11], # tipy zo stubu {0: 2, 1: 1, 2: 0, 3: 1}. self.assertEqual(standings, [[[12, 0, 10, 11]]]) self.assertEqual(guesses, [[[2, 1, 0, 1]]]) def test_player_history(self): ids = self._make_players() gid = str(uuid.uuid4()) run(history.record_game_started(gid, "Test", ids)) run(history.record_completed_rounds(gid, make_core())) rows = run(history.get_player_history(ids[0])) self.assertTrue(any(g["gid"] == gid for g in rows)) mine = next(g for g in rows if g["gid"] == gid) self.assertEqual(mine["my_points"], 12) self.assertEqual(len(mine["players"]), 4) def test_totp_secret_stored_encrypted_not_plaintext(self): username = "dave_" + uuid.uuid4().hex[:6] data = run(auth.register_account(username)) async def _raw_secret(): async with async_session() as session: return await session.scalar( select(Player.totp_secret).where(Player.username == username) ) stored = run(_raw_secret()) self.assertNotEqual(stored, data["secret"]) self.assertEqual(crypto.decrypt(stored), data["secret"]) def test_auth_token_stored_hashed_not_plaintext(self): username = "erin_" + uuid.uuid4().hex[:6] data = run(auth.register_account(username)) ident = run(auth.confirm_account(username, pyotp.TOTP(data["secret"]).now())) async def _raw_token(): async with async_session() as session: return await session.scalar( select(Player.auth_token).where(Player.username == username) ) stored = run(_raw_token()) self.assertNotEqual(stored, ident["token"]) self.assertEqual(stored, crypto.hash_token(ident["token"])) class LeaderboardCase(unittest.TestCase): """Pravidla rebrickov (api/leaderboard.py): radenie nad syntetickymi datami, plus jeden integracny test cez DB.""" UN = {1: "ann", 2: "bob", 3: "cyd", 4: "dee", 5: "bot:heuristic-1", 6: "bot:heuristic-2"} @staticmethod def game(created_h, ended_h, has_bot=False, day=1): base = datetime(2026, 10, day) return { "created_at": base + timedelta(hours=created_h), "ended_at": base + timedelta(hours=ended_h), "has_bot": has_bot, } def board(self, results, games, me=1): return leaderboard.build_leaderboard(results, games, self.UN, me) def test_points_split_humans_and_bots_and_hide_bots(self): games = {"h": self.game(10, 11), "b": self.game(12, 13, has_bot=True)} results = [ ("h", 1, 200), ("h", 2, 150), ("h", 3, 100), ("h", 4, 50), ("b", 1, 300), ("b", 2, 120), ("b", 3, 90), ("b", 5, 400), ] lb = self.board(results, games) humans = lb["points"]["humans"]["top"] self.assertEqual([(e["username"], e["points"]) for e in humans], [("ann", 200), ("bob", 150), ("cyd", 100), ("dee", 50)]) bots = lb["points"]["bots"]["top"] # bot (400) sa v rebricku nezobrazi, hrac ann ma v botej hre svojich 300 self.assertEqual([(e["username"], e["points"]) for e in bots], [("ann", 300), ("bob", 120), ("cyd", 90)]) self.assertEqual([e["rank"] for e in bots], [1, 2, 3]) def test_points_one_row_per_player_best_game(self): games = {"a": self.game(10, 11), "b": self.game(12, 13, day=2)} results = [("a", 1, 100), ("b", 1, 180), ("a", 2, 150)] top = self.board(results, games)["points"]["humans"]["top"] self.assertEqual([(e["username"], e["points"]) for e in top], [("ann", 180), ("bob", 150)]) def test_points_tie_goes_to_earlier_game(self): games = {"early": self.game(10, 11, day=1), "late": self.game(10, 11, day=2)} results = [("late", 1, 150), ("early", 2, 150)] top = self.board(results, games)["points"]["humans"]["top"] self.assertEqual([e["username"] for e in top], ["bob", "ann"]) def test_me_outside_top_n_gets_real_rank(self): games = {"g": self.game(10, 11)} usernames = {i: f"p{i}" for i in range(1, 16)} results = [("g", i, 1000 - i) for i in range(1, 16)] lb = leaderboard.build_leaderboard(results, games, usernames, 12) board = lb["points"]["humans"] self.assertEqual(len(board["top"]), leaderboard.TOP_N) self.assertEqual(board["me"]["rank"], 12) self.assertEqual(board["me"]["points"], 988) self.assertNotIn(12, [e["player_id"] for e in board["top"]]) def test_me_is_none_without_a_game(self): games = {"g": self.game(10, 11)} lb = self.board([("g", 2, 100)], games, me=1) self.assertIsNone(lb["points"]["humans"]["me"]) self.assertIsNone(lb["points"]["bots"]["me"]) self.assertIsNone(lb["hours"]["me"]) def test_hours_sum_includes_bot_games_and_counts_games(self): games = { "a": self.game(10, 11), # 1 h "b": self.game(12, 12.5, has_bot=True), # 30 min, s botom sa pocita } results = [("a", 1, 10), ("b", 1, 10), ("b", 5, 10), ("a", 2, 10)] hours = self.board(results, games)["hours"] self.assertEqual([e["username"] for e in hours["top"]], ["ann", "bob"]) self.assertEqual(hours["top"][0]["seconds"], 5400) self.assertEqual(hours["top"][0]["games"], 2) self.assertEqual(hours["top"][1]["seconds"], 3600) def test_hours_game_across_midnight_counts_but_long_game_does_not(self): games = { "night": { # 23:30 -> 00:15 nasledujuceho dna = 45 min "created_at": datetime(2026, 10, 1, 23, 30), "ended_at": datetime(2026, 10, 2, 0, 15), "has_bot": False, }, "resumed": { # obnovena o den neskor -> pauza by skreslila cas "created_at": datetime(2026, 10, 1, 20, 0), "ended_at": datetime(2026, 10, 2, 20, 30), "has_bot": False, }, } results = [("night", 1, 10), ("resumed", 1, 10), ("resumed", 2, 10)] hours = self.board(results, games)["hours"] self.assertEqual([(e["username"], e["seconds"]) for e in hours["top"]], [("ann", 2700)]) def test_hours_game_at_the_limit_counts(self): games = {"g": self.game(10, 13)} hours = self.board([("g", 1, 10)], games)["hours"] self.assertEqual(hours["top"][0]["seconds"], 3 * 3600) def test_bots_never_in_hours(self): games = {"g": self.game(10, 11, has_bot=True)} hours = self.board([("g", 1, 10), ("g", 5, 10), ("g", 6, 10)], games)["hours"] self.assertEqual([e["username"] for e in hours["top"]], ["ann"]) # --- integracia s DB ----------------------------------------------------- @classmethod def setUpClass(cls): run(init_db()) @staticmethod async def _add_game(gid, ids, totals, created, ended, rounds=history.FULL_GAME_ROUNDS): """Hra so `rounds` dohratymi kolami; cely sucet bodov hraca je v kole 0.""" async with async_session() as session: session.add(Game( id=gid, name="LB", player0_id=ids[0], player1_id=ids[1], player2_id=ids[2], player3_id=ids[3], created_at=created, ended_at=ended, )) for n in range(rounds): for seat in range(4): session.add(Guess( game_id=gid, player_id=ids[seat], series_number=n // ROUNDS_PER_SERIES, round_number=n % ROUNDS_PER_SERIES, guess=0, points=totals[seat] if n == 0 else 0, )) await session.commit() def test_get_leaderboard_from_db(self): humans = make_players(4) bot = run(bots_module.ensure_bot_account("heuristic", set())) mixed = humans[:3] + [bot["player_id"]] start = datetime(2026, 11, 1, 10, 0) # hry s obrovskymi bodmi, nech su urcite na vrchu aj popri inych testoch run(self._add_game(str(uuid.uuid4()), humans, [9001, 9002, 9003, 9004], start, start + timedelta(hours=1))) run(self._add_game(str(uuid.uuid4()), mixed, [9101, 9102, 9103, 99999], start, start + timedelta(hours=2))) # nedohrana hra (len 1 kolo) sa nepocita run(self._add_game(str(uuid.uuid4()), humans, [99990, 0, 0, 0], start, start + timedelta(hours=1), rounds=1)) lb = run(leaderboard.get_leaderboard(humans[0])) hum = lb["points"]["humans"]["top"] self.assertEqual([e["player_id"] for e in hum[:4]], [humans[3], humans[2], humans[1], humans[0]]) self.assertEqual(hum[0]["points"], 9004) bts = lb["points"]["bots"]["top"] self.assertEqual([e["player_id"] for e in bts[:3]], [humans[2], humans[1], humans[0]]) self.assertNotIn(bot["player_id"], [e["player_id"] for e in bts]) self.assertEqual(lb["points"]["humans"]["me"]["points"], 9001) # hodiny: humans[0] odohral 1 h + 2 h, humans[3] len 1 h me_hours = lb["hours"]["me"] self.assertEqual(me_hours["seconds"], 3 * 3600) self.assertEqual(me_hours["games"], 2) class CryptoCase(unittest.TestCase): def test_encrypt_decrypt_roundtrip(self): secret = "JBSWY3DPEHPK3PXP" ciphertext = crypto.encrypt(secret) self.assertNotEqual(ciphertext, secret) self.assertEqual(crypto.decrypt(ciphertext), secret) def test_hash_token_is_deterministic_and_distinct(self): self.assertEqual(crypto.hash_token("abc"), crypto.hash_token("abc")) self.assertNotEqual(crypto.hash_token("abc"), crypto.hash_token("abd")) def test_missing_key_raises(self): saved = os.environ.pop("ENCRYPTION_KEY") try: with self.assertRaises(RuntimeError): crypto.encrypt("x") finally: os.environ["ENCRYPTION_KEY"] = saved def tearDownModule(): from db.db import engine run(engine.dispose()) try: os.remove(_DB_FILE) except OSError: pass if __name__ == "__main__": unittest.main()