From eb022c3d6680ffab26a3e2046e16bb14e5e85983 Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Wed, 8 Apr 2026 08:34:50 -0500 Subject: [PATCH] test: mock-based integration tests for post-game refractor hook (#148) Extract _run_post_game_refractor_hook() from complete_game() so the hook logic can be tested directly without a live game session. Add 13 tests covering endpoint ordering, tier-up notification dispatch, variant render triggers, and non-fatal error handling. Closes #148 Co-Authored-By: Claude Sonnet 4.6 --- command_logic/logic_gameplay.py | 35 ++- tests/test_post_game_refractor_hook.py | 382 +++++++++++++++++++++++++ 2 files changed, 404 insertions(+), 13 deletions(-) create mode 100644 tests/test_post_game_refractor_hook.py diff --git a/command_logic/logic_gameplay.py b/command_logic/logic_gameplay.py index 846fe3e..f8c70cd 100644 --- a/command_logic/logic_gameplay.py +++ b/command_logic/logic_gameplay.py @@ -4244,6 +4244,26 @@ async def get_game_summary_embed( return game_embed +async def _run_post_game_refractor_hook(db_game_id: int, channel) -> None: + """Post-game refractor processing — non-fatal. + + Updates season stats then evaluates refractor milestones for all + participating players. Fires tier-up notifications and triggers variant + card renders. Wrapped in try/except so any failure here is non-fatal — + the game is already saved and refractor will self-heal on the next + evaluate call. + """ + try: + await db_post(f"season-stats/update-game/{db_game_id}") + evo_result = await db_post(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) + await _trigger_variant_renders(evo_result["tier_ups"]) + except Exception as e: + logger.warning(f"Post-game refractor processing failed (non-fatal): {e}") + + async def _trigger_variant_renders(tier_ups: list) -> None: """Fire-and-forget: hit card render URLs to trigger S3 upload for new variants. @@ -4372,19 +4392,8 @@ async def complete_game( log_exception(e, msg="Error while posting game rewards") # Post-game refractor processing (non-blocking) - # WP-13: update season stats then evaluate refractor milestones for all - # participating players. Wrapped in try/except so any failure here is - # non-fatal — the game is already saved and refractor will catch up on the - # next evaluate call. - try: - await db_post(f"season-stats/update-game/{db_game['id']}") - evo_result = await db_post(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(interaction.channel, tier_up) - await _trigger_variant_renders(evo_result["tier_ups"]) - except Exception as e: - logger.warning(f"Post-game refractor processing failed (non-fatal): {e}") + # WP-13: season stats update + refractor milestone evaluation. + await _run_post_game_refractor_hook(db_game["id"], interaction.channel) session.delete(this_play) session.commit() diff --git a/tests/test_post_game_refractor_hook.py b/tests/test_post_game_refractor_hook.py new file mode 100644 index 0000000..719765f --- /dev/null +++ b/tests/test_post_game_refractor_hook.py @@ -0,0 +1,382 @@ +""" +Mock-based integration tests for the post-game refractor hook. + +Tests _run_post_game_refractor_hook() which orchestrates: + 1. POST season-stats/update-game/{game_id} — update player season stats + 2. POST refractor/evaluate-game/{game_id} — evaluate refractor milestones + 3. notify_tier_completion() once per tier-up returned + 4. _trigger_variant_renders() with the full tier_ups list + +The hook is wrapped in try/except so failures are non-fatal — the game +result is already persisted before this block runs. These tests cover the +orchestration logic (REF-50+ scenarios) without requiring a live game. +""" + +from unittest.mock import AsyncMock, MagicMock, call, patch + +from command_logic.logic_gameplay import _run_post_game_refractor_hook + + +def _make_channel(channel_id: int = 999) -> MagicMock: + ch = MagicMock() + ch.id = channel_id + return ch + + +# --------------------------------------------------------------------------- +# Endpoint ordering +# --------------------------------------------------------------------------- + + +class TestEndpointOrder: + """Season-stats must be POSTed before refractor evaluate.""" + + async def test_calls_both_endpoints(self): + """Both POST endpoints are called for every game completion.""" + with patch( + "command_logic.logic_gameplay.db_post", new_callable=AsyncMock + ) as mock_post: + mock_post.return_value = {} + await _run_post_game_refractor_hook(42, _make_channel()) + + assert mock_post.call_count == 2 + + async def test_season_stats_before_evaluate(self): + """Season stats must be updated before refractor evaluate runs. + + player_season_stats must exist before the refractor engine reads them + for milestone evaluation — wrong order yields stale data. + """ + with patch( + "command_logic.logic_gameplay.db_post", new_callable=AsyncMock + ) as mock_post: + mock_post.return_value = {} + await _run_post_game_refractor_hook(42, _make_channel()) + + calls = mock_post.call_args_list + assert calls[0] == call("season-stats/update-game/42") + assert calls[1] == call("refractor/evaluate-game/42") + + async def test_game_id_interpolated_correctly(self): + """The game ID is interpolated into both endpoint URLs.""" + with patch( + "command_logic.logic_gameplay.db_post", new_callable=AsyncMock + ) as mock_post: + mock_post.return_value = {} + await _run_post_game_refractor_hook(99, _make_channel()) + + urls = [c.args[0] for c in mock_post.call_args_list] + assert "season-stats/update-game/99" in urls + assert "refractor/evaluate-game/99" in urls + + +# --------------------------------------------------------------------------- +# Tier-up notifications +# --------------------------------------------------------------------------- + + +class TestTierUpNotifications: + """notify_tier_completion is called once per tier-up in the API response.""" + + async def test_notifies_for_each_tier_up(self): + """Each tier_up dict is forwarded to notify_tier_completion.""" + tier_ups = [ + { + "player_id": 101, + "player_name": "Mike Trout", + "old_tier": 0, + "new_tier": 1, + "current_value": 30.0, + "track_name": "Batter Track", + }, + { + "player_id": 202, + "player_name": "Shohei Ohtani", + "old_tier": 1, + "new_tier": 2, + "current_value": 60.0, + "track_name": "Pitcher Track", + }, + ] + + async def fake_post(endpoint): + if "refractor" in endpoint: + return {"tier_ups": tier_ups} + return {} + + channel = _make_channel() + with ( + patch( + "command_logic.logic_gameplay.db_post", + new_callable=AsyncMock, + side_effect=fake_post, + ), + patch( + "command_logic.logic_gameplay.notify_tier_completion", + new_callable=AsyncMock, + ) as mock_notify, + patch( + "command_logic.logic_gameplay._trigger_variant_renders", + new_callable=AsyncMock, + ), + ): + await _run_post_game_refractor_hook(99, channel) + + assert mock_notify.call_count == 2 + forwarded = [c.args[1] for c in mock_notify.call_args_list] + assert tier_ups[0] in forwarded + assert tier_ups[1] in forwarded + + async def test_channel_passed_to_notify(self): + """notify_tier_completion receives the channel from complete_game.""" + tier_up = { + "player_id": 1, + "player_name": "Mike Trout", + "old_tier": 0, + "new_tier": 1, + "current_value": 30.0, + "track_name": "Batter Track", + } + + async def fake_post(endpoint): + if "refractor" in endpoint: + return {"tier_ups": [tier_up]} + return {} + + channel = _make_channel() + with ( + patch( + "command_logic.logic_gameplay.db_post", + new_callable=AsyncMock, + side_effect=fake_post, + ), + patch( + "command_logic.logic_gameplay.notify_tier_completion", + new_callable=AsyncMock, + ) as mock_notify, + patch( + "command_logic.logic_gameplay._trigger_variant_renders", + new_callable=AsyncMock, + ), + ): + await _run_post_game_refractor_hook(1, channel) + + mock_notify.assert_called_once_with(channel, tier_up) + + async def test_no_notify_when_empty_tier_ups(self): + """No notifications sent when evaluate returns an empty tier_ups list.""" + + async def fake_post(endpoint): + if "refractor" in endpoint: + return {"tier_ups": []} + return {} + + with ( + patch( + "command_logic.logic_gameplay.db_post", + new_callable=AsyncMock, + side_effect=fake_post, + ), + patch( + "command_logic.logic_gameplay.notify_tier_completion", + new_callable=AsyncMock, + ) as mock_notify, + patch( + "command_logic.logic_gameplay._trigger_variant_renders", + new_callable=AsyncMock, + ), + ): + await _run_post_game_refractor_hook(55, _make_channel()) + + mock_notify.assert_not_called() + + async def test_no_notify_when_tier_ups_key_absent(self): + """No notifications when evaluate response has no tier_ups key.""" + + async def fake_post(endpoint): + return {} + + with ( + patch( + "command_logic.logic_gameplay.db_post", + new_callable=AsyncMock, + side_effect=fake_post, + ), + patch( + "command_logic.logic_gameplay.notify_tier_completion", + new_callable=AsyncMock, + ) as mock_notify, + patch( + "command_logic.logic_gameplay._trigger_variant_renders", + new_callable=AsyncMock, + ), + ): + await _run_post_game_refractor_hook(55, _make_channel()) + + mock_notify.assert_not_called() + + +# --------------------------------------------------------------------------- +# Variant render triggers +# --------------------------------------------------------------------------- + + +class TestVariantRenderTriggers: + """_trigger_variant_renders receives the full tier_ups list.""" + + async def test_trigger_renders_called_with_all_tier_ups(self): + """_trigger_variant_renders is called once with the complete tier_ups list.""" + tier_ups = [ + {"player_id": 101, "variant_created": 7, "track_name": "Batter"}, + {"player_id": 202, "variant_created": 3, "track_name": "Pitcher"}, + ] + + async def fake_post(endpoint): + if "refractor" in endpoint: + return {"tier_ups": tier_ups} + return {} + + with ( + patch( + "command_logic.logic_gameplay.db_post", + new_callable=AsyncMock, + side_effect=fake_post, + ), + patch( + "command_logic.logic_gameplay.notify_tier_completion", + new_callable=AsyncMock, + ), + patch( + "command_logic.logic_gameplay._trigger_variant_renders", + new_callable=AsyncMock, + ) as mock_render, + ): + await _run_post_game_refractor_hook(42, _make_channel()) + + mock_render.assert_called_once_with(tier_ups) + + async def test_no_trigger_when_no_tier_ups(self): + """_trigger_variant_renders is not called when tier_ups is empty.""" + + async def fake_post(endpoint): + if "refractor" in endpoint: + return {"tier_ups": []} + return {} + + with ( + patch( + "command_logic.logic_gameplay.db_post", + new_callable=AsyncMock, + side_effect=fake_post, + ), + patch( + "command_logic.logic_gameplay.notify_tier_completion", + new_callable=AsyncMock, + ), + patch( + "command_logic.logic_gameplay._trigger_variant_renders", + new_callable=AsyncMock, + ) as mock_render, + ): + await _run_post_game_refractor_hook(42, _make_channel()) + + mock_render.assert_not_called() + + async def test_notifications_before_render_trigger(self): + """All notify_tier_completion calls happen before _trigger_variant_renders. + + Notifications should be dispatched first so the player sees the message + before any background card renders begin. + """ + call_order = [] + tier_up = {"player_id": 1, "variant_created": 5, "track_name": "Batter"} + + async def fake_post(endpoint): + if "refractor" in endpoint: + return {"tier_ups": [tier_up]} + return {} + + async def fake_notify(ch, tu): + call_order.append("notify") + + async def fake_render(tier_ups): + call_order.append("render") + + with ( + patch( + "command_logic.logic_gameplay.db_post", + new_callable=AsyncMock, + side_effect=fake_post, + ), + patch( + "command_logic.logic_gameplay.notify_tier_completion", + side_effect=fake_notify, + ), + patch( + "command_logic.logic_gameplay._trigger_variant_renders", + side_effect=fake_render, + ), + ): + await _run_post_game_refractor_hook(1, _make_channel()) + + assert call_order == ["notify", "render"] + + +# --------------------------------------------------------------------------- +# Non-fatal error handling +# --------------------------------------------------------------------------- + + +class TestNonFatalErrors: + """Hook failures must never propagate to the caller.""" + + async def test_nonfatal_when_season_stats_raises(self): + """Exception from season-stats update does not propagate. + + The game is already saved — refractor failure must not interrupt + the completion flow or show an error to the user. + """ + with patch( + "command_logic.logic_gameplay.db_post", + new_callable=AsyncMock, + side_effect=Exception("stats API down"), + ): + await _run_post_game_refractor_hook(7, _make_channel()) + + async def test_nonfatal_when_evaluate_game_raises(self): + """Exception from refractor evaluate does not propagate.""" + + async def fake_post(endpoint): + if "refractor" in endpoint: + raise Exception("refractor API unavailable") + return {} + + with patch( + "command_logic.logic_gameplay.db_post", + new_callable=AsyncMock, + side_effect=fake_post, + ): + await _run_post_game_refractor_hook(7, _make_channel()) + + async def test_nonfatal_when_evaluate_returns_none(self): + """None response from evaluate-game does not raise or notify.""" + + async def fake_post(endpoint): + if "refractor" in endpoint: + return None + return {} + + with ( + patch( + "command_logic.logic_gameplay.db_post", + new_callable=AsyncMock, + side_effect=fake_post, + ), + patch( + "command_logic.logic_gameplay.notify_tier_completion", + new_callable=AsyncMock, + ) as mock_notify, + ): + await _run_post_game_refractor_hook(7, _make_channel()) + + mock_notify.assert_not_called()