CoolFace
Apppublic

nifty-coder/stemsplit-backend

sourceHugging Faceupdated 8mo agoView on Hugging Face
0likes
test_rate_limiter.py999 linesDownload Raw Back to tests
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__])