refactor: Remove API response wrapper pattern.

Replace wrapped responses {success, data, message} with direct data returns
following REST best practices. Simplifies 41 endpoints across 7 route files
and updates all 109 tests. Also fixes test config setup to not require
user home directory config file.

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

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
Elisiário Couto
2025-12-07 00:54:51 +00:00
parent a75365d805
commit fabea404ef
17 changed files with 1171 additions and 835 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -4,7 +4,7 @@ import type {
Transaction,
AnalyticsTransaction,
Balance,
ApiResponse,
PaginatedResponse,
NotificationSettings,
NotificationTest,
NotificationService,
@@ -36,14 +36,14 @@ const api = axios.create({
export const apiClient = {
// Get all accounts
getAccounts: async (): Promise<Account[]> => {
const response = await api.get<ApiResponse<Account[]>>("/accounts");
return response.data.data;
const response = await api.get<Account[]>("/accounts");
return response.data;
},
// Get account by ID
getAccount: async (id: string): Promise<Account> => {
const response = await api.get<ApiResponse<Account>>(`/accounts/${id}`);
return response.data.data;
const response = await api.get<Account>(`/accounts/${id}`);
return response.data;
},
// Update account details
@@ -51,16 +51,17 @@ export const apiClient = {
id: string,
updates: AccountUpdate,
): Promise<{ id: string; display_name?: string }> => {
const response = await api.put<
ApiResponse<{ id: string; display_name?: string }>
>(`/accounts/${id}`, updates);
return response.data.data;
const response = await api.put<{ id: string; display_name?: string }>(
`/accounts/${id}`,
updates,
);
return response.data;
},
// Get all balances
getBalances: async (): Promise<Balance[]> => {
const response = await api.get<ApiResponse<Balance[]>>("/balances");
return response.data.data;
const response = await api.get<Balance[]>("/balances");
return response.data;
},
// Get historical balances for balance progression chart
@@ -72,18 +73,18 @@ export const apiClient = {
if (days) queryParams.append("days", days.toString());
if (accountId) queryParams.append("account_id", accountId);
const response = await api.get<ApiResponse<Balance[]>>(
const response = await api.get<Balance[]>(
`/balances/history?${queryParams.toString()}`,
);
return response.data.data;
return response.data;
},
// Get balances for specific account
getAccountBalances: async (accountId: string): Promise<Balance[]> => {
const response = await api.get<ApiResponse<Balance[]>>(
const response = await api.get<Balance[]>(
`/accounts/${accountId}/balances`,
);
return response.data.data;
return response.data;
},
// Get transactions with optional filters
@@ -97,7 +98,7 @@ export const apiClient = {
summaryOnly?: boolean;
minAmount?: number;
maxAmount?: number;
}): Promise<ApiResponse<Transaction[]>> => {
}): Promise<PaginatedResponse<Transaction>> => {
const queryParams = new URLSearchParams();
if (params?.accountId) queryParams.append("account_id", params.accountId);
@@ -117,7 +118,7 @@ export const apiClient = {
queryParams.append("max_amount", params.maxAmount.toString());
}
const response = await api.get<ApiResponse<Transaction[]>>(
const response = await api.get<PaginatedResponse<Transaction>>(
`/transactions?${queryParams.toString()}`,
);
return response.data;
@@ -125,29 +126,27 @@ export const apiClient = {
// Get transaction by ID
getTransaction: async (id: string): Promise<Transaction> => {
const response = await api.get<ApiResponse<Transaction>>(
`/transactions/${id}`,
);
return response.data.data;
const response = await api.get<Transaction>(`/transactions/${id}`);
return response.data;
},
// Get notification settings
getNotificationSettings: async (): Promise<NotificationSettings> => {
const response = await api.get<ApiResponse<NotificationSettings>>(
const response = await api.get<NotificationSettings>(
"/notifications/settings",
);
return response.data.data;
return response.data;
},
// Update notification settings
updateNotificationSettings: async (
settings: NotificationSettings,
): Promise<NotificationSettings> => {
const response = await api.put<ApiResponse<NotificationSettings>>(
const response = await api.put<NotificationSettings>(
"/notifications/settings",
settings,
);
return response.data.data;
return response.data;
},
// Test notification
@@ -157,11 +156,11 @@ export const apiClient = {
// Get notification services
getNotificationServices: async (): Promise<NotificationService[]> => {
const response = await api.get<ApiResponse<NotificationServicesResponse>>(
const response = await api.get<NotificationServicesResponse>(
"/notifications/services",
);
// Convert object to array format
const servicesData = response.data.data;
const servicesData = response.data;
return Object.values(servicesData);
},
@@ -172,8 +171,8 @@ export const apiClient = {
// Health check
getHealth: async (): Promise<HealthData> => {
const response = await api.get<ApiResponse<HealthData>>("/health");
return response.data.data;
const response = await api.get<HealthData>("/health");
return response.data;
},
// Analytics endpoints
@@ -181,10 +180,10 @@ export const apiClient = {
const queryParams = new URLSearchParams();
if (days) queryParams.append("days", days.toString());
const response = await api.get<ApiResponse<TransactionStats>>(
const response = await api.get<TransactionStats>(
`/transactions/stats?${queryParams.toString()}`,
);
return response.data.data;
return response.data;
},
// Get all transactions for analytics (no pagination)
@@ -194,10 +193,10 @@ export const apiClient = {
const queryParams = new URLSearchParams();
if (days) queryParams.append("days", days.toString());
const response = await api.get<ApiResponse<AnalyticsTransaction[]>>(
const response = await api.get<AnalyticsTransaction[]>(
`/transactions/analytics?${queryParams.toString()}`,
);
return response.data.data;
return response.data;
},
// Get monthly transaction statistics (pre-calculated)
@@ -215,16 +214,14 @@ export const apiClient = {
if (days) queryParams.append("days", days.toString());
const response = await api.get<
ApiResponse<
Array<{
month: string;
income: number;
expenses: number;
net: number;
}>
>
Array<{
month: string;
income: number;
expenses: number;
net: number;
}>
>(`/transactions/monthly-stats?${queryParams.toString()}`);
return response.data.data;
return response.data;
},
// Get sync operations history
@@ -232,24 +229,23 @@ export const apiClient = {
limit: number = 50,
offset: number = 0,
): Promise<SyncOperationsResponse> => {
const response = await api.get<ApiResponse<SyncOperationsResponse>>(
const response = await api.get<SyncOperationsResponse>(
`/sync/operations?limit=${limit}&offset=${offset}`,
);
return response.data.data;
return response.data;
},
// Bank management endpoints
getBankInstitutions: async (country: string): Promise<BankInstitution[]> => {
const response = await api.get<ApiResponse<BankInstitution[]>>(
const response = await api.get<BankInstitution[]>(
`/banks/institutions?country=${country}`,
);
return response.data.data;
return response.data;
},
getBankConnectionsStatus: async (): Promise<BankConnectionStatus[]> => {
const response =
await api.get<ApiResponse<BankConnectionStatus[]>>("/banks/status");
return response.data.data;
const response = await api.get<BankConnectionStatus[]>("/banks/status");
return response.data;
},
createBankConnection: async (
@@ -260,14 +256,11 @@ export const apiClient = {
const finalRedirectUrl =
redirectUrl || `${window.location.origin}/bank-connected`;
const response = await api.post<ApiResponse<BankRequisition>>(
"/banks/connect",
{
institution_id: institutionId,
redirect_url: finalRedirectUrl,
},
);
return response.data.data;
const response = await api.post<BankRequisition>("/banks/connect", {
institution_id: institutionId,
redirect_url: finalRedirectUrl,
});
return response.data;
},
deleteBankConnection: async (requisitionId: string): Promise<void> => {
@@ -275,39 +268,44 @@ export const apiClient = {
},
getSupportedCountries: async (): Promise<Country[]> => {
const response = await api.get<ApiResponse<Country[]>>("/banks/countries");
return response.data.data;
const response = await api.get<Country[]>("/banks/countries");
return response.data;
},
// Backup endpoints
getBackupSettings: async (): Promise<BackupSettings> => {
const response =
await api.get<ApiResponse<BackupSettings>>("/backup/settings");
return response.data.data;
const response = await api.get<BackupSettings>("/backup/settings");
return response.data;
},
updateBackupSettings: async (
settings: BackupSettings,
): Promise<BackupSettings> => {
const response = await api.put<ApiResponse<BackupSettings>>(
const response = await api.put<BackupSettings>(
"/backup/settings",
settings,
);
return response.data.data;
return response.data;
},
testBackupConnection: async (test: BackupTest): Promise<ApiResponse<{ connected?: boolean }>> => {
const response = await api.post<ApiResponse<{ connected?: boolean }>>("/backup/test", test);
testBackupConnection: async (test: BackupTest): Promise<{ connected?: boolean }> => {
const response = await api.post<{ connected?: boolean }>(
"/backup/test",
test,
);
return response.data;
},
listBackups: async (): Promise<BackupInfo[]> => {
const response = await api.get<ApiResponse<BackupInfo[]>>("/backup/list");
return response.data.data;
const response = await api.get<BackupInfo[]>("/backup/list");
return response.data;
},
performBackupOperation: async (operation: BackupOperation): Promise<ApiResponse<{ operation: string; completed: boolean }>> => {
const response = await api.post<ApiResponse<{ operation: string; completed: boolean }>>("/backup/operation", operation);
performBackupOperation: async (operation: BackupOperation): Promise<{ operation: string; completed: boolean }> => {
const response = await api.post<{ operation: string; completed: boolean }>(
"/backup/operation",
operation,
);
return response.data;
},
};

View File

@@ -133,26 +133,14 @@ export interface Bank {
logo_url?: string;
}
export interface ApiResponse<T> {
data: T;
message?: string;
success: boolean;
pagination?: {
total: number;
page: number;
per_page: number;
total_pages: number;
has_next: boolean;
has_prev: boolean;
};
}
export interface PaginatedResponse<T> {
data: T[];
total: number;
page: number;
per_page: number;
total_pages: number;
has_next: boolean;
has_prev: boolean;
}
// Notification types

View File

@@ -1,29 +1,17 @@
from typing import Any, Dict, Optional
from typing import Any, Dict, Generic, List, TypeVar
from pydantic import BaseModel
class APIResponse(BaseModel):
"""Base API response model"""
success: bool = True
message: Optional[str] = None
data: Optional[Any] = None
T = TypeVar("T")
class ErrorResponse(BaseModel):
"""Error response model"""
success: bool = False
message: str
error_code: Optional[str] = None
details: Optional[Dict[str, Any]] = None
class PaginatedResponse(BaseModel):
class PaginatedResponse(BaseModel, Generic[T]):
"""Paginated response model"""
success: bool = True
data: list
pagination: Dict[str, Any]
message: Optional[str] = None
data: List[T]
total: int
page: int
per_page: int
total_pages: int
has_next: bool
has_prev: bool

View File

@@ -10,15 +10,14 @@ from leggen.api.models.accounts import (
Transaction,
TransactionSummary,
)
from leggen.api.models.common import APIResponse
from leggen.services.database_service import DatabaseService
router = APIRouter()
database_service = DatabaseService()
@router.get("/accounts", response_model=APIResponse)
async def get_all_accounts() -> APIResponse:
@router.get("/accounts")
async def get_all_accounts() -> List[AccountDetails]:
"""Get all connected accounts from database"""
try:
accounts = []
@@ -68,11 +67,7 @@ async def get_all_accounts() -> APIResponse:
)
continue
return APIResponse(
success=True,
data=accounts,
message=f"Retrieved {len(accounts)} accounts from database",
)
return accounts
except Exception as e:
logger.error(f"Failed to get accounts: {e}")
@@ -81,8 +76,8 @@ async def get_all_accounts() -> APIResponse:
) from e
@router.get("/accounts/{account_id}", response_model=APIResponse)
async def get_account_details(account_id: str) -> APIResponse:
@router.get("/accounts/{account_id}")
async def get_account_details(account_id: str) -> AccountDetails:
"""Get details for a specific account from database"""
try:
# Get account details from database
@@ -122,11 +117,7 @@ async def get_account_details(account_id: str) -> APIResponse:
balances=balances,
)
return APIResponse(
success=True,
data=account,
message=f"Account details retrieved from database for {account_id}",
)
return account
except HTTPException:
raise
@@ -137,8 +128,8 @@ async def get_account_details(account_id: str) -> APIResponse:
) from e
@router.get("/accounts/{account_id}/balances", response_model=APIResponse)
async def get_account_balances(account_id: str) -> APIResponse:
@router.get("/accounts/{account_id}/balances")
async def get_account_balances(account_id: str) -> List[AccountBalance]:
"""Get balances for a specific account from database"""
try:
# Get balances from database instead of GoCardless API
@@ -155,11 +146,7 @@ async def get_account_balances(account_id: str) -> APIResponse:
)
)
return APIResponse(
success=True,
data=balances,
message=f"Retrieved {len(balances)} balances for account {account_id}",
)
return balances
except Exception as e:
logger.error(
@@ -170,8 +157,8 @@ async def get_account_balances(account_id: str) -> APIResponse:
) from e
@router.get("/balances", response_model=APIResponse)
async def get_all_balances() -> APIResponse:
@router.get("/balances")
async def get_all_balances() -> List[dict]:
"""Get all balances from all accounts in database"""
try:
# Get all accounts first to iterate through them
@@ -207,11 +194,7 @@ async def get_all_balances() -> APIResponse:
)
continue
return APIResponse(
success=True,
data=all_balances,
message=f"Retrieved {len(all_balances)} balances from {len(db_accounts)} accounts",
)
return all_balances
except Exception as e:
logger.error(f"Failed to get all balances: {e}")
@@ -220,7 +203,7 @@ async def get_all_balances() -> APIResponse:
) from e
@router.get("/balances/history", response_model=APIResponse)
@router.get("/balances/history")
async def get_historical_balances(
days: Optional[int] = Query(
default=365, le=1095, ge=1, description="Number of days of history to retrieve"
@@ -228,7 +211,7 @@ async def get_historical_balances(
account_id: Optional[str] = Query(
default=None, description="Filter by specific account ID"
),
) -> APIResponse:
) -> List[dict]:
"""Get historical balance progression calculated from transaction history"""
try:
# Get historical balances from database
@@ -236,11 +219,7 @@ async def get_historical_balances(
account_id=account_id, days=days or 365
)
return APIResponse(
success=True,
data=historical_balances,
message=f"Retrieved {len(historical_balances)} historical balance points over {days} days",
)
return historical_balances
except Exception as e:
logger.error(f"Failed to get historical balances: {e}")
@@ -249,7 +228,7 @@ async def get_historical_balances(
) from e
@router.get("/accounts/{account_id}/transactions", response_model=APIResponse)
@router.get("/accounts/{account_id}/transactions")
async def get_account_transactions(
account_id: str,
limit: Optional[int] = Query(default=100, le=500),
@@ -257,7 +236,7 @@ async def get_account_transactions(
summary_only: bool = Query(
default=False, description="Return transaction summaries only"
),
) -> APIResponse:
) -> Union[List[TransactionSummary], List[Transaction]]:
"""Get transactions for a specific account from database"""
try:
# Get transactions from database instead of GoCardless API
@@ -308,12 +287,7 @@ async def get_account_transactions(
for txn in db_transactions
]
actual_offset = offset or 0
return APIResponse(
success=True,
data=data,
message=f"Retrieved {len(data)} transactions (showing {actual_offset + 1}-{actual_offset + len(data)} of {total_transactions})",
)
return data
except Exception as e:
logger.error(
@@ -324,10 +298,10 @@ async def get_account_transactions(
) from e
@router.put("/accounts/{account_id}", response_model=APIResponse)
@router.put("/accounts/{account_id}")
async def update_account_details(
account_id: str, update_data: AccountUpdate
) -> APIResponse:
) -> dict:
"""Update account details (currently only display_name)"""
try:
# Get current account details
@@ -346,11 +320,7 @@ async def update_account_details(
# Persist updated account details
await database_service.persist_account_details(updated_account_data)
return APIResponse(
success=True,
data={"id": account_id, "display_name": update_data.display_name},
message=f"Account {account_id} display name updated successfully",
)
return {"id": account_id, "display_name": update_data.display_name}
except HTTPException:
raise

View File

@@ -9,7 +9,6 @@ from leggen.api.models.backup import (
BackupTest,
S3Config,
)
from leggen.api.models.common import APIResponse
from leggen.models.config import S3BackupConfig
from leggen.services.backup_service import BackupService
from leggen.utils.config import config
@@ -18,8 +17,8 @@ from leggen.utils.paths import path_manager
router = APIRouter()
@router.get("/backup/settings", response_model=APIResponse)
async def get_backup_settings() -> APIResponse:
@router.get("/backup/settings")
async def get_backup_settings() -> BackupSettings:
"""Get current backup settings."""
try:
backup_config = config.backup_config
@@ -41,11 +40,7 @@ async def get_backup_settings() -> APIResponse:
else None,
)
return APIResponse(
success=True,
data=settings,
message="Backup settings retrieved successfully",
)
return settings
except Exception as e:
logger.error(f"Failed to get backup settings: {e}")
@@ -54,8 +49,8 @@ async def get_backup_settings() -> APIResponse:
) from e
@router.put("/backup/settings", response_model=APIResponse)
async def update_backup_settings(settings: BackupSettings) -> APIResponse:
@router.put("/backup/settings")
async def update_backup_settings(settings: BackupSettings) -> dict:
"""Update backup settings."""
try:
# First test the connection if S3 config is provided
@@ -99,11 +94,7 @@ async def update_backup_settings(settings: BackupSettings) -> APIResponse:
if backup_config:
config.update_section("backup", backup_config)
return APIResponse(
success=True,
data={"updated": True},
message="Backup settings updated successfully",
)
return {"updated": True}
except HTTPException:
raise
@@ -114,8 +105,8 @@ async def update_backup_settings(settings: BackupSettings) -> APIResponse:
) from e
@router.post("/backup/test", response_model=APIResponse)
async def test_backup_connection(test_request: BackupTest) -> APIResponse:
@router.post("/backup/test")
async def test_backup_connection(test_request: BackupTest) -> dict:
"""Test backup connection."""
try:
if test_request.service != "s3":
@@ -137,18 +128,13 @@ async def test_backup_connection(test_request: BackupTest) -> APIResponse:
backup_service = BackupService()
success = await backup_service.test_connection(s3_config)
if success:
return APIResponse(
success=True,
data={"connected": True},
message="S3 connection test successful",
)
else:
return APIResponse(
success=False,
message="S3 connection test failed",
if not success:
raise HTTPException(
status_code=400, detail="S3 connection test failed"
)
return {"connected": True}
except HTTPException:
raise
except Exception as e:
@@ -158,18 +144,14 @@ async def test_backup_connection(test_request: BackupTest) -> APIResponse:
) from e
@router.get("/backup/list", response_model=APIResponse)
async def list_backups() -> APIResponse:
@router.get("/backup/list")
async def list_backups() -> list:
"""List available backups."""
try:
backup_config = config.backup_config.get("s3", {})
if not backup_config.get("bucket_name"):
return APIResponse(
success=True,
data=[],
message="No S3 backup configuration found",
)
return []
# Convert config to model
s3_config = S3BackupConfig(**backup_config)
@@ -177,11 +159,7 @@ async def list_backups() -> APIResponse:
backups = await backup_service.list_backups()
return APIResponse(
success=True,
data=backups,
message=f"Found {len(backups)} backups",
)
return backups
except Exception as e:
logger.error(f"Failed to list backups: {e}")
@@ -190,8 +168,8 @@ async def list_backups() -> APIResponse:
) from e
@router.post("/backup/operation", response_model=APIResponse)
async def backup_operation(operation_request: BackupOperation) -> APIResponse:
@router.post("/backup/operation")
async def backup_operation(operation_request: BackupOperation) -> dict:
"""Perform backup operation (backup or restore)."""
try:
backup_config = config.backup_config.get("s3", {})
@@ -214,18 +192,13 @@ async def backup_operation(operation_request: BackupOperation) -> APIResponse:
database_path = path_manager.get_database_path()
success = await backup_service.backup_database(database_path)
if success:
return APIResponse(
success=True,
data={"operation": "backup", "completed": True},
message="Database backup completed successfully",
)
else:
return APIResponse(
success=False,
message="Database backup failed",
if not success:
raise HTTPException(
status_code=500, detail="Database backup failed"
)
return {"operation": "backup", "completed": True}
elif operation_request.operation == "restore":
if not operation_request.backup_key:
raise HTTPException(
@@ -239,17 +212,12 @@ async def backup_operation(operation_request: BackupOperation) -> APIResponse:
operation_request.backup_key, database_path
)
if success:
return APIResponse(
success=True,
data={"operation": "restore", "completed": True},
message="Database restore completed successfully",
)
else:
return APIResponse(
success=False,
message="Database restore failed",
if not success:
raise HTTPException(
status_code=500, detail="Database restore failed"
)
return {"operation": "restore", "completed": True}
else:
raise HTTPException(
status_code=400, detail="Invalid operation. Use 'backup' or 'restore'"

View File

@@ -8,7 +8,6 @@ from leggen.api.models.banks import (
BankInstitution,
BankRequisition,
)
from leggen.api.models.common import APIResponse
from leggen.services.gocardless_service import GoCardlessService
from leggen.utils.gocardless import REQUISITION_STATUS
@@ -16,10 +15,10 @@ router = APIRouter()
gocardless_service = GoCardlessService()
@router.get("/banks/institutions", response_model=APIResponse)
@router.get("/banks/institutions")
async def get_bank_institutions(
country: str = Query(default="PT", description="Country code (e.g., PT, ES, FR)"),
) -> APIResponse:
) -> list[BankInstitution]:
"""Get available bank institutions for a country"""
try:
institutions_response = await gocardless_service.get_institutions(country)
@@ -41,11 +40,7 @@ async def get_bank_institutions(
for inst in institutions_data
]
return APIResponse(
success=True,
data=institutions,
message=f"Found {len(institutions)} institutions for {country}",
)
return institutions
except Exception as e:
logger.error(f"Failed to get institutions for {country}: {e}")
@@ -54,8 +49,8 @@ async def get_bank_institutions(
) from e
@router.post("/banks/connect", response_model=APIResponse)
async def connect_to_bank(request: BankConnectionRequest) -> APIResponse:
@router.post("/banks/connect")
async def connect_to_bank(request: BankConnectionRequest) -> BankRequisition:
"""Create a connection to a bank (requisition)"""
try:
redirect_url = request.redirect_url or "http://localhost:8000/"
@@ -72,11 +67,7 @@ async def connect_to_bank(request: BankConnectionRequest) -> APIResponse:
accounts=requisition_data.get("accounts", []),
)
return APIResponse(
success=True,
data=requisition,
message="Bank connection created. Please visit the link to authorize.",
)
return requisition
except Exception as e:
logger.error(f"Failed to connect to bank {request.institution_id}: {e}")
@@ -85,8 +76,8 @@ async def connect_to_bank(request: BankConnectionRequest) -> APIResponse:
) from e
@router.get("/banks/status", response_model=APIResponse)
async def get_bank_connections_status() -> APIResponse:
@router.get("/banks/status")
async def get_bank_connections_status() -> list[BankConnectionStatus]:
"""Get status of all bank connections"""
try:
requisitions_data = await gocardless_service.get_requisitions()
@@ -110,11 +101,7 @@ async def get_bank_connections_status() -> APIResponse:
)
)
return APIResponse(
success=True,
data=connections,
message=f"Found {len(connections)} bank connections",
)
return connections
except Exception as e:
logger.error(f"Failed to get bank connection status: {e}")
@@ -123,8 +110,8 @@ async def get_bank_connections_status() -> APIResponse:
) from e
@router.delete("/banks/connections/{requisition_id}", response_model=APIResponse)
async def delete_bank_connection(requisition_id: str) -> APIResponse:
@router.delete("/banks/connections/{requisition_id}")
async def delete_bank_connection(requisition_id: str) -> dict:
"""Delete a bank connection"""
try:
# Delete the requisition from GoCardless
@@ -134,10 +121,7 @@ async def delete_bank_connection(requisition_id: str) -> APIResponse:
# We should check if the operation was actually successful
logger.info(f"GoCardless delete response for {requisition_id}: {result}")
return APIResponse(
success=True,
message=f"Bank connection {requisition_id} deleted successfully",
)
return {"deleted": requisition_id}
except httpx.HTTPStatusError as http_err:
logger.error(
@@ -164,8 +148,8 @@ async def delete_bank_connection(requisition_id: str) -> APIResponse:
) from e
@router.get("/banks/countries", response_model=APIResponse)
async def get_supported_countries() -> APIResponse:
@router.get("/banks/countries")
async def get_supported_countries() -> list[dict]:
"""Get list of supported countries"""
countries = [
{"code": "AT", "name": "Austria"},
@@ -201,8 +185,4 @@ async def get_supported_countries() -> APIResponse:
{"code": "GB", "name": "United Kingdom"},
]
return APIResponse(
success=True,
data=countries,
message="Supported countries retrieved successfully",
)
return countries

View File

@@ -3,7 +3,6 @@ from typing import Any, Dict
from fastapi import APIRouter, HTTPException
from loguru import logger
from leggen.api.models.common import APIResponse
from leggen.api.models.notifications import (
DiscordConfig,
NotificationFilters,
@@ -18,8 +17,8 @@ router = APIRouter()
notification_service = NotificationService()
@router.get("/notifications/settings", response_model=APIResponse)
async def get_notification_settings() -> APIResponse:
@router.get("/notifications/settings")
async def get_notification_settings() -> NotificationSettings:
"""Get current notification settings"""
try:
notifications_config = config.notifications_config
@@ -49,11 +48,7 @@ async def get_notification_settings() -> APIResponse:
),
)
return APIResponse(
success=True,
data=settings,
message="Notification settings retrieved successfully",
)
return settings
except Exception as e:
logger.error(f"Failed to get notification settings: {e}")
@@ -62,8 +57,8 @@ async def get_notification_settings() -> APIResponse:
) from e
@router.put("/notifications/settings", response_model=APIResponse)
async def update_notification_settings(settings: NotificationSettings) -> APIResponse:
@router.put("/notifications/settings")
async def update_notification_settings(settings: NotificationSettings) -> dict:
"""Update notification settings"""
try:
# Update notifications config
@@ -95,11 +90,7 @@ async def update_notification_settings(settings: NotificationSettings) -> APIRes
if filters_config:
config.update_section("filters", filters_config)
return APIResponse(
success=True,
data={"updated": True},
message="Notification settings updated successfully",
)
return {"updated": True}
except Exception as e:
logger.error(f"Failed to update notification settings: {e}")
@@ -108,26 +99,24 @@ async def update_notification_settings(settings: NotificationSettings) -> APIRes
) from e
@router.post("/notifications/test", response_model=APIResponse)
async def test_notification(test_request: NotificationTest) -> APIResponse:
@router.post("/notifications/test")
async def test_notification(test_request: NotificationTest) -> dict:
"""Send a test notification"""
try:
success = await notification_service.send_test_notification(
test_request.service, test_request.message
)
if success:
return APIResponse(
success=True,
data={"sent": True},
message=f"Test notification sent to {test_request.service} successfully",
)
else:
return APIResponse(
success=False,
message=f"Failed to send test notification to {test_request.service}",
if not success:
raise HTTPException(
status_code=400,
detail=f"Failed to send test notification to {test_request.service}",
)
return {"sent": True}
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to send test notification: {e}")
raise HTTPException(
@@ -135,8 +124,8 @@ async def test_notification(test_request: NotificationTest) -> APIResponse:
) from e
@router.get("/notifications/services", response_model=APIResponse)
async def get_notification_services() -> APIResponse:
@router.get("/notifications/services")
async def get_notification_services() -> dict:
"""Get available notification services and their status"""
try:
notifications_config = config.notifications_config
@@ -164,11 +153,7 @@ async def get_notification_services() -> APIResponse:
},
}
return APIResponse(
success=True,
data=services,
message="Notification services status retrieved successfully",
)
return services
except Exception as e:
logger.error(f"Failed to get notification services: {e}")
@@ -177,8 +162,8 @@ async def get_notification_services() -> APIResponse:
) from e
@router.delete("/notifications/settings/{service}", response_model=APIResponse)
async def delete_notification_service(service: str) -> APIResponse:
@router.delete("/notifications/settings/{service}")
async def delete_notification_service(service: str) -> dict:
"""Delete/disable a notification service"""
try:
if service not in ["discord", "telegram"]:
@@ -191,12 +176,10 @@ async def delete_notification_service(service: str) -> APIResponse:
del notifications_config[service]
config.update_section("notifications", notifications_config)
return APIResponse(
success=True,
data={"deleted": service},
message=f"{service.capitalize()} notification service deleted successfully",
)
return {"deleted": service}
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to delete notification service {service}: {e}")
raise HTTPException(

View File

@@ -3,7 +3,6 @@ from typing import Optional
from fastapi import APIRouter, BackgroundTasks, HTTPException
from loguru import logger
from leggen.api.models.common import APIResponse
from leggen.api.models.sync import SchedulerConfig, SyncRequest
from leggen.background.scheduler import scheduler
from leggen.services.sync_service import SyncService
@@ -13,8 +12,8 @@ router = APIRouter()
sync_service = SyncService()
@router.get("/sync/status", response_model=APIResponse)
async def get_sync_status() -> APIResponse:
@router.get("/sync/status")
async def get_sync_status() -> dict:
"""Get current sync status"""
try:
status = await sync_service.get_sync_status()
@@ -24,9 +23,7 @@ async def get_sync_status() -> APIResponse:
if next_sync_time:
status.next_sync = next_sync_time
return APIResponse(
success=True, data=status, message="Sync status retrieved successfully"
)
return status
except Exception as e:
logger.error(f"Failed to get sync status: {e}")
@@ -35,18 +32,18 @@ async def get_sync_status() -> APIResponse:
) from e
@router.post("/sync", response_model=APIResponse)
@router.post("/sync")
async def trigger_sync(
background_tasks: BackgroundTasks, sync_request: Optional[SyncRequest] = None
) -> APIResponse:
) -> dict:
"""Trigger a manual sync operation"""
try:
# Check if sync is already running
status = await sync_service.get_sync_status()
if status.is_running and not (sync_request and sync_request.force):
return APIResponse(
success=False,
message="Sync is already running. Use 'force: true' to override.",
raise HTTPException(
status_code=409,
detail="Sync is already running. Use 'force: true' to override.",
)
# Determine what to sync
@@ -58,9 +55,6 @@ async def trigger_sync(
sync_request.force if sync_request else False,
"api", # trigger_type
)
message = (
f"Started sync for {len(sync_request.account_ids)} specific accounts"
)
else:
# Sync all accounts in background
background_tasks.add_task(
@@ -68,17 +62,14 @@ async def trigger_sync(
sync_request.force if sync_request else False,
"api", # trigger_type
)
message = "Started sync for all accounts"
return APIResponse(
success=True,
data={
"sync_started": True,
"force": sync_request.force if sync_request else False,
},
message=message,
)
return {
"sync_started": True,
"force": sync_request.force if sync_request else False,
}
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to trigger sync: {e}")
raise HTTPException(
@@ -86,8 +77,8 @@ async def trigger_sync(
) from e
@router.post("/sync/now", response_model=APIResponse)
async def sync_now(sync_request: Optional[SyncRequest] = None) -> APIResponse:
@router.post("/sync/now")
async def sync_now(sync_request: Optional[SyncRequest] = None) -> dict:
"""Run sync synchronously and return results (slower, for testing)"""
try:
if sync_request and sync_request.account_ids:
@@ -99,13 +90,7 @@ async def sync_now(sync_request: Optional[SyncRequest] = None) -> APIResponse:
sync_request.force if sync_request else False, "api"
)
return APIResponse(
success=result.success,
data=result,
message="Sync completed"
if result.success
else f"Sync failed with {len(result.errors)} errors",
)
return result
except Exception as e:
logger.error(f"Failed to run sync: {e}")
@@ -114,8 +99,8 @@ async def sync_now(sync_request: Optional[SyncRequest] = None) -> APIResponse:
) from e
@router.get("/sync/scheduler", response_model=APIResponse)
async def get_scheduler_config() -> APIResponse:
@router.get("/sync/scheduler")
async def get_scheduler_config() -> dict:
"""Get current scheduler configuration"""
try:
scheduler_config = config.scheduler_config
@@ -131,11 +116,7 @@ async def get_scheduler_config() -> APIResponse:
else False,
}
return APIResponse(
success=True,
data=response_data,
message="Scheduler configuration retrieved successfully",
)
return response_data
except Exception as e:
logger.error(f"Failed to get scheduler config: {e}")
@@ -144,8 +125,8 @@ async def get_scheduler_config() -> APIResponse:
) from e
@router.put("/sync/scheduler", response_model=APIResponse)
async def update_scheduler_config(scheduler_config: SchedulerConfig) -> APIResponse:
@router.put("/sync/scheduler")
async def update_scheduler_config(scheduler_config: SchedulerConfig) -> dict:
"""Update scheduler configuration"""
try:
# Validate cron expression if provided
@@ -168,12 +149,10 @@ async def update_scheduler_config(scheduler_config: SchedulerConfig) -> APIRespo
# Reschedule the job
scheduler.reschedule_sync(schedule_data)
return APIResponse(
success=True,
data=schedule_data,
message="Scheduler configuration updated successfully",
)
return schedule_data
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to update scheduler config: {e}")
raise HTTPException(
@@ -181,15 +160,15 @@ async def update_scheduler_config(scheduler_config: SchedulerConfig) -> APIRespo
) from e
@router.post("/sync/scheduler/start", response_model=APIResponse)
async def start_scheduler() -> APIResponse:
@router.post("/sync/scheduler/start")
async def start_scheduler() -> dict:
"""Start the background scheduler"""
try:
if not scheduler.scheduler.running:
scheduler.start()
return APIResponse(success=True, message="Scheduler started successfully")
return {"started": True}
else:
return APIResponse(success=True, message="Scheduler is already running")
return {"started": False, "message": "Scheduler is already running"}
except Exception as e:
logger.error(f"Failed to start scheduler: {e}")
@@ -198,15 +177,15 @@ async def start_scheduler() -> APIResponse:
) from e
@router.post("/sync/scheduler/stop", response_model=APIResponse)
async def stop_scheduler() -> APIResponse:
@router.post("/sync/scheduler/stop")
async def stop_scheduler() -> dict:
"""Stop the background scheduler"""
try:
if scheduler.scheduler.running:
scheduler.shutdown()
return APIResponse(success=True, message="Scheduler stopped successfully")
return {"stopped": True}
else:
return APIResponse(success=True, message="Scheduler is already stopped")
return {"stopped": False, "message": "Scheduler is already stopped"}
except Exception as e:
logger.error(f"Failed to stop scheduler: {e}")
@@ -215,19 +194,15 @@ async def stop_scheduler() -> APIResponse:
) from e
@router.get("/sync/operations", response_model=APIResponse)
async def get_sync_operations(limit: int = 50, offset: int = 0) -> APIResponse:
@router.get("/sync/operations")
async def get_sync_operations(limit: int = 50, offset: int = 0) -> dict:
"""Get sync operations history"""
try:
operations = await sync_service.database.get_sync_operations(
limit=limit, offset=offset
)
return APIResponse(
success=True,
data={"operations": operations, "count": len(operations)},
message="Sync operations retrieved successfully",
)
return {"operations": operations, "count": len(operations)}
except Exception as e:
logger.error(f"Failed to get sync operations: {e}")

View File

@@ -5,14 +5,14 @@ from fastapi import APIRouter, HTTPException, Query
from loguru import logger
from leggen.api.models.accounts import Transaction, TransactionSummary
from leggen.api.models.common import APIResponse, PaginatedResponse
from leggen.api.models.common import PaginatedResponse
from leggen.services.database_service import DatabaseService
router = APIRouter()
database_service = DatabaseService()
@router.get("/transactions", response_model=PaginatedResponse)
@router.get("/transactions")
async def get_all_transactions(
page: int = Query(default=1, ge=1, description="Page number (1-based)"),
per_page: int = Query(default=50, le=500, description="Items per page"),
@@ -35,7 +35,7 @@ async def get_all_transactions(
default=None, description="Search in transaction descriptions"
),
account_id: Optional[str] = Query(default=None, description="Filter by account ID"),
) -> PaginatedResponse:
) -> PaginatedResponse[Union[TransactionSummary, Transaction]]:
"""Get all transactions from database with filtering options"""
try:
# Calculate offset from page and per_page
@@ -103,16 +103,13 @@ async def get_all_transactions(
total_pages = (total_transactions + per_page - 1) // per_page
return PaginatedResponse(
success=True,
data=data,
pagination={
"total": total_transactions,
"page": page,
"per_page": per_page,
"total_pages": total_pages,
"has_next": page < total_pages,
"has_prev": page > 1,
},
total=total_transactions,
page=page,
per_page=per_page,
total_pages=total_pages,
has_next=page < total_pages,
has_prev=page > 1,
)
except Exception as e:
@@ -122,11 +119,11 @@ async def get_all_transactions(
) from e
@router.get("/transactions/stats", response_model=APIResponse)
@router.get("/transactions/stats")
async def get_transaction_stats(
days: int = Query(default=30, description="Number of days to include in stats"),
account_id: Optional[str] = Query(default=None, description="Filter by account ID"),
) -> APIResponse:
) -> dict:
"""Get transaction statistics for the last N days from database"""
try:
# Date range for stats
@@ -192,11 +189,7 @@ async def get_transaction_stats(
"accounts_included": unique_accounts,
}
return APIResponse(
success=True,
data=stats,
message=f"Transaction statistics for last {days} days",
)
return stats
except Exception as e:
logger.error(f"Failed to get transaction stats from database: {e}")
@@ -205,11 +198,11 @@ async def get_transaction_stats(
) from e
@router.get("/transactions/analytics", response_model=APIResponse)
@router.get("/transactions/analytics")
async def get_transactions_for_analytics(
days: int = Query(default=365, description="Number of days to include"),
account_id: Optional[str] = Query(default=None, description="Filter by account ID"),
) -> APIResponse:
) -> List[dict]:
"""Get all transactions for analytics (no pagination) for the last N days"""
try:
# Date range for analytics
@@ -242,11 +235,7 @@ async def get_transactions_for_analytics(
for txn in transactions
]
return APIResponse(
success=True,
data=transaction_summaries,
message=f"Retrieved {len(transaction_summaries)} transactions for analytics",
)
return transaction_summaries
except Exception as e:
logger.error(f"Failed to get transactions for analytics: {e}")
@@ -255,11 +244,11 @@ async def get_transactions_for_analytics(
) from e
@router.get("/transactions/monthly-stats", response_model=APIResponse)
@router.get("/transactions/monthly-stats")
async def get_monthly_transaction_stats(
days: int = Query(default=365, description="Number of days to include"),
account_id: Optional[str] = Query(default=None, description="Filter by account ID"),
) -> APIResponse:
) -> List[dict]:
"""Get monthly transaction statistics aggregated by the database"""
try:
# Date range for monthly stats
@@ -277,11 +266,7 @@ async def get_monthly_transaction_stats(
date_to=date_to,
)
return APIResponse(
success=True,
data=monthly_stats,
message=f"Retrieved monthly stats for last {days} days",
)
return monthly_stats
except Exception as e:
logger.error(f"Failed to get monthly transaction stats: {e}")

View File

@@ -89,8 +89,6 @@ def create_app() -> FastAPI:
async def health():
"""Health check endpoint for API connectivity"""
try:
from leggen.api.models.common import APIResponse
config_loaded = config._config is not None
# Get version dynamically
@@ -99,25 +97,17 @@ def create_app() -> FastAPI:
except metadata.PackageNotFoundError:
version = "dev"
return APIResponse(
success=True,
data={
"status": "healthy",
"config_loaded": config_loaded,
"version": version,
"message": "API is running and responsive",
},
message="Health check successful",
)
return {
"status": "healthy",
"config_loaded": config_loaded,
"version": version,
}
except Exception as e:
logger.error(f"Health check failed: {e}")
from leggen.api.models.common import APIResponse
return APIResponse(
success=False,
data={"status": "unhealthy", "error": str(e)},
message="Health check failed",
)
return {
"status": "unhealthy",
"error": str(e),
}
return app

View File

@@ -1,6 +1,8 @@
"""Pytest configuration and shared fixtures."""
import json
import os
import shutil
import tempfile
from pathlib import Path
from unittest.mock import patch
@@ -8,9 +10,45 @@ from unittest.mock import patch
import pytest
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
_test_config_dir = tempfile.mkdtemp(prefix="leggen_test_")
_test_config_path = Path(_test_config_dir) / "config.toml"
# Create minimal test config
_config_data = {
"gocardless": {
"key": "test-key",
"secret": "test-secret",
"url": "https://bankaccountdata.gocardless.com/api/v2",
},
"database": {"sqlite": True},
"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."""
# Ensure test config is set
os.environ["LEGGEN_CONFIG_FILE"] = str(_test_config_path)
def pytest_unconfigure(config):
"""Pytest hook called after all tests."""
# Cleanup test config directory
if Path(_test_config_dir).exists():
shutil.rmtree(_test_config_dir)
@pytest.fixture
def temp_config_dir():
@@ -73,9 +111,12 @@ def mock_auth_token(temp_config_dir):
@pytest.fixture
def fastapi_app():
def fastapi_app(mock_db_path):
"""Create FastAPI test application."""
return create_app()
# Patch the database path for the app
with patch("leggen.utils.paths.path_manager.get_database_path", return_value=mock_db_path):
app = create_app()
yield app
@pytest.fixture

View File

@@ -66,8 +66,7 @@ class TestAnalyticsFix:
)
# Verify that the response contains stats for all 600 transactions
assert data["success"] is True
stats = data["data"]
stats = data
assert stats["total_transactions"] == 600, (
"Should process all 600 transactions, not just 100"
)
@@ -132,8 +131,7 @@ class TestAnalyticsFix:
)
# Verify that all 600 transactions are returned
assert data["success"] is True
transactions_data = data["data"]
transactions_data = data
assert len(transactions_data) == 600, (
"Analytics endpoint should return all 600 transactions"
)

View File

@@ -60,9 +60,8 @@ class TestAccountsAPI:
assert response.status_code == 200
data = response.json()
assert data["success"] is True
assert len(data["data"]) == 1
account = data["data"][0]
assert len(data) == 1
account = data[0]
assert account["id"] == "test-account-123"
assert account["institution_id"] == "REVOLUT_REVOLT21"
assert len(account["balances"]) == 1
@@ -117,11 +116,9 @@ class TestAccountsAPI:
assert response.status_code == 200
data = response.json()
assert data["success"] is True
account = data["data"]
assert account["id"] == "test-account-123"
assert account["iban"] == "LT313250081177977789"
assert len(account["balances"]) == 1
assert data["id"] == "test-account-123"
assert data["iban"] == "LT313250081177977789"
assert len(data["balances"]) == 1
def test_get_account_balances_success(
self, api_client, mock_config, mock_auth_token, mock_db_path
@@ -163,11 +160,10 @@ class TestAccountsAPI:
assert response.status_code == 200
data = response.json()
assert data["success"] is True
assert len(data["data"]) == 2
assert data["data"][0]["amount"] == 1000.00
assert data["data"][0]["currency"] == "EUR"
assert data["data"][0]["balance_type"] == "interimAvailable"
assert len(data) == 2
assert data[0]["amount"] == 1000.00
assert data[0]["currency"] == "EUR"
assert data[0]["balance_type"] == "interimAvailable"
def test_get_account_transactions_success(
self,
@@ -212,10 +208,9 @@ class TestAccountsAPI:
assert response.status_code == 200
data = response.json()
assert data["success"] is True
assert len(data["data"]) == 1
assert len(data) == 1
transaction = data["data"][0]
transaction = data[0]
assert transaction["internal_transaction_id"] == "txn-123"
assert transaction["amount"] == -10.50
assert transaction["currency"] == "EUR"
@@ -264,10 +259,9 @@ class TestAccountsAPI:
assert response.status_code == 200
data = response.json()
assert data["success"] is True
assert len(data["data"]) == 1
assert len(data) == 1
transaction = data["data"][0]
transaction = data[0]
assert transaction["internal_transaction_id"] == "txn-123"
assert transaction["institution_id"] == "REVOLUT_REVOLT21"
assert transaction["iban"] == "LT313250081177977789"
@@ -321,9 +315,8 @@ class TestAccountsAPI:
assert response.status_code == 200
data = response.json()
assert data["success"] is True
assert data["data"]["id"] == "test-account-123"
assert data["data"]["display_name"] == "My Custom Account Name"
assert data["id"] == "test-account-123"
assert data["display_name"] == "My Custom Account Name"
def test_update_account_not_found(
self, api_client, mock_config, mock_auth_token, mock_db_path

View File

@@ -19,8 +19,7 @@ class TestBackupAPI:
assert response.status_code == 200
data = response.json()
assert data["success"] is True
assert data["data"]["s3"] is None
assert data["s3"] is None
def test_get_backup_settings_with_s3_config(self, api_client, mock_config):
"""Test getting backup settings with S3 configuration."""
@@ -42,10 +41,9 @@ class TestBackupAPI:
assert response.status_code == 200
data = response.json()
assert data["success"] is True
assert data["data"]["s3"] is not None
assert data["s3"] is not None
s3_config = data["data"]["s3"]
s3_config = data["s3"]
assert s3_config["access_key_id"] == "***" # Masked
assert s3_config["secret_access_key"] == "***" # Masked
assert s3_config["bucket_name"] == "test-bucket"
@@ -77,8 +75,7 @@ class TestBackupAPI:
assert response.status_code == 200
data = response.json()
assert data["success"] is True
assert data["data"]["updated"] is True
assert data["updated"] is True
# Verify connection test was called
mock_test_connection.assert_called_once()
@@ -132,8 +129,7 @@ class TestBackupAPI:
assert response.status_code == 200
data = response.json()
assert data["success"] is True
assert data["data"]["connected"] is True
assert data["connected"] is True
# Verify connection test was called
mock_test_connection.assert_called_once()
@@ -158,9 +154,9 @@ class TestBackupAPI:
response = api_client.post("/api/v1/backup/test", json=request_data)
assert response.status_code == 200
assert response.status_code == 400
data = response.json()
assert data["success"] is False
assert "S3 connection test failed" in data["detail"]
def test_test_backup_connection_invalid_service(self, api_client):
"""Test backup connection test with invalid service."""
@@ -214,10 +210,9 @@ class TestBackupAPI:
assert response.status_code == 200
data = response.json()
assert data["success"] is True
assert len(data["data"]) == 2
assert len(data) == 2
assert (
data["data"][0]["key"]
data[0]["key"]
== "leggen_backups/database_backup_20250101_120000.db"
)
@@ -230,8 +225,7 @@ class TestBackupAPI:
assert response.status_code == 200
data = response.json()
assert data["success"] is True
assert data["data"] == []
assert data == []
@patch("leggen.services.backup_service.BackupService.backup_database")
@patch("leggen.utils.paths.path_manager.get_database_path")
@@ -261,9 +255,8 @@ class TestBackupAPI:
assert response.status_code == 200
data = response.json()
assert data["success"] is True
assert data["data"]["operation"] == "backup"
assert data["data"]["completed"] is True
assert data["operation"] == "backup"
assert data["completed"] is True
# Verify backup was called
mock_backup_db.assert_called_once()

View File

@@ -33,10 +33,9 @@ class TestBanksAPI:
assert response.status_code == 200
data = response.json()
assert data["success"] is True
assert len(data["data"]) == 2
assert data["data"][0]["id"] == "REVOLUT_REVOLT21"
assert data["data"][1]["id"] == "BANCOBPI_BBPIPTPL"
assert len(data) == 2
assert data[0]["id"] == "REVOLUT_REVOLT21"
assert data[1]["id"] == "BANCOBPI_BBPIPTPL"
@respx.mock
def test_get_institutions_invalid_country(self, api_client, mock_config):
@@ -92,9 +91,8 @@ class TestBanksAPI:
assert response.status_code == 200
data = response.json()
assert data["success"] is True
assert data["data"]["id"] == "req-123"
assert data["data"]["institution_id"] == "REVOLUT_REVOLT21"
assert data["id"] == "req-123"
assert data["institution_id"] == "REVOLUT_REVOLT21"
@respx.mock
def test_get_bank_status_success(self, api_client, mock_config, mock_auth_token):
@@ -128,10 +126,9 @@ class TestBanksAPI:
assert response.status_code == 200
data = response.json()
assert data["success"] is True
assert len(data["data"]) == 1
assert data["data"][0]["bank_id"] == "REVOLUT_REVOLT21"
assert data["data"][0]["status_display"] == "LINKED"
assert len(data) == 1
assert data[0]["bank_id"] == "REVOLUT_REVOLT21"
assert data[0]["status_display"] == "LINKED"
def test_get_supported_countries(self, api_client):
"""Test supported countries endpoint."""
@@ -139,11 +136,10 @@ class TestBanksAPI:
assert response.status_code == 200
data = response.json()
assert data["success"] is True
assert len(data["data"]) > 0
assert len(data) > 0
# Check some expected countries
country_codes = [country["code"] for country in data["data"]]
country_codes = [country["code"] for country in data]
assert "PT" in country_codes
assert "GB" in country_codes
assert "DE" in country_codes

View File

@@ -58,7 +58,6 @@ class TestTransactionsAPI:
assert response.status_code == 200
data = response.json()
assert data["success"] is True
assert len(data["data"]) == 2
# Check first transaction summary
@@ -105,7 +104,6 @@ class TestTransactionsAPI:
assert response.status_code == 200
data = response.json()
assert data["success"] is True
assert len(data["data"]) == 1
transaction = data["data"][0]
@@ -160,7 +158,6 @@ class TestTransactionsAPI:
assert response.status_code == 200
data = response.json()
assert data["success"] is True
# Verify the database service was called with correct filters
mock_get_transactions.assert_called_once_with(
@@ -193,11 +190,10 @@ class TestTransactionsAPI:
assert response.status_code == 200
data = response.json()
assert data["success"] is True
assert len(data["data"]) == 0
assert data["pagination"]["total"] == 0
assert data["pagination"]["page"] == 1
assert data["pagination"]["total_pages"] == 0
assert data["total"] == 0
assert data["page"] == 1
assert data["total_pages"] == 0
def test_get_transactions_database_error(
self, api_client, mock_config, mock_auth_token
@@ -254,21 +250,19 @@ class TestTransactionsAPI:
assert response.status_code == 200
data = response.json()
assert data["success"] is True
stats = data["data"]
assert stats["period_days"] == 30
assert stats["total_transactions"] == 3
assert stats["booked_transactions"] == 2
assert stats["pending_transactions"] == 1
assert stats["total_income"] == 100.00
assert stats["total_expenses"] == 35.80 # abs(-10.50) + abs(-25.30)
assert stats["net_change"] == 64.20 # 100.00 - 35.80
assert stats["accounts_included"] == 2 # Two unique account IDs
assert data["period_days"] == 30
assert data["total_transactions"] == 3
assert data["booked_transactions"] == 2
assert data["pending_transactions"] == 1
assert data["total_income"] == 100.00
assert data["total_expenses"] == 35.80 # abs(-10.50) + abs(-25.30)
assert data["net_change"] == 64.20 # 100.00 - 35.80
assert data["accounts_included"] == 2 # Two unique account IDs
# Average transaction: ((-10.50) + 100.00 + (-25.30)) / 3 = 64.20 / 3 = 21.4
expected_avg = round(64.20 / 3, 2)
assert stats["average_transaction"] == expected_avg
assert data["average_transaction"] == expected_avg
def test_get_transaction_stats_with_account_filter(
self, api_client, mock_config, mock_auth_token
@@ -317,15 +311,13 @@ class TestTransactionsAPI:
assert response.status_code == 200
data = response.json()
assert data["success"] is True
stats = data["data"]
assert stats["total_transactions"] == 0
assert stats["total_income"] == 0.0
assert stats["total_expenses"] == 0.0
assert stats["net_change"] == 0.0
assert stats["average_transaction"] == 0 # Division by zero handled
assert stats["accounts_included"] == 0
assert data["total_transactions"] == 0
assert data["total_income"] == 0.0
assert data["total_expenses"] == 0.0
assert data["net_change"] == 0.0
assert data["average_transaction"] == 0 # Division by zero handled
assert data["accounts_included"] == 0
def test_get_transaction_stats_database_error(
self, api_client, mock_config, mock_auth_token
@@ -368,7 +360,7 @@ class TestTransactionsAPI:
assert response.status_code == 200
data = response.json()
assert data["data"]["period_days"] == 7
assert data["period_days"] == 7
# Verify the date range was calculated correctly for 7 days
mock_get_transactions.assert_called_once()