Files
tipibot/tests/test_money_safety_fixes.py
Rene Arumetsa 037628d24f feat(economy): add vanity shop and harden economy money-safety
Vanity shop (/vanity): cosmetic badges/titles as a pure whale coin sink -
purchases burn coins (not credited to the house) and equip a badge shown on
/profile. New vanity_owned/vanity_active fields, schema sync, and tests.

Money-safety and robustness fixes from a codebase review:

- _parse_amount now rejects negative amounts. Every bet/give/request flows
  through it, so a negative value can no longer mint coins on a loss/transfer
  path that trusts the caller's sign (all call sites already guarded <= 0;
  this closes the source).
- do_blackjack_payout no longer raises on a DB failure. The stake was already
  deducted in do_blackjack_bet, so it now logs critical with the owed amount
  (for admin reconciliation) and returns db_error; all payout call sites render
  a clear "payout failed" notice instead of crashing the interaction.
- Instant "kohv" consumable now cancels the pending reminder DMs for the
  cooldowns it wipes (via new INSTANT_RESET_COMMANDS), so no stale/duplicate
  reminders fire.
- Renamed the misleadingly-named _refund_user_safe -> _debit_house_safe (it
  debits the house) and dropped its ignored first arg.
- Added __all__ to vanity.py and consumables.py so `import *` no longer leaks
  incidental imports into the economy namespace.
- Documented Kõrvaklapid's +25 coin daily bonus in README and DEV_NOTES.

Tests: blackjack payout DB-failure safety and INSTANT_RESET_COMMANDS lockstep.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013VbAVsrZuYesea99mPMmPT
2026-09-04 02:09:25 +03:00

116 lines
4.9 KiB
Python

"""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