mirror of
https://github.com/elisiariocouto/leggen.git
synced 2025-12-13 10:12:24 +00:00
feat(frontend): Add S3 backup UI and complete backup functionality
Co-authored-by: elisiariocouto <818914+elisiariocouto@users.noreply.github.com>
This commit is contained in:
committed by
Elisiário Couto
parent
7f2a4634c5
commit
0122913052
297
tests/unit/test_api_backup.py
Normal file
297
tests/unit/test_api_backup.py
Normal file
@@ -0,0 +1,297 @@
|
||||
"""Tests for backup API endpoints."""
|
||||
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from leggen.api.models.backup import BackupSettings, S3Config
|
||||
from leggen.models.config import S3BackupConfig
|
||||
|
||||
|
||||
@pytest.mark.api
|
||||
class TestBackupAPI:
|
||||
"""Test backup-related API endpoints."""
|
||||
|
||||
def test_get_backup_settings_no_config(self, api_client, mock_config):
|
||||
"""Test getting backup settings with no configuration."""
|
||||
# Mock empty backup config by updating the config dict
|
||||
mock_config._config["backup"] = {}
|
||||
|
||||
with patch("leggen.utils.config.config", mock_config):
|
||||
response = api_client.get("/api/v1/backup/settings")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["success"] is True
|
||||
assert data["data"]["s3"] is None
|
||||
|
||||
def test_get_backup_settings_with_s3_config(self, api_client, mock_config):
|
||||
"""Test getting backup settings with S3 configuration."""
|
||||
# Mock S3 backup config (with masked credentials)
|
||||
mock_config._config["backup"] = {
|
||||
"s3": {
|
||||
"access_key_id": "AKIATEST123",
|
||||
"secret_access_key": "secret123",
|
||||
"bucket_name": "test-bucket",
|
||||
"region": "us-east-1",
|
||||
"endpoint_url": None,
|
||||
"path_style": False,
|
||||
"enabled": True,
|
||||
}
|
||||
}
|
||||
|
||||
with patch("leggen.utils.config.config", mock_config):
|
||||
response = api_client.get("/api/v1/backup/settings")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["success"] is True
|
||||
assert data["data"]["s3"] is not None
|
||||
|
||||
s3_config = data["data"]["s3"]
|
||||
assert s3_config["access_key_id"] == "***" # Masked
|
||||
assert s3_config["secret_access_key"] == "***" # Masked
|
||||
assert s3_config["bucket_name"] == "test-bucket"
|
||||
assert s3_config["region"] == "us-east-1"
|
||||
assert s3_config["enabled"] is True
|
||||
|
||||
@patch("leggen.services.backup_service.BackupService.test_connection")
|
||||
def test_update_backup_settings_success(self, mock_test_connection, api_client, mock_config):
|
||||
"""Test successful backup settings update."""
|
||||
mock_test_connection.return_value = True
|
||||
mock_config._config["backup"] = {}
|
||||
|
||||
request_data = {
|
||||
"s3": {
|
||||
"access_key_id": "AKIATEST123",
|
||||
"secret_access_key": "secret123",
|
||||
"bucket_name": "test-bucket",
|
||||
"region": "us-east-1",
|
||||
"endpoint_url": None,
|
||||
"path_style": False,
|
||||
"enabled": True,
|
||||
}
|
||||
}
|
||||
|
||||
with patch("leggen.utils.config.config", mock_config):
|
||||
response = api_client.put("/api/v1/backup/settings", json=request_data)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["success"] is True
|
||||
assert data["data"]["updated"] is True
|
||||
|
||||
# Verify connection test was called
|
||||
mock_test_connection.assert_called_once()
|
||||
|
||||
@patch("leggen.services.backup_service.BackupService.test_connection")
|
||||
def test_update_backup_settings_connection_failure(self, mock_test_connection, api_client, mock_config):
|
||||
"""Test backup settings update with connection test failure."""
|
||||
mock_test_connection.return_value = False
|
||||
mock_config._config["backup"] = {}
|
||||
|
||||
request_data = {
|
||||
"s3": {
|
||||
"access_key_id": "AKIATEST123",
|
||||
"secret_access_key": "secret123",
|
||||
"bucket_name": "invalid-bucket",
|
||||
"region": "us-east-1",
|
||||
"endpoint_url": None,
|
||||
"path_style": False,
|
||||
"enabled": True,
|
||||
}
|
||||
}
|
||||
|
||||
with patch("leggen.utils.config.config", mock_config):
|
||||
response = api_client.put("/api/v1/backup/settings", json=request_data)
|
||||
|
||||
assert response.status_code == 400
|
||||
data = response.json()
|
||||
assert "S3 connection test failed" in data["detail"]
|
||||
|
||||
@patch("leggen.services.backup_service.BackupService.test_connection")
|
||||
def test_test_backup_connection_success(self, mock_test_connection, api_client):
|
||||
"""Test successful backup connection test."""
|
||||
mock_test_connection.return_value = True
|
||||
|
||||
request_data = {
|
||||
"service": "s3",
|
||||
"config": {
|
||||
"access_key_id": "AKIATEST123",
|
||||
"secret_access_key": "secret123",
|
||||
"bucket_name": "test-bucket",
|
||||
"region": "us-east-1",
|
||||
"endpoint_url": None,
|
||||
"path_style": False,
|
||||
"enabled": True,
|
||||
}
|
||||
}
|
||||
|
||||
response = api_client.post("/api/v1/backup/test", json=request_data)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["success"] is True
|
||||
assert data["data"]["connected"] is True
|
||||
|
||||
# Verify connection test was called
|
||||
mock_test_connection.assert_called_once()
|
||||
|
||||
@patch("leggen.services.backup_service.BackupService.test_connection")
|
||||
def test_test_backup_connection_failure(self, mock_test_connection, api_client):
|
||||
"""Test failed backup connection test."""
|
||||
mock_test_connection.return_value = False
|
||||
|
||||
request_data = {
|
||||
"service": "s3",
|
||||
"config": {
|
||||
"access_key_id": "AKIATEST123",
|
||||
"secret_access_key": "secret123",
|
||||
"bucket_name": "invalid-bucket",
|
||||
"region": "us-east-1",
|
||||
"endpoint_url": None,
|
||||
"path_style": False,
|
||||
"enabled": True,
|
||||
}
|
||||
}
|
||||
|
||||
response = api_client.post("/api/v1/backup/test", json=request_data)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["success"] is False
|
||||
|
||||
def test_test_backup_connection_invalid_service(self, api_client):
|
||||
"""Test backup connection test with invalid service."""
|
||||
request_data = {
|
||||
"service": "invalid",
|
||||
"config": {
|
||||
"access_key_id": "AKIATEST123",
|
||||
"secret_access_key": "secret123",
|
||||
"bucket_name": "test-bucket",
|
||||
"region": "us-east-1",
|
||||
"endpoint_url": None,
|
||||
"path_style": False,
|
||||
"enabled": True,
|
||||
}
|
||||
}
|
||||
|
||||
response = api_client.post("/api/v1/backup/test", json=request_data)
|
||||
|
||||
assert response.status_code == 400
|
||||
data = response.json()
|
||||
assert "Only 's3' service is supported" in data["detail"]
|
||||
|
||||
@patch("leggen.services.backup_service.BackupService.list_backups")
|
||||
def test_list_backups_success(self, mock_list_backups, api_client, mock_config):
|
||||
"""Test successful backup listing."""
|
||||
mock_list_backups.return_value = [
|
||||
{
|
||||
"key": "leggen_backups/database_backup_20250101_120000.db",
|
||||
"last_modified": "2025-01-01T12:00:00",
|
||||
"size": 1024,
|
||||
},
|
||||
{
|
||||
"key": "leggen_backups/database_backup_20250101_110000.db",
|
||||
"last_modified": "2025-01-01T11:00:00",
|
||||
"size": 512,
|
||||
},
|
||||
]
|
||||
|
||||
mock_config._config["backup"] = {
|
||||
"s3": {
|
||||
"access_key_id": "AKIATEST123",
|
||||
"secret_access_key": "secret123",
|
||||
"bucket_name": "test-bucket",
|
||||
"region": "us-east-1",
|
||||
"enabled": True,
|
||||
}
|
||||
}
|
||||
|
||||
with patch("leggen.utils.config.config", mock_config):
|
||||
response = api_client.get("/api/v1/backup/list")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["success"] is True
|
||||
assert len(data["data"]) == 2
|
||||
assert data["data"][0]["key"] == "leggen_backups/database_backup_20250101_120000.db"
|
||||
|
||||
def test_list_backups_no_config(self, api_client, mock_config):
|
||||
"""Test backup listing with no configuration."""
|
||||
mock_config._config["backup"] = {}
|
||||
|
||||
with patch("leggen.utils.config.config", mock_config):
|
||||
response = api_client.get("/api/v1/backup/list")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["success"] is True
|
||||
assert data["data"] == []
|
||||
|
||||
@patch("leggen.services.backup_service.BackupService.backup_database")
|
||||
@patch("leggen.utils.paths.path_manager.get_database_path")
|
||||
def test_backup_operation_success(self, mock_get_db_path, mock_backup_db, api_client, mock_config):
|
||||
"""Test successful backup operation."""
|
||||
from pathlib import Path
|
||||
|
||||
mock_get_db_path.return_value = Path("/test/database.db")
|
||||
mock_backup_db.return_value = True
|
||||
|
||||
mock_config._config["backup"] = {
|
||||
"s3": {
|
||||
"access_key_id": "AKIATEST123",
|
||||
"secret_access_key": "secret123",
|
||||
"bucket_name": "test-bucket",
|
||||
"region": "us-east-1",
|
||||
"enabled": True,
|
||||
}
|
||||
}
|
||||
|
||||
request_data = {"operation": "backup"}
|
||||
|
||||
with patch("leggen.utils.config.config", mock_config):
|
||||
response = api_client.post("/api/v1/backup/operation", json=request_data)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["success"] is True
|
||||
assert data["data"]["operation"] == "backup"
|
||||
assert data["data"]["completed"] is True
|
||||
|
||||
# Verify backup was called
|
||||
mock_backup_db.assert_called_once()
|
||||
|
||||
def test_backup_operation_no_config(self, api_client, mock_config):
|
||||
"""Test backup operation with no configuration."""
|
||||
mock_config._config["backup"] = {}
|
||||
|
||||
request_data = {"operation": "backup"}
|
||||
|
||||
with patch("leggen.utils.config.config", mock_config):
|
||||
response = api_client.post("/api/v1/backup/operation", json=request_data)
|
||||
|
||||
assert response.status_code == 400
|
||||
data = response.json()
|
||||
assert "S3 backup is not configured" in data["detail"]
|
||||
|
||||
def test_backup_operation_invalid_operation(self, api_client, mock_config):
|
||||
"""Test backup operation with invalid operation type."""
|
||||
mock_config._config["backup"] = {
|
||||
"s3": {
|
||||
"access_key_id": "AKIATEST123",
|
||||
"secret_access_key": "secret123",
|
||||
"bucket_name": "test-bucket",
|
||||
"region": "us-east-1",
|
||||
"enabled": True,
|
||||
}
|
||||
}
|
||||
|
||||
request_data = {"operation": "invalid"}
|
||||
|
||||
with patch("leggen.utils.config.config", mock_config):
|
||||
response = api_client.post("/api/v1/backup/operation", json=request_data)
|
||||
|
||||
assert response.status_code == 400
|
||||
data = response.json()
|
||||
assert "Invalid operation" in data["detail"]
|
||||
221
tests/unit/test_backup_service.py
Normal file
221
tests/unit/test_backup_service.py
Normal file
@@ -0,0 +1,221 @@
|
||||
"""Tests for backup service functionality."""
|
||||
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from botocore.exceptions import ClientError, NoCredentialsError
|
||||
|
||||
from leggen.models.config import S3BackupConfig
|
||||
from leggen.services.backup_service import BackupService
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestBackupService:
|
||||
"""Test backup service functionality."""
|
||||
|
||||
def test_backup_service_initialization(self):
|
||||
"""Test backup service can be initialized."""
|
||||
service = BackupService()
|
||||
assert service.s3_config is None
|
||||
assert service._s3_client is None
|
||||
|
||||
def test_backup_service_with_config(self):
|
||||
"""Test backup service initialization with config."""
|
||||
s3_config = S3BackupConfig(
|
||||
access_key_id="test-key",
|
||||
secret_access_key="test-secret",
|
||||
bucket_name="test-bucket",
|
||||
region="us-east-1",
|
||||
)
|
||||
service = BackupService(s3_config)
|
||||
assert service.s3_config == s3_config
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_test_connection_success(self):
|
||||
"""Test successful S3 connection test."""
|
||||
s3_config = S3BackupConfig(
|
||||
access_key_id="test-key",
|
||||
secret_access_key="test-secret",
|
||||
bucket_name="test-bucket",
|
||||
region="us-east-1",
|
||||
)
|
||||
|
||||
service = BackupService()
|
||||
|
||||
# Mock S3 client
|
||||
with patch("boto3.Session") as mock_session:
|
||||
mock_client = MagicMock()
|
||||
mock_session.return_value.client.return_value = mock_client
|
||||
|
||||
# Mock successful list_objects_v2 call
|
||||
mock_client.list_objects_v2.return_value = {"Contents": []}
|
||||
|
||||
result = await service.test_connection(s3_config)
|
||||
assert result is True
|
||||
|
||||
# Verify the client was called correctly
|
||||
mock_client.list_objects_v2.assert_called_once_with(
|
||||
Bucket="test-bucket", MaxKeys=1
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_test_connection_no_credentials(self):
|
||||
"""Test S3 connection test with no credentials."""
|
||||
s3_config = S3BackupConfig(
|
||||
access_key_id="test-key",
|
||||
secret_access_key="test-secret",
|
||||
bucket_name="test-bucket",
|
||||
region="us-east-1",
|
||||
)
|
||||
|
||||
service = BackupService()
|
||||
|
||||
# Mock S3 client to raise NoCredentialsError
|
||||
with patch("boto3.Session") as mock_session:
|
||||
mock_client = MagicMock()
|
||||
mock_session.return_value.client.return_value = mock_client
|
||||
mock_client.list_objects_v2.side_effect = NoCredentialsError()
|
||||
|
||||
result = await service.test_connection(s3_config)
|
||||
assert result is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_test_connection_client_error(self):
|
||||
"""Test S3 connection test with client error."""
|
||||
s3_config = S3BackupConfig(
|
||||
access_key_id="test-key",
|
||||
secret_access_key="test-secret",
|
||||
bucket_name="test-bucket",
|
||||
region="us-east-1",
|
||||
)
|
||||
|
||||
service = BackupService()
|
||||
|
||||
# Mock S3 client to raise ClientError
|
||||
with patch("boto3.Session") as mock_session:
|
||||
mock_client = MagicMock()
|
||||
mock_session.return_value.client.return_value = mock_client
|
||||
error_response = {"Error": {"Code": "NoSuchBucket", "Message": "Bucket not found"}}
|
||||
mock_client.list_objects_v2.side_effect = ClientError(error_response, "ListObjectsV2")
|
||||
|
||||
result = await service.test_connection(s3_config)
|
||||
assert result is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_backup_database_no_config(self):
|
||||
"""Test backup database with no configuration."""
|
||||
service = BackupService()
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
db_path = Path(tmpdir) / "test.db"
|
||||
db_path.write_text("test database content")
|
||||
|
||||
result = await service.backup_database(db_path)
|
||||
assert result is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_backup_database_disabled(self):
|
||||
"""Test backup database with disabled configuration."""
|
||||
s3_config = S3BackupConfig(
|
||||
access_key_id="test-key",
|
||||
secret_access_key="test-secret",
|
||||
bucket_name="test-bucket",
|
||||
region="us-east-1",
|
||||
enabled=False,
|
||||
)
|
||||
service = BackupService(s3_config)
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
db_path = Path(tmpdir) / "test.db"
|
||||
db_path.write_text("test database content")
|
||||
|
||||
result = await service.backup_database(db_path)
|
||||
assert result is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_backup_database_file_not_found(self):
|
||||
"""Test backup database with non-existent file."""
|
||||
s3_config = S3BackupConfig(
|
||||
access_key_id="test-key",
|
||||
secret_access_key="test-secret",
|
||||
bucket_name="test-bucket",
|
||||
region="us-east-1",
|
||||
)
|
||||
service = BackupService(s3_config)
|
||||
|
||||
non_existent_path = Path("/non/existent/path.db")
|
||||
result = await service.backup_database(non_existent_path)
|
||||
assert result is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_backup_database_success(self):
|
||||
"""Test successful database backup."""
|
||||
s3_config = S3BackupConfig(
|
||||
access_key_id="test-key",
|
||||
secret_access_key="test-secret",
|
||||
bucket_name="test-bucket",
|
||||
region="us-east-1",
|
||||
)
|
||||
service = BackupService(s3_config)
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
db_path = Path(tmpdir) / "test.db"
|
||||
db_path.write_text("test database content")
|
||||
|
||||
# Mock S3 client
|
||||
with patch("boto3.Session") as mock_session:
|
||||
mock_client = MagicMock()
|
||||
mock_session.return_value.client.return_value = mock_client
|
||||
|
||||
result = await service.backup_database(db_path)
|
||||
assert result is True
|
||||
|
||||
# Verify upload_file was called
|
||||
mock_client.upload_file.assert_called_once()
|
||||
args = mock_client.upload_file.call_args[0]
|
||||
assert args[0] == str(db_path) # source file
|
||||
assert args[1] == "test-bucket" # bucket name
|
||||
assert args[2].startswith("leggen_backups/database_backup_") # key
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_backups_success(self):
|
||||
"""Test successful backup listing."""
|
||||
s3_config = S3BackupConfig(
|
||||
access_key_id="test-key",
|
||||
secret_access_key="test-secret",
|
||||
bucket_name="test-bucket",
|
||||
region="us-east-1",
|
||||
)
|
||||
service = BackupService(s3_config)
|
||||
|
||||
# Mock S3 client response
|
||||
with patch("boto3.Session") as mock_session:
|
||||
mock_client = MagicMock()
|
||||
mock_session.return_value.client.return_value = mock_client
|
||||
|
||||
from datetime import datetime
|
||||
mock_response = {
|
||||
"Contents": [
|
||||
{
|
||||
"Key": "leggen_backups/database_backup_20250101_120000.db",
|
||||
"LastModified": datetime(2025, 1, 1, 12, 0, 0),
|
||||
"Size": 1024,
|
||||
},
|
||||
{
|
||||
"Key": "leggen_backups/database_backup_20250101_130000.db",
|
||||
"LastModified": datetime(2025, 1, 1, 13, 0, 0),
|
||||
"Size": 2048,
|
||||
},
|
||||
]
|
||||
}
|
||||
mock_client.list_objects_v2.return_value = mock_response
|
||||
|
||||
backups = await service.list_backups()
|
||||
assert len(backups) == 2
|
||||
|
||||
# Check that backups are sorted by last modified (newest first)
|
||||
assert backups[0]["last_modified"] > backups[1]["last_modified"]
|
||||
assert backups[0]["size"] == 2048
|
||||
assert backups[1]["size"] == 1024
|
||||
Reference in New Issue
Block a user