"""Tests for the async economy flows against the in-memory PocketBase fake.""" import asyncio import random from datetime import datetime, timedelta, timezone from core import economy from conftest import run UID = 111 OTHER = 222 HOUSE = 999 def _fixed_now(monkeypatch, dt: datetime): # _clock is the time seam shared by every economy submodule monkeypatch.setattr(economy.store, "_clock", lambda: dt) return dt class TestGetUser: def test_creates_default_record(self, fake_pb): user = run(economy.get_user(UID)) assert user["balance"] == 0 assert fake_pb.record_for(UID)["user_id"] == str(UID) def test_roundtrips_existing_data(self, fake_pb): run(economy.get_user(UID)) fake_pb.record_for(UID)["balance"] = 1234 assert run(economy.get_user(UID))["balance"] == 1234 class TestDaily: def test_first_claim(self, fake_pb): res = run(economy.do_daily(UID)) assert res["ok"] and res["streak"] == 1 and res["earned"] == 150 def test_cooldown_blocks_second_claim(self, fake_pb): run(economy.do_daily(UID)) res = run(economy.do_daily(UID)) assert not res["ok"] and res["reason"] == "cooldown" def test_streak_increments_next_day(self, fake_pb, monkeypatch): t0 = _fixed_now(monkeypatch, datetime(2026, 7, 25, 12, tzinfo=timezone.utc)) run(economy.do_daily(UID)) _fixed_now(monkeypatch, t0 + timedelta(days=1)) res = run(economy.do_daily(UID)) assert res["ok"] and res["streak"] == 2 def test_streak_resets_after_missed_day(self, fake_pb, monkeypatch): t0 = _fixed_now(monkeypatch, datetime(2026, 7, 25, 12, tzinfo=timezone.utc)) run(economy.do_daily(UID)) _fixed_now(monkeypatch, t0 + timedelta(days=3)) res = run(economy.do_daily(UID)) assert res["ok"] and res["streak"] == 1 def test_karikas_preserves_streak(self, fake_pb, monkeypatch): t0 = _fixed_now(monkeypatch, datetime(2026, 7, 25, 12, tzinfo=timezone.utc)) run(economy.do_daily(UID)) fake_pb.record_for(UID)["items"] = ["karikas"] fake_pb.record_for(UID)["daily_streak"] = 5 _fixed_now(monkeypatch, t0 + timedelta(days=3)) res = run(economy.do_daily(UID)) assert res["ok"] and res["streak"] == 5 def test_streak_multiplier_tiers(self, fake_pb, monkeypatch): t0 = _fixed_now(monkeypatch, datetime(2026, 7, 25, 12, tzinfo=timezone.utc)) run(economy.get_user(UID)) rec = fake_pb.record_for(UID) rec["daily_streak"] = 13 rec["last_streak_date"] = (t0.date() - timedelta(days=1)).isoformat() res = run(economy.do_daily(UID)) assert res["streak"] == 14 and res["streak_mult"] == 3.0 and res["earned"] == 450 class TestBuy: def test_insufficient_funds(self, fake_pb): res = run(economy.do_buy(UID, "gaming_hiir")) assert not res["ok"] and res["reason"] == "insufficient" def test_purchase_and_rebuy_blocked(self, fake_pb): run(economy.get_user(UID)) fake_pb.record_for(UID)["balance"] = 1000 res = run(economy.do_buy(UID, "gaming_hiir")) assert res["ok"] and res["balance"] == 500 assert "gaming_hiir" in fake_pb.record_for(UID)["items"] res = run(economy.do_buy(UID, "gaming_hiir")) assert not res["ok"] and res["reason"] == "owned" def test_tier2_requires_level(self, fake_pb): run(economy.get_user(UID)) fake_pb.record_for(UID)["balance"] = 100_000 res = run(economy.do_buy(UID, "jellyfin")) assert not res["ok"] and res["reason"] == "level_required" fake_pb.record_for(UID)["exp"] = economy.exp_for_level(10) assert run(economy.do_buy(UID, "jellyfin"))["ok"] def test_anticheat_repurchase_after_depletion(self, fake_pb): run(economy.get_user(UID)) rec = fake_pb.record_for(UID) rec["balance"] = 10_000 assert run(economy.do_buy(UID, "anticheat"))["ok"] assert fake_pb.record_for(UID)["item_uses"]["anticheat"] == 2 fake_pb.record_for(UID)["item_uses"]["anticheat"] = 0 assert run(economy.do_buy(UID, "anticheat"))["ok"] assert fake_pb.record_for(UID)["item_uses"]["anticheat"] == 2 class TestGive: def test_transfer(self, fake_pb): run(economy.get_user(UID)) fake_pb.record_for(UID)["balance"] = 500 res = run(economy.do_give(UID, OTHER, 200)) assert res["ok"] assert fake_pb.record_for(UID)["balance"] == 300 assert fake_pb.record_for(OTHER)["balance"] == 200 def test_insufficient(self, fake_pb): res = run(economy.do_give(UID, OTHER, 50)) assert not res["ok"] and res["reason"] == "insufficient" def test_opposite_transfers_do_not_deadlock(self, fake_pb): async def both(): for uid in (UID, OTHER): await economy.get_user(uid) fake_pb.record_for(uid)["balance"] = 100 await asyncio.wait_for( asyncio.gather( economy.do_give(UID, OTHER, 10), economy.do_give(OTHER, UID, 25), ), timeout=5, ) run(both()) total = fake_pb.record_for(UID)["balance"] + fake_pb.record_for(OTHER)["balance"] assert total == 200 class TestRob: def test_anticheat_blocks_and_depletes(self, fake_pb): run(economy.get_user(UID)) run(economy.get_user(OTHER)) economy.set_house(HOUSE) fake_pb.record_for(UID)["balance"] = 1000 target = fake_pb.record_for(OTHER) target["balance"] = 1000 target["items"] = ["anticheat"] target["item_uses"] = {"anticheat": 1} res = run(economy.do_rob(UID, OTHER)) assert res["ok"] and not res["success"] and res["reason"] == "valvur" assert fake_pb.record_for(UID)["balance"] == 1000 - res["fine"] assert "anticheat" not in fake_pb.record_for(OTHER)["items"] # the fine flows to the house assert fake_pb.record_for(HOUSE)["balance"] == res["fine"] class TestGambling: def test_roulette_conserves_money_with_house(self, fake_pb): economy.set_house(HOUSE) run(economy.get_user(UID)) fake_pb.record_for(UID)["balance"] = 1000 random.seed(3) res = run(economy.do_roulette(UID, 100, "punane")) assert res["ok"] user_bal = fake_pb.record_for(UID)["balance"] if res["won"]: assert user_bal == 1000 + res["change"] else: assert user_bal == 900 assert fake_pb.record_for(HOUSE)["balance"] == 100 def test_bet_larger_than_balance_rejected(self, fake_pb): res = run(economy.do_slots(UID, 50)) assert not res["ok"] and res["reason"] == "insufficient" def test_blackjack_bet_and_payout(self, fake_pb): economy.set_house(HOUSE) run(economy.get_user(UID)) fake_pb.record_for(UID)["balance"] = 500 assert run(economy.do_blackjack_bet(UID, 100))["ok"] assert fake_pb.record_for(UID)["balance"] == 400 # player loses: payout 0 of 100 invested -> house gains the bet run(economy.do_blackjack_payout(UID, 0, total_invested=100)) assert fake_pb.record_for(UID)["balance"] == 400 assert fake_pb.record_for(HOUSE)["balance"] == 100 class TestConcurrency: """The per-user locks must serialize read-modify-write cycles.""" def test_concurrent_exp_awards_are_not_lost(self, fake_pb): async def hammer(): await asyncio.gather(*(economy.award_exp(UID, 10) for _ in range(25))) run(hammer()) assert fake_pb.record_for(UID)["exp"] == 250 def test_concurrent_credit_house_is_atomic(self, fake_pb): economy.set_house(HOUSE) async def hammer(): await economy.get_user(HOUSE) await asyncio.gather(*(economy._credit_house(7) for _ in range(30))) run(hammer()) assert fake_pb.record_for(HOUSE)["balance"] == 210