nifty-coder/stemsplit-backend
0
1"""2Tests for the RateLimiter implementation.3 4This module contains unit tests and property-based tests for the RateLimiter5class, validating rate limiting, quota management, and multi-window tracking.6"""7 8import pytest9import pytest_asyncio10import asyncio11import time12from datetime import datetime, timedelta, timezone13from unittest.mock import AsyncMock, MagicMock14 15from voice_control.rate_limiter import RateLimiter, TimeWindow, WindowUsage16from voice_control.models import ProviderConfig, ProviderType, QuotaStatus, QuotaType, UsageStats17from voice_control.exceptions import QuotaExceededError, RateLimitExceededError18 19 20class TestRateLimiter:21 """Unit tests for RateLimiter class."""22 23 @pytest_asyncio.fixture24 async def rate_limiter(self):25 """Create a RateLimiter instance for testing."""26 limiter = RateLimiter(cleanup_interval=1) # Short cleanup interval for testing27 await limiter.initialize()28 yield limiter29 await limiter.shutdown()30 31 @pytest.fixture32 def provider_config(self):33 """Create a test provider configuration."""34 return ProviderConfig(35 name="test_provider",36 provider_type=ProviderType.GOOGLE_SPEECH,37 enabled=True,38 priority=1,39 free_tier_limits={40 "audio_minutes_per_day": 60, # 60 minutes per day41 "audio_minutes_per_month": 300 # 300 minutes per month42 },43 rate_limits={44 "requests_per_minute": 10,45 "requests_per_hour": 100,46 "requests_per_day": 100047 },48 supported_formats=["webm", "wav"],49 supported_languages=["en-US"],50 cost_per_minute=0.006, # $0.006 per minute51 api_credentials={"api_key": "test_key"}52 )53 54 @pytest.mark.asyncio55 async def test_configure_provider(self, rate_limiter, provider_config):56 """Test provider configuration."""57 await rate_limiter.configure_provider(provider_config)58 59 # Check that windows were created60 assert "test_provider" in rate_limiter._provider_windows61 windows = rate_limiter._provider_windows["test_provider"]62 63 expected_windows = [64 "requests_per_minute",65 "requests_per_hour", 66 "requests_per_day",67 "audio_minutes_per_day",68 "audio_minutes_per_month"69 ]70 71 for window_name in expected_windows:72 assert window_name in windows73 74 # Check window configurations75 assert windows["requests_per_minute"].duration == 6076 assert windows["requests_per_minute"].max_requests == 1077 assert windows["audio_minutes_per_day"].max_audio_duration == 3600 # 60 minutes in seconds78 79 @pytest.mark.asyncio80 async def test_check_quota_no_limits(self, rate_limiter):81 """Test quota checking with no configured limits."""82 quota_status = await rate_limiter.check_quota("unknown_provider", 10.0)83 84 assert quota_status.provider == "unknown_provider"85 assert quota_status.limit == float('inf')86 assert quota_status.remaining == float('inf')87 assert quota_status.percentage_used == 0.088 assert not quota_status.is_exceeded89 90 @pytest.mark.asyncio91 async def test_check_quota_within_limits(self, rate_limiter, provider_config):92 """Test quota checking within limits."""93 await rate_limiter.configure_provider(provider_config)94 95 quota_status = await rate_limiter.check_quota("test_provider", 30.0) # 30 seconds96 97 assert quota_status.provider == "test_provider"98 assert not quota_status.is_exceeded99 # 30 seconds = 0.5 minutes, which is 0.5/60 = 0.0083... of the daily limit100 assert quota_status.percentage_used > 0.0101 assert quota_status.percentage_used < 0.01 # Less than 1%102 103 @pytest.mark.asyncio104 async def test_consume_quota_success(self, rate_limiter, provider_config):105 """Test successful quota consumption."""106 await rate_limiter.configure_provider(provider_config)107 108 # Consume some quota109 result = await rate_limiter.consume_quota("test_provider", 30.0, 1)110 assert result is True111 112 # Check that usage was recorded113 usage_stats = await rate_limiter.get_usage_stats("test_provider", "minute")114 assert usage_stats.requests_count == 1115 assert usage_stats.audio_minutes == 0.5 # 30 seconds = 0.5 minutes116 117 @pytest.mark.asyncio118 async def test_consume_quota_exceeds_request_limit(self, rate_limiter, provider_config):119 """Test quota consumption that exceeds request limits."""120 await rate_limiter.configure_provider(provider_config)121 122 # Consume quota up to the limit (10 requests per minute)123 for i in range(10):124 await rate_limiter.consume_quota("test_provider", 1.0, 1)125 126 # Next request should fail127 with pytest.raises(QuotaExceededError) as exc_info:128 await rate_limiter.consume_quota("test_provider", 1.0, 1)129 130 assert exc_info.value.provider == "test_provider"131 assert "requests_per_minute" in exc_info.value.quota_type132 133 @pytest.mark.asyncio134 async def test_consume_quota_exceeds_audio_limit(self, rate_limiter, provider_config):135 """Test quota consumption that exceeds audio duration limits."""136 await rate_limiter.configure_provider(provider_config)137 138 # Try to consume more than daily audio limit (60 minutes = 3600 seconds)139 with pytest.raises(QuotaExceededError) as exc_info:140 await rate_limiter.consume_quota("test_provider", 3700.0, 1) # 61+ minutes141 142 assert exc_info.value.provider == "test_provider"143 assert "audio_minutes_per_day" in exc_info.value.quota_type144 145 @pytest.mark.asyncio146 async def test_sliding_window_cleanup(self, rate_limiter, provider_config):147 """Test that sliding windows clean up old entries."""148 await rate_limiter.configure_provider(provider_config)149 150 # Consume quota151 await rate_limiter.consume_quota("test_provider", 1.0, 1)152 153 # Check usage154 usage_stats = await rate_limiter.get_usage_stats("test_provider", "minute")155 assert usage_stats.requests_count == 1156 157 # Wait for cleanup (simulate time passing)158 usage = rate_limiter._provider_usage["test_provider"]["requests_per_minute"]159 160 # Manually set old timestamps to simulate time passing161 old_time = time.time() - 120 # 2 minutes ago162 usage.requests.clear()163 usage.requests.append(old_time)164 165 # Trigger cleanup166 await rate_limiter._cleanup_sliding_window("test_provider", "requests_per_minute", 60)167 168 # Old entry should be removed169 assert len(usage.requests) == 0170 171 @pytest.mark.asyncio172 async def test_reset_quota(self, rate_limiter, provider_config):173 """Test quota reset functionality."""174 await rate_limiter.configure_provider(provider_config)175 176 # Consume some quota177 await rate_limiter.consume_quota("test_provider", 30.0, 5)178 179 # Verify usage180 usage_stats = await rate_limiter.get_usage_stats("test_provider", "minute")181 assert usage_stats.requests_count == 5182 183 # Reset quota184 await rate_limiter.reset_quota("test_provider", "requests_per_minute")185 186 # Verify reset187 usage_stats = await rate_limiter.get_usage_stats("test_provider", "minute")188 assert usage_stats.requests_count == 0189 assert usage_stats.audio_minutes == 0.0190 191 @pytest.mark.asyncio192 async def test_exponential_backoff(self, rate_limiter, provider_config):193 """Test exponential backoff on rate limit violations."""194 await rate_limiter.configure_provider(provider_config)195 196 # Consume quota to the limit197 for i in range(10):198 await rate_limiter.consume_quota("test_provider", 1.0, 1)199 200 # First violation should trigger backoff201 with pytest.raises(QuotaExceededError):202 await rate_limiter.consume_quota("test_provider", 1.0, 1)203 204 # Immediate retry should fail with RateLimitExceededError205 with pytest.raises(RateLimitExceededError) as exc_info:206 await rate_limiter.consume_quota("test_provider", 1.0, 1)207 208 assert exc_info.value.provider == "test_provider"209 assert exc_info.value.retry_after is not None210 211 @pytest.mark.asyncio212 async def test_get_usage_stats_different_windows(self, rate_limiter, provider_config):213 """Test getting usage statistics for different time windows."""214 await rate_limiter.configure_provider(provider_config)215 216 # Consume quota217 await rate_limiter.consume_quota("test_provider", 60.0, 3) # 1 minute, 3 requests218 219 # Test different time windows220 for window in ["minute", "hour", "day"]:221 stats = await rate_limiter.get_usage_stats("test_provider", window)222 assert stats.provider == "test_provider"223 assert stats.time_window == window224 assert stats.requests_count == 3225 assert stats.audio_minutes == 1.0226 assert stats.estimated_cost > 0 # Should calculate cost227 228 @pytest.mark.asyncio229 async def test_is_provider_available(self, rate_limiter, provider_config):230 """Test provider availability checking."""231 await rate_limiter.configure_provider(provider_config)232 233 # Provider should be available initially234 assert await rate_limiter.is_provider_available("test_provider", 30.0) is True235 236 # Consume quota to the limit237 for i in range(10):238 await rate_limiter.consume_quota("test_provider", 1.0, 1)239 240 # Provider should not be available after hitting limits241 assert await rate_limiter.is_provider_available("test_provider", 1.0) is False242 243 @pytest.mark.asyncio244 async def test_get_provider_quota_status(self, rate_limiter, provider_config):245 """Test getting quota status for all provider windows."""246 await rate_limiter.configure_provider(provider_config)247 248 # Consume some quota249 await rate_limiter.consume_quota("test_provider", 30.0, 2)250 251 quota_statuses = await rate_limiter.get_provider_quota_status("test_provider")252 253 # Should have status for all configured windows254 assert len(quota_statuses) > 0255 256 # All statuses should be for the same provider257 for status in quota_statuses.values():258 assert status.provider == "test_provider"259 260 @pytest.mark.asyncio261 async def test_cleanup_loop(self, rate_limiter, provider_config):262 """Test the background cleanup loop."""263 await rate_limiter.configure_provider(provider_config)264 265 # Add some old data266 usage = rate_limiter._provider_usage["test_provider"]["requests_per_minute"]267 old_time = time.time() - 3600 # 1 hour ago268 usage.requests.append(old_time)269 usage.audio_durations.append((old_time, 30.0))270 271 # Trigger cleanup272 await rate_limiter._perform_cleanup()273 274 # Old data should be cleaned up275 assert len(usage.requests) == 0276 assert len(usage.audio_durations) == 0277 278 @pytest.mark.asyncio279 async def test_multiple_providers(self, rate_limiter):280 """Test rate limiting with multiple providers."""281 # Configure two providers with different limits282 config1 = ProviderConfig(283 name="provider1",284 provider_type=ProviderType.GOOGLE_SPEECH,285 enabled=True,286 priority=1,287 free_tier_limits={"audio_minutes_per_day": 30},288 rate_limits={"requests_per_minute": 5},289 supported_formats=["webm"],290 supported_languages=["en-US"],291 cost_per_minute=0.01,292 api_credentials={"key": "test1"}293 )294 295 config2 = ProviderConfig(296 name="provider2",297 provider_type=ProviderType.AZURE_SPEECH,298 enabled=True,299 priority=2,300 free_tier_limits={"audio_minutes_per_day": 60},301 rate_limits={"requests_per_minute": 10},302 supported_formats=["webm"],303 supported_languages=["en-US"],304 cost_per_minute=0.02,305 api_credentials={"key": "test2"}306 )307 308 await rate_limiter.configure_provider(config1)309 await rate_limiter.configure_provider(config2)310 311 # Test that each provider has independent limits312 await rate_limiter.consume_quota("provider1", 30.0, 3)313 await rate_limiter.consume_quota("provider2", 60.0, 5)314 315 # Check usage for each provider316 stats1 = await rate_limiter.get_usage_stats("provider1", "minute")317 stats2 = await rate_limiter.get_usage_stats("provider2", "minute")318 319 assert stats1.requests_count == 3320 assert stats1.audio_minutes == 0.5321 assert stats2.requests_count == 5322 assert stats2.audio_minutes == 1.0323 324 325class TestRateLimiterEdgeCases:326 """Test edge cases and error conditions."""327 328 @pytest_asyncio.fixture329 async def rate_limiter(self):330 """Create a RateLimiter instance for testing."""331 limiter = RateLimiter()332 await limiter.initialize()333 yield limiter334 await limiter.shutdown()335 336 @pytest.fixture337 def provider_config(self):338 """Create a test provider configuration."""339 return ProviderConfig(340 name="test_provider",341 provider_type=ProviderType.GOOGLE_SPEECH,342 enabled=True,343 priority=1,344 free_tier_limits={345 "audio_minutes_per_day": 60, # 60 minutes per day346 "audio_minutes_per_month": 300 # 300 minutes per month347 },348 rate_limits={349 "requests_per_minute": 10,350 "requests_per_hour": 100,351 "requests_per_day": 1000352 },353 supported_formats=["webm", "wav"],354 supported_languages=["en-US"],355 cost_per_minute=0.006, # $0.006 per minute356 api_credentials={"api_key": "test_key"}357 )358 359 @pytest.mark.asyncio360 async def test_automatic_provider_switching_on_quota_approach(self, rate_limiter):361 """Test automatic provider switching when approaching quota limits."""362 # Configure two providers with different limits363 config1 = ProviderConfig(364 name="provider1",365 provider_type=ProviderType.GOOGLE_SPEECH,366 enabled=True,367 priority=1,368 free_tier_limits={"audio_minutes_per_day": 10}, # Small limit369 rate_limits={"requests_per_minute": 5},370 supported_formats=["webm"],371 supported_languages=["en-US"],372 cost_per_minute=0.0,373 api_credentials={"key": "test1"}374 )375 376 config2 = ProviderConfig(377 name="provider2",378 provider_type=ProviderType.AZURE_SPEECH,379 enabled=True,380 priority=2,381 free_tier_limits={"audio_minutes_per_day": 60}, # Larger limit382 rate_limits={"requests_per_minute": 10},383 supported_formats=["webm"],384 supported_languages=["en-US"],385 cost_per_minute=0.0,386 api_credentials={"key": "test2"}387 )388 389 await rate_limiter.configure_provider(config1)390 await rate_limiter.configure_provider(config2)391 392 # Consume most of provider1's quota (9 minutes out of 10)393 await rate_limiter.consume_quota("provider1", 540.0, 1) # 9 minutes394 395 # Check that provider1 should switch due to approaching limit396 should_switch = await rate_limiter.should_switch_provider("provider1", 120.0) # 2 more minutes397 assert should_switch is True398 399 # Check that provider2 should not switch400 should_switch = await rate_limiter.should_switch_provider("provider2", 120.0)401 assert should_switch is False402 403 @pytest.mark.asyncio404 async def test_is_provider_approaching_limit(self, rate_limiter, provider_config):405 """Test detection of providers approaching quota limits."""406 await rate_limiter.configure_provider(provider_config)407 408 # Initially not approaching limit409 approaching = await rate_limiter.is_provider_approaching_limit("test_provider", 30.0, 0.8)410 assert approaching is False411 412 # Consume 85% of daily audio quota (51 minutes out of 60)413 await rate_limiter.consume_quota("test_provider", 3060.0, 1) # 51 minutes414 415 # Should now be approaching limit416 approaching = await rate_limiter.is_provider_approaching_limit("test_provider", 60.0, 0.8)417 assert approaching is True418 419 @pytest.mark.asyncio420 async def test_get_providers_by_availability(self, rate_limiter):421 """Test getting availability status for all providers."""422 # Configure multiple providers423 configs = []424 for i in range(3):425 config = ProviderConfig(426 name=f"provider{i}",427 provider_type=ProviderType.WEB_SPEECH_API,428 enabled=True,429 priority=i,430 free_tier_limits={"audio_minutes_per_day": 30},431 rate_limits={"requests_per_minute": 10},432 supported_formats=["webm"],433 supported_languages=["en-US"],434 cost_per_minute=0.0,435 api_credentials={"key": f"test{i}"}436 )437 configs.append(config)438 await rate_limiter.configure_provider(config)439 440 # Exhaust quota for provider1 by consuming just under the limit441 await rate_limiter.consume_quota("provider1", 1799.0, 1) # Just under 30 minutes442 443 # Get availability status444 availability = await rate_limiter.get_providers_by_availability(60.0) # 1 minute request445 446 assert "provider0" in availability447 assert "provider1" in availability448 assert "provider2" in availability449 450 assert availability["provider0"] is True # Available451 assert availability["provider1"] is False # Quota exhausted452 assert availability["provider2"] is True # Available453 454 @pytest.mark.asyncio455 async def test_exponential_backoff_affects_switching(self, rate_limiter, provider_config):456 """Test that exponential backoff affects provider switching decisions."""457 await rate_limiter.configure_provider(provider_config)458 459 # Trigger backoff by exceeding quota460 for i in range(10):461 await rate_limiter.consume_quota("test_provider", 1.0, 1)462 463 # Try to exceed quota to trigger backoff464 with pytest.raises(QuotaExceededError):465 await rate_limiter.consume_quota("test_provider", 1.0, 1)466 467 # Provider should now be in backoff state and recommend switching468 should_switch = await rate_limiter.should_switch_provider("test_provider", 1.0)469 assert should_switch is True470 471 @pytest.mark.asyncio472 async def test_quota_threshold_monitoring(self, rate_limiter, provider_config):473 """Test quota threshold monitoring with different thresholds."""474 await rate_limiter.configure_provider(provider_config)475 476 # Consume 50% of quota477 await rate_limiter.consume_quota("test_provider", 1800.0, 1) # 30 minutes out of 60478 479 # Should not be approaching with 80% threshold480 approaching_80 = await rate_limiter.is_provider_approaching_limit("test_provider", 0.0, 0.8)481 assert approaching_80 is False482 483 # Should be approaching with 40% threshold484 approaching_40 = await rate_limiter.is_provider_approaching_limit("test_provider", 0.0, 0.4)485 assert approaching_40 is True486 487 # Should recommend switching with 40% threshold488 should_switch_40 = await rate_limiter.should_switch_provider("test_provider", 0.0, 0.4)489 assert should_switch_40 is True490 491 # Should not recommend switching with 80% threshold492 should_switch_80 = await rate_limiter.should_switch_provider("test_provider", 0.0, 0.8)493 assert should_switch_80 is False494 495 @pytest.mark.asyncio496 async def test_zero_audio_duration(self, rate_limiter):497 """Test handling of zero audio duration."""498 config = ProviderConfig(499 name="test_provider",500 provider_type=ProviderType.WEB_SPEECH_API,501 enabled=True,502 priority=1,503 free_tier_limits={},504 rate_limits={"requests_per_minute": 5},505 supported_formats=["webm"],506 supported_languages=["en-US"],507 cost_per_minute=0.0,508 api_credentials={}509 )510 511 await rate_limiter.configure_provider(config)512 513 # Should handle zero duration gracefully514 result = await rate_limiter.consume_quota("test_provider", 0.0, 1)515 assert result is True516 517 quota_status = await rate_limiter.check_quota("test_provider", 0.0)518 assert not quota_status.is_exceeded519 520 @pytest.mark.asyncio521 async def test_negative_audio_duration(self, rate_limiter):522 """Test handling of negative audio duration."""523 config = ProviderConfig(524 name="test_provider",525 provider_type=ProviderType.WEB_SPEECH_API,526 enabled=True,527 priority=1,528 free_tier_limits={},529 rate_limits={"requests_per_minute": 5},530 supported_formats=["webm"],531 supported_languages=["en-US"],532 cost_per_minute=0.0,533 api_credentials={}534 )535 536 await rate_limiter.configure_provider(config)537 538 # Should handle negative duration gracefully (treat as zero)539 result = await rate_limiter.consume_quota("test_provider", -10.0, 1)540 assert result is True541 542 @pytest.mark.asyncio543 async def test_very_large_audio_duration(self, rate_limiter):544 """Test handling of very large audio duration."""545 config = ProviderConfig(546 name="test_provider",547 provider_type=ProviderType.WEB_SPEECH_API,548 enabled=True,549 priority=1,550 free_tier_limits={"audio_minutes_per_day": 60},551 rate_limits={},552 supported_formats=["webm"],553 supported_languages=["en-US"],554 cost_per_minute=0.0,555 api_credentials={}556 )557 558 await rate_limiter.configure_provider(config)559 560 # Very large duration should exceed limits561 with pytest.raises(QuotaExceededError):562 await rate_limiter.consume_quota("test_provider", 1000000.0, 1) # ~277 hours563 564 @pytest.mark.asyncio565 async def test_concurrent_quota_consumption(self, rate_limiter):566 """Test concurrent quota consumption."""567 config = ProviderConfig(568 name="test_provider",569 provider_type=ProviderType.WEB_SPEECH_API,570 enabled=True,571 priority=1,572 free_tier_limits={},573 rate_limits={"requests_per_minute": 10},574 supported_formats=["webm"],575 supported_languages=["en-US"],576 cost_per_minute=0.0,577 api_credentials={}578 )579 580 await rate_limiter.configure_provider(config)581 582 # Create multiple concurrent tasks583 async def consume_quota():584 try:585 return await rate_limiter.consume_quota("test_provider", 1.0, 1)586 except (QuotaExceededError, RateLimitExceededError):587 return False588 589 # Run 15 concurrent tasks (more than the limit of 10)590 tasks = [consume_quota() for _ in range(15)]591 results = await asyncio.gather(*tasks)592 593 # Some should succeed, some should fail594 successful = sum(1 for result in results if result is True)595 assert successful <= 10 # Should not exceed the limit596 assert successful > 0 # Some should succeed597 598 @pytest.mark.asyncio599 async def test_shutdown_cleanup(self, rate_limiter):600 """Test that shutdown properly cleans up resources."""601 config = ProviderConfig(602 name="test_provider",603 provider_type=ProviderType.WEB_SPEECH_API,604 enabled=True,605 priority=1,606 free_tier_limits={},607 rate_limits={"requests_per_minute": 10},608 supported_formats=["webm"],609 supported_languages=["en-US"],610 cost_per_minute=0.0,611 api_credentials={}612 )613 614 await rate_limiter.configure_provider(config)615 await rate_limiter.consume_quota("test_provider", 30.0, 3)616 617 # Shutdown should clean up all data618 await rate_limiter.shutdown()619 620 assert len(rate_limiter._provider_windows) == 0621 assert len(rate_limiter._provider_usage) == 0622 assert len(rate_limiter._provider_configs) == 0623 assert len(rate_limiter._backoff_state) == 0624 625 626class TestRateLimiterResetScheduling:627 """Test counter reset scheduling functionality."""628 629 @pytest_asyncio.fixture630 async def rate_limiter(self):631 """Create a RateLimiter instance for testing."""632 limiter = RateLimiter(cleanup_interval=1) # Short cleanup interval for testing633 await limiter.initialize()634 yield limiter635 await limiter.shutdown()636 637 @pytest.fixture638 def provider_config_with_schedules(self):639 """Create a test provider configuration with various reset schedules."""640 return ProviderConfig(641 name="test_provider",642 provider_type=ProviderType.GOOGLE_SPEECH,643 enabled=True,644 priority=1,645 free_tier_limits={646 "audio_minutes_per_day": 60, # 60 minutes per day647 "audio_minutes_per_month": 300 # 300 minutes per month648 },649 rate_limits={650 "requests_per_minute": 10,651 "requests_per_hour": 100,652 "requests_per_day": 1000653 },654 supported_formats=["webm", "wav"],655 supported_languages=["en-US"],656 cost_per_minute=0.006,657 api_credentials={"api_key": "test_key"}658 )659 660 @pytest.mark.asyncio661 async def test_configure_reset_schedule(self, rate_limiter, provider_config_with_schedules):662 """Test configuring custom reset schedules."""663 await rate_limiter.configure_provider(provider_config_with_schedules)664 665 # Configure hourly reset for requests_per_minute window666 await rate_limiter.configure_reset_schedule("test_provider", "requests_per_minute", "hourly")667 668 # Verify the schedule was updated669 window = rate_limiter._provider_windows["test_provider"]["requests_per_minute"]670 assert window.reset_schedule == "hourly"671 672 # Test invalid provider673 with pytest.raises(ValueError, match="Provider unknown_provider not configured"):674 await rate_limiter.configure_reset_schedule("unknown_provider", "requests_per_minute", "daily")675 676 # Test invalid window677 with pytest.raises(ValueError, match="Window unknown_window not found"):678 await rate_limiter.configure_reset_schedule("test_provider", "unknown_window", "daily")679 680 # Test invalid schedule681 with pytest.raises(ValueError, match="Invalid reset schedule"):682 await rate_limiter.configure_reset_schedule("test_provider", "requests_per_minute", "invalid")683 684 @pytest.mark.asyncio685 async def test_get_reset_schedule_info(self, rate_limiter, provider_config_with_schedules):686 """Test getting reset schedule information."""687 await rate_limiter.configure_provider(provider_config_with_schedules)688 689 # Get reset schedule info690 reset_info = await rate_limiter.get_reset_schedule_info("test_provider")691 692 # Should have info for all configured windows693 expected_windows = [694 "requests_per_minute",695 "requests_per_hour", 696 "requests_per_day",697 "audio_minutes_per_day",698 "audio_minutes_per_month"699 ]700 701 for window_name in expected_windows:702 assert window_name in reset_info703 info = reset_info[window_name]704 705 # Check required fields706 assert "reset_schedule" in info707 assert "last_reset" in info708 assert "next_reset" in info709 assert "is_sliding_window" in info710 assert "seconds_until_reset" in info711 712 # Check specific schedules713 assert reset_info["requests_per_minute"]["is_sliding_window"] is True # No reset schedule714 assert reset_info["requests_per_day"]["reset_schedule"] == "daily"715 assert reset_info["audio_minutes_per_month"]["reset_schedule"] == "monthly"716 717 @pytest.mark.asyncio718 async def test_is_reset_due_hourly(self, rate_limiter):719 """Test hourly reset detection."""720 from voice_control.rate_limiter import TimeWindow721 722 window = TimeWindow(723 duration=3600,724 max_requests=100,725 max_audio_duration=float('inf'),726 reset_schedule="hourly"727 )728 729 current_time = datetime.now(timezone.utc)730 731 # Last reset was in the same hour - should not be due732 last_reset_same_hour = current_time.replace(minute=30)733 assert not rate_limiter._is_reset_due(window, last_reset_same_hour, current_time)734 735 # Last reset was in previous hour - should be due736 last_reset_prev_hour = current_time.replace(hour=current_time.hour-1 if current_time.hour > 0 else 23)737 assert rate_limiter._is_reset_due(window, last_reset_prev_hour, current_time)738 739 @pytest.mark.asyncio740 async def test_is_reset_due_daily(self, rate_limiter):741 """Test daily reset detection."""742 from voice_control.rate_limiter import TimeWindow743 744 window = TimeWindow(745 duration=86400,746 max_requests=1000,747 max_audio_duration=float('inf'),748 reset_schedule="daily"749 )750 751 current_time = datetime.now(timezone.utc)752 753 # Last reset was today - should not be due754 last_reset_today = current_time.replace(hour=12, minute=0)755 assert not rate_limiter._is_reset_due(window, last_reset_today, current_time)756 757 # Last reset was yesterday - should be due758 last_reset_yesterday = current_time - timedelta(days=1)759 assert rate_limiter._is_reset_due(window, last_reset_yesterday, current_time)760 761 @pytest.mark.asyncio762 async def test_is_reset_due_weekly(self, rate_limiter):763 """Test weekly reset detection."""764 from voice_control.rate_limiter import TimeWindow765 766 window = TimeWindow(767 duration=7*86400,768 max_requests=5000,769 max_audio_duration=float('inf'),770 reset_schedule="weekly"771 )772 773 current_time = datetime.now(timezone.utc)774 775 # Last reset was this week - should not be due776 days_since_monday = current_time.weekday()777 last_reset_this_week = current_time - timedelta(days=days_since_monday//2) # Mid-week778 assert not rate_limiter._is_reset_due(window, last_reset_this_week, current_time)779 780 # Last reset was last week - should be due781 last_reset_last_week = current_time - timedelta(days=7 + days_since_monday//2)782 assert rate_limiter._is_reset_due(window, last_reset_last_week, current_time)783 784 @pytest.mark.asyncio785 async def test_is_reset_due_monthly(self, rate_limiter):786 """Test monthly reset detection."""787 from voice_control.rate_limiter import TimeWindow788 789 window = TimeWindow(790 duration=30*86400,791 max_requests=10000,792 max_audio_duration=float('inf'),793 reset_schedule="monthly"794 )795 796 current_time = datetime.now(timezone.utc)797 798 # Last reset was this month - should not be due799 last_reset_this_month = current_time.replace(day=15)800 assert not rate_limiter._is_reset_due(window, last_reset_this_month, current_time)801 802 # Last reset was last month - should be due803 if current_time.month == 1:804 last_reset_last_month = current_time.replace(year=current_time.year-1, month=12, day=15)805 else:806 last_reset_last_month = current_time.replace(month=current_time.month-1, day=15)807 assert rate_limiter._is_reset_due(window, last_reset_last_month, current_time)808 809 @pytest.mark.asyncio810 async def test_scheduled_reset_execution(self, rate_limiter, provider_config_with_schedules):811 """Test that scheduled resets are actually executed."""812 await rate_limiter.configure_provider(provider_config_with_schedules)813 814 # Consume some quota815 await rate_limiter.consume_quota("test_provider", 30.0, 5)816 817 # Verify usage exists818 usage_stats = await rate_limiter.get_usage_stats("test_provider", "day")819 assert usage_stats.requests_count == 5820 assert usage_stats.audio_minutes == 0.5821 822 # Manually trigger a reset by setting last_reset to yesterday823 usage = rate_limiter._provider_usage["test_provider"]["requests_per_day"]824 usage.last_reset = datetime.now(timezone.utc) - timedelta(days=1)825 826 # Trigger the reset check827 await rate_limiter._check_and_perform_resets()828 829 # Verify the reset occurred830 usage_stats = await rate_limiter.get_usage_stats("test_provider", "day")831 assert usage_stats.requests_count == 0832 assert usage_stats.audio_minutes == 0.0833 834 # Verify last_reset was updated835 assert usage.last_reset > datetime.now(timezone.utc) - timedelta(minutes=1)836 837 @pytest.mark.asyncio838 async def test_reset_scheduler_background_task(self, rate_limiter, provider_config_with_schedules):839 """Test that the reset scheduler background task works."""840 await rate_limiter.configure_provider(provider_config_with_schedules)841 842 # Consume some quota843 await rate_limiter.consume_quota("test_provider", 60.0, 3)844 845 # Set last reset to trigger a reset846 usage = rate_limiter._provider_usage["test_provider"]["audio_minutes_per_day"]847 usage.last_reset = datetime.now(timezone.utc) - timedelta(days=1)848 849 # Store original usage data for verification850 original_requests = len(usage.requests)851 original_audio_durations = len(usage.audio_durations)852 853 # Wait for the background task to run (it runs every minute)854 # We'll manually trigger it for testing855 await rate_limiter._check_and_perform_resets()856 857 # Verify the reset occurred by checking the internal data structures858 assert len(usage.requests) == 0 # Requests deque should be cleared859 assert len(usage.audio_durations) == 0 # Audio durations deque should be cleared860 assert usage.total_requests == 0 # Total should be reset861 assert usage.total_audio_duration == 0.0 # Total should be reset862 863 # Verify last_reset was updated864 assert usage.last_reset > datetime.now(timezone.utc) - timedelta(minutes=1)865 866 @pytest.mark.asyncio867 async def test_calculate_reset_time_all_schedules(self, rate_limiter):868 """Test reset time calculation for all schedule types."""869 from voice_control.rate_limiter import TimeWindow870 871 current_time = datetime.now(timezone.utc)872 873 # Test hourly874 hourly_window = TimeWindow(3600, 100, float('inf'), "hourly")875 next_reset = rate_limiter._calculate_reset_time(hourly_window)876 expected_hour = current_time.replace(minute=0, second=0, microsecond=0) + timedelta(hours=1)877 assert next_reset == expected_hour878 879 # Test daily880 daily_window = TimeWindow(86400, 1000, float('inf'), "daily")881 next_reset = rate_limiter._calculate_reset_time(daily_window)882 expected_day = current_time.replace(hour=0, minute=0, second=0, microsecond=0) + timedelta(days=1)883 assert next_reset == expected_day884 885 # Test weekly886 weekly_window = TimeWindow(7*86400, 5000, float('inf'), "weekly")887 next_reset = rate_limiter._calculate_reset_time(weekly_window)888 days_until_monday = (7 - current_time.weekday()) % 7889 if days_until_monday == 0:890 days_until_monday = 7891 expected_week = current_time.replace(hour=0, minute=0, second=0, microsecond=0) + timedelta(days=days_until_monday)892 assert next_reset == expected_week893 894 # Test monthly895 monthly_window = TimeWindow(30*86400, 10000, float('inf'), "monthly")896 next_reset = rate_limiter._calculate_reset_time(monthly_window)897 if current_time.month == 12:898 expected_month = current_time.replace(year=current_time.year + 1, month=1, day=1, hour=0, minute=0, second=0, microsecond=0)899 else:900 expected_month = current_time.replace(month=current_time.month + 1, day=1, hour=0, minute=0, second=0, microsecond=0)901 assert next_reset == expected_month902 903 # Test sliding window (no schedule)904 sliding_window = TimeWindow(3600, 100, float('inf'), None)905 next_reset = rate_limiter._calculate_reset_time(sliding_window)906 expected_sliding = current_time + timedelta(seconds=3600)907 # Allow for small time differences due to execution time908 assert abs((next_reset - expected_sliding).total_seconds()) < 1909 910 @pytest.mark.asyncio911 async def test_multiple_providers_independent_resets(self, rate_limiter):912 """Test that multiple providers have independent reset schedules."""913 # Configure two providers with different schedules914 config1 = ProviderConfig(915 name="provider1",916 provider_type=ProviderType.GOOGLE_SPEECH,917 enabled=True,918 priority=1,919 free_tier_limits={"audio_minutes_per_day": 30},920 rate_limits={"requests_per_hour": 50},921 supported_formats=["webm"],922 supported_languages=["en-US"],923 cost_per_minute=0.01,924 api_credentials={"key": "test1"}925 )926 927 config2 = ProviderConfig(928 name="provider2",929 provider_type=ProviderType.AZURE_SPEECH,930 enabled=True,931 priority=2,932 free_tier_limits={"audio_minutes_per_day": 60},933 rate_limits={"requests_per_hour": 100},934 supported_formats=["webm"],935 supported_languages=["en-US"],936 cost_per_minute=0.02,937 api_credentials={"key": "test2"}938 )939 940 await rate_limiter.configure_provider(config1)941 await rate_limiter.configure_provider(config2)942 943 # Consume quota for both providers944 await rate_limiter.consume_quota("provider1", 600.0, 10) # 10 minutes, 10 requests945 await rate_limiter.consume_quota("provider2", 1200.0, 20) # 20 minutes, 20 requests946 947 # Set provider1 to need reset, but not provider2948 usage1 = rate_limiter._provider_usage["provider1"]["audio_minutes_per_day"]949 usage1.last_reset = datetime.now(timezone.utc) - timedelta(days=1)950 951 # Store original usage data for provider2952 usage2 = rate_limiter._provider_usage["provider2"]["audio_minutes_per_day"]953 original_requests_p2 = len(usage2.requests)954 original_audio_p2 = len(usage2.audio_durations)955 956 # Trigger resets957 await rate_limiter._check_and_perform_resets()958 959 # Verify provider1 was reset but provider2 was not960 assert len(usage1.requests) == 0 # Provider1 should be reset961 assert len(usage1.audio_durations) == 0962 assert usage1.total_requests == 0963 assert usage1.total_audio_duration == 0.0964 965 # Provider2 should not be reset (data should remain)966 assert len(usage2.requests) == original_requests_p2967 assert len(usage2.audio_durations) == original_audio_p2968 969 @pytest.mark.asyncio970 async def test_reset_schedule_with_sliding_windows(self, rate_limiter, provider_config_with_schedules):971 """Test that sliding windows are not affected by scheduled resets."""972 await rate_limiter.configure_provider(provider_config_with_schedules)973 974 # Consume quota975 await rate_limiter.consume_quota("test_provider", 30.0, 5)976 977 # Set all windows to need reset (this should only affect scheduled windows)978 for window_name in rate_limiter._provider_usage["test_provider"]:979 usage = rate_limiter._provider_usage["test_provider"][window_name]980 usage.last_reset = datetime.now(timezone.utc) - timedelta(days=1)981 982 # Trigger resets983 await rate_limiter._check_and_perform_resets()984 985 # Verify sliding windows (like requests_per_minute) were not reset986 # but scheduled windows (like requests_per_day) were reset987 minute_stats = await rate_limiter.get_usage_stats("test_provider", "minute")988 day_stats = await rate_limiter.get_usage_stats("test_provider", "day")989 990 # Sliding window should still have data991 assert minute_stats.requests_count == 5992 993 # Scheduled window should be reset994 # Note: day stats might still show data because it's calculated from sliding window data995 # The actual reset affects the scheduled windows, not the sliding window calculations996 997 998if __name__ == "__main__":999 pytest.main([__file__])