mirror of
https://github.com/elisiariocouto/leggen.git
synced 2025-12-13 11:22:21 +00:00
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:
committed by
Elisiário Couto
parent
0122913052
commit
22ec0e36b1
@@ -1,6 +1,7 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { Cloud, TestTube } from "lucide-react";
|
import { Cloud, TestTube } from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
import { apiClient } from "../lib/api";
|
import { apiClient } from "../lib/api";
|
||||||
import { Button } from "./ui/button";
|
import { Button } from "./ui/button";
|
||||||
import { Input } from "./ui/input";
|
import { Input } from "./ui/input";
|
||||||
@@ -55,9 +56,12 @@ export default function S3BackupConfigDrawer({
|
|||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ["backupSettings"] });
|
queryClient.invalidateQueries({ queryKey: ["backupSettings"] });
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
|
toast.success("S3 backup configuration saved successfully");
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error: any) => {
|
||||||
console.error("Failed to update S3 backup configuration:", error);
|
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: () => {
|
onSuccess: () => {
|
||||||
console.log("S3 connection test successful");
|
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);
|
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);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -245,4 +252,4 @@ export default function S3BackupConfigDrawer({
|
|||||||
</DrawerContent>
|
</DrawerContent>
|
||||||
</Drawer>
|
</Drawer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { PWAInstallPrompt, PWAUpdatePrompt } from "../components/PWAPrompts";
|
|||||||
import { usePWA } from "../hooks/usePWA";
|
import { usePWA } from "../hooks/usePWA";
|
||||||
import { useVersionCheck } from "../hooks/useVersionCheck";
|
import { useVersionCheck } from "../hooks/useVersionCheck";
|
||||||
import { SidebarInset, SidebarProvider } from "../components/ui/sidebar";
|
import { SidebarInset, SidebarProvider } from "../components/ui/sidebar";
|
||||||
|
import { Toaster } from "../components/ui/sonner";
|
||||||
|
|
||||||
function RootLayout() {
|
function RootLayout() {
|
||||||
const { updateAvailable, updateSW, forceReload } = usePWA();
|
const { updateAvailable, updateSW, forceReload } = usePWA();
|
||||||
@@ -48,6 +49,9 @@ function RootLayout() {
|
|||||||
updateAvailable={updateAvailable}
|
updateAvailable={updateAvailable}
|
||||||
onUpdate={handlePWAUpdate}
|
onUpdate={handlePWAUpdate}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Toast Notifications */}
|
||||||
|
<Toaster />
|
||||||
</SidebarProvider>
|
</SidebarProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ from fastapi import FastAPI
|
|||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from loguru import logger
|
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.background.scheduler import scheduler
|
||||||
from leggen.utils.config import config
|
from leggen.utils.config import config
|
||||||
from leggen.utils.paths import path_manager
|
from leggen.utils.paths import path_manager
|
||||||
|
|||||||
@@ -38,35 +38,40 @@ class BackupService:
|
|||||||
s3_kwargs["endpoint_url"] = current_config.endpoint_url
|
s3_kwargs["endpoint_url"] = current_config.endpoint_url
|
||||||
|
|
||||||
if current_config.path_style:
|
if current_config.path_style:
|
||||||
s3_kwargs["config"] = boto3.client("s3").meta.config
|
from botocore.config import Config
|
||||||
s3_kwargs["config"].s3 = {"addressing_style": "path"}
|
|
||||||
|
s3_kwargs["config"] = Config(s3={"addressing_style": "path"})
|
||||||
|
|
||||||
return session.client("s3", **s3_kwargs)
|
return session.client("s3", **s3_kwargs)
|
||||||
|
|
||||||
async def test_connection(self, config: S3BackupConfig) -> bool:
|
async def test_connection(self, config: S3BackupConfig) -> bool:
|
||||||
"""Test S3 connection with provided configuration.
|
"""Test S3 connection with provided configuration.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
config: S3 configuration to test
|
config: S3 configuration to test
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
True if connection successful, False otherwise
|
True if connection successful, False otherwise
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
s3_client = self._get_s3_client(config)
|
s3_client = self._get_s3_client(config)
|
||||||
|
|
||||||
# Try to list objects in the bucket (limited to 1 to minimize cost)
|
# Try to list objects in the bucket (limited to 1 to minimize cost)
|
||||||
s3_client.list_objects_v2(Bucket=config.bucket_name, MaxKeys=1)
|
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
|
return True
|
||||||
|
|
||||||
except NoCredentialsError:
|
except NoCredentialsError:
|
||||||
logger.error("S3 credentials not found or invalid")
|
logger.error("S3 credentials not found or invalid")
|
||||||
return False
|
return False
|
||||||
except ClientError as e:
|
except ClientError as e:
|
||||||
error_code = e.response["Error"]["Code"]
|
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
|
return False
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Unexpected error during S3 connection test: {str(e)}")
|
logger.error(f"Unexpected error during S3 connection test: {str(e)}")
|
||||||
@@ -74,10 +79,10 @@ class BackupService:
|
|||||||
|
|
||||||
async def backup_database(self, database_path: Path) -> bool:
|
async def backup_database(self, database_path: Path) -> bool:
|
||||||
"""Backup database file to S3.
|
"""Backup database file to S3.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
database_path: Path to the SQLite database file
|
database_path: Path to the SQLite database file
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
True if backup successful, False otherwise
|
True if backup successful, False otherwise
|
||||||
"""
|
"""
|
||||||
@@ -91,29 +96,27 @@ class BackupService:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
s3_client = self._get_s3_client()
|
s3_client = self._get_s3_client()
|
||||||
|
|
||||||
# Generate backup filename with timestamp
|
# Generate backup filename with timestamp
|
||||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||||
backup_key = f"leggen_backups/database_backup_{timestamp}.db"
|
backup_key = f"leggen_backups/database_backup_{timestamp}.db"
|
||||||
|
|
||||||
# Upload database file
|
# Upload database file
|
||||||
logger.info(f"Starting database backup to S3: {backup_key}")
|
logger.info(f"Starting database backup to S3: {backup_key}")
|
||||||
s3_client.upload_file(
|
s3_client.upload_file(
|
||||||
str(database_path),
|
str(database_path), self.s3_config.bucket_name, backup_key
|
||||||
self.s3_config.bucket_name,
|
|
||||||
backup_key
|
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.info(f"Database backup completed successfully: {backup_key}")
|
logger.info(f"Database backup completed successfully: {backup_key}")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Database backup failed: {str(e)}")
|
logger.error(f"Database backup failed: {str(e)}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
async def list_backups(self) -> list[dict]:
|
async def list_backups(self) -> list[dict]:
|
||||||
"""List available backups in S3.
|
"""List available backups in S3.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
List of backup metadata dictionaries
|
List of backup metadata dictionaries
|
||||||
"""
|
"""
|
||||||
@@ -123,37 +126,38 @@ class BackupService:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
s3_client = self._get_s3_client()
|
s3_client = self._get_s3_client()
|
||||||
|
|
||||||
# List objects with backup prefix
|
# List objects with backup prefix
|
||||||
response = s3_client.list_objects_v2(
|
response = s3_client.list_objects_v2(
|
||||||
Bucket=self.s3_config.bucket_name,
|
Bucket=self.s3_config.bucket_name, Prefix="leggen_backups/"
|
||||||
Prefix="leggen_backups/"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
backups = []
|
backups = []
|
||||||
for obj in response.get("Contents", []):
|
for obj in response.get("Contents", []):
|
||||||
backups.append({
|
backups.append(
|
||||||
"key": obj["Key"],
|
{
|
||||||
"last_modified": obj["LastModified"].isoformat(),
|
"key": obj["Key"],
|
||||||
"size": obj["Size"],
|
"last_modified": obj["LastModified"].isoformat(),
|
||||||
})
|
"size": obj["Size"],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
# Sort by last modified (newest first)
|
# Sort by last modified (newest first)
|
||||||
backups.sort(key=lambda x: x["last_modified"], reverse=True)
|
backups.sort(key=lambda x: x["last_modified"], reverse=True)
|
||||||
|
|
||||||
return backups
|
return backups
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to list backups: {str(e)}")
|
logger.error(f"Failed to list backups: {str(e)}")
|
||||||
return []
|
return []
|
||||||
|
|
||||||
async def restore_database(self, backup_key: str, restore_path: Path) -> bool:
|
async def restore_database(self, backup_key: str, restore_path: Path) -> bool:
|
||||||
"""Restore database from S3 backup.
|
"""Restore database from S3 backup.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
backup_key: S3 key of the backup to restore
|
backup_key: S3 key of the backup to restore
|
||||||
restore_path: Path where to restore the database
|
restore_path: Path where to restore the database
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
True if restore successful, False otherwise
|
True if restore successful, False otherwise
|
||||||
"""
|
"""
|
||||||
@@ -163,28 +167,26 @@ class BackupService:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
s3_client = self._get_s3_client()
|
s3_client = self._get_s3_client()
|
||||||
|
|
||||||
# Download backup file
|
# Download backup file
|
||||||
logger.info(f"Starting database restore from S3: {backup_key}")
|
logger.info(f"Starting database restore from S3: {backup_key}")
|
||||||
|
|
||||||
# Create parent directory if it doesn't exist
|
# Create parent directory if it doesn't exist
|
||||||
restore_path.parent.mkdir(parents=True, exist_ok=True)
|
restore_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
# Download to temporary file first, then move to final location
|
# Download to temporary file first, then move to final location
|
||||||
with tempfile.NamedTemporaryFile(delete=False) as temp_file:
|
with tempfile.NamedTemporaryFile(delete=False) as temp_file:
|
||||||
s3_client.download_file(
|
s3_client.download_file(
|
||||||
self.s3_config.bucket_name,
|
self.s3_config.bucket_name, backup_key, temp_file.name
|
||||||
backup_key,
|
|
||||||
temp_file.name
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Move temp file to final location
|
# Move temp file to final location
|
||||||
temp_path = Path(temp_file.name)
|
temp_path = Path(temp_file.name)
|
||||||
temp_path.replace(restore_path)
|
temp_path.replace(restore_path)
|
||||||
|
|
||||||
logger.info(f"Database restore completed successfully: {restore_path}")
|
logger.info(f"Database restore completed successfully: {restore_path}")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Database restore failed: {str(e)}")
|
logger.error(f"Database restore failed: {str(e)}")
|
||||||
return False
|
return False
|
||||||
|
|||||||
@@ -89,5 +89,5 @@ markers = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[[tool.mypy.overrides]]
|
[[tool.mypy.overrides]]
|
||||||
module = ["apscheduler.*", "discord_webhook.*"]
|
module = ["apscheduler.*", "discord_webhook.*", "botocore.*", "boto3.*"]
|
||||||
ignore_missing_imports = true
|
ignore_missing_imports = true
|
||||||
|
|||||||
@@ -1,12 +1,9 @@
|
|||||||
"""Tests for backup API endpoints."""
|
"""Tests for backup API endpoints."""
|
||||||
|
|
||||||
from unittest.mock import AsyncMock, patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from leggen.api.models.backup import BackupSettings, S3Config
|
|
||||||
from leggen.models.config import S3BackupConfig
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.api
|
@pytest.mark.api
|
||||||
class TestBackupAPI:
|
class TestBackupAPI:
|
||||||
@@ -16,10 +13,10 @@ class TestBackupAPI:
|
|||||||
"""Test getting backup settings with no configuration."""
|
"""Test getting backup settings with no configuration."""
|
||||||
# Mock empty backup config by updating the config dict
|
# Mock empty backup config by updating the config dict
|
||||||
mock_config._config["backup"] = {}
|
mock_config._config["backup"] = {}
|
||||||
|
|
||||||
with patch("leggen.utils.config.config", mock_config):
|
with patch("leggen.utils.config.config", mock_config):
|
||||||
response = api_client.get("/api/v1/backup/settings")
|
response = api_client.get("/api/v1/backup/settings")
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
data = response.json()
|
data = response.json()
|
||||||
assert data["success"] is True
|
assert data["success"] is True
|
||||||
@@ -39,15 +36,15 @@ class TestBackupAPI:
|
|||||||
"enabled": True,
|
"enabled": True,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
with patch("leggen.utils.config.config", mock_config):
|
with patch("leggen.utils.config.config", mock_config):
|
||||||
response = api_client.get("/api/v1/backup/settings")
|
response = api_client.get("/api/v1/backup/settings")
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
data = response.json()
|
data = response.json()
|
||||||
assert data["success"] is True
|
assert data["success"] is True
|
||||||
assert data["data"]["s3"] is not None
|
assert data["data"]["s3"] is not None
|
||||||
|
|
||||||
s3_config = data["data"]["s3"]
|
s3_config = data["data"]["s3"]
|
||||||
assert s3_config["access_key_id"] == "***" # Masked
|
assert s3_config["access_key_id"] == "***" # Masked
|
||||||
assert s3_config["secret_access_key"] == "***" # Masked
|
assert s3_config["secret_access_key"] == "***" # Masked
|
||||||
@@ -56,11 +53,13 @@ class TestBackupAPI:
|
|||||||
assert s3_config["enabled"] is True
|
assert s3_config["enabled"] is True
|
||||||
|
|
||||||
@patch("leggen.services.backup_service.BackupService.test_connection")
|
@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."""
|
"""Test successful backup settings update."""
|
||||||
mock_test_connection.return_value = True
|
mock_test_connection.return_value = True
|
||||||
mock_config._config["backup"] = {}
|
mock_config._config["backup"] = {}
|
||||||
|
|
||||||
request_data = {
|
request_data = {
|
||||||
"s3": {
|
"s3": {
|
||||||
"access_key_id": "AKIATEST123",
|
"access_key_id": "AKIATEST123",
|
||||||
@@ -72,24 +71,26 @@ class TestBackupAPI:
|
|||||||
"enabled": True,
|
"enabled": True,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
with patch("leggen.utils.config.config", mock_config):
|
with patch("leggen.utils.config.config", mock_config):
|
||||||
response = api_client.put("/api/v1/backup/settings", json=request_data)
|
response = api_client.put("/api/v1/backup/settings", json=request_data)
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
data = response.json()
|
data = response.json()
|
||||||
assert data["success"] is True
|
assert data["success"] is True
|
||||||
assert data["data"]["updated"] is True
|
assert data["data"]["updated"] is True
|
||||||
|
|
||||||
# Verify connection test was called
|
# Verify connection test was called
|
||||||
mock_test_connection.assert_called_once()
|
mock_test_connection.assert_called_once()
|
||||||
|
|
||||||
@patch("leggen.services.backup_service.BackupService.test_connection")
|
@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."""
|
"""Test backup settings update with connection test failure."""
|
||||||
mock_test_connection.return_value = False
|
mock_test_connection.return_value = False
|
||||||
mock_config._config["backup"] = {}
|
mock_config._config["backup"] = {}
|
||||||
|
|
||||||
request_data = {
|
request_data = {
|
||||||
"s3": {
|
"s3": {
|
||||||
"access_key_id": "AKIATEST123",
|
"access_key_id": "AKIATEST123",
|
||||||
@@ -101,10 +102,10 @@ class TestBackupAPI:
|
|||||||
"enabled": True,
|
"enabled": True,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
with patch("leggen.utils.config.config", mock_config):
|
with patch("leggen.utils.config.config", mock_config):
|
||||||
response = api_client.put("/api/v1/backup/settings", json=request_data)
|
response = api_client.put("/api/v1/backup/settings", json=request_data)
|
||||||
|
|
||||||
assert response.status_code == 400
|
assert response.status_code == 400
|
||||||
data = response.json()
|
data = response.json()
|
||||||
assert "S3 connection test failed" in data["detail"]
|
assert "S3 connection test failed" in data["detail"]
|
||||||
@@ -113,7 +114,7 @@ class TestBackupAPI:
|
|||||||
def test_test_backup_connection_success(self, mock_test_connection, api_client):
|
def test_test_backup_connection_success(self, mock_test_connection, api_client):
|
||||||
"""Test successful backup connection test."""
|
"""Test successful backup connection test."""
|
||||||
mock_test_connection.return_value = True
|
mock_test_connection.return_value = True
|
||||||
|
|
||||||
request_data = {
|
request_data = {
|
||||||
"service": "s3",
|
"service": "s3",
|
||||||
"config": {
|
"config": {
|
||||||
@@ -124,16 +125,16 @@ class TestBackupAPI:
|
|||||||
"endpoint_url": None,
|
"endpoint_url": None,
|
||||||
"path_style": False,
|
"path_style": False,
|
||||||
"enabled": True,
|
"enabled": True,
|
||||||
}
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
response = api_client.post("/api/v1/backup/test", json=request_data)
|
response = api_client.post("/api/v1/backup/test", json=request_data)
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
data = response.json()
|
data = response.json()
|
||||||
assert data["success"] is True
|
assert data["success"] is True
|
||||||
assert data["data"]["connected"] is True
|
assert data["data"]["connected"] is True
|
||||||
|
|
||||||
# Verify connection test was called
|
# Verify connection test was called
|
||||||
mock_test_connection.assert_called_once()
|
mock_test_connection.assert_called_once()
|
||||||
|
|
||||||
@@ -141,7 +142,7 @@ class TestBackupAPI:
|
|||||||
def test_test_backup_connection_failure(self, mock_test_connection, api_client):
|
def test_test_backup_connection_failure(self, mock_test_connection, api_client):
|
||||||
"""Test failed backup connection test."""
|
"""Test failed backup connection test."""
|
||||||
mock_test_connection.return_value = False
|
mock_test_connection.return_value = False
|
||||||
|
|
||||||
request_data = {
|
request_data = {
|
||||||
"service": "s3",
|
"service": "s3",
|
||||||
"config": {
|
"config": {
|
||||||
@@ -152,11 +153,11 @@ class TestBackupAPI:
|
|||||||
"endpoint_url": None,
|
"endpoint_url": None,
|
||||||
"path_style": False,
|
"path_style": False,
|
||||||
"enabled": True,
|
"enabled": True,
|
||||||
}
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
response = api_client.post("/api/v1/backup/test", json=request_data)
|
response = api_client.post("/api/v1/backup/test", json=request_data)
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
data = response.json()
|
data = response.json()
|
||||||
assert data["success"] is False
|
assert data["success"] is False
|
||||||
@@ -173,11 +174,11 @@ class TestBackupAPI:
|
|||||||
"endpoint_url": None,
|
"endpoint_url": None,
|
||||||
"path_style": False,
|
"path_style": False,
|
||||||
"enabled": True,
|
"enabled": True,
|
||||||
}
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
response = api_client.post("/api/v1/backup/test", json=request_data)
|
response = api_client.post("/api/v1/backup/test", json=request_data)
|
||||||
|
|
||||||
assert response.status_code == 400
|
assert response.status_code == 400
|
||||||
data = response.json()
|
data = response.json()
|
||||||
assert "Only 's3' service is supported" in data["detail"]
|
assert "Only 's3' service is supported" in data["detail"]
|
||||||
@@ -197,7 +198,7 @@ class TestBackupAPI:
|
|||||||
"size": 512,
|
"size": 512,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
mock_config._config["backup"] = {
|
mock_config._config["backup"] = {
|
||||||
"s3": {
|
"s3": {
|
||||||
"access_key_id": "AKIATEST123",
|
"access_key_id": "AKIATEST123",
|
||||||
@@ -207,23 +208,26 @@ class TestBackupAPI:
|
|||||||
"enabled": True,
|
"enabled": True,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
with patch("leggen.utils.config.config", mock_config):
|
with patch("leggen.utils.config.config", mock_config):
|
||||||
response = api_client.get("/api/v1/backup/list")
|
response = api_client.get("/api/v1/backup/list")
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
data = response.json()
|
data = response.json()
|
||||||
assert data["success"] is True
|
assert data["success"] is True
|
||||||
assert len(data["data"]) == 2
|
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):
|
def test_list_backups_no_config(self, api_client, mock_config):
|
||||||
"""Test backup listing with no configuration."""
|
"""Test backup listing with no configuration."""
|
||||||
mock_config._config["backup"] = {}
|
mock_config._config["backup"] = {}
|
||||||
|
|
||||||
with patch("leggen.utils.config.config", mock_config):
|
with patch("leggen.utils.config.config", mock_config):
|
||||||
response = api_client.get("/api/v1/backup/list")
|
response = api_client.get("/api/v1/backup/list")
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
data = response.json()
|
data = response.json()
|
||||||
assert data["success"] is True
|
assert data["success"] is True
|
||||||
@@ -231,13 +235,15 @@ class TestBackupAPI:
|
|||||||
|
|
||||||
@patch("leggen.services.backup_service.BackupService.backup_database")
|
@patch("leggen.services.backup_service.BackupService.backup_database")
|
||||||
@patch("leggen.utils.paths.path_manager.get_database_path")
|
@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."""
|
"""Test successful backup operation."""
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
mock_get_db_path.return_value = Path("/test/database.db")
|
mock_get_db_path.return_value = Path("/test/database.db")
|
||||||
mock_backup_db.return_value = True
|
mock_backup_db.return_value = True
|
||||||
|
|
||||||
mock_config._config["backup"] = {
|
mock_config._config["backup"] = {
|
||||||
"s3": {
|
"s3": {
|
||||||
"access_key_id": "AKIATEST123",
|
"access_key_id": "AKIATEST123",
|
||||||
@@ -247,30 +253,30 @@ class TestBackupAPI:
|
|||||||
"enabled": True,
|
"enabled": True,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
request_data = {"operation": "backup"}
|
request_data = {"operation": "backup"}
|
||||||
|
|
||||||
with patch("leggen.utils.config.config", mock_config):
|
with patch("leggen.utils.config.config", mock_config):
|
||||||
response = api_client.post("/api/v1/backup/operation", json=request_data)
|
response = api_client.post("/api/v1/backup/operation", json=request_data)
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
data = response.json()
|
data = response.json()
|
||||||
assert data["success"] is True
|
assert data["success"] is True
|
||||||
assert data["data"]["operation"] == "backup"
|
assert data["data"]["operation"] == "backup"
|
||||||
assert data["data"]["completed"] is True
|
assert data["data"]["completed"] is True
|
||||||
|
|
||||||
# Verify backup was called
|
# Verify backup was called
|
||||||
mock_backup_db.assert_called_once()
|
mock_backup_db.assert_called_once()
|
||||||
|
|
||||||
def test_backup_operation_no_config(self, api_client, mock_config):
|
def test_backup_operation_no_config(self, api_client, mock_config):
|
||||||
"""Test backup operation with no configuration."""
|
"""Test backup operation with no configuration."""
|
||||||
mock_config._config["backup"] = {}
|
mock_config._config["backup"] = {}
|
||||||
|
|
||||||
request_data = {"operation": "backup"}
|
request_data = {"operation": "backup"}
|
||||||
|
|
||||||
with patch("leggen.utils.config.config", mock_config):
|
with patch("leggen.utils.config.config", mock_config):
|
||||||
response = api_client.post("/api/v1/backup/operation", json=request_data)
|
response = api_client.post("/api/v1/backup/operation", json=request_data)
|
||||||
|
|
||||||
assert response.status_code == 400
|
assert response.status_code == 400
|
||||||
data = response.json()
|
data = response.json()
|
||||||
assert "S3 backup is not configured" in data["detail"]
|
assert "S3 backup is not configured" in data["detail"]
|
||||||
@@ -286,12 +292,12 @@ class TestBackupAPI:
|
|||||||
"enabled": True,
|
"enabled": True,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
request_data = {"operation": "invalid"}
|
request_data = {"operation": "invalid"}
|
||||||
|
|
||||||
with patch("leggen.utils.config.config", mock_config):
|
with patch("leggen.utils.config.config", mock_config):
|
||||||
response = api_client.post("/api/v1/backup/operation", json=request_data)
|
response = api_client.post("/api/v1/backup/operation", json=request_data)
|
||||||
|
|
||||||
assert response.status_code == 400
|
assert response.status_code == 400
|
||||||
data = response.json()
|
data = response.json()
|
||||||
assert "Invalid operation" in data["detail"]
|
assert "Invalid operation" in data["detail"]
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import tempfile
|
import tempfile
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest.mock import AsyncMock, MagicMock, patch
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from botocore.exceptions import ClientError, NoCredentialsError
|
from botocore.exceptions import ClientError, NoCredentialsError
|
||||||
@@ -41,20 +41,20 @@ class TestBackupService:
|
|||||||
bucket_name="test-bucket",
|
bucket_name="test-bucket",
|
||||||
region="us-east-1",
|
region="us-east-1",
|
||||||
)
|
)
|
||||||
|
|
||||||
service = BackupService()
|
service = BackupService()
|
||||||
|
|
||||||
# Mock S3 client
|
# Mock S3 client
|
||||||
with patch("boto3.Session") as mock_session:
|
with patch("boto3.Session") as mock_session:
|
||||||
mock_client = MagicMock()
|
mock_client = MagicMock()
|
||||||
mock_session.return_value.client.return_value = mock_client
|
mock_session.return_value.client.return_value = mock_client
|
||||||
|
|
||||||
# Mock successful list_objects_v2 call
|
# Mock successful list_objects_v2 call
|
||||||
mock_client.list_objects_v2.return_value = {"Contents": []}
|
mock_client.list_objects_v2.return_value = {"Contents": []}
|
||||||
|
|
||||||
result = await service.test_connection(s3_config)
|
result = await service.test_connection(s3_config)
|
||||||
assert result is True
|
assert result is True
|
||||||
|
|
||||||
# Verify the client was called correctly
|
# Verify the client was called correctly
|
||||||
mock_client.list_objects_v2.assert_called_once_with(
|
mock_client.list_objects_v2.assert_called_once_with(
|
||||||
Bucket="test-bucket", MaxKeys=1
|
Bucket="test-bucket", MaxKeys=1
|
||||||
@@ -69,15 +69,15 @@ class TestBackupService:
|
|||||||
bucket_name="test-bucket",
|
bucket_name="test-bucket",
|
||||||
region="us-east-1",
|
region="us-east-1",
|
||||||
)
|
)
|
||||||
|
|
||||||
service = BackupService()
|
service = BackupService()
|
||||||
|
|
||||||
# Mock S3 client to raise NoCredentialsError
|
# Mock S3 client to raise NoCredentialsError
|
||||||
with patch("boto3.Session") as mock_session:
|
with patch("boto3.Session") as mock_session:
|
||||||
mock_client = MagicMock()
|
mock_client = MagicMock()
|
||||||
mock_session.return_value.client.return_value = mock_client
|
mock_session.return_value.client.return_value = mock_client
|
||||||
mock_client.list_objects_v2.side_effect = NoCredentialsError()
|
mock_client.list_objects_v2.side_effect = NoCredentialsError()
|
||||||
|
|
||||||
result = await service.test_connection(s3_config)
|
result = await service.test_connection(s3_config)
|
||||||
assert result is False
|
assert result is False
|
||||||
|
|
||||||
@@ -90,16 +90,20 @@ class TestBackupService:
|
|||||||
bucket_name="test-bucket",
|
bucket_name="test-bucket",
|
||||||
region="us-east-1",
|
region="us-east-1",
|
||||||
)
|
)
|
||||||
|
|
||||||
service = BackupService()
|
service = BackupService()
|
||||||
|
|
||||||
# Mock S3 client to raise ClientError
|
# Mock S3 client to raise ClientError
|
||||||
with patch("boto3.Session") as mock_session:
|
with patch("boto3.Session") as mock_session:
|
||||||
mock_client = MagicMock()
|
mock_client = MagicMock()
|
||||||
mock_session.return_value.client.return_value = mock_client
|
mock_session.return_value.client.return_value = mock_client
|
||||||
error_response = {"Error": {"Code": "NoSuchBucket", "Message": "Bucket not found"}}
|
error_response = {
|
||||||
mock_client.list_objects_v2.side_effect = ClientError(error_response, "ListObjectsV2")
|
"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)
|
result = await service.test_connection(s3_config)
|
||||||
assert result is False
|
assert result is False
|
||||||
|
|
||||||
@@ -107,11 +111,11 @@ class TestBackupService:
|
|||||||
async def test_backup_database_no_config(self):
|
async def test_backup_database_no_config(self):
|
||||||
"""Test backup database with no configuration."""
|
"""Test backup database with no configuration."""
|
||||||
service = BackupService()
|
service = BackupService()
|
||||||
|
|
||||||
with tempfile.TemporaryDirectory() as tmpdir:
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
db_path = Path(tmpdir) / "test.db"
|
db_path = Path(tmpdir) / "test.db"
|
||||||
db_path.write_text("test database content")
|
db_path.write_text("test database content")
|
||||||
|
|
||||||
result = await service.backup_database(db_path)
|
result = await service.backup_database(db_path)
|
||||||
assert result is False
|
assert result is False
|
||||||
|
|
||||||
@@ -126,11 +130,11 @@ class TestBackupService:
|
|||||||
enabled=False,
|
enabled=False,
|
||||||
)
|
)
|
||||||
service = BackupService(s3_config)
|
service = BackupService(s3_config)
|
||||||
|
|
||||||
with tempfile.TemporaryDirectory() as tmpdir:
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
db_path = Path(tmpdir) / "test.db"
|
db_path = Path(tmpdir) / "test.db"
|
||||||
db_path.write_text("test database content")
|
db_path.write_text("test database content")
|
||||||
|
|
||||||
result = await service.backup_database(db_path)
|
result = await service.backup_database(db_path)
|
||||||
assert result is False
|
assert result is False
|
||||||
|
|
||||||
@@ -144,7 +148,7 @@ class TestBackupService:
|
|||||||
region="us-east-1",
|
region="us-east-1",
|
||||||
)
|
)
|
||||||
service = BackupService(s3_config)
|
service = BackupService(s3_config)
|
||||||
|
|
||||||
non_existent_path = Path("/non/existent/path.db")
|
non_existent_path = Path("/non/existent/path.db")
|
||||||
result = await service.backup_database(non_existent_path)
|
result = await service.backup_database(non_existent_path)
|
||||||
assert result is False
|
assert result is False
|
||||||
@@ -159,19 +163,19 @@ class TestBackupService:
|
|||||||
region="us-east-1",
|
region="us-east-1",
|
||||||
)
|
)
|
||||||
service = BackupService(s3_config)
|
service = BackupService(s3_config)
|
||||||
|
|
||||||
with tempfile.TemporaryDirectory() as tmpdir:
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
db_path = Path(tmpdir) / "test.db"
|
db_path = Path(tmpdir) / "test.db"
|
||||||
db_path.write_text("test database content")
|
db_path.write_text("test database content")
|
||||||
|
|
||||||
# Mock S3 client
|
# Mock S3 client
|
||||||
with patch("boto3.Session") as mock_session:
|
with patch("boto3.Session") as mock_session:
|
||||||
mock_client = MagicMock()
|
mock_client = MagicMock()
|
||||||
mock_session.return_value.client.return_value = mock_client
|
mock_session.return_value.client.return_value = mock_client
|
||||||
|
|
||||||
result = await service.backup_database(db_path)
|
result = await service.backup_database(db_path)
|
||||||
assert result is True
|
assert result is True
|
||||||
|
|
||||||
# Verify upload_file was called
|
# Verify upload_file was called
|
||||||
mock_client.upload_file.assert_called_once()
|
mock_client.upload_file.assert_called_once()
|
||||||
args = mock_client.upload_file.call_args[0]
|
args = mock_client.upload_file.call_args[0]
|
||||||
@@ -189,13 +193,14 @@ class TestBackupService:
|
|||||||
region="us-east-1",
|
region="us-east-1",
|
||||||
)
|
)
|
||||||
service = BackupService(s3_config)
|
service = BackupService(s3_config)
|
||||||
|
|
||||||
# Mock S3 client response
|
# Mock S3 client response
|
||||||
with patch("boto3.Session") as mock_session:
|
with patch("boto3.Session") as mock_session:
|
||||||
mock_client = MagicMock()
|
mock_client = MagicMock()
|
||||||
mock_session.return_value.client.return_value = mock_client
|
mock_session.return_value.client.return_value = mock_client
|
||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
mock_response = {
|
mock_response = {
|
||||||
"Contents": [
|
"Contents": [
|
||||||
{
|
{
|
||||||
@@ -211,11 +216,11 @@ class TestBackupService:
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
mock_client.list_objects_v2.return_value = mock_response
|
mock_client.list_objects_v2.return_value = mock_response
|
||||||
|
|
||||||
backups = await service.list_backups()
|
backups = await service.list_backups()
|
||||||
assert len(backups) == 2
|
assert len(backups) == 2
|
||||||
|
|
||||||
# Check that backups are sorted by last modified (newest first)
|
# Check that backups are sorted by last modified (newest first)
|
||||||
assert backups[0]["last_modified"] > backups[1]["last_modified"]
|
assert backups[0]["last_modified"] > backups[1]["last_modified"]
|
||||||
assert backups[0]["size"] == 2048
|
assert backups[0]["size"] == 2048
|
||||||
assert backups[1]["size"] == 1024
|
assert backups[1]["size"] == 1024
|
||||||
|
|||||||
Reference in New Issue
Block a user