All checks were successful
Ruff Lint / lint (pull_request) Successful in 14s
- Change `evolution/evaluate-game/` API call to `refractor/evaluate-game/` in complete_game() hook (was calling the wrong endpoint path) - Update all test assertions in test_complete_game_hook.py to match the corrected endpoint path and update docstrings to "refractor" naming - Remove helpers/evolution_notifs.py and tests/test_evolution_notifications.py from this PR — they belong to PR #112 (WP-14 tier notifications). The notify_tier_completion stub in logic_gameplay.py remains as the WP-14 integration target. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
202 lines
6.9 KiB
Python
202 lines
6.9 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 asyncio
|
|
import logging
|
|
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_stub_logs_and_does_not_raise(caplog):
|
|
"""
|
|
The WP-14 stub must log the event and return cleanly.
|
|
|
|
Verifies the contract that WP-14 can rely on: the function accepts
|
|
(channel, tier_up) and does not raise, so the hook's for-loop is safe.
|
|
"""
|
|
from command_logic.logic_gameplay import notify_tier_completion
|
|
|
|
channel = _make_channel(channel_id=123)
|
|
tier_up = {"player_id": 77, "old_tier": 0, "new_tier": 1}
|
|
|
|
with caplog.at_level(logging.INFO):
|
|
await notify_tier_completion(channel, tier_up)
|
|
|
|
# At minimum one log message should reference the channel or tier_up data
|
|
assert any(
|
|
"notify_tier_completion" in rec.message or "77" in rec.message
|
|
for rec in caplog.records
|
|
)
|