mantimon-tcg/backend/tests/unit/services/test_turn_timeout_service.py
Cal Corum f452e69999 Complete Phase 4 implementation files
- 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>
2026-01-30 08:03:43 -06:00

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