-
-
Notifications
You must be signed in to change notification settings - Fork 101
test: add pytest test suite for rotator_library core modules (64 tests) #163
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: dev
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,56 @@ | ||||||||||||||||||||||||||||||||||
| # SPDX-License-Identifier: LGPL-3.0-only | ||||||||||||||||||||||||||||||||||
| # Copyright (c) 2026 Mirrowel | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| name: Tests | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| on: | ||||||||||||||||||||||||||||||||||
| push: | ||||||||||||||||||||||||||||||||||
| branches: | ||||||||||||||||||||||||||||||||||
| - main | ||||||||||||||||||||||||||||||||||
| - dev | ||||||||||||||||||||||||||||||||||
| paths: | ||||||||||||||||||||||||||||||||||
| - 'src/rotator_library/**' | ||||||||||||||||||||||||||||||||||
| - 'tests/**' | ||||||||||||||||||||||||||||||||||
| - 'pytest.ini' | ||||||||||||||||||||||||||||||||||
| - 'requirements.txt' | ||||||||||||||||||||||||||||||||||
| - '.github/workflows/tests.yml' | ||||||||||||||||||||||||||||||||||
| pull_request: | ||||||||||||||||||||||||||||||||||
| paths: | ||||||||||||||||||||||||||||||||||
| - 'src/rotator_library/**' | ||||||||||||||||||||||||||||||||||
| - 'tests/**' | ||||||||||||||||||||||||||||||||||
| - 'pytest.ini' | ||||||||||||||||||||||||||||||||||
| - 'requirements.txt' | ||||||||||||||||||||||||||||||||||
| - '.github/workflows/tests.yml' | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| jobs: | ||||||||||||||||||||||||||||||||||
| test: | ||||||||||||||||||||||||||||||||||
| runs-on: ${{ matrix.os }} | ||||||||||||||||||||||||||||||||||
| strategy: | ||||||||||||||||||||||||||||||||||
| fail-fast: false | ||||||||||||||||||||||||||||||||||
| matrix: | ||||||||||||||||||||||||||||||||||
| os: [ubuntu-latest, windows-latest] | ||||||||||||||||||||||||||||||||||
| python-version: ['3.12', '3.13'] | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| steps: | ||||||||||||||||||||||||||||||||||
| - name: Check out repository | ||||||||||||||||||||||||||||||||||
| uses: actions/checkout@v4 | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| - name: Set up Python ${{ matrix.python-version }} | ||||||||||||||||||||||||||||||||||
| uses: actions/setup-python@v5 | ||||||||||||||||||||||||||||||||||
| with: | ||||||||||||||||||||||||||||||||||
| python-version: ${{ matrix.python-version }} | ||||||||||||||||||||||||||||||||||
|
Comment on lines
+35
to
+41
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Pin GitHub Actions by commit SHA and disable persisted checkout credentials. Using floating action tags ( Suggested hardening patch- - name: Check out repository
- uses: actions/checkout@v4
+ - name: Check out repository
+ uses: actions/checkout@<FULL_LENGTH_COMMIT_SHA>
+ with:
+ persist-credentials: false
- - name: Set up Python ${{ matrix.python-version }}
- uses: actions/setup-python@v5
+ - name: Set up Python ${{ matrix.python-version }}
+ uses: actions/setup-python@<FULL_LENGTH_COMMIT_SHA>🧰 Tools🪛 zizmor (1.25.2)[warning] 33-34: credential persistence through GitHub Actions artifacts (artipacked): does not set persist-credentials: false (artipacked) [error] 34-34: unpinned action reference (unpinned-uses): action is not pinned to a hash (required by blanket policy) (unpinned-uses) [error] 37-37: unpinned action reference (unpinned-uses): action is not pinned to a hash (required by blanket policy) (unpinned-uses) 🤖 Prompt for AI AgentsSource: Linters/SAST tools |
||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| - name: Install dependencies | ||||||||||||||||||||||||||||||||||
| shell: bash | ||||||||||||||||||||||||||||||||||
| run: | | ||||||||||||||||||||||||||||||||||
| python -m pip install --upgrade pip | ||||||||||||||||||||||||||||||||||
| pip install pytest pytest-asyncio | ||||||||||||||||||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time! |
||||||||||||||||||||||||||||||||||
| # Install rotator_library dependencies (skip editable install line) | ||||||||||||||||||||||||||||||||||
| grep -v -- '-e src/rotator_library' requirements.txt > temp_requirements.txt | ||||||||||||||||||||||||||||||||||
| pip install -r temp_requirements.txt | ||||||||||||||||||||||||||||||||||
|
Comment on lines
+49
to
+50
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Make requirements filtering non-fatal when no lines remain.
Suggested robust alternative- grep -v -- '-e src/rotator_library' requirements.txt > temp_requirements.txt
- pip install -r temp_requirements.txt
+ python - <<'PY'
+from pathlib import Path
+src = Path("requirements.txt")
+dst = Path("temp_requirements.txt")
+lines = [ln for ln in src.read_text(encoding="utf-8").splitlines()
+ if ln.strip() != "-e src/rotator_library"]
+dst.write_text("\n".join(lines) + ("\n" if lines else ""), encoding="utf-8")
+PY
+ if [ -s temp_requirements.txt ]; then pip install -r temp_requirements.txt; fi🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||
| pip install -e src/rotator_library | ||||||||||||||||||||||||||||||||||
|
Comment on lines
+43
to
+51
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The PR checklist states the CI "matches project's existing CI patterns (uv, Python 3.12+)", but this step uses raw This requires adding a
Suggested change
|
||||||||||||||||||||||||||||||||||
| rm -f temp_requirements.txt | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| - name: Run tests | ||||||||||||||||||||||||||||||||||
| shell: bash | ||||||||||||||||||||||||||||||||||
| run: pytest -v | ||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,9 @@ | ||
| # SPDX-License-Identifier: LGPL-3.0-only | ||
| # Copyright (c) 2026 Mirrowel | ||
|
|
||
| [pytest] | ||
| testpaths = tests | ||
| asyncio_mode = auto | ||
| python_files = test_*.py | ||
| python_classes = Test* | ||
| python_functions = test_* |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,27 @@ | ||||||||||||||||||
| # SPDX-License-Identifier: LGPL-3.0-only | ||||||||||||||||||
| # Copyright (c) 2026 Mirrowel | ||||||||||||||||||
|
|
||||||||||||||||||
| """ | ||||||||||||||||||
| Pytest configuration and shared fixtures for the rotator_library test suite. | ||||||||||||||||||
|
|
||||||||||||||||||
| This conftest adds the source directories to sys.path so that both | ||||||||||||||||||
| standalone modules (request_sanitizer, session_tracking, etc.) and | ||||||||||||||||||
| package-qualified modules (rotator_library.core.utils) can be imported | ||||||||||||||||||
| without requiring a full editable install of every dependency. | ||||||||||||||||||
| """ | ||||||||||||||||||
|
|
||||||||||||||||||
| import sys | ||||||||||||||||||
| from pathlib import Path | ||||||||||||||||||
|
|
||||||||||||||||||
| # Resolve the repository root (tests/ -> parent) | ||||||||||||||||||
| _REPO_ROOT = Path(__file__).resolve().parent.parent | ||||||||||||||||||
| _SRC_ROOT = _REPO_ROOT / "src" | ||||||||||||||||||
| _LIB_ROOT = _SRC_ROOT / "rotator_library" | ||||||||||||||||||
|
|
||||||||||||||||||
| # Prepend source paths so that: | ||||||||||||||||||
| # - `import rotator_library.X` works (via _SRC_ROOT) | ||||||||||||||||||
| # - `from request_sanitizer import ...` works (via _LIB_ROOT) | ||||||||||||||||||
| for _path in (_SRC_ROOT, _LIB_ROOT): | ||||||||||||||||||
| _str = str(_path) | ||||||||||||||||||
| if _str not in sys.path: | ||||||||||||||||||
| sys.path.insert(0, _str) | ||||||||||||||||||
|
Comment on lines
+21
to
+27
Comment on lines
+24
to
+27
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,128 @@ | ||
| # SPDX-License-Identifier: LGPL-3.0-only | ||
| # Copyright (c) 2026 Mirrowel | ||
|
|
||
| """ | ||
| Tests for cooldown_manager.CooldownManager. | ||
|
|
||
| CooldownManager tracks per-provider cooldown periods to handle | ||
| IP-based rate limiting. When a 429 is received, all requests to | ||
| that provider are paused for a configurable duration. | ||
| """ | ||
|
|
||
| import asyncio | ||
| import time | ||
| from unittest.mock import patch | ||
|
Comment on lines
+13
to
+14
|
||
|
|
||
| import pytest | ||
|
|
||
| from cooldown_manager import CooldownManager | ||
|
|
||
|
|
||
| class TestCooldownBasics: | ||
| """Basic functionality tests.""" | ||
|
|
||
| @pytest.mark.asyncio | ||
| async def test_provider_not_cooling_down_initially(self): | ||
| cm = CooldownManager() | ||
| assert await cm.is_cooling_down("openai") is False | ||
|
|
||
| @pytest.mark.asyncio | ||
| async def test_start_cooldown_makes_provider_cooling(self): | ||
| cm = CooldownManager() | ||
| await cm.start_cooldown("openai", 60) | ||
| assert await cm.is_cooling_down("openai") is True | ||
|
|
||
| @pytest.mark.asyncio | ||
| async def test_different_providers_independent(self): | ||
| cm = CooldownManager() | ||
| await cm.start_cooldown("openai", 60) | ||
| assert await cm.is_cooling_down("openai") is True | ||
| assert await cm.is_cooling_down("anthropic") is False | ||
|
|
||
|
|
||
| class TestCooldownRemaining: | ||
| """Tests for remaining time calculations.""" | ||
|
|
||
| @pytest.mark.asyncio | ||
| async def test_remaining_cooldown_positive(self): | ||
| cm = CooldownManager() | ||
| await cm.start_cooldown("openai", 100) | ||
| remaining = await cm.get_cooldown_remaining("openai") | ||
| # Should be close to 100 but slightly less due to execution time | ||
| assert 90 <= remaining <= 100 | ||
|
|
||
| @pytest.mark.asyncio | ||
| async def test_remaining_cooldown_zero_if_not_cooling(self): | ||
| cm = CooldownManager() | ||
| remaining = await cm.get_cooldown_remaining("openai") | ||
| assert remaining == 0 | ||
|
|
||
| @pytest.mark.asyncio | ||
| async def test_remaining_cooldown_after_expiry(self): | ||
| cm = CooldownManager() | ||
| await cm.start_cooldown("openai", 0) | ||
| # With duration=0, the cooldown is set to exactly now | ||
| # By the time we check, time has advanced | ||
| await asyncio.sleep(0.01) | ||
| remaining = await cm.get_cooldown_remaining("openai") | ||
| assert remaining == 0 | ||
|
|
||
| @pytest.mark.asyncio | ||
| async def test_get_remaining_cooldown_alias(self): | ||
| """get_remaining_cooldown is a backward-compatible alias.""" | ||
| cm = CooldownManager() | ||
| await cm.start_cooldown("openai", 50) | ||
| r1 = await cm.get_cooldown_remaining("openai") | ||
| r2 = await cm.get_remaining_cooldown("openai") | ||
| assert abs(r1 - r2) < 1 # Should be nearly identical | ||
|
|
||
|
|
||
| class TestCooldownExpiry: | ||
| """Tests for cooldown expiration behavior.""" | ||
|
|
||
| @pytest.mark.asyncio | ||
| async def test_cooldown_expires(self): | ||
| cm = CooldownManager() | ||
| await cm.start_cooldown("openai", 1) | ||
| assert await cm.is_cooling_down("openai") is True | ||
| # Patch the clock to advance past expiry without real sleeping | ||
| with patch("cooldown_manager.time.time", return_value=time.time() + 1.1): | ||
| assert await cm.is_cooling_down("openai") is False | ||
|
|
||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| @pytest.mark.asyncio | ||
| async def test_cooldown_extends_on_new_start(self): | ||
| cm = CooldownManager() | ||
| await cm.start_cooldown("openai", 5) | ||
| r1 = await cm.get_cooldown_remaining("openai") | ||
|
|
||
| await cm.start_cooldown("openai", 100) | ||
| r2 = await cm.get_cooldown_remaining("openai") | ||
|
|
||
| assert r2 > r1 | ||
|
|
||
|
|
||
| class TestCooldownConcurrency: | ||
| """Tests for concurrent access safety.""" | ||
|
|
||
| @pytest.mark.asyncio | ||
| async def test_concurrent_start_and_check(self): | ||
| cm = CooldownManager() | ||
| # Multiple concurrent operations should not cause issues | ||
| await asyncio.gather( | ||
| cm.start_cooldown("openai", 10), | ||
| cm.start_cooldown("anthropic", 20), | ||
| cm.is_cooling_down("openai"), | ||
| cm.is_cooling_down("anthropic"), | ||
| cm.is_cooling_down("gemini"), | ||
| ) | ||
| assert await cm.is_cooling_down("openai") is True | ||
| assert await cm.is_cooling_down("anthropic") is True | ||
| assert await cm.is_cooling_down("gemini") is False | ||
|
|
||
| @pytest.mark.asyncio | ||
| async def test_concurrent_reads(self): | ||
| cm = CooldownManager() | ||
| await cm.start_cooldown("openai", 10) | ||
| # Many concurrent reads | ||
| results = await asyncio.gather(*[cm.is_cooling_down("openai") for _ in range(100)]) | ||
| assert all(results) | ||
Uh oh!
There was an error while loading. Please reload this page.