mirror of
https://github.com/elisiariocouto/leggen.git
synced 2025-12-13 11:22:21 +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
248
frontend/src/components/S3BackupConfigDrawer.tsx
Normal file
248
frontend/src/components/S3BackupConfigDrawer.tsx
Normal file
@@ -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<S3Config>({
|
||||||
|
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 (
|
||||||
|
<Drawer open={open} onOpenChange={setOpen}>
|
||||||
|
<DrawerTrigger asChild>
|
||||||
|
{trigger || <EditButton />}
|
||||||
|
</DrawerTrigger>
|
||||||
|
<DrawerContent>
|
||||||
|
<div className="mx-auto w-full max-w-sm">
|
||||||
|
<DrawerHeader>
|
||||||
|
<DrawerTitle className="flex items-center space-x-2">
|
||||||
|
<Cloud className="h-5 w-5 text-primary" />
|
||||||
|
<span>S3 Backup Configuration</span>
|
||||||
|
</DrawerTitle>
|
||||||
|
<DrawerDescription>
|
||||||
|
Configure S3 settings for automatic database backups
|
||||||
|
</DrawerDescription>
|
||||||
|
</DrawerHeader>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="px-4 space-y-4">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Switch
|
||||||
|
id="enabled"
|
||||||
|
checked={config.enabled}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
setConfig({ ...config, enabled: checked })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="enabled">Enable S3 backups</Label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{config.enabled && (
|
||||||
|
<>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="access_key_id">Access Key ID</Label>
|
||||||
|
<Input
|
||||||
|
id="access_key_id"
|
||||||
|
type="text"
|
||||||
|
value={config.access_key_id}
|
||||||
|
onChange={(e) =>
|
||||||
|
setConfig({ ...config, access_key_id: e.target.value })
|
||||||
|
}
|
||||||
|
placeholder="Your AWS Access Key ID"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="secret_access_key">Secret Access Key</Label>
|
||||||
|
<Input
|
||||||
|
id="secret_access_key"
|
||||||
|
type="password"
|
||||||
|
value={config.secret_access_key}
|
||||||
|
onChange={(e) =>
|
||||||
|
setConfig({ ...config, secret_access_key: e.target.value })
|
||||||
|
}
|
||||||
|
placeholder="Your AWS Secret Access Key"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="bucket_name">Bucket Name</Label>
|
||||||
|
<Input
|
||||||
|
id="bucket_name"
|
||||||
|
type="text"
|
||||||
|
value={config.bucket_name}
|
||||||
|
onChange={(e) =>
|
||||||
|
setConfig({ ...config, bucket_name: e.target.value })
|
||||||
|
}
|
||||||
|
placeholder="my-backup-bucket"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="region">Region</Label>
|
||||||
|
<Input
|
||||||
|
id="region"
|
||||||
|
type="text"
|
||||||
|
value={config.region}
|
||||||
|
onChange={(e) =>
|
||||||
|
setConfig({ ...config, region: e.target.value })
|
||||||
|
}
|
||||||
|
placeholder="us-east-1"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="endpoint_url">
|
||||||
|
Custom Endpoint URL (Optional)
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="endpoint_url"
|
||||||
|
type="url"
|
||||||
|
value={config.endpoint_url || ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
setConfig({ ...config, endpoint_url: e.target.value })
|
||||||
|
}
|
||||||
|
placeholder="https://custom-s3-endpoint.com"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
For S3-compatible services like MinIO or DigitalOcean Spaces
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Switch
|
||||||
|
id="path_style"
|
||||||
|
checked={config.path_style}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
setConfig({ ...config, path_style: checked })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="path_style">Use path-style addressing</Label>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Enable for older S3 implementations or certain S3-compatible services
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DrawerFooter className="px-0">
|
||||||
|
<div className="flex space-x-2">
|
||||||
|
<Button type="submit" disabled={updateMutation.isPending || !config.enabled}>
|
||||||
|
{updateMutation.isPending ? "Saving..." : "Save Configuration"}
|
||||||
|
</Button>
|
||||||
|
{config.enabled && isConfigValid && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleTest}
|
||||||
|
disabled={testMutation.isPending}
|
||||||
|
>
|
||||||
|
{testMutation.isPending ? (
|
||||||
|
<>
|
||||||
|
<TestTube className="h-4 w-4 mr-2 animate-spin" />
|
||||||
|
Testing...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<TestTube className="h-4 w-4 mr-2" />
|
||||||
|
Test
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<DrawerClose asChild>
|
||||||
|
<Button variant="ghost">Cancel</Button>
|
||||||
|
</DrawerClose>
|
||||||
|
</DrawerFooter>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</DrawerContent>
|
||||||
|
</Drawer>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
Trash2,
|
Trash2,
|
||||||
User,
|
User,
|
||||||
Filter,
|
Filter,
|
||||||
|
Cloud,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { apiClient } from "../lib/api";
|
import { apiClient } from "../lib/api";
|
||||||
import { formatCurrency, formatDate } from "../lib/utils";
|
import { formatCurrency, formatDate } from "../lib/utils";
|
||||||
@@ -35,11 +36,13 @@ import NotificationFiltersDrawer from "./NotificationFiltersDrawer";
|
|||||||
import DiscordConfigDrawer from "./DiscordConfigDrawer";
|
import DiscordConfigDrawer from "./DiscordConfigDrawer";
|
||||||
import TelegramConfigDrawer from "./TelegramConfigDrawer";
|
import TelegramConfigDrawer from "./TelegramConfigDrawer";
|
||||||
import AddBankAccountDrawer from "./AddBankAccountDrawer";
|
import AddBankAccountDrawer from "./AddBankAccountDrawer";
|
||||||
|
import S3BackupConfigDrawer from "./S3BackupConfigDrawer";
|
||||||
import type {
|
import type {
|
||||||
Account,
|
Account,
|
||||||
Balance,
|
Balance,
|
||||||
NotificationSettings,
|
NotificationSettings,
|
||||||
NotificationService,
|
NotificationService,
|
||||||
|
BackupSettings,
|
||||||
} from "../types/api";
|
} from "../types/api";
|
||||||
|
|
||||||
// Helper function to get status indicator color and styles
|
// Helper function to get status indicator color and styles
|
||||||
@@ -125,6 +128,17 @@ export default function Settings() {
|
|||||||
queryFn: apiClient.getBankConnectionsStatus,
|
queryFn: apiClient.getBankConnectionsStatus,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Backup queries
|
||||||
|
const {
|
||||||
|
data: backupSettings,
|
||||||
|
isLoading: backupLoading,
|
||||||
|
error: backupError,
|
||||||
|
refetch: refetchBackup,
|
||||||
|
} = useQuery<BackupSettings>({
|
||||||
|
queryKey: ["backupSettings"],
|
||||||
|
queryFn: apiClient.getBackupSettings,
|
||||||
|
});
|
||||||
|
|
||||||
// Account mutations
|
// Account mutations
|
||||||
const updateAccountMutation = useMutation({
|
const updateAccountMutation = useMutation({
|
||||||
mutationFn: ({ id, display_name }: { id: string; display_name: string }) =>
|
mutationFn: ({ id, display_name }: { id: string; display_name: string }) =>
|
||||||
@@ -189,8 +203,8 @@ export default function Settings() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const isLoading = accountsLoading || settingsLoading || servicesLoading;
|
const isLoading = accountsLoading || settingsLoading || servicesLoading || backupLoading;
|
||||||
const hasError = accountsError || settingsError || servicesError;
|
const hasError = accountsError || settingsError || servicesError || backupError;
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return <AccountsSkeleton />;
|
return <AccountsSkeleton />;
|
||||||
@@ -211,6 +225,7 @@ export default function Settings() {
|
|||||||
refetchAccounts();
|
refetchAccounts();
|
||||||
refetchSettings();
|
refetchSettings();
|
||||||
refetchServices();
|
refetchServices();
|
||||||
|
refetchBackup();
|
||||||
}}
|
}}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -226,7 +241,7 @@ export default function Settings() {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<Tabs defaultValue="accounts" className="space-y-6">
|
<Tabs defaultValue="accounts" className="space-y-6">
|
||||||
<TabsList className="grid w-full grid-cols-2">
|
<TabsList className="grid w-full grid-cols-3">
|
||||||
<TabsTrigger value="accounts" className="flex items-center space-x-2">
|
<TabsTrigger value="accounts" className="flex items-center space-x-2">
|
||||||
<User className="h-4 w-4" />
|
<User className="h-4 w-4" />
|
||||||
<span>Accounts</span>
|
<span>Accounts</span>
|
||||||
@@ -238,6 +253,10 @@ export default function Settings() {
|
|||||||
<Bell className="h-4 w-4" />
|
<Bell className="h-4 w-4" />
|
||||||
<span>Notifications</span>
|
<span>Notifications</span>
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="backup" className="flex items-center space-x-2">
|
||||||
|
<Cloud className="h-4 w-4" />
|
||||||
|
<span>Backup</span>
|
||||||
|
</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
<TabsContent value="accounts" className="space-y-6">
|
<TabsContent value="accounts" className="space-y-6">
|
||||||
@@ -728,6 +747,107 @@ export default function Settings() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="backup" className="space-y-6">
|
||||||
|
{/* S3 Backup Configuration */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center space-x-2">
|
||||||
|
<Cloud className="h-5 w-5 text-primary" />
|
||||||
|
<span>S3 Backup Configuration</span>
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Configure automatic database backups to Amazon S3 or S3-compatible storage
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent>
|
||||||
|
{!backupSettings?.s3 ? (
|
||||||
|
<div className="text-center py-8">
|
||||||
|
<Cloud className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
|
||||||
|
<h3 className="text-lg font-medium text-foreground mb-2">
|
||||||
|
No S3 backup configured
|
||||||
|
</h3>
|
||||||
|
<p className="text-muted-foreground mb-4">
|
||||||
|
Set up S3 backup to automatically backup your database to the cloud.
|
||||||
|
</p>
|
||||||
|
<S3BackupConfigDrawer settings={backupSettings} />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between p-4 border rounded-lg">
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
|
<div className="p-3 bg-muted rounded-full">
|
||||||
|
<Cloud className="h-6 w-6 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
<h4 className="text-lg font-medium text-foreground">
|
||||||
|
S3 Backup
|
||||||
|
</h4>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<div className={`w-2 h-2 rounded-full ${
|
||||||
|
backupSettings.s3.enabled
|
||||||
|
? 'bg-green-500'
|
||||||
|
: 'bg-muted-foreground'
|
||||||
|
}`} />
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{backupSettings.s3.enabled ? 'Enabled' : 'Disabled'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 space-y-1">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
<span className="font-medium">Bucket:</span> {backupSettings.s3.bucket_name}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
<span className="font-medium">Region:</span> {backupSettings.s3.region}
|
||||||
|
</p>
|
||||||
|
{backupSettings.s3.endpoint_url && (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
<span className="font-medium">Endpoint:</span> {backupSettings.s3.endpoint_url}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<S3BackupConfigDrawer settings={backupSettings} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 bg-muted rounded-lg">
|
||||||
|
<h5 className="font-medium mb-2">Backup Information</h5>
|
||||||
|
<p className="text-sm text-muted-foreground mb-3">
|
||||||
|
Database backups are stored in the "leggen_backups/" folder in your S3 bucket.
|
||||||
|
Backups include the complete SQLite database file.
|
||||||
|
</p>
|
||||||
|
<div className="flex space-x-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
// TODO: Implement manual backup trigger
|
||||||
|
console.log("Manual backup triggered");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Create Backup Now
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
// TODO: Implement backup list view
|
||||||
|
console.log("View backups");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
View Backups
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -17,6 +17,10 @@ import type {
|
|||||||
BankConnectionStatus,
|
BankConnectionStatus,
|
||||||
BankRequisition,
|
BankRequisition,
|
||||||
Country,
|
Country,
|
||||||
|
BackupSettings,
|
||||||
|
BackupTest,
|
||||||
|
BackupInfo,
|
||||||
|
BackupOperation,
|
||||||
} from "../types/api";
|
} from "../types/api";
|
||||||
|
|
||||||
// Use VITE_API_URL for development, relative URLs for production
|
// Use VITE_API_URL for development, relative URLs for production
|
||||||
@@ -274,6 +278,37 @@ export const apiClient = {
|
|||||||
const response = await api.get<ApiResponse<Country[]>>("/banks/countries");
|
const response = await api.get<ApiResponse<Country[]>>("/banks/countries");
|
||||||
return response.data.data;
|
return response.data.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Backup endpoints
|
||||||
|
getBackupSettings: async (): Promise<BackupSettings> => {
|
||||||
|
const response = await api.get<ApiResponse<BackupSettings>>(
|
||||||
|
"/backup/settings",
|
||||||
|
);
|
||||||
|
return response.data.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
updateBackupSettings: async (
|
||||||
|
settings: BackupSettings,
|
||||||
|
): Promise<BackupSettings> => {
|
||||||
|
const response = await api.put<ApiResponse<BackupSettings>>(
|
||||||
|
"/backup/settings",
|
||||||
|
settings,
|
||||||
|
);
|
||||||
|
return response.data.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
testBackupConnection: async (test: BackupTest): Promise<void> => {
|
||||||
|
await api.post("/backup/test", test);
|
||||||
|
},
|
||||||
|
|
||||||
|
listBackups: async (): Promise<BackupInfo[]> => {
|
||||||
|
const response = await api.get<ApiResponse<BackupInfo[]>>("/backup/list");
|
||||||
|
return response.data.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
performBackupOperation: async (operation: BackupOperation): Promise<void> => {
|
||||||
|
await api.post("/backup/operation", operation);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default apiClient;
|
export default apiClient;
|
||||||
|
|||||||
@@ -277,3 +277,34 @@ export interface Country {
|
|||||||
code: string;
|
code: string;
|
||||||
name: 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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -150,6 +150,8 @@ async def test_backup_connection(test_request: BackupTest) -> APIResponse:
|
|||||||
message="S3 connection test failed",
|
message="S3 connection test failed",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to test backup connection: {e}")
|
logger.error(f"Failed to test backup connection: {e}")
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
@@ -200,8 +202,14 @@ async def backup_operation(operation_request: BackupOperation) -> APIResponse:
|
|||||||
status_code=400, detail="S3 backup is not configured"
|
status_code=400, detail="S3 backup is not configured"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Convert config to model
|
# Convert config to model with validation
|
||||||
s3_config = S3BackupConfig(**backup_config)
|
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)
|
backup_service = BackupService(s3_config)
|
||||||
|
|
||||||
if operation_request.operation == "backup":
|
if operation_request.operation == "backup":
|
||||||
|
|||||||
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