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:
Elisiário Couto
2025-09-02 00:34:54 +01:00
committed by Elisiário Couto
parent 4018b263f2
commit 34e793c75c
11 changed files with 1248 additions and 1 deletions

View 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

View 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"]

View 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
View 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

View 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