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
116 lines
4.9 KiB
Python
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
|