All checks were successful
Ruff Lint / lint (pull_request) Successful in 25s
Replace the logging-only stub in logic_gameplay.py with the real notify_tier_completion from helpers/refractor_notifs.py. Tier-up events now send Discord embeds instead of just logging. - Import notify_tier_completion from helpers.refractor_notifs - Remove 16-line stub function and redundant inline logging - Update tests: verify real embed-sending behavior, replace bug-documenting T1-5 diagnostic with shape validation guards Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
206 lines
7.0 KiB
Python
206 lines
7.0 KiB
Python
"""
|
|
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
|