test: mock-based integration tests for post-game refractor hook (#148) #158

Merged
cal merged 1 commits from issue/148-test-mock-based-integration-tests-for-post-game-re into main 2026-04-08 14:26:07 +00:00
2 changed files with 404 additions and 13 deletions

View File

@ -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()

View File

@ -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()