mirror of
https://github.com/elisiariocouto/leggen.git
synced 2025-12-13 09:02:23 +00:00
Compare commits
8 Commits
0cef352a6b
...
9c760703ca
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9c760703ca | ||
|
|
be32b8e518 | ||
|
|
b4bf00c140 | ||
|
|
61e579901a | ||
|
|
5de9badfde | ||
|
|
159cba508e | ||
|
|
966440006a | ||
|
|
a592b827aa |
@@ -23,6 +23,7 @@ import {
|
||||
import { Button } from "./ui/button";
|
||||
import { Alert, AlertDescription, AlertTitle } from "./ui/alert";
|
||||
import AccountsSkeleton from "./AccountsSkeleton";
|
||||
import { BlurredValue } from "./ui/blurred-value";
|
||||
import type { Account, Balance } from "../types/api";
|
||||
|
||||
// Helper function to get status indicator color and styles
|
||||
@@ -158,7 +159,7 @@ export default function AccountsOverview() {
|
||||
Total Balance
|
||||
</p>
|
||||
<p className="text-2xl font-bold text-foreground">
|
||||
{formatCurrency(totalBalance)}
|
||||
<BlurredValue>{formatCurrency(totalBalance)}</BlurredValue>
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-3 bg-green-100 dark:bg-green-900/20 rounded-full">
|
||||
@@ -369,7 +370,9 @@ export default function AccountsOverview() {
|
||||
isPositive ? "text-green-600" : "text-red-600"
|
||||
}`}
|
||||
>
|
||||
{formatCurrency(balance, currency)}
|
||||
<BlurredValue>
|
||||
{formatCurrency(balance, currency)}
|
||||
</BlurredValue>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -16,6 +16,7 @@ import { apiClient } from "../lib/api";
|
||||
import { formatCurrency } from "../lib/utils";
|
||||
import { useState } from "react";
|
||||
import type { Account } from "../types/api";
|
||||
import { BlurredValue } from "./ui/blurred-value";
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
@@ -130,7 +131,7 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||
|
||||
<div className="px-3 pb-2">
|
||||
<p className="text-xl font-bold text-foreground">
|
||||
{formatCurrency(totalBalance)}
|
||||
<BlurredValue>{formatCurrency(totalBalance)}</BlurredValue>
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{accounts?.length || 0} accounts
|
||||
@@ -163,7 +164,9 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||
"Unnamed Account"}
|
||||
</p>
|
||||
<p className="text-xs font-semibold text-foreground">
|
||||
{formatCurrency(primaryBalance, currency)}
|
||||
<BlurredValue>
|
||||
{formatCurrency(primaryBalance, currency)}
|
||||
</BlurredValue>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -34,6 +34,7 @@ import { Button } from "./ui/button";
|
||||
import { Alert, AlertDescription, AlertTitle } from "./ui/alert";
|
||||
import { Label } from "./ui/label";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "./ui/tabs";
|
||||
import { BlurredValue } from "./ui/blurred-value";
|
||||
import AccountsSkeleton from "./AccountsSkeleton";
|
||||
import NotificationFiltersDrawer from "./NotificationFiltersDrawer";
|
||||
import DiscordConfigDrawer from "./DiscordConfigDrawer";
|
||||
@@ -491,13 +492,13 @@ export default function Settings() {
|
||||
) : (
|
||||
<TrendingDown className="h-4 w-4 text-red-500" />
|
||||
)}
|
||||
<p
|
||||
<BlurredValue
|
||||
className={`text-base sm:text-lg font-semibold ${
|
||||
isPositive ? "text-green-600" : "text-red-600"
|
||||
}`}
|
||||
>
|
||||
{formatCurrency(balance, currency)}
|
||||
</p>
|
||||
</BlurredValue>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Activity, Wifi, WifiOff } from "lucide-react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { apiClient } from "../lib/api";
|
||||
import { ThemeToggle } from "./ui/theme-toggle";
|
||||
import { BalanceToggle } from "./ui/balance-toggle";
|
||||
import { Separator } from "./ui/separator";
|
||||
import { SidebarTrigger } from "./ui/sidebar";
|
||||
|
||||
@@ -77,6 +78,7 @@ export function SiteHeader() {
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<BalanceToggle />
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
useReactTable,
|
||||
@@ -31,7 +31,8 @@ import { DataTablePagination } from "./ui/data-table-pagination";
|
||||
import { Card } from "./ui/card";
|
||||
import { Alert, AlertDescription, AlertTitle } from "./ui/alert";
|
||||
import { Button } from "./ui/button";
|
||||
import type { Account, Transaction, ApiResponse } from "../types/api";
|
||||
import { BlurredValue } from "./ui/blurred-value";
|
||||
import type { Account, Transaction, PaginatedResponse } from "../types/api";
|
||||
|
||||
export default function TransactionsTable() {
|
||||
// Filter state consolidated into a single object
|
||||
@@ -102,7 +103,7 @@ export default function TransactionsTable() {
|
||||
isLoading: transactionsLoading,
|
||||
error: transactionsError,
|
||||
refetch: refetchTransactions,
|
||||
} = useQuery<ApiResponse<Transaction[]>>({
|
||||
} = useQuery<PaginatedResponse<Transaction>>({
|
||||
queryKey: [
|
||||
"transactions",
|
||||
filterState.selectedAccount,
|
||||
@@ -125,7 +126,20 @@ export default function TransactionsTable() {
|
||||
});
|
||||
|
||||
const transactions = transactionsResponse?.data || [];
|
||||
const pagination = transactionsResponse?.pagination;
|
||||
const pagination = useMemo(
|
||||
() =>
|
||||
transactionsResponse
|
||||
? {
|
||||
page: transactionsResponse.page,
|
||||
total_pages: transactionsResponse.total_pages,
|
||||
per_page: transactionsResponse.per_page,
|
||||
total: transactionsResponse.total,
|
||||
has_next: transactionsResponse.has_next,
|
||||
has_prev: transactionsResponse.has_prev,
|
||||
}
|
||||
: undefined,
|
||||
[transactionsResponse],
|
||||
);
|
||||
|
||||
// Check if search is currently debouncing
|
||||
const isSearchLoading = filterState.searchTerm !== debouncedSearchTerm;
|
||||
@@ -221,11 +235,13 @@ export default function TransactionsTable() {
|
||||
isPositive ? "text-green-600" : "text-red-600"
|
||||
}`}
|
||||
>
|
||||
{isPositive ? "+" : ""}
|
||||
{formatCurrency(
|
||||
transaction.transaction_value,
|
||||
transaction.transaction_currency,
|
||||
)}
|
||||
<BlurredValue>
|
||||
{isPositive ? "+" : ""}
|
||||
{formatCurrency(
|
||||
transaction.transaction_value,
|
||||
transaction.transaction_currency,
|
||||
)}
|
||||
</BlurredValue>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
@@ -525,11 +541,13 @@ export default function TransactionsTable() {
|
||||
isPositive ? "text-green-600" : "text-red-600"
|
||||
}`}
|
||||
>
|
||||
{isPositive ? "+" : ""}
|
||||
{formatCurrency(
|
||||
transaction.transaction_value,
|
||||
transaction.transaction_currency,
|
||||
)}
|
||||
<BlurredValue>
|
||||
{isPositive ? "+" : ""}
|
||||
{formatCurrency(
|
||||
transaction.transaction_value,
|
||||
transaction.transaction_currency,
|
||||
)}
|
||||
</BlurredValue>
|
||||
</p>
|
||||
<Button
|
||||
onClick={() => handleViewRaw(transaction)}
|
||||
|
||||
@@ -8,6 +8,8 @@ import {
|
||||
ResponsiveContainer,
|
||||
Legend,
|
||||
} from "recharts";
|
||||
import { useBalanceVisibility } from "../../contexts/BalanceVisibilityContext";
|
||||
import { cn } from "../../lib/utils";
|
||||
import type { Balance, Account } from "../../types/api";
|
||||
|
||||
interface BalanceChartProps {
|
||||
@@ -42,6 +44,8 @@ export default function BalanceChart({
|
||||
accounts,
|
||||
className,
|
||||
}: BalanceChartProps) {
|
||||
const { isBalanceVisible } = useBalanceVisibility();
|
||||
|
||||
// Create a lookup map for account info
|
||||
const accountMap = accounts.reduce(
|
||||
(map, account) => {
|
||||
@@ -149,7 +153,7 @@ export default function BalanceChart({
|
||||
<h3 className="text-lg font-medium text-foreground mb-4">
|
||||
Balance Progress Over Time
|
||||
</h3>
|
||||
<div className="h-80">
|
||||
<div className={cn("h-80", !isBalanceVisible && "blur-md select-none")}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart data={finalData}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
|
||||
@@ -8,6 +8,8 @@ import {
|
||||
ResponsiveContainer,
|
||||
} from "recharts";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useBalanceVisibility } from "../../contexts/BalanceVisibilityContext";
|
||||
import { cn } from "../../lib/utils";
|
||||
import apiClient from "../../lib/api";
|
||||
|
||||
interface MonthlyTrendsProps {
|
||||
@@ -29,6 +31,8 @@ export default function MonthlyTrends({
|
||||
className,
|
||||
days = 365,
|
||||
}: MonthlyTrendsProps) {
|
||||
const { isBalanceVisible } = useBalanceVisibility();
|
||||
|
||||
// Get pre-calculated monthly stats from the new endpoint
|
||||
const { data: monthlyData, isLoading } = useQuery({
|
||||
queryKey: ["monthly-stats", days],
|
||||
@@ -103,7 +107,7 @@ export default function MonthlyTrends({
|
||||
<h3 className="text-lg font-medium text-foreground mb-4">
|
||||
{getTitle(days)}
|
||||
</h3>
|
||||
<div className="h-80">
|
||||
<div className={cn("h-80", !isBalanceVisible && "blur-md select-none")}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart
|
||||
data={displayData}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { Card, CardContent } from "../ui/card";
|
||||
import { BlurredValue } from "../ui/blurred-value";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
interface StatCardProps {
|
||||
@@ -13,6 +14,7 @@ interface StatCardProps {
|
||||
};
|
||||
className?: string;
|
||||
iconColor?: "green" | "blue" | "red" | "purple" | "orange" | "default";
|
||||
shouldBlur?: boolean;
|
||||
}
|
||||
|
||||
export default function StatCard({
|
||||
@@ -23,6 +25,7 @@ export default function StatCard({
|
||||
trend,
|
||||
className,
|
||||
iconColor = "default",
|
||||
shouldBlur = false,
|
||||
}: StatCardProps) {
|
||||
return (
|
||||
<Card className={cn(className)}>
|
||||
@@ -31,7 +34,9 @@ export default function StatCard({
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">{title}</p>
|
||||
<div className="flex items-baseline">
|
||||
<p className="text-2xl font-bold text-foreground">{value}</p>
|
||||
<p className="text-2xl font-bold text-foreground">
|
||||
{shouldBlur ? <BlurredValue>{value}</BlurredValue> : value}
|
||||
</p>
|
||||
{trend && (
|
||||
<div
|
||||
className={cn(
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
Tooltip,
|
||||
Legend,
|
||||
} from "recharts";
|
||||
import { BlurredValue } from "../ui/blurred-value";
|
||||
import type { Account } from "../../types/api";
|
||||
|
||||
interface TransactionDistributionProps {
|
||||
@@ -85,7 +86,8 @@ export default function TransactionDistribution({
|
||||
<div className="bg-card p-3 border rounded shadow-lg">
|
||||
<p className="font-medium text-foreground">{data.name}</p>
|
||||
<p className="text-primary">
|
||||
Balance: €{data.value.toLocaleString()}
|
||||
Balance:{" "}
|
||||
<BlurredValue>€{data.value.toLocaleString()}</BlurredValue>
|
||||
</p>
|
||||
<p className="text-muted-foreground">{percentage}% of total</p>
|
||||
</div>
|
||||
@@ -138,7 +140,7 @@ export default function TransactionDistribution({
|
||||
<span className="text-foreground">{item.name}</span>
|
||||
</div>
|
||||
<span className="font-medium text-foreground">
|
||||
€{item.value.toLocaleString()}
|
||||
<BlurredValue>€{item.value.toLocaleString()}</BlurredValue>
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
26
frontend/src/components/ui/balance-toggle.tsx
Normal file
26
frontend/src/components/ui/balance-toggle.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import { Eye, EyeOff } from "lucide-react";
|
||||
import { Button } from "./button";
|
||||
import { useBalanceVisibility } from "../../contexts/BalanceVisibilityContext";
|
||||
|
||||
export function BalanceToggle() {
|
||||
const { isBalanceVisible, toggleBalanceVisibility } = useBalanceVisibility();
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={toggleBalanceVisibility}
|
||||
className="h-8 w-8"
|
||||
title={isBalanceVisible ? "Hide balances" : "Show balances"}
|
||||
>
|
||||
{isBalanceVisible ? (
|
||||
<Eye className="h-4 w-4" />
|
||||
) : (
|
||||
<EyeOff className="h-4 w-4" />
|
||||
)}
|
||||
<span className="sr-only">
|
||||
{isBalanceVisible ? "Hide balances" : "Show balances"}
|
||||
</span>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
23
frontend/src/components/ui/blurred-value.tsx
Normal file
23
frontend/src/components/ui/blurred-value.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import React from "react";
|
||||
import { useBalanceVisibility } from "../../contexts/BalanceVisibilityContext";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
interface BlurredValueProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function BlurredValue({ children, className }: BlurredValueProps) {
|
||||
const { isBalanceVisible } = useBalanceVisibility();
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
isBalanceVisible ? "" : "blur-md select-none",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
48
frontend/src/contexts/BalanceVisibilityContext.tsx
Normal file
48
frontend/src/contexts/BalanceVisibilityContext.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import React, { createContext, useContext, useEffect, useState } from "react";
|
||||
|
||||
interface BalanceVisibilityContextType {
|
||||
isBalanceVisible: boolean;
|
||||
toggleBalanceVisibility: () => void;
|
||||
}
|
||||
|
||||
const BalanceVisibilityContext = createContext<
|
||||
BalanceVisibilityContextType | undefined
|
||||
>(undefined);
|
||||
|
||||
export function BalanceVisibilityProvider({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const [isBalanceVisible, setIsBalanceVisible] = useState<boolean>(() => {
|
||||
const stored = localStorage.getItem("balanceVisible");
|
||||
// Default to true (visible) if not set
|
||||
return stored === null ? true : stored === "true";
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem("balanceVisible", String(isBalanceVisible));
|
||||
}, [isBalanceVisible]);
|
||||
|
||||
const toggleBalanceVisibility = () => {
|
||||
setIsBalanceVisible((prev) => !prev);
|
||||
};
|
||||
|
||||
return (
|
||||
<BalanceVisibilityContext.Provider
|
||||
value={{ isBalanceVisible, toggleBalanceVisibility }}
|
||||
>
|
||||
{children}
|
||||
</BalanceVisibilityContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useBalanceVisibility() {
|
||||
const context = useContext(BalanceVisibilityContext);
|
||||
if (context === undefined) {
|
||||
throw new Error(
|
||||
"useBalanceVisibility must be used within a BalanceVisibilityProvider",
|
||||
);
|
||||
}
|
||||
return context;
|
||||
}
|
||||
@@ -288,11 +288,14 @@ export const apiClient = {
|
||||
return response.data;
|
||||
},
|
||||
|
||||
testBackupConnection: async (test: BackupTest): Promise<{ connected?: boolean }> => {
|
||||
const response = await api.post<{ connected?: boolean }>(
|
||||
"/backup/test",
|
||||
test,
|
||||
);
|
||||
testBackupConnection: async (
|
||||
test: BackupTest,
|
||||
): Promise<{ connected?: boolean; success?: boolean; message?: string }> => {
|
||||
const response = await api.post<{
|
||||
connected?: boolean;
|
||||
success?: boolean;
|
||||
message?: string;
|
||||
}>("/backup/test", test);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
@@ -301,11 +304,20 @@ export const apiClient = {
|
||||
return response.data;
|
||||
},
|
||||
|
||||
performBackupOperation: async (operation: BackupOperation): Promise<{ operation: string; completed: boolean }> => {
|
||||
const response = await api.post<{ operation: string; completed: boolean }>(
|
||||
"/backup/operation",
|
||||
operation,
|
||||
);
|
||||
performBackupOperation: async (
|
||||
operation: BackupOperation,
|
||||
): Promise<{
|
||||
operation: string;
|
||||
completed: boolean;
|
||||
success?: boolean;
|
||||
message?: string;
|
||||
}> => {
|
||||
const response = await api.post<{
|
||||
operation: string;
|
||||
completed: boolean;
|
||||
success?: boolean;
|
||||
message?: string;
|
||||
}>("/backup/operation", operation);
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -3,6 +3,7 @@ import { createRoot } from "react-dom/client";
|
||||
import { createRouter, RouterProvider } from "@tanstack/react-router";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { ThemeProvider } from "./contexts/ThemeContext";
|
||||
import { BalanceVisibilityProvider } from "./contexts/BalanceVisibilityContext";
|
||||
import "./index.css";
|
||||
import { routeTree } from "./routeTree.gen";
|
||||
import { registerSW } from "virtual:pwa-register";
|
||||
@@ -73,7 +74,9 @@ createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ThemeProvider>
|
||||
<RouterProvider router={router} />
|
||||
<BalanceVisibilityProvider>
|
||||
<RouterProvider router={router} />
|
||||
</BalanceVisibilityProvider>
|
||||
</ThemeProvider>
|
||||
</QueryClientProvider>
|
||||
</StrictMode>,
|
||||
|
||||
@@ -88,6 +88,7 @@ function AnalyticsDashboard() {
|
||||
subtitle="Inflows this period"
|
||||
icon={TrendingUp}
|
||||
iconColor="green"
|
||||
shouldBlur={true}
|
||||
/>
|
||||
<StatCard
|
||||
title="Total Expenses"
|
||||
@@ -95,6 +96,7 @@ function AnalyticsDashboard() {
|
||||
subtitle="Outflows this period"
|
||||
icon={TrendingDown}
|
||||
iconColor="red"
|
||||
shouldBlur={true}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -106,6 +108,7 @@ function AnalyticsDashboard() {
|
||||
subtitle="Income minus expenses"
|
||||
icon={CreditCard}
|
||||
iconColor={(stats?.net_change || 0) >= 0 ? "green" : "red"}
|
||||
shouldBlur={true}
|
||||
/>
|
||||
<StatCard
|
||||
title="Average Transaction"
|
||||
@@ -113,6 +116,7 @@ function AnalyticsDashboard() {
|
||||
subtitle="Per transaction"
|
||||
icon={Activity}
|
||||
iconColor="purple"
|
||||
shouldBlur={true}
|
||||
/>
|
||||
<StatCard
|
||||
title="Active Accounts"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from typing import Any, Dict, Generic, List, TypeVar
|
||||
from typing import Generic, List, TypeVar
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
@@ -246,11 +246,6 @@ async def get_account_transactions(
|
||||
offset=offset,
|
||||
)
|
||||
|
||||
# Get total count for pagination info
|
||||
total_transactions = await database_service.get_transaction_count_from_db(
|
||||
account_id=account_id,
|
||||
)
|
||||
|
||||
data: Union[List[TransactionSummary], List[Transaction]]
|
||||
|
||||
if summary_only:
|
||||
@@ -299,9 +294,7 @@ async def get_account_transactions(
|
||||
|
||||
|
||||
@router.put("/accounts/{account_id}")
|
||||
async def update_account_details(
|
||||
account_id: str, update_data: AccountUpdate
|
||||
) -> dict:
|
||||
async def update_account_details(account_id: str, update_data: AccountUpdate) -> dict:
|
||||
"""Update account details (currently only display_name)"""
|
||||
try:
|
||||
# Get current account details
|
||||
|
||||
@@ -129,9 +129,7 @@ async def test_backup_connection(test_request: BackupTest) -> dict:
|
||||
success = await backup_service.test_connection(s3_config)
|
||||
|
||||
if not success:
|
||||
raise HTTPException(
|
||||
status_code=400, detail="S3 connection test failed"
|
||||
)
|
||||
raise HTTPException(status_code=400, detail="S3 connection test failed")
|
||||
|
||||
return {"connected": True}
|
||||
|
||||
@@ -193,9 +191,7 @@ async def backup_operation(operation_request: BackupOperation) -> dict:
|
||||
success = await backup_service.backup_database(database_path)
|
||||
|
||||
if not success:
|
||||
raise HTTPException(
|
||||
status_code=500, detail="Database backup failed"
|
||||
)
|
||||
raise HTTPException(status_code=500, detail="Database backup failed")
|
||||
|
||||
return {"operation": "backup", "completed": True}
|
||||
|
||||
@@ -213,9 +209,7 @@ async def backup_operation(operation_request: BackupOperation) -> dict:
|
||||
)
|
||||
|
||||
if not success:
|
||||
raise HTTPException(
|
||||
status_code=500, detail="Database restore failed"
|
||||
)
|
||||
raise HTTPException(status_code=500, detail="Database restore failed")
|
||||
|
||||
return {"operation": "restore", "completed": True}
|
||||
else:
|
||||
|
||||
@@ -3,7 +3,7 @@ from typing import Optional
|
||||
from fastapi import APIRouter, BackgroundTasks, HTTPException
|
||||
from loguru import logger
|
||||
|
||||
from leggen.api.models.sync import SchedulerConfig, SyncRequest
|
||||
from leggen.api.models.sync import SchedulerConfig, SyncRequest, SyncResult, SyncStatus
|
||||
from leggen.background.scheduler import scheduler
|
||||
from leggen.services.sync_service import SyncService
|
||||
from leggen.utils.config import config
|
||||
@@ -13,7 +13,7 @@ sync_service = SyncService()
|
||||
|
||||
|
||||
@router.get("/sync/status")
|
||||
async def get_sync_status() -> dict:
|
||||
async def get_sync_status() -> SyncStatus:
|
||||
"""Get current sync status"""
|
||||
try:
|
||||
status = await sync_service.get_sync_status()
|
||||
@@ -78,7 +78,7 @@ async def trigger_sync(
|
||||
|
||||
|
||||
@router.post("/sync/now")
|
||||
async def sync_now(sync_request: Optional[SyncRequest] = None) -> dict:
|
||||
async def sync_now(sync_request: Optional[SyncRequest] = None) -> SyncResult:
|
||||
"""Run sync synchronously and return results (slower, for testing)"""
|
||||
try:
|
||||
if sync_request and sync_request.account_ids:
|
||||
|
||||
@@ -64,11 +64,9 @@ async def get_all_transactions(
|
||||
search=search,
|
||||
)
|
||||
|
||||
data: Union[List[TransactionSummary], List[Transaction]]
|
||||
|
||||
if summary_only:
|
||||
# Return simplified transaction summaries
|
||||
data = [
|
||||
data: list[TransactionSummary | Transaction] = [
|
||||
TransactionSummary(
|
||||
transaction_id=txn["transactionId"], # NEW: stable bank-provided ID
|
||||
internal_transaction_id=txn.get("internalTransactionId"),
|
||||
|
||||
@@ -5,11 +5,19 @@ import random
|
||||
import sqlite3
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from typing import Any, TypedDict
|
||||
|
||||
import click
|
||||
|
||||
|
||||
class TransactionType(TypedDict):
|
||||
"""Type definition for transaction type configuration."""
|
||||
|
||||
description: str
|
||||
amount_range: tuple[float, float]
|
||||
frequency: float
|
||||
|
||||
|
||||
class SampleDataGenerator:
|
||||
"""Generates realistic sample data for testing Leggen."""
|
||||
|
||||
@@ -42,7 +50,7 @@ class SampleDataGenerator:
|
||||
},
|
||||
]
|
||||
|
||||
self.transaction_types = [
|
||||
self.transaction_types: list[TransactionType] = [
|
||||
{
|
||||
"description": "Grocery Store",
|
||||
"amount_range": (-150, -20),
|
||||
@@ -227,6 +235,8 @@ class SampleDataGenerator:
|
||||
)[0]
|
||||
|
||||
# Generate transaction amount
|
||||
min_amount: float
|
||||
max_amount: float
|
||||
min_amount, max_amount = transaction_type["amount_range"]
|
||||
amount = round(random.uniform(min_amount, max_amount), 2)
|
||||
|
||||
@@ -245,7 +255,7 @@ class SampleDataGenerator:
|
||||
internal_transaction_id = f"int-txn-{random.randint(100000, 999999)}"
|
||||
|
||||
# Create realistic descriptions
|
||||
descriptions = {
|
||||
descriptions: dict[str, list[str]] = {
|
||||
"Grocery Store": [
|
||||
"TESCO",
|
||||
"SAINSBURY'S",
|
||||
@@ -273,7 +283,7 @@ class SampleDataGenerator:
|
||||
"Transfer to Savings": ["SAVINGS TRANSFER", "INVESTMENT TRANSFER"],
|
||||
}
|
||||
|
||||
specific_descriptions = descriptions.get(
|
||||
specific_descriptions: list[str] = descriptions.get(
|
||||
transaction_type["description"], [transaction_type["description"]]
|
||||
)
|
||||
description = random.choice(specific_descriptions)
|
||||
|
||||
@@ -61,17 +61,13 @@ def send_sync_failure_notification(ctx: click.Context, notification: dict):
|
||||
info("Sending sync failure notification to Discord")
|
||||
webhook = DiscordWebhook(url=ctx.obj["notifications"]["discord"]["webhook"])
|
||||
|
||||
# Determine color and title based on failure type
|
||||
if notification.get("type") == "sync_final_failure":
|
||||
color = "ff0000" # Red for final failure
|
||||
title = "🚨 Sync Final Failure"
|
||||
description = (
|
||||
f"Sync failed permanently after {notification['retry_count']} attempts"
|
||||
)
|
||||
else:
|
||||
color = "ffaa00" # Orange for retry
|
||||
title = "⚠️ Sync Failure"
|
||||
description = f"Sync failed (attempt {notification['retry_count']}/{notification['max_retries']}). Will retry automatically..."
|
||||
color = "ffaa00" # Orange for sync failure
|
||||
title = "⚠️ Sync Failure"
|
||||
|
||||
# Build description with account info if available
|
||||
description = "Account sync failed"
|
||||
if notification.get("account_id"):
|
||||
description = f"Account {notification['account_id']} sync failed"
|
||||
|
||||
embed = DiscordEmbed(
|
||||
title=title,
|
||||
|
||||
@@ -87,19 +87,14 @@ def send_sync_failure_notification(ctx: click.Context, notification: dict):
|
||||
bot_url = f"https://api.telegram.org/bot{token}/sendMessage"
|
||||
info("Sending sync failure notification to Telegram")
|
||||
|
||||
message = "*🚨 [Leggen](https://github.com/elisiariocouto/leggen)*\n"
|
||||
message = "*⚠️ [Leggen](https://github.com/elisiariocouto/leggen)*\n"
|
||||
message += "*Sync Failed*\n\n"
|
||||
message += escape_markdown(f"Error: {notification['error']}\n")
|
||||
|
||||
if notification.get("type") == "sync_final_failure":
|
||||
message += escape_markdown(
|
||||
f"❌ Final failure after {notification['retry_count']} attempts\n"
|
||||
)
|
||||
else:
|
||||
message += escape_markdown(
|
||||
f"🔄 Attempt {notification['retry_count']}/{notification['max_retries']}\n"
|
||||
)
|
||||
message += escape_markdown("Will retry automatically...\n")
|
||||
# Add account info if available
|
||||
if notification.get("account_id"):
|
||||
message += escape_markdown(f"Account: {notification['account_id']}\n")
|
||||
|
||||
message += escape_markdown(f"Error: {notification['error']}\n")
|
||||
|
||||
res = requests.post(
|
||||
bot_url,
|
||||
|
||||
@@ -52,11 +52,17 @@ class NotificationService:
|
||||
|
||||
async def send_expiry_notification(self, notification_data: Dict[str, Any]) -> None:
|
||||
"""Send notification about account expiry"""
|
||||
if self._is_discord_enabled():
|
||||
await self._send_discord_expiry(notification_data)
|
||||
try:
|
||||
if self._is_discord_enabled():
|
||||
await self._send_discord_expiry(notification_data)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send Discord expiry notification: {e}")
|
||||
|
||||
if self._is_telegram_enabled():
|
||||
await self._send_telegram_expiry(notification_data)
|
||||
try:
|
||||
if self._is_telegram_enabled():
|
||||
await self._send_telegram_expiry(notification_data)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send Telegram expiry notification: {e}")
|
||||
|
||||
def _filter_transactions(
|
||||
self, transactions: List[Dict[str, Any]]
|
||||
@@ -262,7 +268,6 @@ class NotificationService:
|
||||
logger.info(f"Sent Discord expiry notification: {notification_data}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send Discord expiry notification: {e}")
|
||||
raise
|
||||
|
||||
async def _send_telegram_expiry(self, notification_data: Dict[str, Any]) -> None:
|
||||
"""Send Telegram expiry notification"""
|
||||
@@ -288,17 +293,22 @@ class NotificationService:
|
||||
logger.info(f"Sent Telegram expiry notification: {notification_data}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send Telegram expiry notification: {e}")
|
||||
raise
|
||||
|
||||
async def send_sync_failure_notification(
|
||||
self, notification_data: Dict[str, Any]
|
||||
) -> None:
|
||||
"""Send notification about sync failure"""
|
||||
if self._is_discord_enabled():
|
||||
await self._send_discord_sync_failure(notification_data)
|
||||
try:
|
||||
if self._is_discord_enabled():
|
||||
await self._send_discord_sync_failure(notification_data)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send Discord sync failure notification: {e}")
|
||||
|
||||
if self._is_telegram_enabled():
|
||||
await self._send_telegram_sync_failure(notification_data)
|
||||
try:
|
||||
if self._is_telegram_enabled():
|
||||
await self._send_telegram_sync_failure(notification_data)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send Telegram sync failure notification: {e}")
|
||||
|
||||
async def _send_discord_sync_failure(
|
||||
self, notification_data: Dict[str, Any]
|
||||
@@ -326,7 +336,6 @@ class NotificationService:
|
||||
logger.info(f"Sent Discord sync failure notification: {notification_data}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send Discord sync failure notification: {e}")
|
||||
raise
|
||||
|
||||
async def _send_telegram_sync_failure(
|
||||
self, notification_data: Dict[str, Any]
|
||||
@@ -354,4 +363,3 @@ class NotificationService:
|
||||
logger.info(f"Sent Telegram sync failure notification: {notification_data}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send Telegram sync failure notification: {e}")
|
||||
raise
|
||||
|
||||
@@ -8,6 +8,9 @@ from leggen.services.database_service import DatabaseService
|
||||
from leggen.services.gocardless_service import GoCardlessService
|
||||
from leggen.services.notification_service import NotificationService
|
||||
|
||||
# Constants for notification
|
||||
EXPIRED_DAYS_LEFT = 0
|
||||
|
||||
|
||||
class SyncService:
|
||||
def __init__(self):
|
||||
@@ -67,6 +70,9 @@ class SyncService:
|
||||
self._sync_status.total_accounts = len(all_accounts)
|
||||
logs.append(f"Found {len(all_accounts)} accounts to sync")
|
||||
|
||||
# Check for expired or expiring requisitions
|
||||
await self._check_requisition_expiry(requisitions.get("results", []))
|
||||
|
||||
# Process each account
|
||||
for account_id in all_accounts:
|
||||
try:
|
||||
@@ -166,6 +172,15 @@ class SyncService:
|
||||
logger.error(error_msg)
|
||||
logs.append(error_msg)
|
||||
|
||||
# Send notification for account sync failure
|
||||
await self.notifications.send_sync_failure_notification(
|
||||
{
|
||||
"account_id": account_id,
|
||||
"error": error_msg,
|
||||
"type": "account_sync_failure",
|
||||
}
|
||||
)
|
||||
|
||||
end_time = datetime.now()
|
||||
duration = (end_time - start_time).total_seconds()
|
||||
|
||||
@@ -252,6 +267,31 @@ class SyncService:
|
||||
finally:
|
||||
self._sync_status.is_running = False
|
||||
|
||||
async def _check_requisition_expiry(self, requisitions: List[dict]) -> None:
|
||||
"""Check requisitions for expiry and send notifications.
|
||||
|
||||
Args:
|
||||
requisitions: List of requisition dictionaries to check
|
||||
"""
|
||||
for req in requisitions:
|
||||
requisition_id = req.get("id", "unknown")
|
||||
institution_id = req.get("institution_id", "unknown")
|
||||
status = req.get("status", "")
|
||||
|
||||
# Check if requisition is expired
|
||||
if status == "EX":
|
||||
logger.warning(
|
||||
f"Requisition {requisition_id} for {institution_id} has expired"
|
||||
)
|
||||
await self.notifications.send_expiry_notification(
|
||||
{
|
||||
"bank": institution_id,
|
||||
"requisition_id": requisition_id,
|
||||
"status": "expired",
|
||||
"days_left": EXPIRED_DAYS_LEFT,
|
||||
}
|
||||
)
|
||||
|
||||
async def sync_specific_accounts(
|
||||
self, account_ids: List[str], force: bool = False, trigger_type: str = "manual"
|
||||
) -> SyncResult:
|
||||
|
||||
@@ -8,8 +8,10 @@ from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
import tomli_w
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from leggen.commands.server import create_app
|
||||
from leggen.utils.config import Config
|
||||
|
||||
# Create test config before any imports that might load it
|
||||
@@ -27,15 +29,12 @@ _config_data = {
|
||||
"scheduler": {"sync": {"enabled": True, "hour": 3, "minute": 0}},
|
||||
}
|
||||
|
||||
import tomli_w
|
||||
with open(_test_config_path, "wb") as f:
|
||||
tomli_w.dump(_config_data, f)
|
||||
|
||||
# Set environment variables to point to test config BEFORE importing the app
|
||||
os.environ["LEGGEN_CONFIG_FILE"] = str(_test_config_path)
|
||||
|
||||
from leggen.commands.server import create_app
|
||||
|
||||
|
||||
def pytest_configure(config):
|
||||
"""Pytest hook called before test collection."""
|
||||
@@ -114,7 +113,9 @@ def mock_auth_token(temp_config_dir):
|
||||
def fastapi_app(mock_db_path):
|
||||
"""Create FastAPI test application."""
|
||||
# Patch the database path for the app
|
||||
with patch("leggen.utils.paths.path_manager.get_database_path", return_value=mock_db_path):
|
||||
with patch(
|
||||
"leggen.utils.paths.path_manager.get_database_path", return_value=mock_db_path
|
||||
):
|
||||
app = create_app()
|
||||
yield app
|
||||
|
||||
|
||||
@@ -211,10 +211,7 @@ class TestBackupAPI:
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert len(data) == 2
|
||||
assert (
|
||||
data[0]["key"]
|
||||
== "leggen_backups/database_backup_20250101_120000.db"
|
||||
)
|
||||
assert 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."""
|
||||
|
||||
@@ -157,7 +157,6 @@ class TestTransactionsAPI:
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
|
||||
# Verify the database service was called with correct filters
|
||||
mock_get_transactions.assert_called_once_with(
|
||||
|
||||
254
tests/unit/test_sync_notifications.py
Normal file
254
tests/unit/test_sync_notifications.py
Normal file
@@ -0,0 +1,254 @@
|
||||
"""Tests for sync service notification functionality."""
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from leggen.services.sync_service import SyncService
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestSyncNotifications:
|
||||
"""Test sync service notification functionality."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sync_failure_sends_notification(self):
|
||||
"""Test that sync failures trigger notifications."""
|
||||
sync_service = SyncService()
|
||||
|
||||
# Mock the dependencies
|
||||
with (
|
||||
patch.object(
|
||||
sync_service.gocardless, "get_requisitions"
|
||||
) as mock_get_requisitions,
|
||||
patch.object(
|
||||
sync_service.gocardless, "get_account_details"
|
||||
) as mock_get_details,
|
||||
patch.object(
|
||||
sync_service.notifications, "send_sync_failure_notification"
|
||||
) as mock_send_notification,
|
||||
patch.object(
|
||||
sync_service.database, "persist_sync_operation", return_value=1
|
||||
),
|
||||
):
|
||||
# Setup: One requisition with one account that will fail
|
||||
mock_get_requisitions.return_value = {
|
||||
"results": [
|
||||
{
|
||||
"id": "req-123",
|
||||
"institution_id": "TEST_BANK",
|
||||
"status": "LN",
|
||||
"accounts": ["account-1"],
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
# Make account details fail
|
||||
mock_get_details.side_effect = Exception("API Error")
|
||||
|
||||
# Execute: Run sync which should fail for the account
|
||||
await sync_service.sync_all_accounts()
|
||||
|
||||
# Assert: Notification should be sent for the failed account
|
||||
mock_send_notification.assert_called_once()
|
||||
call_args = mock_send_notification.call_args[0][0]
|
||||
assert call_args["account_id"] == "account-1"
|
||||
assert "API Error" in call_args["error"]
|
||||
assert call_args["type"] == "account_sync_failure"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_expired_requisition_sends_notification(self):
|
||||
"""Test that expired requisitions trigger expiry notifications."""
|
||||
sync_service = SyncService()
|
||||
|
||||
# Mock the dependencies
|
||||
with (
|
||||
patch.object(
|
||||
sync_service.gocardless, "get_requisitions"
|
||||
) as mock_get_requisitions,
|
||||
patch.object(
|
||||
sync_service.notifications, "send_expiry_notification"
|
||||
) as mock_send_expiry,
|
||||
patch.object(
|
||||
sync_service.database, "persist_sync_operation", return_value=1
|
||||
),
|
||||
):
|
||||
# Setup: One expired requisition
|
||||
mock_get_requisitions.return_value = {
|
||||
"results": [
|
||||
{
|
||||
"id": "req-expired",
|
||||
"institution_id": "EXPIRED_BANK",
|
||||
"status": "EX",
|
||||
"accounts": [],
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
# Execute: Run sync
|
||||
await sync_service.sync_all_accounts()
|
||||
|
||||
# Assert: Expiry notification should be sent
|
||||
mock_send_expiry.assert_called_once()
|
||||
call_args = mock_send_expiry.call_args[0][0]
|
||||
assert call_args["requisition_id"] == "req-expired"
|
||||
assert call_args["bank"] == "EXPIRED_BANK"
|
||||
assert call_args["status"] == "expired"
|
||||
assert call_args["days_left"] == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_multiple_failures_send_multiple_notifications(self):
|
||||
"""Test that multiple account failures send multiple notifications."""
|
||||
sync_service = SyncService()
|
||||
|
||||
# Mock the dependencies
|
||||
with (
|
||||
patch.object(
|
||||
sync_service.gocardless, "get_requisitions"
|
||||
) as mock_get_requisitions,
|
||||
patch.object(
|
||||
sync_service.gocardless, "get_account_details"
|
||||
) as mock_get_details,
|
||||
patch.object(
|
||||
sync_service.notifications, "send_sync_failure_notification"
|
||||
) as mock_send_notification,
|
||||
patch.object(
|
||||
sync_service.database, "persist_sync_operation", return_value=1
|
||||
),
|
||||
):
|
||||
# Setup: One requisition with two accounts that will fail
|
||||
mock_get_requisitions.return_value = {
|
||||
"results": [
|
||||
{
|
||||
"id": "req-123",
|
||||
"institution_id": "TEST_BANK",
|
||||
"status": "LN",
|
||||
"accounts": ["account-1", "account-2"],
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
# Make all account details fail
|
||||
mock_get_details.side_effect = Exception("API Error")
|
||||
|
||||
# Execute: Run sync
|
||||
await sync_service.sync_all_accounts()
|
||||
|
||||
# Assert: Two notifications should be sent
|
||||
assert mock_send_notification.call_count == 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_successful_sync_no_failure_notification(self):
|
||||
"""Test that successful syncs don't send failure notifications."""
|
||||
sync_service = SyncService()
|
||||
|
||||
# Mock the dependencies
|
||||
with (
|
||||
patch.object(
|
||||
sync_service.gocardless, "get_requisitions"
|
||||
) as mock_get_requisitions,
|
||||
patch.object(
|
||||
sync_service.gocardless, "get_account_details"
|
||||
) as mock_get_details,
|
||||
patch.object(
|
||||
sync_service.gocardless, "get_account_balances"
|
||||
) as mock_get_balances,
|
||||
patch.object(
|
||||
sync_service.gocardless, "get_account_transactions"
|
||||
) as mock_get_transactions,
|
||||
patch.object(
|
||||
sync_service.notifications, "send_sync_failure_notification"
|
||||
) as mock_send_notification,
|
||||
patch.object(sync_service.notifications, "send_transaction_notifications"),
|
||||
patch.object(sync_service.database, "persist_account_details"),
|
||||
patch.object(sync_service.database, "persist_balance"),
|
||||
patch.object(
|
||||
sync_service.database, "process_transactions", return_value=[]
|
||||
),
|
||||
patch.object(
|
||||
sync_service.database, "persist_transactions", return_value=[]
|
||||
),
|
||||
patch.object(
|
||||
sync_service.database, "persist_sync_operation", return_value=1
|
||||
),
|
||||
):
|
||||
# Setup: One requisition with one account that succeeds
|
||||
mock_get_requisitions.return_value = {
|
||||
"results": [
|
||||
{
|
||||
"id": "req-123",
|
||||
"institution_id": "TEST_BANK",
|
||||
"status": "LN",
|
||||
"accounts": ["account-1"],
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
mock_get_details.return_value = {
|
||||
"id": "account-1",
|
||||
"institution_id": "TEST_BANK",
|
||||
"status": "READY",
|
||||
"iban": "TEST123",
|
||||
}
|
||||
|
||||
mock_get_balances.return_value = {
|
||||
"balances": [{"balanceAmount": {"amount": "100", "currency": "EUR"}}]
|
||||
}
|
||||
|
||||
mock_get_transactions.return_value = {"transactions": {"booked": []}}
|
||||
|
||||
# Execute: Run sync
|
||||
await sync_service.sync_all_accounts()
|
||||
|
||||
# Assert: No failure notification should be sent
|
||||
mock_send_notification.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_notification_failure_does_not_stop_sync(self):
|
||||
"""Test that notification failures don't stop the sync process."""
|
||||
sync_service = SyncService()
|
||||
|
||||
# Mock the dependencies
|
||||
with (
|
||||
patch.object(
|
||||
sync_service.gocardless, "get_requisitions"
|
||||
) as mock_get_requisitions,
|
||||
patch.object(
|
||||
sync_service.gocardless, "get_account_details"
|
||||
) as mock_get_details,
|
||||
patch.object(
|
||||
sync_service.notifications, "_send_discord_sync_failure"
|
||||
) as mock_discord_notification,
|
||||
patch.object(
|
||||
sync_service.notifications, "_send_telegram_sync_failure"
|
||||
) as mock_telegram_notification,
|
||||
patch.object(
|
||||
sync_service.database, "persist_sync_operation", return_value=1
|
||||
),
|
||||
):
|
||||
# Setup: One requisition with one account that will fail
|
||||
mock_get_requisitions.return_value = {
|
||||
"results": [
|
||||
{
|
||||
"id": "req-123",
|
||||
"institution_id": "TEST_BANK",
|
||||
"status": "LN",
|
||||
"accounts": ["account-1"],
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
# Make account details fail
|
||||
mock_get_details.side_effect = Exception("API Error")
|
||||
|
||||
# Make both notification methods fail
|
||||
mock_discord_notification.side_effect = Exception("Discord Error")
|
||||
mock_telegram_notification.side_effect = Exception("Telegram Error")
|
||||
|
||||
# Execute: Run sync - should not raise exception from notification
|
||||
result = await sync_service.sync_all_accounts()
|
||||
|
||||
# The sync should complete with errors but not crash from notifications
|
||||
assert result.success is False
|
||||
assert len(result.errors) > 0
|
||||
assert "API Error" in result.errors[0]
|
||||
Reference in New Issue
Block a user