From 0122913052793bcbf011cb557ef182be21c5de93 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 23 Sep 2025 00:32:18 +0000 Subject: [PATCH] feat(frontend): Add S3 backup UI and complete backup functionality Co-authored-by: elisiariocouto <818914+elisiariocouto@users.noreply.github.com> --- .../src/components/S3BackupConfigDrawer.tsx | 248 +++++++++++++++ frontend/src/components/Settings.tsx | 126 +++++++- frontend/src/lib/api.ts | 35 +++ frontend/src/types/api.ts | 31 ++ leggen/api/routes/backup.py | 12 +- tests/unit/test_api_backup.py | 297 ++++++++++++++++++ tests/unit/test_backup_service.py | 221 +++++++++++++ 7 files changed, 965 insertions(+), 5 deletions(-) create mode 100644 frontend/src/components/S3BackupConfigDrawer.tsx create mode 100644 tests/unit/test_api_backup.py create mode 100644 tests/unit/test_backup_service.py diff --git a/frontend/src/components/S3BackupConfigDrawer.tsx b/frontend/src/components/S3BackupConfigDrawer.tsx new file mode 100644 index 0000000..daa468f --- /dev/null +++ b/frontend/src/components/S3BackupConfigDrawer.tsx @@ -0,0 +1,248 @@ +import { useState, useEffect } from "react"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { Cloud, TestTube } from "lucide-react"; +import { apiClient } from "../lib/api"; +import { Button } from "./ui/button"; +import { Input } from "./ui/input"; +import { Label } from "./ui/label"; +import { Switch } from "./ui/switch"; +import { EditButton } from "./ui/edit-button"; +import { + Drawer, + DrawerClose, + DrawerContent, + DrawerDescription, + DrawerFooter, + DrawerHeader, + DrawerTitle, + DrawerTrigger, +} from "./ui/drawer"; +import type { BackupSettings, S3Config } from "../types/api"; + +interface S3BackupConfigDrawerProps { + settings?: BackupSettings; + trigger?: React.ReactNode; +} + +export default function S3BackupConfigDrawer({ + settings, + trigger, +}: S3BackupConfigDrawerProps) { + const [open, setOpen] = useState(false); + const [config, setConfig] = useState({ + access_key_id: "", + secret_access_key: "", + bucket_name: "", + region: "us-east-1", + endpoint_url: "", + path_style: false, + enabled: true, + }); + + const queryClient = useQueryClient(); + + useEffect(() => { + if (settings?.s3) { + setConfig({ ...settings.s3 }); + } + }, [settings]); + + const updateMutation = useMutation({ + mutationFn: (s3Config: S3Config) => + apiClient.updateBackupSettings({ + s3: s3Config, + }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["backupSettings"] }); + setOpen(false); + }, + onError: (error) => { + console.error("Failed to update S3 backup configuration:", error); + }, + }); + + const testMutation = useMutation({ + mutationFn: () => + apiClient.testBackupConnection({ + service: "s3", + config: config, + }), + onSuccess: () => { + console.log("S3 connection test successful"); + }, + onError: (error) => { + console.error("Failed to test S3 connection:", error); + }, + }); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + updateMutation.mutate(config); + }; + + const handleTest = () => { + testMutation.mutate(); + }; + + const isConfigValid = + config.access_key_id.trim().length > 0 && + config.secret_access_key.trim().length > 0 && + config.bucket_name.trim().length > 0; + + return ( + + + {trigger || } + + +
+ + + + S3 Backup Configuration + + + Configure S3 settings for automatic database backups + + + +
+
+ + setConfig({ ...config, enabled: checked }) + } + /> + +
+ + {config.enabled && ( + <> +
+ + + setConfig({ ...config, access_key_id: e.target.value }) + } + placeholder="Your AWS Access Key ID" + required + /> +
+ +
+ + + setConfig({ ...config, secret_access_key: e.target.value }) + } + placeholder="Your AWS Secret Access Key" + required + /> +
+ +
+ + + setConfig({ ...config, bucket_name: e.target.value }) + } + placeholder="my-backup-bucket" + required + /> +
+ +
+ + + setConfig({ ...config, region: e.target.value }) + } + placeholder="us-east-1" + required + /> +
+ +
+ + + setConfig({ ...config, endpoint_url: e.target.value }) + } + placeholder="https://custom-s3-endpoint.com" + /> +

+ For S3-compatible services like MinIO or DigitalOcean Spaces +

+
+ +
+ + setConfig({ ...config, path_style: checked }) + } + /> + +
+

+ Enable for older S3 implementations or certain S3-compatible services +

+ + )} + + +
+ + {config.enabled && isConfigValid && ( + + )} +
+ + + +
+
+
+
+
+ ); +} \ No newline at end of file diff --git a/frontend/src/components/Settings.tsx b/frontend/src/components/Settings.tsx index 3bb06ee..ea13550 100644 --- a/frontend/src/components/Settings.tsx +++ b/frontend/src/components/Settings.tsx @@ -16,6 +16,7 @@ import { Trash2, User, Filter, + Cloud, } from "lucide-react"; import { apiClient } from "../lib/api"; import { formatCurrency, formatDate } from "../lib/utils"; @@ -35,11 +36,13 @@ import NotificationFiltersDrawer from "./NotificationFiltersDrawer"; import DiscordConfigDrawer from "./DiscordConfigDrawer"; import TelegramConfigDrawer from "./TelegramConfigDrawer"; import AddBankAccountDrawer from "./AddBankAccountDrawer"; +import S3BackupConfigDrawer from "./S3BackupConfigDrawer"; import type { Account, Balance, NotificationSettings, NotificationService, + BackupSettings, } from "../types/api"; // Helper function to get status indicator color and styles @@ -125,6 +128,17 @@ export default function Settings() { queryFn: apiClient.getBankConnectionsStatus, }); + // Backup queries + const { + data: backupSettings, + isLoading: backupLoading, + error: backupError, + refetch: refetchBackup, + } = useQuery({ + queryKey: ["backupSettings"], + queryFn: apiClient.getBackupSettings, + }); + // Account mutations const updateAccountMutation = useMutation({ mutationFn: ({ id, display_name }: { id: string; display_name: string }) => @@ -189,8 +203,8 @@ export default function Settings() { } }; - const isLoading = accountsLoading || settingsLoading || servicesLoading; - const hasError = accountsError || settingsError || servicesError; + const isLoading = accountsLoading || settingsLoading || servicesLoading || backupLoading; + const hasError = accountsError || settingsError || servicesError || backupError; if (isLoading) { return ; @@ -211,6 +225,7 @@ export default function Settings() { refetchAccounts(); refetchSettings(); refetchServices(); + refetchBackup(); }} variant="outline" size="sm" @@ -226,7 +241,7 @@ export default function Settings() { return (
- + Accounts @@ -238,6 +253,10 @@ export default function Settings() { Notifications + + + Backup + @@ -728,6 +747,107 @@ export default function Settings() { + + + {/* S3 Backup Configuration */} + + + + + S3 Backup Configuration + + + Configure automatic database backups to Amazon S3 or S3-compatible storage + + + + + {!backupSettings?.s3 ? ( +
+ +

+ No S3 backup configured +

+

+ Set up S3 backup to automatically backup your database to the cloud. +

+ +
+ ) : ( +
+
+
+
+ +
+
+
+

+ S3 Backup +

+
+
+ + {backupSettings.s3.enabled ? 'Enabled' : 'Disabled'} + +
+
+
+

+ Bucket: {backupSettings.s3.bucket_name} +

+

+ Region: {backupSettings.s3.region} +

+ {backupSettings.s3.endpoint_url && ( +

+ Endpoint: {backupSettings.s3.endpoint_url} +

+ )} +
+
+
+ +
+ +
+
Backup Information
+

+ Database backups are stored in the "leggen_backups/" folder in your S3 bucket. + Backups include the complete SQLite database file. +

+
+ + +
+
+
+ )} + + +
); diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 7e6704d..3091553 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -17,6 +17,10 @@ import type { BankConnectionStatus, BankRequisition, Country, + BackupSettings, + BackupTest, + BackupInfo, + BackupOperation, } from "../types/api"; // Use VITE_API_URL for development, relative URLs for production @@ -274,6 +278,37 @@ export const apiClient = { const response = await api.get>("/banks/countries"); return response.data.data; }, + + // Backup endpoints + getBackupSettings: async (): Promise => { + const response = await api.get>( + "/backup/settings", + ); + return response.data.data; + }, + + updateBackupSettings: async ( + settings: BackupSettings, + ): Promise => { + const response = await api.put>( + "/backup/settings", + settings, + ); + return response.data.data; + }, + + testBackupConnection: async (test: BackupTest): Promise => { + await api.post("/backup/test", test); + }, + + listBackups: async (): Promise => { + const response = await api.get>("/backup/list"); + return response.data.data; + }, + + performBackupOperation: async (operation: BackupOperation): Promise => { + await api.post("/backup/operation", operation); + }, }; export default apiClient; diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index a6a7f4c..e4ae1d6 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -277,3 +277,34 @@ export interface Country { code: string; name: string; } + +// Backup types +export interface S3Config { + access_key_id: string; + secret_access_key: string; + bucket_name: string; + region: string; + endpoint_url?: string; + path_style: boolean; + enabled: boolean; +} + +export interface BackupSettings { + s3?: S3Config; +} + +export interface BackupTest { + service: string; + config: S3Config; +} + +export interface BackupInfo { + key: string; + last_modified: string; + size: number; +} + +export interface BackupOperation { + operation: string; + backup_key?: string; +} diff --git a/leggen/api/routes/backup.py b/leggen/api/routes/backup.py index 4923e9b..3f59f27 100644 --- a/leggen/api/routes/backup.py +++ b/leggen/api/routes/backup.py @@ -150,6 +150,8 @@ async def test_backup_connection(test_request: BackupTest) -> APIResponse: message="S3 connection test failed", ) + except HTTPException: + raise except Exception as e: logger.error(f"Failed to test backup connection: {e}") raise HTTPException( @@ -200,8 +202,14 @@ async def backup_operation(operation_request: BackupOperation) -> APIResponse: status_code=400, detail="S3 backup is not configured" ) - # Convert config to model - s3_config = S3BackupConfig(**backup_config) + # Convert config to model with validation + try: + s3_config = S3BackupConfig(**backup_config) + except Exception as e: + raise HTTPException( + status_code=400, detail=f"Invalid S3 configuration: {str(e)}" + ) from e + backup_service = BackupService(s3_config) if operation_request.operation == "backup": diff --git a/tests/unit/test_api_backup.py b/tests/unit/test_api_backup.py new file mode 100644 index 0000000..f7205ab --- /dev/null +++ b/tests/unit/test_api_backup.py @@ -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"] \ No newline at end of file diff --git a/tests/unit/test_backup_service.py b/tests/unit/test_backup_service.py new file mode 100644 index 0000000..14353af --- /dev/null +++ b/tests/unit/test_backup_service.py @@ -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 \ No newline at end of file