mirror of
https://github.com/elisiariocouto/leggen.git
synced 2025-12-13 13:42:19 +00:00
feat: Add comprehensive test suite with 46 passing tests
- Add pytest configuration in pyproject.toml with markers and async support - Create shared test fixtures in tests/conftest.py for config, auth, and sample data - Implement unit tests for all major components: * Configuration management (11 tests) - TOML loading/saving, singleton pattern * FastAPI API endpoints (12 tests) - Banks, accounts, transactions with mocks * CLI API client (11 tests) - HTTP client integration and error handling * Background scheduler (12 tests) - APScheduler job management and async ops - Fix GoCardless API authentication mocking by adding token endpoints - Resolve TOML file writing issues (binary vs text mode for tomli_w) - Add comprehensive testing documentation to README - Update code structure documentation to include test organization Testing framework includes: - respx for HTTP request mocking - pytest-asyncio for async test support - pytest-mock for advanced mocking capabilities - requests-mock for CLI HTTP client testing - Realistic test data fixtures for banks, accounts, and transactions 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
committed by
Elisiário Couto
parent
4018b263f2
commit
34e793c75c
@@ -1,7 +1,9 @@
|
|||||||
{
|
{
|
||||||
"permissions": {
|
"permissions": {
|
||||||
"allow": [
|
"allow": [
|
||||||
"Bash(mkdir:*)"
|
"Bash(mkdir:*)",
|
||||||
|
"Bash(uv sync:*)",
|
||||||
|
"Bash(uv run pytest:*)"
|
||||||
],
|
],
|
||||||
"deny": [],
|
"deny": [],
|
||||||
"ask": []
|
"ask": []
|
||||||
|
|||||||
41
README.md
41
README.md
@@ -260,6 +260,38 @@ uv run leggend --reload
|
|||||||
uv run leggen status
|
uv run leggen status
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
|
||||||
|
Run the comprehensive test suite with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run all tests
|
||||||
|
uv run pytest
|
||||||
|
|
||||||
|
# Run unit tests only
|
||||||
|
uv run pytest tests/unit/
|
||||||
|
|
||||||
|
# Run with verbose output
|
||||||
|
uv run pytest tests/unit/ -v
|
||||||
|
|
||||||
|
# Run specific test files
|
||||||
|
uv run pytest tests/unit/test_config.py -v
|
||||||
|
uv run pytest tests/unit/test_scheduler.py -v
|
||||||
|
uv run pytest tests/unit/test_api_banks.py -v
|
||||||
|
|
||||||
|
# Run tests by markers
|
||||||
|
uv run pytest -m unit # Unit tests
|
||||||
|
uv run pytest -m api # API endpoint tests
|
||||||
|
uv run pytest -m cli # CLI tests
|
||||||
|
```
|
||||||
|
|
||||||
|
The test suite includes:
|
||||||
|
- **Configuration management tests** - TOML config loading/saving
|
||||||
|
- **API endpoint tests** - FastAPI route testing with mocked dependencies
|
||||||
|
- **CLI API client tests** - HTTP client integration testing
|
||||||
|
- **Background scheduler tests** - APScheduler job management
|
||||||
|
- **Mock data and fixtures** - Realistic test data for banks, accounts, transactions
|
||||||
|
|
||||||
### Code Structure
|
### Code Structure
|
||||||
```
|
```
|
||||||
leggen/ # CLI application
|
leggen/ # CLI application
|
||||||
@@ -272,6 +304,15 @@ leggend/ # FastAPI backend service
|
|||||||
├── services/ # Business logic
|
├── services/ # Business logic
|
||||||
├── background/ # Background job scheduler
|
├── background/ # Background job scheduler
|
||||||
└── main.py # FastAPI application
|
└── main.py # FastAPI application
|
||||||
|
|
||||||
|
tests/ # Test suite
|
||||||
|
├── conftest.py # Shared test fixtures
|
||||||
|
└── unit/ # Unit tests
|
||||||
|
├── test_config.py # Configuration tests
|
||||||
|
├── test_scheduler.py # Background scheduler tests
|
||||||
|
├── test_api_banks.py # Banks API tests
|
||||||
|
├── test_api_accounts.py # Accounts API tests
|
||||||
|
└── test_api_client.py # CLI API client tests
|
||||||
```
|
```
|
||||||
|
|
||||||
### Contributing
|
### Contributing
|
||||||
|
|||||||
@@ -49,6 +49,11 @@ leggend = "leggend.main:main"
|
|||||||
dev = [
|
dev = [
|
||||||
"ruff>=0.6.1",
|
"ruff>=0.6.1",
|
||||||
"pre-commit>=3.6.0",
|
"pre-commit>=3.6.0",
|
||||||
|
"pytest>=8.0.0",
|
||||||
|
"pytest-asyncio>=0.23.0",
|
||||||
|
"pytest-mock>=3.12.0",
|
||||||
|
"respx>=0.21.0",
|
||||||
|
"requests-mock>=1.12.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.hatch.build.targets.sdist]
|
[tool.hatch.build.targets.sdist]
|
||||||
@@ -64,3 +69,24 @@ build-backend = "hatchling.build"
|
|||||||
[tool.ruff]
|
[tool.ruff]
|
||||||
lint.ignore = ["E501", "B008", "B006"]
|
lint.ignore = ["E501", "B008", "B006"]
|
||||||
lint.extend-select = ["B", "C4", "PIE", "T20", "SIM", "TCH"]
|
lint.extend-select = ["B", "C4", "PIE", "T20", "SIM", "TCH"]
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
testpaths = ["tests"]
|
||||||
|
python_files = "test_*.py"
|
||||||
|
python_classes = "Test*"
|
||||||
|
python_functions = "test_*"
|
||||||
|
addopts = [
|
||||||
|
"-v",
|
||||||
|
"--tb=short",
|
||||||
|
"--strict-markers",
|
||||||
|
"--disable-warnings"
|
||||||
|
]
|
||||||
|
asyncio_mode = "auto"
|
||||||
|
asyncio_default_fixture_loop_scope = "function"
|
||||||
|
markers = [
|
||||||
|
"unit: Unit tests",
|
||||||
|
"integration: Integration tests",
|
||||||
|
"slow: Slow running tests",
|
||||||
|
"api: API endpoint tests",
|
||||||
|
"cli: CLI command tests"
|
||||||
|
]
|
||||||
|
|||||||
0
tests/__init__.py
Normal file
0
tests/__init__.py
Normal file
138
tests/conftest.py
Normal file
138
tests/conftest.py
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
"""Pytest configuration and shared fixtures."""
|
||||||
|
import pytest
|
||||||
|
import tempfile
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from leggend.main import create_app
|
||||||
|
from leggend.config import Config
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def temp_config_dir():
|
||||||
|
"""Create a temporary config directory for testing."""
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
config_dir = Path(tmpdir) / ".config" / "leggen"
|
||||||
|
config_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
yield config_dir
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_config(temp_config_dir):
|
||||||
|
"""Mock configuration for testing."""
|
||||||
|
config_data = {
|
||||||
|
"gocardless": {
|
||||||
|
"key": "test-key",
|
||||||
|
"secret": "test-secret",
|
||||||
|
"url": "https://bankaccountdata.gocardless.com/api/v2"
|
||||||
|
},
|
||||||
|
"database": {
|
||||||
|
"sqlite": True,
|
||||||
|
"mongodb": False
|
||||||
|
},
|
||||||
|
"scheduler": {
|
||||||
|
"sync": {
|
||||||
|
"enabled": True,
|
||||||
|
"hour": 3,
|
||||||
|
"minute": 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
config_file = temp_config_dir / "config.toml"
|
||||||
|
with open(config_file, "wb") as f:
|
||||||
|
import tomli_w
|
||||||
|
tomli_w.dump(config_data, f)
|
||||||
|
|
||||||
|
# Mock the config path
|
||||||
|
with patch.object(Config, 'load_config') as mock_load:
|
||||||
|
mock_load.return_value = config_data
|
||||||
|
config = Config()
|
||||||
|
config._config = config_data
|
||||||
|
config._config_path = str(config_file)
|
||||||
|
yield config
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_auth_token(temp_config_dir):
|
||||||
|
"""Mock GoCardless authentication token."""
|
||||||
|
auth_data = {
|
||||||
|
"access": "mock-access-token",
|
||||||
|
"refresh": "mock-refresh-token"
|
||||||
|
}
|
||||||
|
|
||||||
|
auth_file = temp_config_dir / "auth.json"
|
||||||
|
with open(auth_file, "w") as f:
|
||||||
|
json.dump(auth_data, f)
|
||||||
|
|
||||||
|
return auth_data
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def fastapi_app():
|
||||||
|
"""Create FastAPI test application."""
|
||||||
|
return create_app()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def api_client(fastapi_app):
|
||||||
|
"""Create FastAPI test client."""
|
||||||
|
return TestClient(fastapi_app)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def sample_bank_data():
|
||||||
|
"""Sample bank/institution data for testing."""
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": "REVOLUT_REVOLT21",
|
||||||
|
"name": "Revolut",
|
||||||
|
"bic": "REVOLT21",
|
||||||
|
"transaction_total_days": 90,
|
||||||
|
"countries": ["GB", "LT"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "BANCOBPI_BBPIPTPL",
|
||||||
|
"name": "Banco BPI",
|
||||||
|
"bic": "BBPIPTPL",
|
||||||
|
"transaction_total_days": 90,
|
||||||
|
"countries": ["PT"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def sample_account_data():
|
||||||
|
"""Sample account data for testing."""
|
||||||
|
return {
|
||||||
|
"id": "test-account-123",
|
||||||
|
"institution_id": "REVOLUT_REVOLT21",
|
||||||
|
"status": "READY",
|
||||||
|
"iban": "LT313250081177977789",
|
||||||
|
"created": "2024-02-13T23:56:00Z",
|
||||||
|
"last_accessed": "2025-09-01T09:30:00Z"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def sample_transaction_data():
|
||||||
|
"""Sample transaction data for testing."""
|
||||||
|
return {
|
||||||
|
"transactions": {
|
||||||
|
"booked": [
|
||||||
|
{
|
||||||
|
"internalTransactionId": "txn-123",
|
||||||
|
"bookingDate": "2025-09-01",
|
||||||
|
"valueDate": "2025-09-01",
|
||||||
|
"transactionAmount": {
|
||||||
|
"amount": "-10.50",
|
||||||
|
"currency": "EUR"
|
||||||
|
},
|
||||||
|
"remittanceInformationUnstructured": "Coffee Shop Payment"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"pending": []
|
||||||
|
}
|
||||||
|
}
|
||||||
213
tests/unit/test_api_accounts.py
Normal file
213
tests/unit/test_api_accounts.py
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
"""Tests for accounts API endpoints."""
|
||||||
|
import pytest
|
||||||
|
import respx
|
||||||
|
import httpx
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.api
|
||||||
|
class TestAccountsAPI:
|
||||||
|
"""Test account-related API endpoints."""
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
def test_get_all_accounts_success(self, api_client, mock_config, mock_auth_token, sample_account_data):
|
||||||
|
"""Test successful retrieval of all accounts."""
|
||||||
|
requisitions_data = {
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"id": "req-123",
|
||||||
|
"accounts": ["test-account-123"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
balances_data = {
|
||||||
|
"balances": [
|
||||||
|
{
|
||||||
|
"balanceAmount": {"amount": "100.50", "currency": "EUR"},
|
||||||
|
"balanceType": "interimAvailable",
|
||||||
|
"lastChangeDateTime": "2025-09-01T09:30:00Z"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
# Mock GoCardless token creation
|
||||||
|
respx.post("https://bankaccountdata.gocardless.com/api/v2/token/new/").mock(
|
||||||
|
return_value=httpx.Response(200, json={"access": "test-token", "refresh": "test-refresh"})
|
||||||
|
)
|
||||||
|
|
||||||
|
# Mock GoCardless API calls
|
||||||
|
respx.get("https://bankaccountdata.gocardless.com/api/v2/requisitions/").mock(
|
||||||
|
return_value=httpx.Response(200, json=requisitions_data)
|
||||||
|
)
|
||||||
|
respx.get("https://bankaccountdata.gocardless.com/api/v2/accounts/test-account-123/").mock(
|
||||||
|
return_value=httpx.Response(200, json=sample_account_data)
|
||||||
|
)
|
||||||
|
respx.get("https://bankaccountdata.gocardless.com/api/v2/accounts/test-account-123/balances/").mock(
|
||||||
|
return_value=httpx.Response(200, json=balances_data)
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch('leggend.config.config', mock_config):
|
||||||
|
response = api_client.get("/api/v1/accounts")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["success"] is True
|
||||||
|
assert len(data["data"]) == 1
|
||||||
|
account = data["data"][0]
|
||||||
|
assert account["id"] == "test-account-123"
|
||||||
|
assert account["institution_id"] == "REVOLUT_REVOLT21"
|
||||||
|
assert len(account["balances"]) == 1
|
||||||
|
assert account["balances"][0]["amount"] == 100.50
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
def test_get_account_details_success(self, api_client, mock_config, mock_auth_token, sample_account_data):
|
||||||
|
"""Test successful retrieval of specific account details."""
|
||||||
|
balances_data = {
|
||||||
|
"balances": [
|
||||||
|
{
|
||||||
|
"balanceAmount": {"amount": "250.75", "currency": "EUR"},
|
||||||
|
"balanceType": "interimAvailable"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
# Mock GoCardless token creation
|
||||||
|
respx.post("https://bankaccountdata.gocardless.com/api/v2/token/new/").mock(
|
||||||
|
return_value=httpx.Response(200, json={"access": "test-token", "refresh": "test-refresh"})
|
||||||
|
)
|
||||||
|
|
||||||
|
# Mock GoCardless API calls
|
||||||
|
respx.get("https://bankaccountdata.gocardless.com/api/v2/accounts/test-account-123/").mock(
|
||||||
|
return_value=httpx.Response(200, json=sample_account_data)
|
||||||
|
)
|
||||||
|
respx.get("https://bankaccountdata.gocardless.com/api/v2/accounts/test-account-123/balances/").mock(
|
||||||
|
return_value=httpx.Response(200, json=balances_data)
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch('leggend.config.config', mock_config):
|
||||||
|
response = api_client.get("/api/v1/accounts/test-account-123")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["success"] is True
|
||||||
|
account = data["data"]
|
||||||
|
assert account["id"] == "test-account-123"
|
||||||
|
assert account["iban"] == "LT313250081177977789"
|
||||||
|
assert len(account["balances"]) == 1
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
def test_get_account_balances_success(self, api_client, mock_config, mock_auth_token):
|
||||||
|
"""Test successful retrieval of account balances."""
|
||||||
|
balances_data = {
|
||||||
|
"balances": [
|
||||||
|
{
|
||||||
|
"balanceAmount": {"amount": "1000.00", "currency": "EUR"},
|
||||||
|
"balanceType": "interimAvailable",
|
||||||
|
"lastChangeDateTime": "2025-09-01T10:00:00Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"balanceAmount": {"amount": "950.00", "currency": "EUR"},
|
||||||
|
"balanceType": "expected"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
# Mock GoCardless token creation
|
||||||
|
respx.post("https://bankaccountdata.gocardless.com/api/v2/token/new/").mock(
|
||||||
|
return_value=httpx.Response(200, json={"access": "test-token", "refresh": "test-refresh"})
|
||||||
|
)
|
||||||
|
|
||||||
|
# Mock GoCardless API
|
||||||
|
respx.get("https://bankaccountdata.gocardless.com/api/v2/accounts/test-account-123/balances/").mock(
|
||||||
|
return_value=httpx.Response(200, json=balances_data)
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch('leggend.config.config', mock_config):
|
||||||
|
response = api_client.get("/api/v1/accounts/test-account-123/balances")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["success"] is True
|
||||||
|
assert len(data["data"]) == 2
|
||||||
|
assert data["data"][0]["amount"] == 1000.00
|
||||||
|
assert data["data"][0]["currency"] == "EUR"
|
||||||
|
assert data["data"][0]["balance_type"] == "interimAvailable"
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
def test_get_account_transactions_success(self, api_client, mock_config, mock_auth_token, sample_account_data, sample_transaction_data):
|
||||||
|
"""Test successful retrieval of account transactions."""
|
||||||
|
# Mock GoCardless token creation
|
||||||
|
respx.post("https://bankaccountdata.gocardless.com/api/v2/token/new/").mock(
|
||||||
|
return_value=httpx.Response(200, json={"access": "test-token", "refresh": "test-refresh"})
|
||||||
|
)
|
||||||
|
|
||||||
|
# Mock GoCardless API calls
|
||||||
|
respx.get("https://bankaccountdata.gocardless.com/api/v2/accounts/test-account-123/").mock(
|
||||||
|
return_value=httpx.Response(200, json=sample_account_data)
|
||||||
|
)
|
||||||
|
respx.get("https://bankaccountdata.gocardless.com/api/v2/accounts/test-account-123/transactions/").mock(
|
||||||
|
return_value=httpx.Response(200, json=sample_transaction_data)
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch('leggend.config.config', mock_config):
|
||||||
|
response = api_client.get("/api/v1/accounts/test-account-123/transactions?summary_only=true")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["success"] is True
|
||||||
|
assert len(data["data"]) == 1
|
||||||
|
|
||||||
|
transaction = data["data"][0]
|
||||||
|
assert transaction["internal_transaction_id"] == "txn-123"
|
||||||
|
assert transaction["amount"] == -10.50
|
||||||
|
assert transaction["currency"] == "EUR"
|
||||||
|
assert transaction["description"] == "Coffee Shop Payment"
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
def test_get_account_transactions_full_details(self, api_client, mock_config, mock_auth_token, sample_account_data, sample_transaction_data):
|
||||||
|
"""Test retrieval of full transaction details."""
|
||||||
|
# Mock GoCardless token creation
|
||||||
|
respx.post("https://bankaccountdata.gocardless.com/api/v2/token/new/").mock(
|
||||||
|
return_value=httpx.Response(200, json={"access": "test-token", "refresh": "test-refresh"})
|
||||||
|
)
|
||||||
|
|
||||||
|
# Mock GoCardless API calls
|
||||||
|
respx.get("https://bankaccountdata.gocardless.com/api/v2/accounts/test-account-123/").mock(
|
||||||
|
return_value=httpx.Response(200, json=sample_account_data)
|
||||||
|
)
|
||||||
|
respx.get("https://bankaccountdata.gocardless.com/api/v2/accounts/test-account-123/transactions/").mock(
|
||||||
|
return_value=httpx.Response(200, json=sample_transaction_data)
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch('leggend.config.config', mock_config):
|
||||||
|
response = api_client.get("/api/v1/accounts/test-account-123/transactions?summary_only=false")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["success"] is True
|
||||||
|
assert len(data["data"]) == 1
|
||||||
|
|
||||||
|
transaction = data["data"][0]
|
||||||
|
assert transaction["internal_transaction_id"] == "txn-123"
|
||||||
|
assert transaction["institution_id"] == "REVOLUT_REVOLT21"
|
||||||
|
assert transaction["iban"] == "LT313250081177977789"
|
||||||
|
assert "raw_transaction" in transaction
|
||||||
|
|
||||||
|
def test_get_account_not_found(self, api_client, mock_config, mock_auth_token):
|
||||||
|
"""Test handling of non-existent account."""
|
||||||
|
# Mock 404 response from GoCardless
|
||||||
|
with respx.mock:
|
||||||
|
# Mock GoCardless token creation
|
||||||
|
respx.post("https://bankaccountdata.gocardless.com/api/v2/token/new/").mock(
|
||||||
|
return_value=httpx.Response(200, json={"access": "test-token", "refresh": "test-refresh"})
|
||||||
|
)
|
||||||
|
|
||||||
|
respx.get("https://bankaccountdata.gocardless.com/api/v2/accounts/nonexistent/").mock(
|
||||||
|
return_value=httpx.Response(404, json={"detail": "Account not found"})
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch('leggend.config.config', mock_config):
|
||||||
|
response = api_client.get("/api/v1/accounts/nonexistent")
|
||||||
|
|
||||||
|
assert response.status_code == 404
|
||||||
154
tests/unit/test_api_banks.py
Normal file
154
tests/unit/test_api_banks.py
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
"""Tests for banks API endpoints."""
|
||||||
|
import pytest
|
||||||
|
import respx
|
||||||
|
import httpx
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from leggend.services.gocardless_service import GoCardlessService
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.api
|
||||||
|
class TestBanksAPI:
|
||||||
|
"""Test bank-related API endpoints."""
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
def test_get_institutions_success(self, api_client, mock_config, mock_auth_token, sample_bank_data):
|
||||||
|
"""Test successful retrieval of bank institutions."""
|
||||||
|
# Mock GoCardless token creation/refresh
|
||||||
|
respx.post("https://bankaccountdata.gocardless.com/api/v2/token/new/").mock(
|
||||||
|
return_value=httpx.Response(200, json={"access": "test-token", "refresh": "test-refresh"})
|
||||||
|
)
|
||||||
|
|
||||||
|
# Mock GoCardless institutions API
|
||||||
|
respx.get("https://bankaccountdata.gocardless.com/api/v2/institutions/").mock(
|
||||||
|
return_value=httpx.Response(200, json=sample_bank_data)
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch('leggend.config.config', mock_config):
|
||||||
|
response = api_client.get("/api/v1/banks/institutions?country=PT")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["success"] is True
|
||||||
|
assert len(data["data"]) == 2
|
||||||
|
assert data["data"][0]["id"] == "REVOLUT_REVOLT21"
|
||||||
|
assert data["data"][1]["id"] == "BANCOBPI_BBPIPTPL"
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
def test_get_institutions_invalid_country(self, api_client, mock_config):
|
||||||
|
"""Test institutions endpoint with invalid country code."""
|
||||||
|
# Mock GoCardless token creation
|
||||||
|
respx.post("https://bankaccountdata.gocardless.com/api/v2/token/new/").mock(
|
||||||
|
return_value=httpx.Response(200, json={"access": "test-token", "refresh": "test-refresh"})
|
||||||
|
)
|
||||||
|
|
||||||
|
# Mock empty institutions response for invalid country
|
||||||
|
respx.get("https://bankaccountdata.gocardless.com/api/v2/institutions/").mock(
|
||||||
|
return_value=httpx.Response(200, json=[])
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch('leggend.config.config', mock_config):
|
||||||
|
response = api_client.get("/api/v1/banks/institutions?country=XX")
|
||||||
|
|
||||||
|
# Should still work but return empty or filtered results
|
||||||
|
assert response.status_code in [200, 404]
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
def test_connect_to_bank_success(self, api_client, mock_config, mock_auth_token):
|
||||||
|
"""Test successful bank connection creation."""
|
||||||
|
requisition_data = {
|
||||||
|
"id": "req-123",
|
||||||
|
"institution_id": "REVOLUT_REVOLT21",
|
||||||
|
"status": "CR",
|
||||||
|
"created": "2025-09-02T00:00:00Z",
|
||||||
|
"link": "https://example.com/auth"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Mock GoCardless token creation
|
||||||
|
respx.post("https://bankaccountdata.gocardless.com/api/v2/token/new/").mock(
|
||||||
|
return_value=httpx.Response(200, json={"access": "test-token", "refresh": "test-refresh"})
|
||||||
|
)
|
||||||
|
|
||||||
|
# Mock GoCardless requisitions API
|
||||||
|
respx.post("https://bankaccountdata.gocardless.com/api/v2/requisitions/").mock(
|
||||||
|
return_value=httpx.Response(200, json=requisition_data)
|
||||||
|
)
|
||||||
|
|
||||||
|
request_data = {
|
||||||
|
"institution_id": "REVOLUT_REVOLT21",
|
||||||
|
"redirect_url": "http://localhost:8000/"
|
||||||
|
}
|
||||||
|
|
||||||
|
with patch('leggend.config.config', mock_config):
|
||||||
|
response = api_client.post("/api/v1/banks/connect", json=request_data)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["success"] is True
|
||||||
|
assert data["data"]["id"] == "req-123"
|
||||||
|
assert data["data"]["institution_id"] == "REVOLUT_REVOLT21"
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
def test_get_bank_status_success(self, api_client, mock_config, mock_auth_token):
|
||||||
|
"""Test successful retrieval of bank connection status."""
|
||||||
|
requisitions_data = {
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"id": "req-123",
|
||||||
|
"institution_id": "REVOLUT_REVOLT21",
|
||||||
|
"status": "LN",
|
||||||
|
"created": "2025-09-02T00:00:00Z",
|
||||||
|
"accounts": ["acc-123"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
# Mock GoCardless token creation
|
||||||
|
respx.post("https://bankaccountdata.gocardless.com/api/v2/token/new/").mock(
|
||||||
|
return_value=httpx.Response(200, json={"access": "test-token", "refresh": "test-refresh"})
|
||||||
|
)
|
||||||
|
|
||||||
|
# Mock GoCardless requisitions API
|
||||||
|
respx.get("https://bankaccountdata.gocardless.com/api/v2/requisitions/").mock(
|
||||||
|
return_value=httpx.Response(200, json=requisitions_data)
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch('leggend.config.config', mock_config):
|
||||||
|
response = api_client.get("/api/v1/banks/status")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["success"] is True
|
||||||
|
assert len(data["data"]) == 1
|
||||||
|
assert data["data"][0]["bank_id"] == "REVOLUT_REVOLT21"
|
||||||
|
assert data["data"][0]["status_display"] == "LINKED"
|
||||||
|
|
||||||
|
def test_get_supported_countries(self, api_client):
|
||||||
|
"""Test supported countries endpoint."""
|
||||||
|
response = api_client.get("/api/v1/banks/countries")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["success"] is True
|
||||||
|
assert len(data["data"]) > 0
|
||||||
|
|
||||||
|
# Check some expected countries
|
||||||
|
country_codes = [country["code"] for country in data["data"]]
|
||||||
|
assert "PT" in country_codes
|
||||||
|
assert "GB" in country_codes
|
||||||
|
assert "DE" in country_codes
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
def test_authentication_failure(self, api_client, mock_config):
|
||||||
|
"""Test handling of authentication failures."""
|
||||||
|
# Mock token creation failure
|
||||||
|
respx.post("https://bankaccountdata.gocardless.com/api/v2/token/new/").mock(
|
||||||
|
return_value=httpx.Response(401, json={"detail": "Invalid credentials"})
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch('leggend.config.config', mock_config):
|
||||||
|
response = api_client.get("/api/v1/banks/institutions")
|
||||||
|
|
||||||
|
assert response.status_code == 500
|
||||||
|
data = response.json()
|
||||||
|
assert "Failed to get institutions" in data["detail"]
|
||||||
150
tests/unit/test_api_client.py
Normal file
150
tests/unit/test_api_client.py
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
"""Tests for CLI API client."""
|
||||||
|
import pytest
|
||||||
|
import requests_mock
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from leggen.api_client import LeggendAPIClient
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.cli
|
||||||
|
class TestLeggendAPIClient:
|
||||||
|
"""Test the CLI API client."""
|
||||||
|
|
||||||
|
def test_health_check_success(self):
|
||||||
|
"""Test successful health check."""
|
||||||
|
client = LeggendAPIClient("http://localhost:8000")
|
||||||
|
|
||||||
|
with requests_mock.Mocker() as m:
|
||||||
|
m.get("http://localhost:8000/health", json={"status": "healthy"})
|
||||||
|
|
||||||
|
result = client.health_check()
|
||||||
|
assert result is True
|
||||||
|
|
||||||
|
def test_health_check_failure(self):
|
||||||
|
"""Test health check failure."""
|
||||||
|
client = LeggendAPIClient("http://localhost:8000")
|
||||||
|
|
||||||
|
with requests_mock.Mocker() as m:
|
||||||
|
m.get("http://localhost:8000/health", status_code=500)
|
||||||
|
|
||||||
|
result = client.health_check()
|
||||||
|
assert result is False
|
||||||
|
|
||||||
|
def test_get_institutions_success(self, sample_bank_data):
|
||||||
|
"""Test getting institutions via API client."""
|
||||||
|
client = LeggendAPIClient("http://localhost:8000")
|
||||||
|
|
||||||
|
api_response = {
|
||||||
|
"success": True,
|
||||||
|
"data": sample_bank_data,
|
||||||
|
"message": "Found 2 institutions for PT"
|
||||||
|
}
|
||||||
|
|
||||||
|
with requests_mock.Mocker() as m:
|
||||||
|
m.get("http://localhost:8000/api/v1/banks/institutions", json=api_response)
|
||||||
|
|
||||||
|
result = client.get_institutions("PT")
|
||||||
|
assert len(result) == 2
|
||||||
|
assert result[0]["id"] == "REVOLUT_REVOLT21"
|
||||||
|
|
||||||
|
def test_get_accounts_success(self, sample_account_data):
|
||||||
|
"""Test getting accounts via API client."""
|
||||||
|
client = LeggendAPIClient("http://localhost:8000")
|
||||||
|
|
||||||
|
api_response = {
|
||||||
|
"success": True,
|
||||||
|
"data": [sample_account_data],
|
||||||
|
"message": "Retrieved 1 accounts"
|
||||||
|
}
|
||||||
|
|
||||||
|
with requests_mock.Mocker() as m:
|
||||||
|
m.get("http://localhost:8000/api/v1/accounts", json=api_response)
|
||||||
|
|
||||||
|
result = client.get_accounts()
|
||||||
|
assert len(result) == 1
|
||||||
|
assert result[0]["id"] == "test-account-123"
|
||||||
|
|
||||||
|
def test_trigger_sync_success(self):
|
||||||
|
"""Test triggering sync via API client."""
|
||||||
|
client = LeggendAPIClient("http://localhost:8000")
|
||||||
|
|
||||||
|
api_response = {
|
||||||
|
"success": True,
|
||||||
|
"data": {"sync_started": True, "force": False},
|
||||||
|
"message": "Started sync for all accounts"
|
||||||
|
}
|
||||||
|
|
||||||
|
with requests_mock.Mocker() as m:
|
||||||
|
m.post("http://localhost:8000/api/v1/sync", json=api_response)
|
||||||
|
|
||||||
|
result = client.trigger_sync()
|
||||||
|
assert result["sync_started"] is True
|
||||||
|
|
||||||
|
def test_connection_error_handling(self):
|
||||||
|
"""Test handling of connection errors."""
|
||||||
|
client = LeggendAPIClient("http://localhost:9999") # Non-existent service
|
||||||
|
|
||||||
|
with pytest.raises(Exception):
|
||||||
|
client.get_accounts()
|
||||||
|
|
||||||
|
def test_http_error_handling(self):
|
||||||
|
"""Test handling of HTTP errors."""
|
||||||
|
client = LeggendAPIClient("http://localhost:8000")
|
||||||
|
|
||||||
|
with requests_mock.Mocker() as m:
|
||||||
|
m.get("http://localhost:8000/api/v1/accounts", status_code=500,
|
||||||
|
json={"detail": "Internal server error"})
|
||||||
|
|
||||||
|
with pytest.raises(Exception):
|
||||||
|
client.get_accounts()
|
||||||
|
|
||||||
|
def test_custom_api_url(self):
|
||||||
|
"""Test using custom API URL."""
|
||||||
|
custom_url = "http://custom-host:9000"
|
||||||
|
client = LeggendAPIClient(custom_url)
|
||||||
|
|
||||||
|
assert client.base_url == custom_url
|
||||||
|
|
||||||
|
def test_environment_variable_url(self):
|
||||||
|
"""Test using environment variable for API URL."""
|
||||||
|
with patch.dict('os.environ', {'LEGGEND_API_URL': 'http://env-host:7000'}):
|
||||||
|
client = LeggendAPIClient()
|
||||||
|
assert client.base_url == "http://env-host:7000"
|
||||||
|
|
||||||
|
def test_sync_with_options(self):
|
||||||
|
"""Test sync with various options."""
|
||||||
|
client = LeggendAPIClient("http://localhost:8000")
|
||||||
|
|
||||||
|
api_response = {
|
||||||
|
"success": True,
|
||||||
|
"data": {"sync_started": True, "force": True},
|
||||||
|
"message": "Started sync for 2 specific accounts"
|
||||||
|
}
|
||||||
|
|
||||||
|
with requests_mock.Mocker() as m:
|
||||||
|
m.post("http://localhost:8000/api/v1/sync", json=api_response)
|
||||||
|
|
||||||
|
result = client.trigger_sync(account_ids=["acc1", "acc2"], force=True)
|
||||||
|
assert result["sync_started"] is True
|
||||||
|
assert result["force"] is True
|
||||||
|
|
||||||
|
def test_get_scheduler_config(self):
|
||||||
|
"""Test getting scheduler configuration."""
|
||||||
|
client = LeggendAPIClient("http://localhost:8000")
|
||||||
|
|
||||||
|
api_response = {
|
||||||
|
"success": True,
|
||||||
|
"data": {
|
||||||
|
"enabled": True,
|
||||||
|
"hour": 3,
|
||||||
|
"minute": 0,
|
||||||
|
"next_scheduled_sync": "2025-09-03T03:00:00Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
with requests_mock.Mocker() as m:
|
||||||
|
m.get("http://localhost:8000/api/v1/sync/scheduler", json=api_response)
|
||||||
|
|
||||||
|
result = client.get_scheduler_config()
|
||||||
|
assert result["enabled"] is True
|
||||||
|
assert result["hour"] == 3
|
||||||
202
tests/unit/test_config.py
Normal file
202
tests/unit/test_config.py
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
"""Tests for configuration management."""
|
||||||
|
import pytest
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from leggend.config import Config
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestConfig:
|
||||||
|
"""Test configuration management."""
|
||||||
|
|
||||||
|
def test_singleton_behavior(self):
|
||||||
|
"""Test that Config is a singleton."""
|
||||||
|
config1 = Config()
|
||||||
|
config2 = Config()
|
||||||
|
assert config1 is config2
|
||||||
|
|
||||||
|
def test_load_config_success(self, temp_config_dir):
|
||||||
|
"""Test successful configuration loading."""
|
||||||
|
config_data = {
|
||||||
|
"gocardless": {
|
||||||
|
"key": "test-key",
|
||||||
|
"secret": "test-secret",
|
||||||
|
"url": "https://test.example.com"
|
||||||
|
},
|
||||||
|
"database": {
|
||||||
|
"sqlite": True
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
config_file = temp_config_dir / "config.toml"
|
||||||
|
with open(config_file, "wb") as f:
|
||||||
|
import tomli_w
|
||||||
|
tomli_w.dump(config_data, f)
|
||||||
|
|
||||||
|
config = Config()
|
||||||
|
# Reset singleton state for testing
|
||||||
|
config._config = None
|
||||||
|
config._config_path = None
|
||||||
|
|
||||||
|
result = config.load_config(str(config_file))
|
||||||
|
|
||||||
|
assert result == config_data
|
||||||
|
assert config.gocardless_config["key"] == "test-key"
|
||||||
|
assert config.database_config["sqlite"] is True
|
||||||
|
|
||||||
|
def test_load_config_file_not_found(self):
|
||||||
|
"""Test handling of missing configuration file."""
|
||||||
|
config = Config()
|
||||||
|
config._config = None # Reset for test
|
||||||
|
|
||||||
|
with pytest.raises(FileNotFoundError):
|
||||||
|
config.load_config("/nonexistent/config.toml")
|
||||||
|
|
||||||
|
def test_save_config_success(self, temp_config_dir):
|
||||||
|
"""Test successful configuration saving."""
|
||||||
|
config_data = {
|
||||||
|
"gocardless": {
|
||||||
|
"key": "new-key",
|
||||||
|
"secret": "new-secret"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
config_file = temp_config_dir / "new_config.toml"
|
||||||
|
config = Config()
|
||||||
|
config._config = None
|
||||||
|
|
||||||
|
config.save_config(config_data, str(config_file))
|
||||||
|
|
||||||
|
# Verify file was created and contains correct data
|
||||||
|
assert config_file.exists()
|
||||||
|
|
||||||
|
import tomllib
|
||||||
|
with open(config_file, "rb") as f:
|
||||||
|
saved_data = tomllib.load(f)
|
||||||
|
|
||||||
|
assert saved_data == config_data
|
||||||
|
|
||||||
|
def test_update_config_success(self, temp_config_dir):
|
||||||
|
"""Test updating configuration values."""
|
||||||
|
initial_config = {
|
||||||
|
"gocardless": {"key": "old-key"},
|
||||||
|
"database": {"sqlite": True}
|
||||||
|
}
|
||||||
|
|
||||||
|
config_file = temp_config_dir / "config.toml"
|
||||||
|
with open(config_file, "wb") as f:
|
||||||
|
import tomli_w
|
||||||
|
tomli_w.dump(initial_config, f)
|
||||||
|
|
||||||
|
config = Config()
|
||||||
|
config._config = None
|
||||||
|
config.load_config(str(config_file))
|
||||||
|
|
||||||
|
config.update_config("gocardless", "key", "new-key")
|
||||||
|
|
||||||
|
assert config.gocardless_config["key"] == "new-key"
|
||||||
|
|
||||||
|
# Verify it was saved to file
|
||||||
|
import tomllib
|
||||||
|
with open(config_file, "rb") as f:
|
||||||
|
saved_data = tomllib.load(f)
|
||||||
|
assert saved_data["gocardless"]["key"] == "new-key"
|
||||||
|
|
||||||
|
def test_update_section_success(self, temp_config_dir):
|
||||||
|
"""Test updating entire configuration section."""
|
||||||
|
initial_config = {
|
||||||
|
"database": {"sqlite": True, "mongodb": False}
|
||||||
|
}
|
||||||
|
|
||||||
|
config_file = temp_config_dir / "config.toml"
|
||||||
|
with open(config_file, "wb") as f:
|
||||||
|
import tomli_w
|
||||||
|
tomli_w.dump(initial_config, f)
|
||||||
|
|
||||||
|
config = Config()
|
||||||
|
config._config = None
|
||||||
|
config.load_config(str(config_file))
|
||||||
|
|
||||||
|
new_db_config = {"sqlite": False, "mongodb": True, "uri": "mongodb://localhost"}
|
||||||
|
config.update_section("database", new_db_config)
|
||||||
|
|
||||||
|
assert config.database_config == new_db_config
|
||||||
|
|
||||||
|
def test_scheduler_config_defaults(self):
|
||||||
|
"""Test scheduler configuration with defaults."""
|
||||||
|
config = Config()
|
||||||
|
config._config = {} # Empty config
|
||||||
|
|
||||||
|
scheduler_config = config.scheduler_config
|
||||||
|
|
||||||
|
assert scheduler_config["sync"]["enabled"] is True
|
||||||
|
assert scheduler_config["sync"]["hour"] == 3
|
||||||
|
assert scheduler_config["sync"]["minute"] == 0
|
||||||
|
assert scheduler_config["sync"]["cron"] is None
|
||||||
|
|
||||||
|
def test_scheduler_config_custom(self):
|
||||||
|
"""Test scheduler configuration with custom values."""
|
||||||
|
custom_config = {
|
||||||
|
"scheduler": {
|
||||||
|
"sync": {
|
||||||
|
"enabled": False,
|
||||||
|
"hour": 6,
|
||||||
|
"minute": 30,
|
||||||
|
"cron": "0 6 * * 1-5"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
config = Config()
|
||||||
|
config._config = custom_config
|
||||||
|
|
||||||
|
scheduler_config = config.scheduler_config
|
||||||
|
|
||||||
|
assert scheduler_config["sync"]["enabled"] is False
|
||||||
|
assert scheduler_config["sync"]["hour"] == 6
|
||||||
|
assert scheduler_config["sync"]["minute"] == 30
|
||||||
|
assert scheduler_config["sync"]["cron"] == "0 6 * * 1-5"
|
||||||
|
|
||||||
|
def test_environment_variable_config_path(self):
|
||||||
|
"""Test using environment variable for config path."""
|
||||||
|
with patch.dict('os.environ', {'LEGGEN_CONFIG_FILE': '/custom/path/config.toml'}):
|
||||||
|
config = Config()
|
||||||
|
config._config = None
|
||||||
|
|
||||||
|
with patch('builtins.open', side_effect=FileNotFoundError):
|
||||||
|
with pytest.raises(FileNotFoundError):
|
||||||
|
config.load_config()
|
||||||
|
|
||||||
|
def test_notifications_config(self):
|
||||||
|
"""Test notifications configuration access."""
|
||||||
|
custom_config = {
|
||||||
|
"notifications": {
|
||||||
|
"discord": {"webhook": "https://discord.webhook", "enabled": True},
|
||||||
|
"telegram": {"token": "bot-token", "chat_id": 123}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
config = Config()
|
||||||
|
config._config = custom_config
|
||||||
|
|
||||||
|
notifications = config.notifications_config
|
||||||
|
assert notifications["discord"]["webhook"] == "https://discord.webhook"
|
||||||
|
assert notifications["telegram"]["token"] == "bot-token"
|
||||||
|
|
||||||
|
def test_filters_config(self):
|
||||||
|
"""Test filters configuration access."""
|
||||||
|
custom_config = {
|
||||||
|
"filters": {
|
||||||
|
"case-insensitive": {"salary": "SALARY", "bills": "BILL"},
|
||||||
|
"amount_threshold": 100.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
config = Config()
|
||||||
|
config._config = custom_config
|
||||||
|
|
||||||
|
filters = config.filters_config
|
||||||
|
assert filters["case-insensitive"]["salary"] == "SALARY"
|
||||||
|
assert filters["amount_threshold"] == 100.0
|
||||||
211
tests/unit/test_scheduler.py
Normal file
211
tests/unit/test_scheduler.py
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
"""Tests for background scheduler."""
|
||||||
|
import pytest
|
||||||
|
import asyncio
|
||||||
|
from unittest.mock import Mock, patch, AsyncMock, MagicMock
|
||||||
|
from datetime import datetime
|
||||||
|
from apscheduler.schedulers.blocking import BlockingScheduler
|
||||||
|
|
||||||
|
from leggend.background.scheduler import BackgroundScheduler
|
||||||
|
from leggend.services.sync_service import SyncService
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestBackgroundScheduler:
|
||||||
|
"""Test background job scheduler."""
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_config(self):
|
||||||
|
"""Mock configuration for scheduler tests."""
|
||||||
|
return {
|
||||||
|
"sync": {
|
||||||
|
"enabled": True,
|
||||||
|
"hour": 3,
|
||||||
|
"minute": 0,
|
||||||
|
"cron": None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def scheduler(self):
|
||||||
|
"""Create scheduler instance for testing."""
|
||||||
|
with patch('leggend.background.scheduler.SyncService'), \
|
||||||
|
patch('leggend.background.scheduler.config') as mock_config:
|
||||||
|
|
||||||
|
mock_config.scheduler_config = {"sync": {"enabled": True, "hour": 3, "minute": 0}}
|
||||||
|
|
||||||
|
# Create scheduler and replace its AsyncIO scheduler with a mock
|
||||||
|
scheduler = BackgroundScheduler()
|
||||||
|
mock_scheduler = MagicMock()
|
||||||
|
mock_scheduler.running = False
|
||||||
|
mock_scheduler.get_jobs.return_value = []
|
||||||
|
scheduler.scheduler = mock_scheduler
|
||||||
|
return scheduler
|
||||||
|
|
||||||
|
def test_scheduler_start_default_config(self, scheduler, mock_config):
|
||||||
|
"""Test starting scheduler with default configuration."""
|
||||||
|
with patch('leggend.config.config') as mock_config_obj:
|
||||||
|
mock_config_obj.scheduler_config = mock_config
|
||||||
|
|
||||||
|
# Mock the job that gets added
|
||||||
|
mock_job = MagicMock()
|
||||||
|
mock_job.id = "daily_sync"
|
||||||
|
scheduler.scheduler.get_jobs.return_value = [mock_job]
|
||||||
|
|
||||||
|
scheduler.start()
|
||||||
|
|
||||||
|
# Verify scheduler.start() was called
|
||||||
|
scheduler.scheduler.start.assert_called_once()
|
||||||
|
# Verify add_job was called
|
||||||
|
scheduler.scheduler.add_job.assert_called_once()
|
||||||
|
|
||||||
|
def test_scheduler_start_disabled(self, scheduler):
|
||||||
|
"""Test scheduler behavior when sync is disabled."""
|
||||||
|
disabled_config = {
|
||||||
|
"sync": {"enabled": False}
|
||||||
|
}
|
||||||
|
|
||||||
|
with patch.object(scheduler, 'scheduler') as mock_scheduler, \
|
||||||
|
patch('leggend.background.scheduler.config') as mock_config_obj:
|
||||||
|
|
||||||
|
mock_config_obj.scheduler_config = disabled_config
|
||||||
|
mock_scheduler.running = False
|
||||||
|
|
||||||
|
scheduler.start()
|
||||||
|
|
||||||
|
# Verify scheduler.start() was called
|
||||||
|
mock_scheduler.start.assert_called_once()
|
||||||
|
# Verify add_job was NOT called for disabled sync
|
||||||
|
mock_scheduler.add_job.assert_not_called()
|
||||||
|
|
||||||
|
def test_scheduler_start_with_cron(self, scheduler):
|
||||||
|
"""Test starting scheduler with custom cron expression."""
|
||||||
|
cron_config = {
|
||||||
|
"sync": {
|
||||||
|
"enabled": True,
|
||||||
|
"cron": "0 6 * * 1-5" # 6 AM on weekdays
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
with patch('leggend.config.config') as mock_config_obj:
|
||||||
|
mock_config_obj.scheduler_config = cron_config
|
||||||
|
|
||||||
|
scheduler.start()
|
||||||
|
|
||||||
|
# Verify scheduler.start() and add_job were called
|
||||||
|
scheduler.scheduler.start.assert_called_once()
|
||||||
|
scheduler.scheduler.add_job.assert_called_once()
|
||||||
|
# Verify job was added with correct ID
|
||||||
|
call_args = scheduler.scheduler.add_job.call_args
|
||||||
|
assert call_args.kwargs['id'] == 'daily_sync'
|
||||||
|
|
||||||
|
def test_scheduler_start_invalid_cron(self, scheduler):
|
||||||
|
"""Test handling of invalid cron expressions."""
|
||||||
|
invalid_cron_config = {
|
||||||
|
"sync": {
|
||||||
|
"enabled": True,
|
||||||
|
"cron": "invalid cron"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
with patch.object(scheduler, 'scheduler') as mock_scheduler, \
|
||||||
|
patch('leggend.background.scheduler.config') as mock_config_obj:
|
||||||
|
|
||||||
|
mock_config_obj.scheduler_config = invalid_cron_config
|
||||||
|
mock_scheduler.running = False
|
||||||
|
|
||||||
|
scheduler.start()
|
||||||
|
|
||||||
|
# With invalid cron, scheduler.start() should not be called due to early return
|
||||||
|
# and add_job should not be called
|
||||||
|
mock_scheduler.start.assert_not_called()
|
||||||
|
mock_scheduler.add_job.assert_not_called()
|
||||||
|
|
||||||
|
def test_scheduler_shutdown(self, scheduler):
|
||||||
|
"""Test scheduler shutdown."""
|
||||||
|
scheduler.scheduler.running = True
|
||||||
|
|
||||||
|
scheduler.shutdown()
|
||||||
|
|
||||||
|
scheduler.scheduler.shutdown.assert_called_once()
|
||||||
|
|
||||||
|
def test_reschedule_sync(self, scheduler, mock_config):
|
||||||
|
"""Test rescheduling sync job."""
|
||||||
|
scheduler.scheduler.running = True
|
||||||
|
|
||||||
|
# Reschedule with new config
|
||||||
|
new_config = {
|
||||||
|
"enabled": True,
|
||||||
|
"hour": 6,
|
||||||
|
"minute": 30
|
||||||
|
}
|
||||||
|
|
||||||
|
scheduler.reschedule_sync(new_config)
|
||||||
|
|
||||||
|
# Verify remove_job and add_job were called
|
||||||
|
scheduler.scheduler.remove_job.assert_called_once_with("daily_sync")
|
||||||
|
scheduler.scheduler.add_job.assert_called_once()
|
||||||
|
|
||||||
|
def test_reschedule_sync_disable(self, scheduler, mock_config):
|
||||||
|
"""Test disabling sync via reschedule."""
|
||||||
|
scheduler.scheduler.running = True
|
||||||
|
|
||||||
|
# Disable sync
|
||||||
|
disabled_config = {"enabled": False}
|
||||||
|
scheduler.reschedule_sync(disabled_config)
|
||||||
|
|
||||||
|
# Job should be removed but not re-added
|
||||||
|
scheduler.scheduler.remove_job.assert_called_once_with("daily_sync")
|
||||||
|
scheduler.scheduler.add_job.assert_not_called()
|
||||||
|
|
||||||
|
def test_get_next_sync_time(self, scheduler, mock_config):
|
||||||
|
"""Test getting next scheduled sync time."""
|
||||||
|
mock_job = MagicMock()
|
||||||
|
mock_job.next_run_time = datetime(2025, 9, 2, 3, 0)
|
||||||
|
scheduler.scheduler.get_job.return_value = mock_job
|
||||||
|
|
||||||
|
next_time = scheduler.get_next_sync_time()
|
||||||
|
|
||||||
|
assert next_time is not None
|
||||||
|
assert isinstance(next_time, datetime)
|
||||||
|
scheduler.scheduler.get_job.assert_called_once_with("daily_sync")
|
||||||
|
|
||||||
|
def test_get_next_sync_time_no_job(self, scheduler):
|
||||||
|
"""Test getting next sync time when no job is scheduled."""
|
||||||
|
scheduler.scheduler.get_job.return_value = None
|
||||||
|
|
||||||
|
next_time = scheduler.get_next_sync_time()
|
||||||
|
|
||||||
|
assert next_time is None
|
||||||
|
scheduler.scheduler.get_job.assert_called_once_with("daily_sync")
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_run_sync_success(self, scheduler):
|
||||||
|
"""Test successful sync job execution."""
|
||||||
|
mock_sync_service = AsyncMock()
|
||||||
|
scheduler.sync_service = mock_sync_service
|
||||||
|
|
||||||
|
await scheduler._run_sync()
|
||||||
|
|
||||||
|
mock_sync_service.sync_all_accounts.assert_called_once()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_run_sync_failure(self, scheduler):
|
||||||
|
"""Test sync job execution with failure."""
|
||||||
|
mock_sync_service = AsyncMock()
|
||||||
|
mock_sync_service.sync_all_accounts.side_effect = Exception("Sync failed")
|
||||||
|
scheduler.sync_service = mock_sync_service
|
||||||
|
|
||||||
|
# Should not raise exception, just log error
|
||||||
|
await scheduler._run_sync()
|
||||||
|
|
||||||
|
mock_sync_service.sync_all_accounts.assert_called_once()
|
||||||
|
|
||||||
|
def test_scheduler_job_max_instances(self, scheduler, mock_config):
|
||||||
|
"""Test that sync jobs have max_instances=1."""
|
||||||
|
with patch('leggend.config.config') as mock_config_obj:
|
||||||
|
mock_config_obj.scheduler_config = mock_config
|
||||||
|
scheduler.start()
|
||||||
|
|
||||||
|
# Verify add_job was called with max_instances=1
|
||||||
|
call_args = scheduler.scheduler.add_job.call_args
|
||||||
|
assert call_args.kwargs['max_instances'] == 1
|
||||||
110
uv.lock
generated
110
uv.lock
generated
@@ -221,6 +221,15 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" },
|
{ url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "iniconfig"
|
||||||
|
version = "2.1.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793, upload-time = "2025-03-19T20:09:59.721Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050, upload-time = "2025-03-19T20:10:01.071Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "leggen"
|
name = "leggen"
|
||||||
version = "0.6.11"
|
version = "0.6.11"
|
||||||
@@ -242,6 +251,11 @@ dependencies = [
|
|||||||
[package.dev-dependencies]
|
[package.dev-dependencies]
|
||||||
dev = [
|
dev = [
|
||||||
{ name = "pre-commit" },
|
{ name = "pre-commit" },
|
||||||
|
{ name = "pytest" },
|
||||||
|
{ name = "pytest-asyncio" },
|
||||||
|
{ name = "pytest-mock" },
|
||||||
|
{ name = "requests-mock" },
|
||||||
|
{ name = "respx" },
|
||||||
{ name = "ruff" },
|
{ name = "ruff" },
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -263,6 +277,11 @@ requires-dist = [
|
|||||||
[package.metadata.requires-dev]
|
[package.metadata.requires-dev]
|
||||||
dev = [
|
dev = [
|
||||||
{ name = "pre-commit", specifier = ">=3.6.0" },
|
{ name = "pre-commit", specifier = ">=3.6.0" },
|
||||||
|
{ name = "pytest", specifier = ">=8.0.0" },
|
||||||
|
{ name = "pytest-asyncio", specifier = ">=0.23.0" },
|
||||||
|
{ name = "pytest-mock", specifier = ">=3.12.0" },
|
||||||
|
{ name = "requests-mock", specifier = ">=1.12.0" },
|
||||||
|
{ name = "respx", specifier = ">=0.21.0" },
|
||||||
{ name = "ruff", specifier = ">=0.6.1" },
|
{ name = "ruff", specifier = ">=0.6.1" },
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -288,6 +307,15 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/d2/1d/1b658dbd2b9fa9c4c9f32accbfc0205d532c8c6194dc0f2a4c0428e7128a/nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9", size = 22314, upload-time = "2024-06-04T18:44:08.352Z" },
|
{ url = "https://files.pythonhosted.org/packages/d2/1d/1b658dbd2b9fa9c4c9f32accbfc0205d532c8c6194dc0f2a4c0428e7128a/nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9", size = 22314, upload-time = "2024-06-04T18:44:08.352Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "packaging"
|
||||||
|
version = "25.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "platformdirs"
|
name = "platformdirs"
|
||||||
version = "4.3.6"
|
version = "4.3.6"
|
||||||
@@ -297,6 +325,15 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/3c/a6/bc1012356d8ece4d66dd75c4b9fc6c1f6650ddd5991e421177d9f8f671be/platformdirs-4.3.6-py3-none-any.whl", hash = "sha256:73e575e1408ab8103900836b97580d5307456908a03e92031bab39e4554cc3fb", size = 18439, upload-time = "2024-09-17T19:06:49.212Z" },
|
{ url = "https://files.pythonhosted.org/packages/3c/a6/bc1012356d8ece4d66dd75c4b9fc6c1f6650ddd5991e421177d9f8f671be/platformdirs-4.3.6-py3-none-any.whl", hash = "sha256:73e575e1408ab8103900836b97580d5307456908a03e92031bab39e4554cc3fb", size = 18439, upload-time = "2024-09-17T19:06:49.212Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pluggy"
|
||||||
|
version = "1.6.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pre-commit"
|
name = "pre-commit"
|
||||||
version = "4.0.1"
|
version = "4.0.1"
|
||||||
@@ -353,6 +390,15 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/0d/24/8b11e8b3e2be9dd82df4b11408a67c61bb4dc4f8e11b5b0fc888b38118b5/pydantic_core-2.33.2-cp312-cp312-win_arm64.whl", hash = "sha256:cca3868ddfaccfbc4bfb1d608e2ccaaebe0ae628e1416aeb9c4d88c001bb45ab", size = 1888894, upload-time = "2025-04-23T18:31:51.609Z" },
|
{ url = "https://files.pythonhosted.org/packages/0d/24/8b11e8b3e2be9dd82df4b11408a67c61bb4dc4f8e11b5b0fc888b38118b5/pydantic_core-2.33.2-cp312-cp312-win_arm64.whl", hash = "sha256:cca3868ddfaccfbc4bfb1d608e2ccaaebe0ae628e1416aeb9c4d88c001bb45ab", size = 1888894, upload-time = "2025-04-23T18:31:51.609Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pygments"
|
||||||
|
version = "2.19.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pymongo"
|
name = "pymongo"
|
||||||
version = "4.10.1"
|
version = "4.10.1"
|
||||||
@@ -373,6 +419,46 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/76/8b/5afce891d78159912c43726fab32641e3f9718f14be40f978c148ea8db48/pymongo-4.10.1-cp312-cp312-win_amd64.whl", hash = "sha256:dcc07b1277e8b4bf4d7382ca133850e323b7ab048b8353af496d050671c7ac52", size = 926686, upload-time = "2024-10-01T23:07:04.403Z" },
|
{ url = "https://files.pythonhosted.org/packages/76/8b/5afce891d78159912c43726fab32641e3f9718f14be40f978c148ea8db48/pymongo-4.10.1-cp312-cp312-win_amd64.whl", hash = "sha256:dcc07b1277e8b4bf4d7382ca133850e323b7ab048b8353af496d050671c7ac52", size = 926686, upload-time = "2024-10-01T23:07:04.403Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pytest"
|
||||||
|
version = "8.4.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||||
|
{ name = "iniconfig" },
|
||||||
|
{ name = "packaging" },
|
||||||
|
{ name = "pluggy" },
|
||||||
|
{ name = "pygments" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/08/ba/45911d754e8eba3d5a841a5ce61a65a685ff1798421ac054f85aa8747dfb/pytest-8.4.1.tar.gz", hash = "sha256:7c67fd69174877359ed9371ec3af8a3d2b04741818c51e5e99cc1742251fa93c", size = 1517714, upload-time = "2025-06-18T05:48:06.109Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/29/16/c8a903f4c4dffe7a12843191437d7cd8e32751d5de349d45d3fe69544e87/pytest-8.4.1-py3-none-any.whl", hash = "sha256:539c70ba6fcead8e78eebbf1115e8b589e7565830d7d006a8723f19ac8a0afb7", size = 365474, upload-time = "2025-06-18T05:48:03.955Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pytest-asyncio"
|
||||||
|
version = "1.1.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "pytest" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/4e/51/f8794af39eeb870e87a8c8068642fc07bce0c854d6865d7dd0f2a9d338c2/pytest_asyncio-1.1.0.tar.gz", hash = "sha256:796aa822981e01b68c12e4827b8697108f7205020f24b5793b3c41555dab68ea", size = 46652, upload-time = "2025-07-16T04:29:26.393Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c7/9d/bf86eddabf8c6c9cb1ea9a869d6873b46f105a5d292d3a6f7071f5b07935/pytest_asyncio-1.1.0-py3-none-any.whl", hash = "sha256:5fe2d69607b0bd75c656d1211f969cadba035030156745ee09e7d71740e58ecf", size = 15157, upload-time = "2025-07-16T04:29:24.929Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pytest-mock"
|
||||||
|
version = "3.14.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "pytest" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/71/28/67172c96ba684058a4d24ffe144d64783d2a270d0af0d9e792737bddc75c/pytest_mock-3.14.1.tar.gz", hash = "sha256:159e9edac4c451ce77a5cdb9fc5d1100708d2dd4ba3c3df572f14097351af80e", size = 33241, upload-time = "2025-05-26T13:58:45.167Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b2/05/77b60e520511c53d1c1ca75f1930c7dd8e971d0c4379b7f4b3f9644685ba/pytest_mock-3.14.1-py3-none-any.whl", hash = "sha256:178aefcd11307d874b4cd3100344e7e2d888d9791a6a1d9bfe90fbc1b74fd1d0", size = 9923, upload-time = "2025-05-26T13:58:43.487Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "python-dotenv"
|
name = "python-dotenv"
|
||||||
version = "1.1.1"
|
version = "1.1.1"
|
||||||
@@ -414,6 +500,30 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/f9/9b/335f9764261e915ed497fcdeb11df5dfd6f7bf257d4a6a2a686d80da4d54/requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6", size = 64928, upload-time = "2024-05-29T15:37:47.027Z" },
|
{ url = "https://files.pythonhosted.org/packages/f9/9b/335f9764261e915ed497fcdeb11df5dfd6f7bf257d4a6a2a686d80da4d54/requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6", size = 64928, upload-time = "2024-05-29T15:37:47.027Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "requests-mock"
|
||||||
|
version = "1.12.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "requests" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/92/32/587625f91f9a0a3d84688bf9cfc4b2480a7e8ec327cefd0ff2ac891fd2cf/requests-mock-1.12.1.tar.gz", hash = "sha256:e9e12e333b525156e82a3c852f22016b9158220d2f47454de9cae8a77d371401", size = 60901, upload-time = "2024-03-29T03:54:29.446Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/97/ec/889fbc557727da0c34a33850950310240f2040f3b1955175fdb2b36a8910/requests_mock-1.12.1-py2.py3-none-any.whl", hash = "sha256:b1e37054004cdd5e56c84454cc7df12b25f90f382159087f4b6915aaeef39563", size = 27695, upload-time = "2024-03-29T03:54:27.64Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "respx"
|
||||||
|
version = "0.22.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "httpx" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/f4/7c/96bd0bc759cf009675ad1ee1f96535edcb11e9666b985717eb8c87192a95/respx-0.22.0.tar.gz", hash = "sha256:3c8924caa2a50bd71aefc07aa812f2466ff489f1848c96e954a5362d17095d91", size = 28439, upload-time = "2024-12-19T22:33:59.374Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8e/67/afbb0978d5399bc9ea200f1d4489a23c9a1dad4eee6376242b8182389c79/respx-0.22.0-py2.py3-none-any.whl", hash = "sha256:631128d4c9aba15e56903fb5f66fb1eff412ce28dd387ca3a81339e52dbd3ad0", size = 25127, upload-time = "2024-12-19T22:33:57.837Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ruff"
|
name = "ruff"
|
||||||
version = "0.9.1"
|
version = "0.9.1"
|
||||||
|
|||||||
Reference in New Issue
Block a user