""" Tests for the WP-13 post-game callback integration hook. These tests verify that after a game is saved to the API, two additional POST requests are fired in the correct order: 1. POST season-stats/update-game/{game_id} — update player_season_stats 2. POST refractor/evaluate-game/{game_id} — evaluate refractor milestones Key design constraints being tested: - Season stats MUST be updated before refractor is evaluated (ordering). - Failure of either refractor call must NOT propagate — the game result has already been committed; refractor will self-heal on the next evaluate pass. - Tier-up dicts returned by the refractor endpoint are passed to notify_tier_completion so WP-14 can present them to the player. """ import pytest from unittest.mock import AsyncMock, MagicMock, call, patch # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _make_channel(channel_id: int = 999) -> MagicMock: ch = MagicMock() ch.id = channel_id return ch async def _run_hook(db_post_mock, db_game_id: int = 42): """ Execute the post-game hook in isolation. We import the hook logic inline rather than calling the full complete_game() function (which requires a live DB session, Discord interaction, and Play object). The hook is a self-contained try/except block so we replicate it verbatim here to test its behaviour. """ channel = _make_channel() from command_logic.logic_gameplay import notify_tier_completion db_game = {"id": db_game_id} try: await db_post_mock(f"season-stats/update-game/{db_game['id']}") evo_result = await db_post_mock(f"refractor/evaluate-game/{db_game['id']}") if evo_result and evo_result.get("tier_ups"): for tier_up in evo_result["tier_ups"]: await notify_tier_completion(channel, tier_up) except Exception: pass # non-fatal — mirrors the logger.warning in production return channel # --------------------------------------------------------------------------- # Tests # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_hook_posts_to_both_endpoints_in_order(): """ Both refractor endpoints are called, and season-stats comes first. The ordering is critical: player_season_stats must be populated before the refractor engine tries to read them for milestone evaluation. """ db_post_mock = AsyncMock(return_value={}) await _run_hook(db_post_mock, db_game_id=42) assert db_post_mock.call_count == 2 calls = db_post_mock.call_args_list # First call must be season-stats assert calls[0] == call("season-stats/update-game/42") # Second call must be refractor evaluate assert calls[1] == call("refractor/evaluate-game/42") @pytest.mark.asyncio async def test_hook_is_nonfatal_when_db_post_raises(): """ A failure inside the hook must not raise to the caller. The game result is already persisted when the hook runs. If the refractor API is down or returns an error, we log a warning and continue — the game completion flow must not be interrupted. """ db_post_mock = AsyncMock(side_effect=Exception("refractor API unavailable")) # Should not raise try: await _run_hook(db_post_mock, db_game_id=7) except Exception as exc: pytest.fail(f"Hook raised unexpectedly: {exc}") @pytest.mark.asyncio async def test_hook_processes_tier_ups_from_evo_result(): """ When the refractor endpoint returns tier_ups, each entry is forwarded to notify_tier_completion. This confirms the data path between the API response and the WP-14 notification stub so that WP-14 only needs to replace the stub body. """ tier_ups = [ {"player_id": 101, "old_tier": 1, "new_tier": 2}, {"player_id": 202, "old_tier": 2, "new_tier": 3}, ] async def fake_db_post(endpoint): if "refractor" in endpoint: return {"tier_ups": tier_ups} return {} db_post_mock = AsyncMock(side_effect=fake_db_post) with patch( "command_logic.logic_gameplay.notify_tier_completion", new_callable=AsyncMock, ) as mock_notify: channel = _make_channel() db_game = {"id": 99} try: await db_post_mock(f"season-stats/update-game/{db_game['id']}") evo_result = await db_post_mock(f"refractor/evaluate-game/{db_game['id']}") if evo_result and evo_result.get("tier_ups"): for tier_up in evo_result["tier_ups"]: await mock_notify(channel, tier_up) except Exception: pass assert mock_notify.call_count == 2 # Verify both tier_up dicts were forwarded forwarded = [c.args[1] for c in mock_notify.call_args_list] assert {"player_id": 101, "old_tier": 1, "new_tier": 2} in forwarded assert {"player_id": 202, "old_tier": 2, "new_tier": 3} in forwarded @pytest.mark.asyncio async def test_hook_no_tier_ups_does_not_call_notify(): """ When the refractor response has no tier_ups (empty list or missing key), notify_tier_completion is never called. Avoids spurious Discord messages for routine game completions. """ async def fake_db_post(endpoint): if "refractor" in endpoint: return {"tier_ups": []} return {} db_post_mock = AsyncMock(side_effect=fake_db_post) with patch( "command_logic.logic_gameplay.notify_tier_completion", new_callable=AsyncMock, ) as mock_notify: channel = _make_channel() db_game = {"id": 55} try: await db_post_mock(f"season-stats/update-game/{db_game['id']}") evo_result = await db_post_mock(f"refractor/evaluate-game/{db_game['id']}") if evo_result and evo_result.get("tier_ups"): for tier_up in evo_result["tier_ups"]: await mock_notify(channel, tier_up) except Exception: pass mock_notify.assert_not_called() @pytest.mark.asyncio async def test_notify_tier_completion_sends_embed_and_does_not_raise(): """ notify_tier_completion sends a Discord embed and does not raise. Now that WP-14 is wired, the function imported via logic_gameplay is the real embed-sending implementation from helpers.refractor_notifs. """ from command_logic.logic_gameplay import notify_tier_completion channel = AsyncMock() # Full API response shape — the evaluate-game endpoint returns all these keys tier_up = { "player_id": 77, "team_id": 1, "player_name": "Mike Trout", "old_tier": 0, "new_tier": 1, "current_value": 45.0, "track_name": "Batter Track", } await notify_tier_completion(channel, tier_up) channel.send.assert_called_once() embed = channel.send.call_args.kwargs["embed"] assert "Mike Trout" in embed.description