diff --git a/src/gw2/cogs/sessions.py b/src/gw2/cogs/sessions.py index a1be57f..910a1df 100644 --- a/src/gw2/cogs/sessions.py +++ b/src/gw2/cogs/sessions.py @@ -107,7 +107,7 @@ async def session(ctx): else: # End data missing and user stopped playing - finalize the session now progress_msg = await gw2_utils.send_progress_embed(ctx) - await gw2_utils.end_session(ctx.bot, ctx.message.author, api_key) + await gw2_utils.end_session(ctx.bot, ctx.message.author, api_key, skip_delay=True) rs_session = await gw2_session_dal.get_user_last_session(user_id) if not rs_session or rs_session[0]["end"] is None: await progress_msg.delete() @@ -179,7 +179,7 @@ async def session(ctx): if not is_live_snapshot and is_playing: still_playing_msg = f"{ctx.message.author.mention}\n {gw2_messages.SESSION_USER_STILL_PLAYING}" - await gw2_utils.end_session(ctx.bot, ctx.message.author, api_key) + await gw2_utils.end_session(ctx.bot, ctx.message.author, api_key, skip_delay=True) await ctx.send(still_playing_msg) await progress_msg.delete() diff --git a/src/gw2/constants/gw2_settings.py b/src/gw2/constants/gw2_settings.py index d6aae99..96c62a3 100644 --- a/src/gw2/constants/gw2_settings.py +++ b/src/gw2/constants/gw2_settings.py @@ -31,6 +31,7 @@ class Gw2Settings(BaseSettings): api_retry_max_attempts: int | None = Field(default=5) api_retry_delay: float | None = Field(default=3.0) api_session_retry_bg_delay: float | None = Field(default=30.0) + api_session_end_delay: float | None = Field(default=300.0) @lru_cache(maxsize=1) diff --git a/src/gw2/tools/gw2_utils.py b/src/gw2/tools/gw2_utils.py index d28dafa..2a42509 100644 --- a/src/gw2/tools/gw2_utils.py +++ b/src/gw2/tools/gw2_utils.py @@ -442,8 +442,18 @@ async def _do_start_session(bot: Bot, member: discord.Member, api_key: str, sess await insert_start_char_deaths(bot, member, api_key, session_id) -async def end_session(bot: Bot, member: discord.Member, api_key: str) -> None: - """End a GW2 session for a member.""" +async def end_session(bot: Bot, member: discord.Member, api_key: str, *, skip_delay: bool = False) -> None: + """End a GW2 session for a member. + + Waits for the GW2 API cache to refresh before fetching end stats, + so that wallet/achievement values reflect in-game changes. + Set skip_delay=True for user-facing commands where the user is actively waiting. + """ + end_delay = _gw2_settings.api_session_end_delay + if not skip_delay and end_delay and end_delay > 0: + bot.log.debug(f"Waiting {end_delay}s for GW2 API cache to refresh before ending session for user {member.id}") + await asyncio.sleep(end_delay) + session = await get_user_stats(bot, api_key) if not session: bot.log.warning(f"Failed to end session for user {member.id}: unable to fetch stats from GW2 API") diff --git a/tests/integration/test_session_flow.py b/tests/integration/test_session_flow.py index 0519f5d..695654c 100644 --- a/tests/integration/test_session_flow.py +++ b/tests/integration/test_session_flow.py @@ -151,7 +151,7 @@ async def test_session_start_end_lifecycle(db_session, log): mock_gw2_api_end.call_api = AsyncMock(side_effect=_mock_call_api_end) with patch("src.gw2.tools.gw2_utils.Gw2Client", return_value=mock_gw2_api_end): - await gw2_utils.end_session(mock_bot, mock_member, API_KEY) + await gw2_utils.end_session(mock_bot, mock_member, API_KEY, skip_delay=True) # Verify session end JSONB was populated sessions = await sessions_dal.get_user_last_session(USER_ID) @@ -186,7 +186,7 @@ async def test_end_session_without_start_is_noop(db_session, log): mock_gw2_api.call_api = AsyncMock(side_effect=_mock_call_api_end) with patch("src.gw2.tools.gw2_utils.Gw2Client", return_value=mock_gw2_api): - await gw2_utils.end_session(mock_bot, mock_member, API_KEY) + await gw2_utils.end_session(mock_bot, mock_member, API_KEY, skip_delay=True) mock_log.warning.assert_called_once() assert "999888" in mock_log.warning.call_args[0][0] diff --git a/tests/unit/gw2/tools/test_gw2_utils.py b/tests/unit/gw2/tools/test_gw2_utils.py index 11015f0..139a5c3 100644 --- a/tests/unit/gw2/tools/test_gw2_utils.py +++ b/tests/unit/gw2/tools/test_gw2_utils.py @@ -1004,7 +1004,7 @@ async def test_get_user_stats_returns_none_schedules_bg_retry(self, mock_bot, mo mock_task = MagicMock() mock_create_task.return_value = mock_task - await end_session(mock_bot, mock_member, "api-key") + await end_session(mock_bot, mock_member, "api-key", skip_delay=True) mock_create_task.assert_called_once() mock_task.add_done_callback.assert_called_once() @@ -1031,7 +1031,7 @@ async def test_successful_end_session(self, mock_bot, mock_member): with patch("src.gw2.tools.gw2_utils.update_end_char_deaths") as mock_update_char: mock_update_char.return_value = None - await end_session(mock_bot, mock_member, "api-key") + await end_session(mock_bot, mock_member, "api-key", skip_delay=True) mock_instance.update_end_session.assert_called_once() call_arg = mock_instance.update_end_session.call_args[0][0] @@ -1059,12 +1059,43 @@ async def test_end_session_no_active_session(self, mock_bot, mock_member): mock_instance.update_end_session = AsyncMock(return_value=None) with patch("src.gw2.tools.gw2_utils.update_end_char_deaths") as mock_update_char: - await end_session(mock_bot, mock_member, "api-key") + await end_session(mock_bot, mock_member, "api-key", skip_delay=True) mock_instance.update_end_session.assert_called_once() mock_update_char.assert_not_called() mock_bot.log.warning.assert_called_once() + @pytest.mark.asyncio + async def test_end_session_waits_for_api_cache(self, mock_bot, mock_member): + """Test that end_session waits for the API cache delay before fetching stats.""" + with patch("src.gw2.tools.gw2_utils._gw2_settings") as mock_settings: + mock_settings.api_session_end_delay = 300.0 + + with patch("src.gw2.tools.gw2_utils.asyncio.sleep", new_callable=AsyncMock) as mock_sleep: + with patch("src.gw2.tools.gw2_utils.get_user_stats") as mock_stats: + mock_stats.return_value = None + + with patch("src.gw2.tools.gw2_utils.asyncio.create_task") as mock_task: + mock_task.return_value = MagicMock() + + await end_session(mock_bot, mock_member, "api-key") + + mock_sleep.assert_called_once_with(300.0) + + @pytest.mark.asyncio + async def test_end_session_skip_delay(self, mock_bot, mock_member): + """Test that skip_delay=True bypasses the API cache wait.""" + with patch("src.gw2.tools.gw2_utils.asyncio.sleep", new_callable=AsyncMock) as mock_sleep: + with patch("src.gw2.tools.gw2_utils.get_user_stats") as mock_stats: + mock_stats.return_value = None + + with patch("src.gw2.tools.gw2_utils.asyncio.create_task") as mock_task: + mock_task.return_value = MagicMock() + + await end_session(mock_bot, mock_member, "api-key", skip_delay=True) + + mock_sleep.assert_not_called() + class TestGetUserStats: """Test cases for get_user_stats function."""