- TurnTimeoutService with percentage-based warnings (35 tests) - ConnectionManager enhancements for spectators and reconnection - GameService with timer integration, spectator support, handle_timeout - GameNamespace with spectate/leave_spectate handlers, reconnection - WebSocket message schemas for spectator events - WinConditionsConfig additions for turn timer thresholds - 83 GameService tests, 37 ConnectionManager tests, 37 GameNamespace tests Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
931 lines
30 KiB
Python
931 lines
30 KiB
Python
"""Unit tests for TurnTimeoutService.
|
|
|
|
This module tests the turn timeout management functionality including
|
|
timer lifecycle (start, cancel, extend), warning detection, and
|
|
expiration checking.
|
|
|
|
All tests use mocked Redis to avoid external dependencies.
|
|
"""
|
|
|
|
import json
|
|
from contextlib import asynccontextmanager
|
|
from datetime import UTC, datetime
|
|
from unittest.mock import AsyncMock
|
|
|
|
import pytest
|
|
|
|
from app.services.turn_timeout_service import (
|
|
DEFAULT_KEY_TTL_BUFFER,
|
|
TURN_TIMEOUT_PREFIX,
|
|
PendingWarning,
|
|
TurnTimeoutInfo,
|
|
TurnTimeoutService,
|
|
)
|
|
|
|
# =============================================================================
|
|
# Fixtures
|
|
# =============================================================================
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_redis() -> AsyncMock:
|
|
"""Create a mock Redis client with common methods."""
|
|
redis = AsyncMock()
|
|
redis.hset = AsyncMock()
|
|
redis.hgetall = AsyncMock(return_value={})
|
|
redis.delete = AsyncMock(return_value=1)
|
|
redis.expire = AsyncMock()
|
|
redis.exists = AsyncMock(return_value=0)
|
|
|
|
# Mock scan_iter to return an empty async iterator by default
|
|
async def empty_scan_iter(match=None):
|
|
return
|
|
yield # Make this an async generator
|
|
|
|
redis.scan_iter = empty_scan_iter
|
|
return redis
|
|
|
|
|
|
@pytest.fixture
|
|
def turn_timeout_service(mock_redis: AsyncMock) -> TurnTimeoutService:
|
|
"""Create a TurnTimeoutService with injected mock Redis."""
|
|
|
|
@asynccontextmanager
|
|
async def mock_redis_factory():
|
|
yield mock_redis
|
|
|
|
return TurnTimeoutService(redis_factory=mock_redis_factory)
|
|
|
|
|
|
# =============================================================================
|
|
# Timer Lifecycle Tests
|
|
# =============================================================================
|
|
|
|
|
|
class TestStartTurnTimer:
|
|
"""Tests for TurnTimeoutService.start_turn_timer."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_start_turn_timer_creates_redis_entry(
|
|
self, turn_timeout_service: TurnTimeoutService, mock_redis: AsyncMock
|
|
) -> None:
|
|
"""
|
|
Test that starting a timer creates the correct Redis hash entry.
|
|
|
|
Verifies that all required fields are stored: player_id, deadline,
|
|
timeout_seconds, warnings_sent, and warning_thresholds.
|
|
"""
|
|
await turn_timeout_service.start_turn_timer(
|
|
game_id="game-123",
|
|
player_id="player-1",
|
|
timeout_seconds=180,
|
|
warning_thresholds=[50, 25],
|
|
)
|
|
|
|
# Verify Redis hset was called with correct data
|
|
mock_redis.hset.assert_called_once()
|
|
call_args = mock_redis.hset.call_args
|
|
assert call_args[0][0] == f"{TURN_TIMEOUT_PREFIX}game-123"
|
|
|
|
mapping = call_args[1]["mapping"]
|
|
assert mapping["player_id"] == "player-1"
|
|
assert mapping["timeout_seconds"] == "180"
|
|
assert mapping["warnings_sent"] == "[]"
|
|
assert json.loads(mapping["warning_thresholds"]) == [50, 25]
|
|
|
|
# Verify deadline is approximately correct (within 2 seconds)
|
|
deadline = float(mapping["deadline"])
|
|
expected = datetime.now(UTC).timestamp() + 180
|
|
assert abs(deadline - expected) < 2
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_start_turn_timer_returns_info(
|
|
self, turn_timeout_service: TurnTimeoutService
|
|
) -> None:
|
|
"""
|
|
Test that starting a timer returns correct TurnTimeoutInfo.
|
|
|
|
The returned info should have all fields populated correctly
|
|
with remaining_seconds equal to timeout_seconds (just started).
|
|
"""
|
|
result = await turn_timeout_service.start_turn_timer(
|
|
game_id="game-123",
|
|
player_id="player-1",
|
|
timeout_seconds=180,
|
|
)
|
|
|
|
assert isinstance(result, TurnTimeoutInfo)
|
|
assert result.game_id == "game-123"
|
|
assert result.player_id == "player-1"
|
|
assert result.timeout_seconds == 180
|
|
assert result.remaining_seconds == 180
|
|
assert result.warnings_sent == []
|
|
assert not result.is_expired
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_start_turn_timer_default_thresholds(
|
|
self, turn_timeout_service: TurnTimeoutService, mock_redis: AsyncMock
|
|
) -> None:
|
|
"""
|
|
Test that default warning thresholds [50, 25] are used when not specified.
|
|
|
|
This ensures games always have reasonable warning points even if
|
|
the caller doesn't specify custom thresholds.
|
|
"""
|
|
await turn_timeout_service.start_turn_timer(
|
|
game_id="game-123",
|
|
player_id="player-1",
|
|
timeout_seconds=180,
|
|
)
|
|
|
|
mapping = mock_redis.hset.call_args[1]["mapping"]
|
|
thresholds = json.loads(mapping["warning_thresholds"])
|
|
assert thresholds == [50, 25]
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_start_turn_timer_sorts_thresholds_descending(
|
|
self, turn_timeout_service: TurnTimeoutService, mock_redis: AsyncMock
|
|
) -> None:
|
|
"""
|
|
Test that warning thresholds are sorted in descending order.
|
|
|
|
Warnings should be processed from highest to lowest percentage
|
|
so players get warned at 50% before 25%, regardless of input order.
|
|
"""
|
|
await turn_timeout_service.start_turn_timer(
|
|
game_id="game-123",
|
|
player_id="player-1",
|
|
timeout_seconds=180,
|
|
warning_thresholds=[25, 75, 50], # Unsorted input
|
|
)
|
|
|
|
mapping = mock_redis.hset.call_args[1]["mapping"]
|
|
thresholds = json.loads(mapping["warning_thresholds"])
|
|
assert thresholds == [75, 50, 25] # Should be sorted descending
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_start_turn_timer_sets_ttl(
|
|
self, turn_timeout_service: TurnTimeoutService, mock_redis: AsyncMock
|
|
) -> None:
|
|
"""
|
|
Test that the Redis key TTL is set beyond the timeout duration.
|
|
|
|
The TTL includes a buffer to ensure the key persists long enough
|
|
for the expiration to be detected and handled.
|
|
"""
|
|
await turn_timeout_service.start_turn_timer(
|
|
game_id="game-123",
|
|
player_id="player-1",
|
|
timeout_seconds=180,
|
|
)
|
|
|
|
mock_redis.expire.assert_called_once()
|
|
call_args = mock_redis.expire.call_args
|
|
assert call_args[0][0] == f"{TURN_TIMEOUT_PREFIX}game-123"
|
|
assert call_args[0][1] == 180 + DEFAULT_KEY_TTL_BUFFER
|
|
|
|
|
|
class TestCancelTimer:
|
|
"""Tests for TurnTimeoutService.cancel_timer."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_cancel_timer_deletes_redis_key(
|
|
self, turn_timeout_service: TurnTimeoutService, mock_redis: AsyncMock
|
|
) -> None:
|
|
"""
|
|
Test that canceling a timer deletes the Redis key.
|
|
|
|
When a turn ends normally, the timer should be cleaned up
|
|
to prevent false expiration detection.
|
|
"""
|
|
result = await turn_timeout_service.cancel_timer("game-123")
|
|
|
|
mock_redis.delete.assert_called_once_with(f"{TURN_TIMEOUT_PREFIX}game-123")
|
|
assert result is True
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_cancel_timer_returns_false_when_not_found(
|
|
self, turn_timeout_service: TurnTimeoutService, mock_redis: AsyncMock
|
|
) -> None:
|
|
"""
|
|
Test that canceling a non-existent timer returns False.
|
|
|
|
This allows callers to know if there was actually a timer
|
|
to cancel, useful for debugging and logging.
|
|
"""
|
|
mock_redis.delete = AsyncMock(return_value=0)
|
|
|
|
result = await turn_timeout_service.cancel_timer("nonexistent-game")
|
|
|
|
assert result is False
|
|
|
|
|
|
class TestExtendTimer:
|
|
"""Tests for TurnTimeoutService.extend_timer."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_extend_timer_increases_deadline(
|
|
self, turn_timeout_service: TurnTimeoutService, mock_redis: AsyncMock
|
|
) -> None:
|
|
"""
|
|
Test that extending a timer increases the deadline.
|
|
|
|
When a player reconnects, they should get additional time
|
|
to complete their turn.
|
|
"""
|
|
now = datetime.now(UTC).timestamp()
|
|
original_deadline = now + 60 # 60 seconds remaining
|
|
|
|
mock_redis.hgetall = AsyncMock(
|
|
return_value={
|
|
"player_id": "player-1",
|
|
"deadline": str(original_deadline),
|
|
"timeout_seconds": "180",
|
|
"warnings_sent": "[]",
|
|
"warning_thresholds": "[50, 25]",
|
|
}
|
|
)
|
|
|
|
result = await turn_timeout_service.extend_timer("game-123", extension_seconds=15)
|
|
|
|
assert result is not None
|
|
# Deadline should be extended by 15 seconds
|
|
assert result.deadline > original_deadline
|
|
assert result.remaining_seconds >= 60 # At least original remaining
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_extend_timer_capped_at_original_timeout(
|
|
self, turn_timeout_service: TurnTimeoutService, mock_redis: AsyncMock
|
|
) -> None:
|
|
"""
|
|
Test that timer extension is capped at the original timeout.
|
|
|
|
Players shouldn't be able to get more time than the original
|
|
timeout by repeatedly reconnecting.
|
|
"""
|
|
now = datetime.now(UTC).timestamp()
|
|
original_deadline = now + 170 # 170 seconds remaining (almost full)
|
|
|
|
mock_redis.hgetall = AsyncMock(
|
|
return_value={
|
|
"player_id": "player-1",
|
|
"deadline": str(original_deadline),
|
|
"timeout_seconds": "180",
|
|
"warnings_sent": "[]",
|
|
"warning_thresholds": "[50, 25]",
|
|
}
|
|
)
|
|
|
|
result = await turn_timeout_service.extend_timer("game-123", extension_seconds=30)
|
|
|
|
assert result is not None
|
|
# Should be capped at original timeout (180s from now)
|
|
max_deadline = now + 180
|
|
assert result.deadline <= max_deadline + 1 # Allow 1s tolerance
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_extend_timer_returns_none_when_not_found(
|
|
self, turn_timeout_service: TurnTimeoutService, mock_redis: AsyncMock
|
|
) -> None:
|
|
"""
|
|
Test that extending a non-existent timer returns None.
|
|
|
|
This handles the case where the game ended or the timer
|
|
was already canceled.
|
|
"""
|
|
mock_redis.hgetall = AsyncMock(return_value={})
|
|
|
|
result = await turn_timeout_service.extend_timer("nonexistent-game", extension_seconds=15)
|
|
|
|
assert result is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_extend_timer_preserves_warnings_sent(
|
|
self, turn_timeout_service: TurnTimeoutService, mock_redis: AsyncMock
|
|
) -> None:
|
|
"""
|
|
Test that extending a timer preserves the warnings_sent state.
|
|
|
|
If a 50% warning was already sent, extending shouldn't cause
|
|
it to be sent again.
|
|
"""
|
|
now = datetime.now(UTC).timestamp()
|
|
|
|
mock_redis.hgetall = AsyncMock(
|
|
return_value={
|
|
"player_id": "player-1",
|
|
"deadline": str(now + 30), # 30 seconds remaining
|
|
"timeout_seconds": "180",
|
|
"warnings_sent": "[50]", # 50% warning already sent
|
|
"warning_thresholds": "[50, 25]",
|
|
}
|
|
)
|
|
|
|
result = await turn_timeout_service.extend_timer("game-123", extension_seconds=15)
|
|
|
|
assert result is not None
|
|
assert 50 in result.warnings_sent
|
|
|
|
|
|
# =============================================================================
|
|
# Query Tests
|
|
# =============================================================================
|
|
|
|
|
|
class TestGetTimeoutInfo:
|
|
"""Tests for TurnTimeoutService.get_timeout_info."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_timeout_info_returns_correct_data(
|
|
self, turn_timeout_service: TurnTimeoutService, mock_redis: AsyncMock
|
|
) -> None:
|
|
"""
|
|
Test that get_timeout_info returns all stored data correctly.
|
|
|
|
This is the primary method for getting timer state and must
|
|
return accurate information for all fields.
|
|
"""
|
|
now = datetime.now(UTC).timestamp()
|
|
deadline = now + 120 # 120 seconds remaining
|
|
|
|
mock_redis.hgetall = AsyncMock(
|
|
return_value={
|
|
"player_id": "player-1",
|
|
"deadline": str(deadline),
|
|
"timeout_seconds": "180",
|
|
"warnings_sent": "[50]",
|
|
"warning_thresholds": "[50, 25]",
|
|
}
|
|
)
|
|
|
|
result = await turn_timeout_service.get_timeout_info("game-123")
|
|
|
|
assert result is not None
|
|
assert result.game_id == "game-123"
|
|
assert result.player_id == "player-1"
|
|
assert result.timeout_seconds == 180
|
|
assert result.warnings_sent == [50]
|
|
assert result.warning_thresholds == [50, 25]
|
|
# Allow small variance due to test execution time
|
|
assert 118 <= result.remaining_seconds <= 122
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_timeout_info_returns_none_when_not_found(
|
|
self, turn_timeout_service: TurnTimeoutService, mock_redis: AsyncMock
|
|
) -> None:
|
|
"""
|
|
Test that get_timeout_info returns None for non-existent timers.
|
|
|
|
This allows callers to check if a game has an active timer.
|
|
"""
|
|
mock_redis.hgetall = AsyncMock(return_value={})
|
|
|
|
result = await turn_timeout_service.get_timeout_info("nonexistent-game")
|
|
|
|
assert result is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_timeout_info_handles_corrupted_data(
|
|
self, turn_timeout_service: TurnTimeoutService, mock_redis: AsyncMock
|
|
) -> None:
|
|
"""
|
|
Test that corrupted Redis data is handled gracefully.
|
|
|
|
If a key exists but has missing or invalid fields, we should
|
|
return None rather than crashing.
|
|
"""
|
|
mock_redis.hgetall = AsyncMock(
|
|
return_value={
|
|
"player_id": "player-1",
|
|
# Missing deadline and timeout_seconds
|
|
}
|
|
)
|
|
|
|
result = await turn_timeout_service.get_timeout_info("game-123")
|
|
|
|
assert result is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_timeout_info_expired_timer(
|
|
self, turn_timeout_service: TurnTimeoutService, mock_redis: AsyncMock
|
|
) -> None:
|
|
"""
|
|
Test that expired timers are correctly identified.
|
|
|
|
The is_expired property should be True when the deadline
|
|
has passed.
|
|
"""
|
|
now = datetime.now(UTC).timestamp()
|
|
past_deadline = now - 10 # 10 seconds ago
|
|
|
|
mock_redis.hgetall = AsyncMock(
|
|
return_value={
|
|
"player_id": "player-1",
|
|
"deadline": str(past_deadline),
|
|
"timeout_seconds": "180",
|
|
"warnings_sent": "[]",
|
|
"warning_thresholds": "[50, 25]",
|
|
}
|
|
)
|
|
|
|
result = await turn_timeout_service.get_timeout_info("game-123")
|
|
|
|
assert result is not None
|
|
assert result.is_expired
|
|
assert result.remaining_seconds == 0
|
|
|
|
|
|
class TestGetRemainingTime:
|
|
"""Tests for TurnTimeoutService.get_remaining_time."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_remaining_time_returns_seconds(
|
|
self, turn_timeout_service: TurnTimeoutService, mock_redis: AsyncMock
|
|
) -> None:
|
|
"""
|
|
Test that get_remaining_time returns the correct seconds.
|
|
|
|
This convenience method should return just the remaining
|
|
time without requiring full TurnTimeoutInfo parsing.
|
|
"""
|
|
now = datetime.now(UTC).timestamp()
|
|
deadline = now + 90
|
|
|
|
mock_redis.hgetall = AsyncMock(
|
|
return_value={
|
|
"player_id": "player-1",
|
|
"deadline": str(deadline),
|
|
"timeout_seconds": "180",
|
|
"warnings_sent": "[]",
|
|
"warning_thresholds": "[50, 25]",
|
|
}
|
|
)
|
|
|
|
result = await turn_timeout_service.get_remaining_time("game-123")
|
|
|
|
assert result is not None
|
|
assert 88 <= result <= 92 # Allow small variance
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_remaining_time_returns_none_when_not_found(
|
|
self, turn_timeout_service: TurnTimeoutService, mock_redis: AsyncMock
|
|
) -> None:
|
|
"""
|
|
Test that get_remaining_time returns None for non-existent timers.
|
|
"""
|
|
mock_redis.hgetall = AsyncMock(return_value={})
|
|
|
|
result = await turn_timeout_service.get_remaining_time("nonexistent-game")
|
|
|
|
assert result is None
|
|
|
|
|
|
# =============================================================================
|
|
# Warning Tests
|
|
# =============================================================================
|
|
|
|
|
|
class TestGetPendingWarning:
|
|
"""Tests for TurnTimeoutService.get_pending_warning."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_pending_warning_at_50_percent(
|
|
self, turn_timeout_service: TurnTimeoutService, mock_redis: AsyncMock
|
|
) -> None:
|
|
"""
|
|
Test that a warning is returned when time drops below 50%.
|
|
|
|
With 180s timeout, 50% = 90s. If remaining is 85s (47%),
|
|
we should get a warning for the 50% threshold.
|
|
"""
|
|
now = datetime.now(UTC).timestamp()
|
|
deadline = now + 85 # 85s remaining out of 180s = 47%
|
|
|
|
mock_redis.hgetall = AsyncMock(
|
|
return_value={
|
|
"player_id": "player-1",
|
|
"deadline": str(deadline),
|
|
"timeout_seconds": "180",
|
|
"warnings_sent": "[]",
|
|
"warning_thresholds": "[50, 25]",
|
|
}
|
|
)
|
|
|
|
result = await turn_timeout_service.get_pending_warning("game-123")
|
|
|
|
assert result is not None
|
|
assert isinstance(result, PendingWarning)
|
|
assert result.threshold == 50
|
|
assert result.player_id == "player-1"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_pending_warning_skips_already_sent(
|
|
self, turn_timeout_service: TurnTimeoutService, mock_redis: AsyncMock
|
|
) -> None:
|
|
"""
|
|
Test that warnings already sent are not returned again.
|
|
|
|
If 50% warning was sent, and time is now at 20% (below 25%),
|
|
we should get the 25% warning, not the 50% warning again.
|
|
"""
|
|
now = datetime.now(UTC).timestamp()
|
|
deadline = now + 36 # 36s remaining out of 180s = 20%
|
|
|
|
mock_redis.hgetall = AsyncMock(
|
|
return_value={
|
|
"player_id": "player-1",
|
|
"deadline": str(deadline),
|
|
"timeout_seconds": "180",
|
|
"warnings_sent": "[50]", # 50% already sent
|
|
"warning_thresholds": "[50, 25]",
|
|
}
|
|
)
|
|
|
|
result = await turn_timeout_service.get_pending_warning("game-123")
|
|
|
|
assert result is not None
|
|
assert result.threshold == 25 # Should be 25%, not 50%
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_pending_warning_none_when_above_threshold(
|
|
self, turn_timeout_service: TurnTimeoutService, mock_redis: AsyncMock
|
|
) -> None:
|
|
"""
|
|
Test that no warning is returned when time is above all thresholds.
|
|
|
|
If 60% of time remains and thresholds are [50, 25], no warning
|
|
should be pending.
|
|
"""
|
|
now = datetime.now(UTC).timestamp()
|
|
deadline = now + 108 # 108s remaining out of 180s = 60%
|
|
|
|
mock_redis.hgetall = AsyncMock(
|
|
return_value={
|
|
"player_id": "player-1",
|
|
"deadline": str(deadline),
|
|
"timeout_seconds": "180",
|
|
"warnings_sent": "[]",
|
|
"warning_thresholds": "[50, 25]",
|
|
}
|
|
)
|
|
|
|
result = await turn_timeout_service.get_pending_warning("game-123")
|
|
|
|
assert result is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_pending_warning_none_when_all_sent(
|
|
self, turn_timeout_service: TurnTimeoutService, mock_redis: AsyncMock
|
|
) -> None:
|
|
"""
|
|
Test that no warning is returned when all warnings have been sent.
|
|
|
|
Even if time is very low, if all warnings were already sent,
|
|
there's nothing pending.
|
|
"""
|
|
now = datetime.now(UTC).timestamp()
|
|
deadline = now + 18 # 18s remaining out of 180s = 10%
|
|
|
|
mock_redis.hgetall = AsyncMock(
|
|
return_value={
|
|
"player_id": "player-1",
|
|
"deadline": str(deadline),
|
|
"timeout_seconds": "180",
|
|
"warnings_sent": "[50, 25]", # All sent
|
|
"warning_thresholds": "[50, 25]",
|
|
}
|
|
)
|
|
|
|
result = await turn_timeout_service.get_pending_warning("game-123")
|
|
|
|
assert result is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_pending_warning_none_when_expired(
|
|
self, turn_timeout_service: TurnTimeoutService, mock_redis: AsyncMock
|
|
) -> None:
|
|
"""
|
|
Test that no warning is returned for expired timers.
|
|
|
|
Once a timer expires, warnings are no longer relevant - the
|
|
timeout handler takes over.
|
|
"""
|
|
now = datetime.now(UTC).timestamp()
|
|
past_deadline = now - 5 # Expired 5 seconds ago
|
|
|
|
mock_redis.hgetall = AsyncMock(
|
|
return_value={
|
|
"player_id": "player-1",
|
|
"deadline": str(past_deadline),
|
|
"timeout_seconds": "180",
|
|
"warnings_sent": "[]",
|
|
"warning_thresholds": "[50, 25]",
|
|
}
|
|
)
|
|
|
|
result = await turn_timeout_service.get_pending_warning("game-123")
|
|
|
|
assert result is None
|
|
|
|
|
|
class TestMarkWarningSent:
|
|
"""Tests for TurnTimeoutService.mark_warning_sent."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_mark_warning_sent_updates_redis(
|
|
self, turn_timeout_service: TurnTimeoutService, mock_redis: AsyncMock
|
|
) -> None:
|
|
"""
|
|
Test that marking a warning updates the warnings_sent list.
|
|
|
|
After sending a warning, it should be added to the list
|
|
to prevent duplicate sends.
|
|
"""
|
|
now = datetime.now(UTC).timestamp()
|
|
|
|
mock_redis.hgetall = AsyncMock(
|
|
return_value={
|
|
"player_id": "player-1",
|
|
"deadline": str(now + 85),
|
|
"timeout_seconds": "180",
|
|
"warnings_sent": "[]",
|
|
"warning_thresholds": "[50, 25]",
|
|
}
|
|
)
|
|
|
|
result = await turn_timeout_service.mark_warning_sent("game-123", 50)
|
|
|
|
assert result is True
|
|
# Check that hset was called with updated warnings_sent
|
|
call_args = mock_redis.hset.call_args
|
|
assert call_args[0][0] == f"{TURN_TIMEOUT_PREFIX}game-123"
|
|
assert call_args[0][1] == "warnings_sent"
|
|
assert 50 in json.loads(call_args[0][2])
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_mark_warning_sent_returns_false_when_not_found(
|
|
self, turn_timeout_service: TurnTimeoutService, mock_redis: AsyncMock
|
|
) -> None:
|
|
"""
|
|
Test that marking a warning returns False if timer doesn't exist.
|
|
"""
|
|
mock_redis.hgetall = AsyncMock(return_value={})
|
|
|
|
result = await turn_timeout_service.mark_warning_sent("nonexistent-game", 50)
|
|
|
|
assert result is False
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_mark_warning_sent_idempotent(
|
|
self, turn_timeout_service: TurnTimeoutService, mock_redis: AsyncMock
|
|
) -> None:
|
|
"""
|
|
Test that marking the same warning twice is idempotent.
|
|
|
|
Re-marking shouldn't cause errors or duplicate entries.
|
|
"""
|
|
now = datetime.now(UTC).timestamp()
|
|
|
|
mock_redis.hgetall = AsyncMock(
|
|
return_value={
|
|
"player_id": "player-1",
|
|
"deadline": str(now + 85),
|
|
"timeout_seconds": "180",
|
|
"warnings_sent": "[50]", # Already marked
|
|
"warning_thresholds": "[50, 25]",
|
|
}
|
|
)
|
|
|
|
result = await turn_timeout_service.mark_warning_sent("game-123", 50)
|
|
|
|
assert result is True
|
|
# hset should not be called since it's already marked
|
|
mock_redis.hset.assert_not_called()
|
|
|
|
|
|
# =============================================================================
|
|
# Expiration Tests
|
|
# =============================================================================
|
|
|
|
|
|
class TestCheckExpiredTimers:
|
|
"""Tests for TurnTimeoutService.check_expired_timers."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_check_expired_timers_returns_expired(
|
|
self, turn_timeout_service: TurnTimeoutService, mock_redis: AsyncMock
|
|
) -> None:
|
|
"""
|
|
Test that check_expired_timers returns expired timers.
|
|
|
|
This is the main polling method that background tasks use
|
|
to detect timeouts.
|
|
"""
|
|
now = datetime.now(UTC).timestamp()
|
|
|
|
# Mock scan_iter to return one game
|
|
async def mock_scan_iter(match=None):
|
|
yield f"{TURN_TIMEOUT_PREFIX}game-123"
|
|
|
|
mock_redis.scan_iter = mock_scan_iter
|
|
|
|
# Mock hgetall to return expired timer
|
|
mock_redis.hgetall = AsyncMock(
|
|
return_value={
|
|
"player_id": "player-1",
|
|
"deadline": str(now - 10), # Expired 10 seconds ago
|
|
"timeout_seconds": "180",
|
|
"warnings_sent": "[]",
|
|
"warning_thresholds": "[50, 25]",
|
|
}
|
|
)
|
|
|
|
result = await turn_timeout_service.check_expired_timers()
|
|
|
|
assert len(result) == 1
|
|
assert result[0].game_id == "game-123"
|
|
assert result[0].is_expired
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_check_expired_timers_excludes_active(
|
|
self, turn_timeout_service: TurnTimeoutService, mock_redis: AsyncMock
|
|
) -> None:
|
|
"""
|
|
Test that check_expired_timers excludes active (non-expired) timers.
|
|
|
|
Only expired timers should be returned; active games should
|
|
continue uninterrupted.
|
|
"""
|
|
now = datetime.now(UTC).timestamp()
|
|
|
|
async def mock_scan_iter(match=None):
|
|
yield f"{TURN_TIMEOUT_PREFIX}game-123"
|
|
|
|
mock_redis.scan_iter = mock_scan_iter
|
|
|
|
# Mock hgetall to return active timer
|
|
mock_redis.hgetall = AsyncMock(
|
|
return_value={
|
|
"player_id": "player-1",
|
|
"deadline": str(now + 60), # 60 seconds remaining
|
|
"timeout_seconds": "180",
|
|
"warnings_sent": "[]",
|
|
"warning_thresholds": "[50, 25]",
|
|
}
|
|
)
|
|
|
|
result = await turn_timeout_service.check_expired_timers()
|
|
|
|
assert len(result) == 0
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_check_expired_timers_empty_when_no_timers(
|
|
self, turn_timeout_service: TurnTimeoutService, mock_redis: AsyncMock
|
|
) -> None:
|
|
"""
|
|
Test that check_expired_timers returns empty list when no timers exist.
|
|
"""
|
|
|
|
async def mock_scan_iter(match=None):
|
|
return
|
|
yield # Empty async generator
|
|
|
|
mock_redis.scan_iter = mock_scan_iter
|
|
|
|
result = await turn_timeout_service.check_expired_timers()
|
|
|
|
assert result == []
|
|
|
|
|
|
# =============================================================================
|
|
# TurnTimeoutInfo Tests
|
|
# =============================================================================
|
|
|
|
|
|
class TestTurnTimeoutInfo:
|
|
"""Tests for TurnTimeoutInfo dataclass properties."""
|
|
|
|
def test_is_expired_true_when_remaining_zero(self) -> None:
|
|
"""
|
|
Test that is_expired returns True when remaining_seconds is 0.
|
|
"""
|
|
info = TurnTimeoutInfo(
|
|
game_id="game-123",
|
|
player_id="player-1",
|
|
deadline=0,
|
|
timeout_seconds=180,
|
|
remaining_seconds=0,
|
|
warnings_sent=[],
|
|
warning_thresholds=[50, 25],
|
|
)
|
|
|
|
assert info.is_expired is True
|
|
|
|
def test_is_expired_false_when_time_remaining(self) -> None:
|
|
"""
|
|
Test that is_expired returns False when time remains.
|
|
"""
|
|
info = TurnTimeoutInfo(
|
|
game_id="game-123",
|
|
player_id="player-1",
|
|
deadline=0,
|
|
timeout_seconds=180,
|
|
remaining_seconds=60,
|
|
warnings_sent=[],
|
|
warning_thresholds=[50, 25],
|
|
)
|
|
|
|
assert info.is_expired is False
|
|
|
|
def test_percent_remaining_calculation(self) -> None:
|
|
"""
|
|
Test that percent_remaining calculates correctly.
|
|
|
|
90 seconds remaining out of 180 should be 50%.
|
|
"""
|
|
info = TurnTimeoutInfo(
|
|
game_id="game-123",
|
|
player_id="player-1",
|
|
deadline=0,
|
|
timeout_seconds=180,
|
|
remaining_seconds=90,
|
|
warnings_sent=[],
|
|
warning_thresholds=[50, 25],
|
|
)
|
|
|
|
assert info.percent_remaining == 50.0
|
|
|
|
def test_percent_remaining_zero_timeout(self) -> None:
|
|
"""
|
|
Test that percent_remaining handles zero timeout gracefully.
|
|
|
|
Prevents division by zero errors.
|
|
"""
|
|
info = TurnTimeoutInfo(
|
|
game_id="game-123",
|
|
player_id="player-1",
|
|
deadline=0,
|
|
timeout_seconds=0,
|
|
remaining_seconds=0,
|
|
warnings_sent=[],
|
|
warning_thresholds=[50, 25],
|
|
)
|
|
|
|
assert info.percent_remaining == 0.0
|
|
|
|
|
|
# =============================================================================
|
|
# Utility Method Tests
|
|
# =============================================================================
|
|
|
|
|
|
class TestUtilityMethods:
|
|
"""Tests for utility methods."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_has_active_timer_true(
|
|
self, turn_timeout_service: TurnTimeoutService, mock_redis: AsyncMock
|
|
) -> None:
|
|
"""
|
|
Test that has_active_timer returns True when timer exists.
|
|
"""
|
|
mock_redis.exists = AsyncMock(return_value=1)
|
|
|
|
result = await turn_timeout_service.has_active_timer("game-123")
|
|
|
|
assert result is True
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_has_active_timer_false(
|
|
self, turn_timeout_service: TurnTimeoutService, mock_redis: AsyncMock
|
|
) -> None:
|
|
"""
|
|
Test that has_active_timer returns False when no timer exists.
|
|
"""
|
|
mock_redis.exists = AsyncMock(return_value=0)
|
|
|
|
result = await turn_timeout_service.has_active_timer("nonexistent-game")
|
|
|
|
assert result is False
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_active_timer_count(
|
|
self, turn_timeout_service: TurnTimeoutService, mock_redis: AsyncMock
|
|
) -> None:
|
|
"""
|
|
Test that get_active_timer_count returns correct count.
|
|
|
|
Useful for monitoring active games with turn timers.
|
|
"""
|
|
|
|
async def mock_scan_iter(match=None):
|
|
yield f"{TURN_TIMEOUT_PREFIX}game-1"
|
|
yield f"{TURN_TIMEOUT_PREFIX}game-2"
|
|
yield f"{TURN_TIMEOUT_PREFIX}game-3"
|
|
|
|
mock_redis.scan_iter = mock_scan_iter
|
|
|
|
result = await turn_timeout_service.get_active_timer_count()
|
|
|
|
assert result == 3
|