test: mock-based integration tests for post-game refractor hook (#148) #158
@ -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()
|
||||
|
||||
382
tests/test_post_game_refractor_hook.py
Normal file
382
tests/test_post_game_refractor_hook.py
Normal 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()
|
||||
Loading…
Reference in New Issue
Block a user