fix(api): Fix S3 backup path-style configuration and improve UX.

- Fix critical S3 client configuration bug for path-style addressing
- Add toast notifications for better user feedback on S3 config operations
- Set up Toaster component in root layout for app-wide notifications
- Clean up unused imports in test files

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Elisiário Couto
2025-09-28 22:55:15 +01:00
committed by Elisiário Couto
parent 0122913052
commit 22ec0e36b1
7 changed files with 150 additions and 126 deletions

View File

@@ -1,6 +1,7 @@
import { useState, useEffect } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Cloud, TestTube } from "lucide-react";
import { toast } from "sonner";
import { apiClient } from "../lib/api";
import { Button } from "./ui/button";
import { Input } from "./ui/input";
@@ -55,9 +56,12 @@ export default function S3BackupConfigDrawer({
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["backupSettings"] });
setOpen(false);
toast.success("S3 backup configuration saved successfully");
},
onError: (error) => {
onError: (error: any) => {
console.error("Failed to update S3 backup configuration:", error);
const message = error?.response?.data?.detail || "Failed to save S3 configuration. Please check your settings and try again.";
toast.error(message);
},
});
@@ -69,9 +73,12 @@ export default function S3BackupConfigDrawer({
}),
onSuccess: () => {
console.log("S3 connection test successful");
toast.success("S3 connection test successful! Your configuration is working correctly.");
},
onError: (error) => {
onError: (error: any) => {
console.error("Failed to test S3 connection:", error);
const message = error?.response?.data?.detail || "S3 connection test failed. Please verify your credentials and settings.";
toast.error(message);
},
});

View File

@@ -5,6 +5,7 @@ import { PWAInstallPrompt, PWAUpdatePrompt } from "../components/PWAPrompts";
import { usePWA } from "../hooks/usePWA";
import { useVersionCheck } from "../hooks/useVersionCheck";
import { SidebarInset, SidebarProvider } from "../components/ui/sidebar";
import { Toaster } from "../components/ui/sonner";
function RootLayout() {
const { updateAvailable, updateSW, forceReload } = usePWA();
@@ -48,6 +49,9 @@ function RootLayout() {
updateAvailable={updateAvailable}
onUpdate={handlePWAUpdate}
/>
{/* Toast Notifications */}
<Toaster />
</SidebarProvider>
);
}

View File

@@ -7,7 +7,7 @@ from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from loguru import logger
from leggen.api.routes import accounts, banks, backup, notifications, sync, transactions
from leggen.api.routes import accounts, backup, banks, notifications, sync, transactions
from leggen.background.scheduler import scheduler
from leggen.utils.config import config
from leggen.utils.paths import path_manager

View File

@@ -38,8 +38,9 @@ class BackupService:
s3_kwargs["endpoint_url"] = current_config.endpoint_url
if current_config.path_style:
s3_kwargs["config"] = boto3.client("s3").meta.config
s3_kwargs["config"].s3 = {"addressing_style": "path"}
from botocore.config import Config
s3_kwargs["config"] = Config(s3={"addressing_style": "path"})
return session.client("s3", **s3_kwargs)
@@ -58,7 +59,9 @@ class BackupService:
# Try to list objects in the bucket (limited to 1 to minimize cost)
s3_client.list_objects_v2(Bucket=config.bucket_name, MaxKeys=1)
logger.info(f"S3 connection test successful for bucket: {config.bucket_name}")
logger.info(
f"S3 connection test successful for bucket: {config.bucket_name}"
)
return True
except NoCredentialsError:
@@ -66,7 +69,9 @@ class BackupService:
return False
except ClientError as e:
error_code = e.response["Error"]["Code"]
logger.error(f"S3 connection test failed: {error_code} - {e.response['Error']['Message']}")
logger.error(
f"S3 connection test failed: {error_code} - {e.response['Error']['Message']}"
)
return False
except Exception as e:
logger.error(f"Unexpected error during S3 connection test: {str(e)}")
@@ -99,9 +104,7 @@ class BackupService:
# Upload database file
logger.info(f"Starting database backup to S3: {backup_key}")
s3_client.upload_file(
str(database_path),
self.s3_config.bucket_name,
backup_key
str(database_path), self.s3_config.bucket_name, backup_key
)
logger.info(f"Database backup completed successfully: {backup_key}")
@@ -126,17 +129,18 @@ class BackupService:
# List objects with backup prefix
response = s3_client.list_objects_v2(
Bucket=self.s3_config.bucket_name,
Prefix="leggen_backups/"
Bucket=self.s3_config.bucket_name, Prefix="leggen_backups/"
)
backups = []
for obj in response.get("Contents", []):
backups.append({
backups.append(
{
"key": obj["Key"],
"last_modified": obj["LastModified"].isoformat(),
"size": obj["Size"],
})
}
)
# Sort by last modified (newest first)
backups.sort(key=lambda x: x["last_modified"], reverse=True)
@@ -173,9 +177,7 @@ class BackupService:
# Download to temporary file first, then move to final location
with tempfile.NamedTemporaryFile(delete=False) as temp_file:
s3_client.download_file(
self.s3_config.bucket_name,
backup_key,
temp_file.name
self.s3_config.bucket_name, backup_key, temp_file.name
)
# Move temp file to final location

View File

@@ -89,5 +89,5 @@ markers = [
]
[[tool.mypy.overrides]]
module = ["apscheduler.*", "discord_webhook.*"]
module = ["apscheduler.*", "discord_webhook.*", "botocore.*", "boto3.*"]
ignore_missing_imports = true

View File

@@ -1,12 +1,9 @@
"""Tests for backup API endpoints."""
from unittest.mock import AsyncMock, patch
from unittest.mock import patch
import pytest
from leggen.api.models.backup import BackupSettings, S3Config
from leggen.models.config import S3BackupConfig
@pytest.mark.api
class TestBackupAPI:
@@ -56,7 +53,9 @@ class TestBackupAPI:
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):
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"] = {}
@@ -85,7 +84,9 @@ class TestBackupAPI:
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):
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"] = {}
@@ -124,7 +125,7 @@ class TestBackupAPI:
"endpoint_url": None,
"path_style": False,
"enabled": True,
}
},
}
response = api_client.post("/api/v1/backup/test", json=request_data)
@@ -152,7 +153,7 @@ class TestBackupAPI:
"endpoint_url": None,
"path_style": False,
"enabled": True,
}
},
}
response = api_client.post("/api/v1/backup/test", json=request_data)
@@ -173,7 +174,7 @@ class TestBackupAPI:
"endpoint_url": None,
"path_style": False,
"enabled": True,
}
},
}
response = api_client.post("/api/v1/backup/test", json=request_data)
@@ -215,7 +216,10 @@ class TestBackupAPI:
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"
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."""
@@ -231,7 +235,9 @@ class TestBackupAPI:
@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):
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

View File

@@ -2,7 +2,7 @@
import tempfile
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock, patch
from unittest.mock import MagicMock, patch
import pytest
from botocore.exceptions import ClientError, NoCredentialsError
@@ -97,8 +97,12 @@ class TestBackupService:
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")
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
@@ -196,6 +200,7 @@ class TestBackupService:
mock_session.return_value.client.return_value = mock_client
from datetime import datetime
mock_response = {
"Contents": [
{