"""Regression tests for the money-safety audit fixes. Covers the two pure-logic fixes: - heist payout conserves coins (no minting when the house is poor) and the win path no longer crashes on the shadowed `house` local. - do_bail is idempotent: a jailed user can only be charged once per sentence, so a double-click or a stale BailView cannot destroy coins with a second fine. """ from datetime import datetime, timedelta, timezone from core import economy from core.pb_client import DatabaseError from conftest import run UID = 111 OTHER = 222 HOUSE = 999 def _fixed_now(monkeypatch, dt: datetime): monkeypatch.setattr(economy.store, "_clock", lambda: dt) return dt class TestHeistConservation: def _setup_house(self, fake_pb, monkeypatch, balance: int): monkeypatch.setattr(economy.house, "HOUSE_ID", HOUSE) monkeypatch.setattr(economy.house, "_house_pb_id", None) run(economy.get_user(HOUSE)) fake_pb.record_for(HOUSE)["balance"] = balance def test_poor_house_win_does_not_mint(self, fake_pb, monkeypatch): # Poor house is the case the pre-fix code minted from: the desired pot # (floored at 300) exceeded the balance, so payouts outran the debit. self._setup_house(fake_pb, monkeypatch, balance=100) run(economy.get_user(UID)) run(economy.get_user(OTHER)) house_before = fake_pb.record_for(HOUSE)["balance"] res = run(economy.do_heist_resolve([UID, OTHER], True)) # must not raise assert res["ok"] and res["success"] house_after = fake_pb.record_for(HOUSE)["balance"] gains = fake_pb.record_for(UID)["balance"] + fake_pb.record_for(OTHER)["balance"] # Coin conservation: the house loses exactly what the players gain. assert house_before - house_after == gains assert house_after >= 0 assert res["payout_each"] * 2 == gains def test_rich_house_win_pays_out_and_conserves(self, fake_pb, monkeypatch): self._setup_house(fake_pb, monkeypatch, balance=100_000) run(economy.get_user(UID)) run(economy.get_user(OTHER)) house_before = fake_pb.record_for(HOUSE)["balance"] res = run(economy.do_heist_resolve([UID, OTHER], True)) assert res["ok"] and res["payout_each"] > 0 house_after = fake_pb.record_for(HOUSE)["balance"] gains = fake_pb.record_for(UID)["balance"] + fake_pb.record_for(OTHER)["balance"] assert house_before - house_after == gains class TestBailIdempotency: def _jail(self, fake_pb, now, balance: int): run(economy.get_user(UID)) rec = fake_pb.record_for(UID) rec["balance"] = balance rec["jailed_until"] = (now + timedelta(minutes=30)).isoformat() def test_second_bail_when_free_is_noop(self, fake_pb, monkeypatch): now = _fixed_now(monkeypatch, datetime(2026, 7, 25, 12, tzinfo=timezone.utc)) self._jail(fake_pb, now, balance=1000) first = run(economy.do_bail(UID)) assert first["ok"] bal_after_first = fake_pb.record_for(UID)["balance"] assert bal_after_first < 1000 # a fine was charged assert fake_pb.record_for(UID)["jailed_until"] is None # freed # A double-click / stale view fires do_bail again while already free. second = run(economy.do_bail(UID)) assert not second["ok"] and second["reason"] == "not_jailed" assert fake_pb.record_for(UID)["balance"] == bal_after_first # no second charge def test_bail_charges_once_for_a_real_sentence(self, fake_pb, monkeypatch): now = _fixed_now(monkeypatch, datetime(2026, 7, 25, 12, tzinfo=timezone.utc)) self._jail(fake_pb, now, balance=1000) res = run(economy.do_bail(UID)) assert res["ok"] and res["fine"] >= economy.MIN_BAIL class TestBlackjackPayoutSafety: """do_blackjack_payout must not raise on a DB failure - the stake was already deducted in do_blackjack_bet, so a raised exception would blow up the interaction handler and swallow the outcome. It reports db_error instead.""" async def _boom(self, *args, **kwargs): raise DatabaseError("simulated PocketBase outage") def test_payout_returns_db_error_when_read_fails(self, fake_pb, monkeypatch): monkeypatch.setattr(economy.gambling, "get_user", self._boom) res = run(economy.do_blackjack_payout(UID, payout=200, total_invested=100)) assert res == {"ok": False, "reason": "db_error"} def test_payout_returns_db_error_when_commit_fails(self, fake_pb, monkeypatch): run(economy.get_user(UID)) fake_pb.record_for(UID)["balance"] = 500 monkeypatch.setattr(economy.gambling, "_commit", self._boom) res = run(economy.do_blackjack_payout(UID, payout=200, total_invested=100)) assert res == {"ok": False, "reason": "db_error"} # The failed credit did not persist; balance is untouched (no partial win). assert fake_pb.record_for(UID)["balance"] == 500