from __future__ import annotations import asyncio from datetime import UTC, datetime, timedelta import pytest async def _seed_points(points, *, chat_id: int, user_id: int, amount: int) -> None: await points.adjust_points( chat_id=chat_id, user_id=user_id, delta=amount, source=points.SOURCE_ADMIN, idempotency_key=f"seed:{chat_id}:{user_id}", reason="test seed", ) async def test_entry_cost_duplicate_click_and_cancel_refund(app_modules): points = app_modules.load("wbb.utils.dbpoints") giveaway_db = app_modules.load("wbb.utils.dbgiveaway") service = app_modules.load("wbb.services.giveaways") await _seed_points(points, chat_id=-100, user_id=10, amount=100) giveaway = await giveaway_db.create_giveaway( chat_id=-100, creator_id=1, creator_name="Admin", title="Launch", description="", prizes=[{"name": "First", "count": 1, "points_reward": 5}], ends_at=datetime.now(UTC) + timedelta(hours=1), minimum_points=50, entry_cost=10, participation_reward=2, ) first, _ = await service.join_giveaway( giveaway["giveaway_id"], user_id=10, username="alice", first_name="Alice", display_name="Alice Example", ) duplicate, _ = await service.join_giveaway( giveaway["giveaway_id"], user_id=10, username="alice", first_name="Alice", display_name="Alice Example", ) assert (first, duplicate) == ("ok", "duplicate") account = await points.get_point_account(-100, 10) assert account["balance"] == 90 assert account["display_name"] == "Alice Example" participant = await giveaway_db.get_participant(giveaway["giveaway_id"], 10) assert participant["display_name"] == "Alice Example" assert "Alice Example" in service._participant_mention(participant) assert "@alice" not in service._participant_mention(participant) ok, _, _ = await service.cancel_and_refund_giveaway( giveaway["giveaway_id"], chat_id=-100, publish=False ) again, _, _ = await service.cancel_and_refund_giveaway( giveaway["giveaway_id"], chat_id=-100, publish=False ) assert ok is True assert again is False assert (await points.get_point_account(-100, 10))["balance"] == 100 async def test_registration_window_blocks_early_entry_and_refreshes_count(app_modules): giveaway_db = app_modules.load("wbb.utils.dbgiveaway") service = app_modules.load("wbb.services.giveaways") now = datetime.now(UTC) future = await giveaway_db.create_giveaway( chat_id=-100, creator_id=1, creator_name="Admin", title="Scheduled", description="", prizes=[{"name": "Winner", "count": 1}], starts_at=now + timedelta(hours=1), ends_at=now + timedelta(hours=2), ) status, _ = await service.join_giveaway( future["giveaway_id"], user_id=10, username=None, first_name="Alice", ) assert status == "not_started" assert await giveaway_db.count_participants(future["giveaway_id"]) == 0 assert await giveaway_db.list_unannounced_started_giveaways() == [] await giveaway_db.giveawaysdb.update_one( {"giveaway_id": future["giveaway_id"]}, {"$set": {"starts_at": now - timedelta(minutes=1)}}, ) started = await giveaway_db.list_unannounced_started_giveaways() assert [item["giveaway_id"] for item in started] == [future["giveaway_id"]] await giveaway_db.mark_giveaway_start_announced(future["giveaway_id"]) assert await giveaway_db.list_unannounced_started_giveaways() == [] current = await giveaway_db.create_giveaway( chat_id=-100, creator_id=1, creator_name="Admin", title="Open", description="", prizes=[{"name": "Winner", "count": 1}], starts_at=now - timedelta(minutes=1), ends_at=now + timedelta(hours=1), ) await giveaway_db.attach_giveaway_message(current["giveaway_id"], -100, 99) status, _ = await service.join_giveaway( current["giveaway_id"], user_id=10, username="alice", first_name="Alice", ) assert status == "ok" assert len(app_modules.app.edited_messages) == 1 assert app_modules.app.edited_messages[0][:2] == (-100, 99) assert "参与人数:1" in app_modules.app.edited_messages[0][2] async def test_registration_start_must_precede_draw_time(app_modules): giveaway_db = app_modules.load("wbb.utils.dbgiveaway") now = datetime.now(UTC) with pytest.raises(ValueError, match="报名开始时间必须早于开奖时间"): await giveaway_db.create_giveaway( chat_id=-100, creator_id=1, creator_name="Admin", title="Invalid schedule", description="", prizes=[{"name": "Winner", "count": 1}], starts_at=now + timedelta(hours=2), ends_at=now + timedelta(hours=1), ) async def test_draw_rewards_are_idempotent_under_competing_finish_calls(app_modules): points = app_modules.load("wbb.utils.dbpoints") giveaway_db = app_modules.load("wbb.utils.dbgiveaway") service = app_modules.load("wbb.services.giveaways") for user_id in (10, 11): await _seed_points(points, chat_id=-100, user_id=user_id, amount=20) giveaway = await giveaway_db.create_giveaway( chat_id=-100, creator_id=1, creator_name="Admin", title="Draw", description="", prizes=[{"name": "Winner", "count": 1, "points_reward": 5}], ends_at=datetime.now(UTC) + timedelta(hours=1), entry_cost=3, participation_reward=2, ) for user_id in (10, 11): status, _ = await service.join_giveaway( giveaway["giveaway_id"], user_id=user_id, username=None, first_name=str(user_id), ) assert status == "ok" results = await asyncio.gather( service.finish_and_publish_giveaway(giveaway["giveaway_id"], publish=False), service.finish_and_publish_giveaway(giveaway["giveaway_id"], publish=False), ) assert sum(1 for result in results if result[0]) == 1 finished = await giveaway_db.get_giveaway(giveaway["giveaway_id"]) assert finished["status"] == giveaway_db.STATUS_FINISHED winner_id = int(finished["winners"][0]["user_id"]) balances = { user_id: (await points.get_point_account(-100, user_id))["balance"] for user_id in (10, 11) } assert balances[winner_id] == 24 assert balances[10 if winner_id == 11 else 11] == 19 async def test_participant_write_failure_creates_compensating_refund( app_modules, monkeypatch ): points = app_modules.load("wbb.utils.dbpoints") giveaway_db = app_modules.load("wbb.utils.dbgiveaway") service = app_modules.load("wbb.services.giveaways") await _seed_points(points, chat_id=-100, user_id=10, amount=20) giveaway = await giveaway_db.create_giveaway( chat_id=-100, creator_id=1, creator_name="Admin", title="Failure", description="", prizes=[{"name": "Winner", "count": 1}], ends_at=datetime.now(UTC) + timedelta(hours=1), entry_cost=5, ) original_add_participant = service.add_participant async def fail_insert(**_kwargs): raise RuntimeError("simulated participant write failure") monkeypatch.setattr(service, "add_participant", fail_insert) with pytest.raises(RuntimeError): await service.join_giveaway( giveaway["giveaway_id"], user_id=10, username=None, first_name="Alice", ) assert (await points.get_point_account(-100, 10))["balance"] == 20 transactions, _ = await points.list_point_transactions( chat_id=-100, user_id=10, page_size=20 ) assert {item["source"] for item in transactions} >= { points.SOURCE_GIVEAWAY_ENTRY, points.SOURCE_GIVEAWAY_REFUND, } monkeypatch.setattr(service, "add_participant", original_add_participant) status, _ = await service.join_giveaway( giveaway["giveaway_id"], user_id=10, username=None, first_name="Alice", ) assert status == "ok" assert (await points.get_point_account(-100, 10))["balance"] == 15 async def test_retry_reuses_uncompensated_entry_debit(app_modules): points = app_modules.load("wbb.utils.dbpoints") giveaway_db = app_modules.load("wbb.utils.dbgiveaway") service = app_modules.load("wbb.services.giveaways") await _seed_points(points, chat_id=-100, user_id=10, amount=20) giveaway = await giveaway_db.create_giveaway( chat_id=-100, creator_id=1, creator_name="Admin", title="Crash recovery", description="", prizes=[{"name": "Winner", "count": 1}], ends_at=datetime.now(UTC) + timedelta(hours=1), minimum_points=20, entry_cost=5, ) await points.adjust_points( chat_id=-100, user_id=10, delta=-5, source=points.SOURCE_GIVEAWAY_ENTRY, idempotency_key=f"giveaway-entry:{giveaway['giveaway_id']}:10", reference_id=giveaway["giveaway_id"], reason="simulated crash after debit", ) status, _ = await service.join_giveaway( giveaway["giveaway_id"], user_id=10, username=None, first_name="Alice", ) assert status == "ok" assert (await points.get_point_account(-100, 10))["balance"] == 15 transactions, _ = await points.list_point_transactions( chat_id=-100, user_id=10, source=points.SOURCE_GIVEAWAY_ENTRY ) assert len(transactions) == 1