refactor: Unify leggen and leggend packages into single leggen package

- Merge leggend API components into leggen (api/, services/, background/)
- Replace leggend command with 'leggen server' subcommand
- Consolidate configuration systems into leggen.utils.config
- Update environment variables: LEGGEND_API_URL -> LEGGEN_API_URL
- Rename LeggendAPIClient -> LeggenAPIClient
- Update all documentation, Docker configs, and compose files
- Fix all import statements and test references
- Remove duplicate utility files and clean up package structure

All tests passing (101/101), linting clean, server functionality preserved.

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Elisiário Couto
2025-09-14 18:02:55 +01:00
committed by Elisiário Couto
parent 0e645d9bae
commit 318ca517f7
50 changed files with 494 additions and 463 deletions

View File

@@ -15,8 +15,8 @@ repos:
hooks:
- id: mypy
name: Static type check with mypy
entry: uv run mypy leggen leggend --check-untyped-defs
files: "^leggen(d)?/.*"
entry: uv run mypy leggen --check-untyped-defs
files: "^leggen/.*"
language: "system"
types: ["python"]
always_run: true

View File

@@ -38,9 +38,9 @@ The command outputs instructions for setting the required environment variable t
```
4. Start the API server:
```bash
uv run leggend
uv run leggen server
```
- For development mode with auto-reload: `uv run leggend --reload`
- For development mode with auto-reload: `uv run leggen server --reload`
- API will be available at `http://localhost:8000` with docs at `http://localhost:8000/docs`
### Start the Frontend
@@ -60,7 +60,7 @@ The command outputs instructions for setting the required environment variable t
### Backend (Python)
- **Lint**: `uv run ruff check .`
- **Format**: `uv run ruff format .`
- **Type check**: `uv run mypy leggen leggend --check-untyped-defs`
- **Type check**: `uv run mypy leggen --check-untyped-defs`
- **All checks**: `uv run pre-commit run --all-files`
- **Run all tests**: `uv run pytest`
- **Run single test**: `uv run pytest tests/unit/test_api_accounts.py::TestAccountsAPI::test_get_all_accounts_success -v`

1
CLAUDE.md Symbolic link
View File

@@ -0,0 +1 @@
AGENTS.md

View File

@@ -18,7 +18,7 @@ FROM python:3.13-alpine
LABEL org.opencontainers.image.source="https://github.com/elisiariocouto/leggen"
LABEL org.opencontainers.image.authors="Elisiário Couto <elisiario@couto.io>"
LABEL org.opencontainers.image.licenses="MIT"
LABEL org.opencontainers.image.title="Leggend API"
LABEL org.opencontainers.image.title="Leggen API"
LABEL org.opencontainers.image.description="Open Banking API for Leggen"
LABEL org.opencontainers.image.url="https://github.com/elisiariocouto/leggen"
@@ -30,4 +30,4 @@ EXPOSE 8000
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s CMD wget -q --spider http://127.0.0.1:8000/api/v1/health || exit 1
CMD ["/app/.venv/bin/leggend"]
CMD ["/app/.venv/bin/leggen", "server"]

View File

@@ -2,14 +2,14 @@
An Open Banking CLI and API service for managing bank connections and transactions.
This tool provides **FastAPI backend service** (`leggend`), a **React Web Interface** and a **command-line interface** (`leggen`) to connect to banks using the GoCardless Open Banking API.
This tool provides a **unified command-line interface** (`leggen`) with both CLI commands and an integrated **FastAPI backend service**, plus a **React Web Interface** to connect to banks using the GoCardless Open Banking API.
Having your bank data accessible through both CLI and REST API gives you the power to backup, analyze, create reports, and integrate with other applications.
## 🛠️ Technologies
### 🔌 API & Backend
- [FastAPI](https://fastapi.tiangolo.com/): High-performance async API backend (`leggend` service)
- [FastAPI](https://fastapi.tiangolo.com/): High-performance async API backend (integrated into `leggen server`)
- [GoCardless Open Banking API](https://developer.gocardless.com/bank-account-data/overview): for connecting to banks
- [APScheduler](https://apscheduler.readthedocs.io/): Background job scheduling with configurable cron
@@ -107,7 +107,7 @@ For development or local installation:
uv sync # or pip install -e .
# Start the API service
uv run leggend --reload # Development mode with auto-reload
uv run leggen server --reload # Development mode with auto-reload
# Use the CLI (in another terminal)
uv run leggen --help
@@ -152,19 +152,19 @@ case-sensitive = ["SpecificStore"]
## 📖 Usage
### API Service (`leggend`)
### API Service (`leggen server`)
Start the FastAPI backend service:
```bash
# Production mode
leggend
leggen server
# Development mode with auto-reload
leggend --reload
leggen server --reload
# Custom host and port
leggend --host 127.0.0.1 --port 8080
leggen server --host 127.0.0.1 --port 8080
```
**API Documentation**: Visit `http://localhost:8000/docs` for interactive API documentation.
@@ -207,7 +207,7 @@ leggen sync --force --wait
leggen --api-url http://localhost:8080 status
# Set via environment variable
export LEGGEND_API_URL=http://localhost:8080
export LEGGEN_API_URL=http://localhost:8080
leggen status
```
@@ -223,7 +223,7 @@ docker compose -f compose.dev.yml ps
# Check logs
docker compose -f compose.dev.yml logs frontend
docker compose -f compose.dev.yml logs leggend
docker compose -f compose.dev.yml logs leggen-server
# Stop development services
docker compose -f compose.dev.yml down
@@ -239,7 +239,7 @@ docker compose ps
# Check logs
docker compose logs frontend
docker compose logs leggend
docker compose logs leggen-server
# Access the web interface at http://localhost:3000
# API documentation at http://localhost:8000/docs
@@ -290,7 +290,7 @@ cd leggen
uv sync
# Start API service with auto-reload
uv run leggend --reload
uv run leggen server --reload
# Use CLI commands
uv run leggen status
@@ -333,13 +333,10 @@ The test suite includes:
leggen/ # CLI application
├── commands/ # CLI command implementations
├── utils/ # Shared utilities
── api_client.py # API client for leggend service
leggend/ # FastAPI backend service
├── api/ # API routes and models
── api/ # FastAPI API routes and models
├── services/ # Business logic
├── background/ # Background job scheduler
└── main.py # FastAPI application
└── api_client.py # API client for server communication
tests/ # Test suite
├── conftest.py # Shared test fixtures

View File

@@ -8,13 +8,13 @@ services:
ports:
- "127.0.0.1:3000:80"
environment:
- API_BACKEND_URL=${API_BACKEND_URL:-http://leggend:8000}
- API_BACKEND_URL=${API_BACKEND_URL:-http://leggen-server:8000}
depends_on:
leggend:
leggen-server:
condition: service_healthy
# FastAPI backend service
leggend:
leggen-server:
build:
context: .
dockerfile: Dockerfile

View File

@@ -6,11 +6,11 @@ services:
ports:
- "127.0.0.1:3000:80"
depends_on:
leggend:
leggen-server:
condition: service_healthy
# FastAPI backend service
leggend:
leggen-server:
image: ghcr.io/elisiariocouto/leggen:latest
restart: "unless-stopped"
ports:

View File

@@ -25,7 +25,7 @@ COPY --from=builder /app/dist /usr/share/nginx/html
COPY default.conf.template /etc/nginx/templates/default.conf.template
# Set default API backend URL (can be overridden at runtime)
ENV API_BACKEND_URL=http://leggend:8000
ENV API_BACKEND_URL=http://leggen-server:8000
# Expose port 80
EXPOSE 80

View File

@@ -93,7 +93,7 @@ The frontend supports configurable API URLs through environment variables:
- Uses relative URLs (`/api/v1`) that nginx proxies to the backend
- Configure nginx proxy target via `API_BACKEND_URL` environment variable
- Default: `http://leggend:8000`
- Default: `http://leggen-server:8000`
**Docker Compose:**

View File

@@ -2,15 +2,15 @@ from typing import Optional, List, Union
from fastapi import APIRouter, HTTPException, Query
from loguru import logger
from leggend.api.models.common import APIResponse
from leggend.api.models.accounts import (
from leggen.api.models.common import APIResponse
from leggen.api.models.accounts import (
AccountDetails,
AccountBalance,
Transaction,
TransactionSummary,
AccountUpdate,
)
from leggend.services.database_service import DatabaseService
from leggen.services.database_service import DatabaseService
router = APIRouter()
database_service = DatabaseService()
@@ -217,8 +217,12 @@ async def get_all_balances() -> APIResponse:
@router.get("/balances/history", response_model=APIResponse)
async def get_historical_balances(
days: Optional[int] = Query(default=365, le=1095, ge=1, description="Number of days of history to retrieve"),
account_id: Optional[str] = Query(default=None, description="Filter by specific account ID")
days: Optional[int] = Query(
default=365, le=1095, ge=1, description="Number of days of history to retrieve"
),
account_id: Optional[str] = Query(
default=None, description="Filter by specific account ID"
),
) -> APIResponse:
"""Get historical balance progression calculated from transaction history"""
try:

View File

@@ -1,15 +1,15 @@
from fastapi import APIRouter, HTTPException, Query
from loguru import logger
from leggend.api.models.common import APIResponse
from leggend.api.models.banks import (
from leggen.api.models.common import APIResponse
from leggen.api.models.banks import (
BankInstitution,
BankConnectionRequest,
BankRequisition,
BankConnectionStatus,
)
from leggend.services.gocardless_service import GoCardlessService
from leggend.utils.gocardless import REQUISITION_STATUS
from leggen.services.gocardless_service import GoCardlessService
from leggen.utils.gocardless import REQUISITION_STATUS
router = APIRouter()
gocardless_service = GoCardlessService()

View File

@@ -2,16 +2,16 @@ from typing import Dict, Any
from fastapi import APIRouter, HTTPException
from loguru import logger
from leggend.api.models.common import APIResponse
from leggend.api.models.notifications import (
from leggen.api.models.common import APIResponse
from leggen.api.models.notifications import (
NotificationSettings,
NotificationTest,
DiscordConfig,
TelegramConfig,
NotificationFilters,
)
from leggend.services.notification_service import NotificationService
from leggend.config import config
from leggen.services.notification_service import NotificationService
from leggen.utils.config import config
router = APIRouter()
notification_service = NotificationService()

View File

@@ -2,11 +2,11 @@ from typing import Optional
from fastapi import APIRouter, HTTPException, BackgroundTasks
from loguru import logger
from leggend.api.models.common import APIResponse
from leggend.api.models.sync import SyncRequest, SchedulerConfig
from leggend.services.sync_service import SyncService
from leggend.background.scheduler import scheduler
from leggend.config import config
from leggen.api.models.common import APIResponse
from leggen.api.models.sync import SyncRequest, SchedulerConfig
from leggen.services.sync_service import SyncService
from leggen.background.scheduler import scheduler
from leggen.utils.config import config
router = APIRouter()
sync_service = SyncService()

View File

@@ -3,9 +3,9 @@ from datetime import datetime, timedelta
from fastapi import APIRouter, HTTPException, Query
from loguru import logger
from leggend.api.models.common import APIResponse, PaginatedResponse
from leggend.api.models.accounts import Transaction, TransactionSummary
from leggend.services.database_service import DatabaseService
from leggen.api.models.common import APIResponse, PaginatedResponse
from leggen.api.models.accounts import Transaction, TransactionSummary
from leggen.services.database_service import DatabaseService
router = APIRouter()
database_service = DatabaseService()
@@ -252,5 +252,3 @@ async def get_transactions_for_analytics(
raise HTTPException(
status_code=500, detail=f"Failed to get analytics transactions: {str(e)}"
) from e

View File

@@ -6,15 +6,15 @@ from urllib.parse import urljoin
from leggen.utils.text import error
class LeggendAPIClient:
"""Client for communicating with the leggend FastAPI service"""
class LeggenAPIClient:
"""Client for communicating with the leggen FastAPI service"""
base_url: str
def __init__(self, base_url: Optional[str] = None):
self.base_url = (
base_url
or os.environ.get("LEGGEND_API_URL", "http://localhost:8000")
or os.environ.get("LEGGEN_API_URL", "http://localhost:8000")
or "http://localhost:8000"
)
self.session = requests.Session()
@@ -31,7 +31,7 @@ class LeggendAPIClient:
response.raise_for_status()
return response.json()
except requests.exceptions.ConnectionError:
error("Could not connect to leggend service. Is it running?")
error("Could not connect to leggen server. Is it running?")
error(f"Trying to connect to: {self.base_url}")
raise
except requests.exceptions.HTTPError as e:
@@ -48,7 +48,7 @@ class LeggendAPIClient:
raise
def health_check(self) -> bool:
"""Check if the leggend service is healthy"""
"""Check if the leggen server is healthy"""
try:
response = self._make_request("GET", "/health")
return response.get("status") == "healthy"

View File

@@ -2,9 +2,9 @@ from apscheduler.schedulers.asyncio import AsyncIOScheduler
from apscheduler.triggers.cron import CronTrigger
from loguru import logger
from leggend.config import config
from leggend.services.sync_service import SyncService
from leggend.services.notification_service import NotificationService
from leggen.utils.config import config
from leggen.services.sync_service import SyncService
from leggen.services.notification_service import NotificationService
class BackgroundScheduler:

View File

@@ -1,7 +1,7 @@
import click
from leggen.main import cli
from leggen.api_client import LeggendAPIClient
from leggen.api_client import LeggenAPIClient
from leggen.utils.text import datefmt, print_table
@@ -11,12 +11,12 @@ def balances(ctx: click.Context):
"""
List balances of all connected accounts
"""
api_client = LeggendAPIClient(ctx.obj.get("api_url"))
api_client = LeggenAPIClient(ctx.obj.get("api_url"))
# Check if leggend service is available
# Check if leggen server is available
if not api_client.health_check():
click.echo(
"Error: Cannot connect to leggend service. Please ensure it's running."
"Error: Cannot connect to leggen server. Please ensure it's running."
)
return

View File

@@ -1,7 +1,7 @@
import click
from leggen.main import cli
from leggen.api_client import LeggendAPIClient
from leggen.api_client import LeggenAPIClient
from leggen.utils.disk import save_file
from leggen.utils.text import info, print_table, warning, success
@@ -12,12 +12,12 @@ def add(ctx):
"""
Connect to a bank
"""
api_client = LeggendAPIClient(ctx.obj.get("api_url"))
api_client = LeggenAPIClient(ctx.obj.get("api_url"))
# Check if leggend service is available
# Check if leggen server is available
if not api_client.health_check():
click.echo(
"Error: Cannot connect to leggend service. Please ensure it's running."
"Error: Cannot connect to leggen server. Please ensure it's running."
)
return

View File

@@ -4,7 +4,6 @@ import click
from pathlib import Path
@click.command()
@click.option(
"--database",

View File

@@ -1,20 +1,22 @@
from contextlib import asynccontextmanager
from importlib import metadata
import click
import uvicorn
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from loguru import logger
from leggend.api.routes import banks, accounts, sync, notifications, transactions
from leggend.background.scheduler import scheduler
from leggend.config import config
from leggen.api.routes import banks, accounts, sync, notifications, transactions
from leggen.background.scheduler import scheduler
from leggen.utils.config import config
from leggen.utils.paths import path_manager
@asynccontextmanager
async def lifespan(app: FastAPI):
# Startup
logger.info("Starting leggend service...")
logger.info("Starting leggen server...")
# Load configuration
try:
@@ -26,7 +28,7 @@ async def lifespan(app: FastAPI):
# Run database migrations
try:
from leggend.services.database_service import DatabaseService
from leggen.services.database_service import DatabaseService
db_service = DatabaseService()
await db_service.run_migrations_if_needed()
@@ -42,7 +44,7 @@ async def lifespan(app: FastAPI):
yield
# Shutdown
logger.info("Shutting down leggend service...")
logger.info("Shutting down leggen server...")
scheduler.shutdown()
@@ -54,7 +56,7 @@ def create_app() -> FastAPI:
version = "unknown"
app = FastAPI(
title="Leggend API",
title="Leggen API",
description="Open Banking API for Leggen",
version=version,
lifespan=lifespan,
@@ -87,13 +89,13 @@ def create_app() -> FastAPI:
version = metadata.version("leggen")
except metadata.PackageNotFoundError:
version = "unknown"
return {"message": "Leggend API is running", "version": version}
return {"message": "Leggen API is running", "version": version}
@app.get("/api/v1/health")
async def health():
"""Health check endpoint for API connectivity"""
try:
from leggend.api.models.common import APIResponse
from leggen.api.models.common import APIResponse
config_loaded = config._config is not None
@@ -108,7 +110,7 @@ def create_app() -> FastAPI:
)
except Exception as e:
logger.error(f"Health check failed: {e}")
from leggend.api.models.common import APIResponse
from leggen.api.models.common import APIResponse
return APIResponse(
success=False,
@@ -119,61 +121,58 @@ def create_app() -> FastAPI:
return app
def main():
import argparse
from pathlib import Path
from leggen.utils.paths import path_manager
@click.command()
@click.option(
"--reload",
is_flag=True,
help="Enable auto-reload for development",
)
@click.option(
"--host",
default="0.0.0.0",
help="Host to bind to (default: 0.0.0.0)",
)
@click.option(
"--port",
type=int,
default=8000,
help="Port to bind to (default: 8000)",
)
@click.pass_context
def server(ctx: click.Context, reload: bool, host: str, port: int):
"""Start the Leggen API server"""
parser = argparse.ArgumentParser(description="Start the Leggend API service")
parser.add_argument(
"--reload", action="store_true", help="Enable auto-reload for development"
)
parser.add_argument(
"--host", default="0.0.0.0", help="Host to bind to (default: 0.0.0.0)"
)
parser.add_argument(
"--port", type=int, default=8000, help="Port to bind to (default: 8000)"
)
parser.add_argument(
"--config-dir",
type=Path,
help="Directory containing configuration files (default: ~/.config/leggen)",
)
parser.add_argument(
"--database",
type=Path,
help="Path to SQLite database file (default: <config-dir>/leggen.db)",
)
args = parser.parse_args()
# Get config_dir and database from main CLI context
config_dir = None
database = None
if ctx.parent:
config_dir = ctx.parent.params.get("config_dir")
database = ctx.parent.params.get("database")
# Set up path manager with user-provided paths
if args.config_dir:
path_manager.set_config_dir(args.config_dir)
if args.database:
path_manager.set_database_path(args.database)
if config_dir:
path_manager.set_config_dir(config_dir)
if database:
path_manager.set_database_path(database)
if args.reload:
if reload:
# Use string import for reload to work properly
uvicorn.run(
"leggend.main:create_app",
"leggen.commands.server:create_app",
factory=True,
host=args.host,
port=args.port,
host=host,
port=port,
log_level="info",
access_log=True,
reload=True,
reload_dirs=["leggend", "leggen"], # Watch both directories
reload_dirs=["leggen"], # Watch leggen directory
)
else:
app = create_app()
uvicorn.run(
app,
host=args.host,
port=args.port,
host=host,
port=port,
log_level="info",
access_log=True,
)
if __name__ == "__main__":
main()

View File

@@ -1,7 +1,7 @@
import click
from leggen.main import cli
from leggen.api_client import LeggendAPIClient
from leggen.api_client import LeggenAPIClient
from leggen.utils.text import datefmt, echo, info, print_table
@@ -11,12 +11,12 @@ def status(ctx: click.Context):
"""
List all connected banks and their status
"""
api_client = LeggendAPIClient(ctx.obj.get("api_url"))
api_client = LeggenAPIClient(ctx.obj.get("api_url"))
# Check if leggend service is available
# Check if leggen server is available
if not api_client.health_check():
click.echo(
"Error: Cannot connect to leggend service. Please ensure it's running."
"Error: Cannot connect to leggen server. Please ensure it's running."
)
return

View File

@@ -1,7 +1,7 @@
import click
from leggen.main import cli
from leggen.api_client import LeggendAPIClient
from leggen.api_client import LeggenAPIClient
from leggen.utils.text import error, info, success
@@ -13,11 +13,11 @@ def sync(ctx: click.Context, wait: bool, force: bool):
"""
Sync all transactions with database
"""
api_client = LeggendAPIClient(ctx.obj.get("api_url"))
api_client = LeggenAPIClient(ctx.obj.get("api_url"))
# Check if leggend service is available
# Check if leggen server is available
if not api_client.health_check():
error("Cannot connect to leggend service. Please ensure it's running.")
error("Cannot connect to leggen server. Please ensure it's running.")
return
try:

View File

@@ -1,7 +1,7 @@
import click
from leggen.main import cli
from leggen.api_client import LeggendAPIClient
from leggen.api_client import LeggenAPIClient
from leggen.utils.text import datefmt, info, print_table
@@ -20,12 +20,12 @@ def transactions(ctx: click.Context, account: str, limit: int, full: bool):
If the --account option is used, it will only list transactions for that account.
"""
api_client = LeggendAPIClient(ctx.obj.get("api_url"))
api_client = LeggenAPIClient(ctx.obj.get("api_url"))
# Check if leggend service is available
# Check if leggen server is available
if not api_client.health_check():
click.echo(
"Error: Cannot connect to leggend service. Please ensure it's running."
"Error: Cannot connect to leggen server. Please ensure it's running."
)
return

View File

@@ -527,11 +527,11 @@ def get_historical_balances(account_id=None, days=365):
db_path = path_manager.get_database_path()
if not db_path.exists():
return []
conn = sqlite3.connect(str(db_path))
conn.row_factory = sqlite3.Row
cursor = conn.cursor()
try:
# Get current balance for each account/type to use as the final balance
current_balances_query = """
@@ -544,107 +544,115 @@ def get_historical_balances(account_id=None, days=365):
)
"""
params = []
if account_id:
current_balances_query += " AND b1.account_id = ?"
params.append(account_id)
cursor.execute(current_balances_query, params)
current_balances = {
(row['account_id'], row['type']): {
'amount': row['amount'],
'currency': row['currency']
(row["account_id"], row["type"]): {
"amount": row["amount"],
"currency": row["currency"],
}
for row in cursor.fetchall()
}
# Get transactions for the specified period, ordered by date descending
from datetime import datetime, timedelta
cutoff_date = (datetime.now() - timedelta(days=days)).isoformat()
transactions_query = """
SELECT accountId, transactionDate, transactionValue
FROM transactions
WHERE transactionDate >= ?
"""
if account_id:
transactions_query += " AND accountId = ?"
params = [cutoff_date, account_id]
else:
params = [cutoff_date]
transactions_query += " ORDER BY transactionDate DESC"
cursor.execute(transactions_query, params)
transactions = cursor.fetchall()
# Calculate historical balances by working backwards from current balance
historical_balances = []
account_running_balances: dict[str, dict[str, float]] = {}
# Initialize running balances with current balances
for (acc_id, balance_type), balance_info in current_balances.items():
if acc_id not in account_running_balances:
account_running_balances[acc_id] = {}
account_running_balances[acc_id][balance_type] = balance_info['amount']
account_running_balances[acc_id][balance_type] = balance_info["amount"]
# Group transactions by date
from collections import defaultdict
transactions_by_date = defaultdict(list)
for txn in transactions:
date_str = txn['transactionDate'][:10] # Extract just the date part
date_str = txn["transactionDate"][:10] # Extract just the date part
transactions_by_date[date_str].append(txn)
# Generate historical balance points
# Start from today and work backwards
current_date = datetime.now().date()
for day_offset in range(0, days, 7): # Sample every 7 days for performance
target_date = current_date - timedelta(days=day_offset)
target_date_str = target_date.isoformat()
# For each account, create balance entries
for acc_id in account_running_balances:
for balance_type in ['closingBooked']: # Focus on closingBooked for the chart
for balance_type in [
"closingBooked"
]: # Focus on closingBooked for the chart
if balance_type in account_running_balances[acc_id]:
balance_amount = account_running_balances[acc_id][balance_type]
currency = current_balances.get((acc_id, balance_type), {}).get('currency', 'EUR')
historical_balances.append({
'id': f"{acc_id}_{balance_type}_{target_date_str}",
'account_id': acc_id,
'balance_amount': balance_amount,
'balance_type': balance_type,
'currency': currency,
'reference_date': target_date_str,
'created_at': None,
'updated_at': None
})
currency = current_balances.get((acc_id, balance_type), {}).get(
"currency", "EUR"
)
historical_balances.append(
{
"id": f"{acc_id}_{balance_type}_{target_date_str}",
"account_id": acc_id,
"balance_amount": balance_amount,
"balance_type": balance_type,
"currency": currency,
"reference_date": target_date_str,
"created_at": None,
"updated_at": None,
}
)
# Subtract transactions that occurred on this date and later dates
# to simulate going back in time
for date_str in list(transactions_by_date.keys()):
if date_str >= target_date_str:
for txn in transactions_by_date[date_str]:
acc_id = txn['accountId']
amount = txn['transactionValue']
acc_id = txn["accountId"]
amount = txn["transactionValue"]
if acc_id in account_running_balances:
for balance_type in account_running_balances[acc_id]:
account_running_balances[acc_id][balance_type] -= amount
# Remove processed transactions to avoid double-processing
del transactions_by_date[date_str]
conn.close()
# Sort by date for proper chronological order
historical_balances.sort(key=lambda x: x['reference_date'])
historical_balances.sort(key=lambda x: x["reference_date"])
return historical_balances
except Exception as e:
conn.close()
raise e

View File

@@ -105,9 +105,9 @@ class Group(click.Group):
"--api-url",
type=str,
default="http://localhost:8000",
envvar="LEGGEND_API_URL",
envvar="LEGGEN_API_URL",
show_envvar=True,
help="URL of the leggend API service",
help="URL of the leggen API service",
)
@click.group(
cls=Group,

View File

@@ -4,7 +4,7 @@ import sqlite3
from loguru import logger
from leggend.config import config
from leggen.utils.config import config
import leggen.database.sqlite as sqlite_db
from leggen.utils.paths import path_manager
@@ -204,8 +204,12 @@ class DatabaseService:
return []
try:
balances = sqlite_db.get_historical_balances(account_id=account_id, days=days)
logger.debug(f"Retrieved {len(balances)} historical balance points from database")
balances = sqlite_db.get_historical_balances(
account_id=account_id, days=days
)
logger.debug(
f"Retrieved {len(balances)} historical balance points from database"
)
return balances
except Exception as e:
logger.error(f"Failed to get historical balances from database: {e}")

View File

@@ -5,7 +5,7 @@ from typing import Dict, Any, List
from loguru import logger
from leggend.config import config
from leggen.utils.config import config
from leggen.utils.paths import path_manager

View File

@@ -2,7 +2,7 @@ from typing import List, Dict, Any
from loguru import logger
from leggend.config import config
from leggen.utils.config import config
class NotificationService:

View File

@@ -3,10 +3,10 @@ from typing import List
from loguru import logger
from leggend.api.models.sync import SyncResult, SyncStatus
from leggend.services.gocardless_service import GoCardlessService
from leggend.services.database_service import DatabaseService
from leggend.services.notification_service import NotificationService
from leggen.api.models.sync import SyncResult, SyncStatus
from leggen.services.gocardless_service import GoCardlessService
from leggen.services.database_service import DatabaseService
from leggen.services.notification_service import NotificationService
class SyncService:

View File

@@ -1,9 +1,146 @@
import os
import sys
import tomllib
import tomli_w
from pathlib import Path
from typing import Dict, Any, Optional
import click
from loguru import logger
from leggen.utils.text import error
from leggen.utils.paths import path_manager
class Config:
_instance = None
_config = None
_config_path = None
def __new__(cls):
if cls._instance is None:
cls._instance = super().__new__(cls)
return cls._instance
def load_config(self, config_path: Optional[str] = None) -> Dict[str, Any]:
if self._config is not None:
return self._config
if config_path is None:
config_path = os.environ.get("LEGGEN_CONFIG_FILE")
if not config_path:
config_path = str(path_manager.get_config_file_path())
self._config_path = config_path
try:
with open(config_path, "rb") as f:
self._config = tomllib.load(f)
logger.info(f"Configuration loaded from {config_path}")
except FileNotFoundError:
logger.error(f"Configuration file not found: {config_path}")
raise
except Exception as e:
logger.error(f"Error loading configuration: {e}")
raise
return self._config
def save_config(
self,
config_data: Optional[Dict[str, Any]] = None,
config_path: Optional[str] = None,
) -> None:
"""Save configuration to TOML file"""
if config_data is None:
config_data = self._config
if config_path is None:
config_path = self._config_path or os.environ.get("LEGGEN_CONFIG_FILE")
if not config_path:
config_path = str(path_manager.get_config_file_path())
if config_path is None:
raise ValueError("No config path specified")
if config_data is None:
raise ValueError("No config data to save")
# Ensure directory exists
Path(config_path).parent.mkdir(parents=True, exist_ok=True)
try:
with open(config_path, "wb") as f:
tomli_w.dump(config_data, f)
# Update in-memory config
self._config = config_data
self._config_path = config_path
logger.info(f"Configuration saved to {config_path}")
except Exception as e:
logger.error(f"Error saving configuration: {e}")
raise
def update_config(self, section: str, key: str, value: Any) -> None:
"""Update a specific configuration value"""
if self._config is None:
self.load_config()
if self._config is None:
raise RuntimeError("Failed to load config")
if section not in self._config:
self._config[section] = {}
self._config[section][key] = value
self.save_config()
def update_section(self, section: str, data: Dict[str, Any]) -> None:
"""Update an entire configuration section"""
if self._config is None:
self.load_config()
if self._config is None:
raise RuntimeError("Failed to load config")
self._config[section] = data
self.save_config()
@property
def config(self) -> Dict[str, Any]:
if self._config is None:
self.load_config()
if self._config is None:
raise RuntimeError("Failed to load config")
return self._config
@property
def gocardless_config(self) -> Dict[str, str]:
return self.config.get("gocardless", {})
@property
def database_config(self) -> Dict[str, Any]:
return self.config.get("database", {})
@property
def notifications_config(self) -> Dict[str, Any]:
return self.config.get("notifications", {})
@property
def filters_config(self) -> Dict[str, Any]:
return self.config.get("filters", {})
@property
def scheduler_config(self) -> Dict[str, Any]:
"""Get scheduler configuration with defaults"""
default_schedule = {
"sync": {
"enabled": True,
"hour": 3,
"minute": 0,
"cron": None, # Optional custom cron expression
}
}
return self.config.get("scheduler", default_schedule)
def load_config(ctx: click.Context, _, filename):
@@ -16,3 +153,7 @@ def load_config(ctx: click.Context, _, filename):
"Configuration file not found. Provide a valid configuration file path with leggen --config <path> or LEGGEN_CONFIG=<path> environment variable."
)
sys.exit(1)
# Global singleton instance
config = Config()

View File

View File

@@ -1,142 +0,0 @@
import os
import tomllib
import tomli_w
from pathlib import Path
from typing import Dict, Any, Optional
from loguru import logger
from leggen.utils.paths import path_manager
class Config:
_instance = None
_config = None
_config_path = None
def __new__(cls):
if cls._instance is None:
cls._instance = super().__new__(cls)
return cls._instance
def load_config(self, config_path: Optional[str] = None) -> Dict[str, Any]:
if self._config is not None:
return self._config
if config_path is None:
config_path = os.environ.get("LEGGEN_CONFIG_FILE")
if not config_path:
config_path = str(path_manager.get_config_file_path())
self._config_path = config_path
try:
with open(config_path, "rb") as f:
self._config = tomllib.load(f)
logger.info(f"Configuration loaded from {config_path}")
except FileNotFoundError:
logger.error(f"Configuration file not found: {config_path}")
raise
except Exception as e:
logger.error(f"Error loading configuration: {e}")
raise
return self._config
def save_config(
self,
config_data: Optional[Dict[str, Any]] = None,
config_path: Optional[str] = None,
) -> None:
"""Save configuration to TOML file"""
if config_data is None:
config_data = self._config
if config_path is None:
config_path = self._config_path or os.environ.get("LEGGEN_CONFIG_FILE")
if not config_path:
config_path = str(path_manager.get_config_file_path())
if config_path is None:
raise ValueError("No config path specified")
if config_data is None:
raise ValueError("No config data to save")
# Ensure directory exists
Path(config_path).parent.mkdir(parents=True, exist_ok=True)
try:
with open(config_path, "wb") as f:
tomli_w.dump(config_data, f)
# Update in-memory config
self._config = config_data
self._config_path = config_path
logger.info(f"Configuration saved to {config_path}")
except Exception as e:
logger.error(f"Error saving configuration: {e}")
raise
def update_config(self, section: str, key: str, value: Any) -> None:
"""Update a specific configuration value"""
if self._config is None:
self.load_config()
if self._config is None:
raise RuntimeError("Failed to load config")
if section not in self._config:
self._config[section] = {}
self._config[section][key] = value
self.save_config()
def update_section(self, section: str, data: Dict[str, Any]) -> None:
"""Update an entire configuration section"""
if self._config is None:
self.load_config()
if self._config is None:
raise RuntimeError("Failed to load config")
self._config[section] = data
self.save_config()
@property
def config(self) -> Dict[str, Any]:
if self._config is None:
self.load_config()
if self._config is None:
raise RuntimeError("Failed to load config")
return self._config
@property
def gocardless_config(self) -> Dict[str, str]:
return self.config.get("gocardless", {})
@property
def database_config(self) -> Dict[str, Any]:
return self.config.get("database", {})
@property
def notifications_config(self) -> Dict[str, Any]:
return self.config.get("notifications", {})
@property
def filters_config(self) -> Dict[str, Any]:
return self.config.get("filters", {})
@property
def scheduler_config(self) -> Dict[str, Any]:
"""Get scheduler configuration with defaults"""
default_schedule = {
"sync": {
"enabled": True,
"hour": 3,
"minute": 0,
"cron": None, # Optional custom cron expression
}
}
return self.config.get("scheduler", default_schedule)
config = Config()

View File

@@ -1,10 +0,0 @@
REQUISITION_STATUS = {
"CR": "CREATED",
"GC": "GIVING_CONSENT",
"UA": "UNDERGOING_AUTHENTICATION",
"RJ": "REJECTED",
"SA": "SELECTING_ACCOUNTS",
"GA": "GRANTING_ACCESS",
"LN": "LINKED",
"EX": "EXPIRED",
}

View File

@@ -41,7 +41,6 @@ Repository = "https://github.com/elisiariocouto/leggen"
[project.scripts]
leggen = "leggen.main:cli"
leggend = "leggend.main:main"
[dependency-groups]
dev = [
@@ -58,10 +57,10 @@ dev = [
]
[tool.hatch.build.targets.sdist]
include = ["leggen", "leggend"]
include = ["leggen"]
[tool.hatch.build.targets.wheel]
include = ["leggen", "leggend"]
include = ["leggen"]
[build-system]
requires = ["hatchling"]

View File

@@ -372,7 +372,7 @@ class SampleDataGenerator:
for account in accounts:
cursor.execute(
"""
INSERT OR REPLACE INTO accounts
INSERT OR REPLACE INTO accounts
(id, institution_id, status, iban, name, currency, created, last_accessed, last_updated)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
@@ -393,9 +393,9 @@ class SampleDataGenerator:
for transaction in transactions:
cursor.execute(
"""
INSERT OR REPLACE INTO transactions
(accountId, transactionId, internalTransactionId, institutionId, iban,
transactionDate, description, transactionValue, transactionCurrency,
INSERT OR REPLACE INTO transactions
(accountId, transactionId, internalTransactionId, institutionId, iban,
transactionDate, description, transactionValue, transactionCurrency,
transactionStatus, rawTransaction)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
@@ -418,7 +418,7 @@ class SampleDataGenerator:
for balance in balances:
cursor.execute(
"""
INSERT INTO balances
INSERT INTO balances
(account_id, bank, status, iban, amount, currency, type, timestamp)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""",
@@ -538,8 +538,8 @@ def main(database: Path, accounts: int, transactions: int, force: bool):
click.echo(f" export LEGGEN_DATABASE_PATH={db_path}")
click.echo(" leggen transactions")
click.echo("")
click.echo("To use this sample database with leggend API:")
click.echo(f" leggend --database {db_path}")
click.echo("To use this sample database with leggen server:")
click.echo(f" leggen server --database {db_path}")
if __name__ == "__main__":

View File

@@ -7,8 +7,8 @@ from pathlib import Path
from unittest.mock import patch
from fastapi.testclient import TestClient
from leggend.main import create_app
from leggend.config import Config
from leggen.commands.server import create_app
from leggen.utils.config import Config
@pytest.fixture

View File

@@ -5,8 +5,8 @@ from datetime import datetime, timedelta
from unittest.mock import Mock, AsyncMock, patch
from fastapi.testclient import TestClient
from leggend.main import create_app
from leggend.services.database_service import DatabaseService
from leggen.commands.server import create_app
from leggen.services.database_service import DatabaseService
class TestAnalyticsFix:
@@ -27,78 +27,112 @@ class TestAnalyticsFix:
# Mock data for 600 transactions (simulating the issue)
mock_transactions = []
for i in range(600):
mock_transactions.append({
"transactionId": f"txn-{i}",
"transactionDate": (datetime.now() - timedelta(days=i % 365)).isoformat(),
"description": f"Transaction {i}",
"transactionValue": 10.0 if i % 2 == 0 else -5.0,
"transactionCurrency": "EUR",
"transactionStatus": "booked",
"accountId": f"account-{i % 3}",
})
mock_transactions.append(
{
"transactionId": f"txn-{i}",
"transactionDate": (
datetime.now() - timedelta(days=i % 365)
).isoformat(),
"description": f"Transaction {i}",
"transactionValue": 10.0 if i % 2 == 0 else -5.0,
"transactionCurrency": "EUR",
"transactionStatus": "booked",
"accountId": f"account-{i % 3}",
}
)
mock_database_service.get_transactions_from_db = AsyncMock(return_value=mock_transactions)
mock_database_service.get_transactions_from_db = AsyncMock(
return_value=mock_transactions
)
# Test that the endpoint calls get_transactions_from_db with limit=None
with patch('leggend.api.routes.transactions.database_service', mock_database_service):
with patch(
"leggen.api.routes.transactions.database_service", mock_database_service
):
app = create_app()
client = TestClient(app)
response = client.get("/api/v1/transactions/stats?days=365")
assert response.status_code == 200
data = response.json()
# Verify that limit=None was passed to get all transactions
mock_database_service.get_transactions_from_db.assert_called_once()
call_args = mock_database_service.get_transactions_from_db.call_args
assert call_args.kwargs.get("limit") is None, "Stats endpoint should pass limit=None to get all transactions"
assert call_args.kwargs.get("limit") is None, (
"Stats endpoint should pass limit=None to get all transactions"
)
# Verify that the response contains stats for all 600 transactions
assert data["success"] is True
stats = data["data"]
assert stats["total_transactions"] == 600, "Should process all 600 transactions, not just 100"
assert stats["total_transactions"] == 600, (
"Should process all 600 transactions, not just 100"
)
# Verify calculations are correct for all transactions
expected_income = sum(txn["transactionValue"] for txn in mock_transactions if txn["transactionValue"] > 0)
expected_expenses = sum(abs(txn["transactionValue"]) for txn in mock_transactions if txn["transactionValue"] < 0)
expected_income = sum(
txn["transactionValue"]
for txn in mock_transactions
if txn["transactionValue"] > 0
)
expected_expenses = sum(
abs(txn["transactionValue"])
for txn in mock_transactions
if txn["transactionValue"] < 0
)
assert stats["total_income"] == expected_income
assert stats["total_expenses"] == expected_expenses
@pytest.mark.asyncio
async def test_analytics_endpoint_returns_all_transactions(self, mock_database_service):
@pytest.mark.asyncio
async def test_analytics_endpoint_returns_all_transactions(
self, mock_database_service
):
"""Test that the new analytics endpoint returns all transactions without pagination"""
# Mock data for 600 transactions
mock_transactions = []
for i in range(600):
mock_transactions.append({
"transactionId": f"txn-{i}",
"transactionDate": (datetime.now() - timedelta(days=i % 365)).isoformat(),
"description": f"Transaction {i}",
"transactionValue": 10.0 if i % 2 == 0 else -5.0,
"transactionCurrency": "EUR",
"transactionStatus": "booked",
"accountId": f"account-{i % 3}",
})
mock_transactions.append(
{
"transactionId": f"txn-{i}",
"transactionDate": (
datetime.now() - timedelta(days=i % 365)
).isoformat(),
"description": f"Transaction {i}",
"transactionValue": 10.0 if i % 2 == 0 else -5.0,
"transactionCurrency": "EUR",
"transactionStatus": "booked",
"accountId": f"account-{i % 3}",
}
)
mock_database_service.get_transactions_from_db = AsyncMock(return_value=mock_transactions)
mock_database_service.get_transactions_from_db = AsyncMock(
return_value=mock_transactions
)
with patch('leggend.api.routes.transactions.database_service', mock_database_service):
with patch(
"leggen.api.routes.transactions.database_service", mock_database_service
):
app = create_app()
client = TestClient(app)
response = client.get("/api/v1/transactions/analytics?days=365")
assert response.status_code == 200
data = response.json()
# Verify that limit=None was passed to get all transactions
mock_database_service.get_transactions_from_db.assert_called_once()
call_args = mock_database_service.get_transactions_from_db.call_args
assert call_args.kwargs.get("limit") is None, "Analytics endpoint should pass limit=None"
assert call_args.kwargs.get("limit") is None, (
"Analytics endpoint should pass limit=None"
)
# Verify that all 600 transactions are returned
assert data["success"] is True
transactions_data = data["data"]
assert len(transactions_data) == 600, "Analytics endpoint should return all 600 transactions"
assert len(transactions_data) == 600, (
"Analytics endpoint should return all 600 transactions"
)

View File

@@ -43,13 +43,13 @@ class TestAccountsAPI:
]
with (
patch("leggend.config.config", mock_config),
patch("leggen.utils.config.config", mock_config),
patch(
"leggend.api.routes.accounts.database_service.get_accounts_from_db",
"leggen.api.routes.accounts.database_service.get_accounts_from_db",
return_value=mock_accounts,
),
patch(
"leggend.api.routes.accounts.database_service.get_balances_from_db",
"leggen.api.routes.accounts.database_service.get_balances_from_db",
return_value=mock_balances,
),
):
@@ -98,13 +98,13 @@ class TestAccountsAPI:
]
with (
patch("leggend.config.config", mock_config),
patch("leggen.utils.config.config", mock_config),
patch(
"leggend.api.routes.accounts.database_service.get_account_details_from_db",
"leggen.api.routes.accounts.database_service.get_account_details_from_db",
return_value=mock_account,
),
patch(
"leggend.api.routes.accounts.database_service.get_balances_from_db",
"leggen.api.routes.accounts.database_service.get_balances_from_db",
return_value=mock_balances,
),
):
@@ -148,9 +148,9 @@ class TestAccountsAPI:
]
with (
patch("leggend.config.config", mock_config),
patch("leggen.utils.config.config", mock_config),
patch(
"leggend.api.routes.accounts.database_service.get_balances_from_db",
"leggen.api.routes.accounts.database_service.get_balances_from_db",
return_value=mock_balances,
),
):
@@ -191,13 +191,13 @@ class TestAccountsAPI:
]
with (
patch("leggend.config.config", mock_config),
patch("leggen.utils.config.config", mock_config),
patch(
"leggend.api.routes.accounts.database_service.get_transactions_from_db",
"leggen.api.routes.accounts.database_service.get_transactions_from_db",
return_value=mock_transactions,
),
patch(
"leggend.api.routes.accounts.database_service.get_transaction_count_from_db",
"leggen.api.routes.accounts.database_service.get_transaction_count_from_db",
return_value=1,
),
):
@@ -243,13 +243,13 @@ class TestAccountsAPI:
]
with (
patch("leggend.config.config", mock_config),
patch("leggen.utils.config.config", mock_config),
patch(
"leggend.api.routes.accounts.database_service.get_transactions_from_db",
"leggen.api.routes.accounts.database_service.get_transactions_from_db",
return_value=mock_transactions,
),
patch(
"leggend.api.routes.accounts.database_service.get_transaction_count_from_db",
"leggen.api.routes.accounts.database_service.get_transaction_count_from_db",
return_value=1,
),
):
@@ -273,9 +273,9 @@ class TestAccountsAPI:
):
"""Test handling of non-existent account."""
with (
patch("leggend.config.config", mock_config),
patch("leggen.utils.config.config", mock_config),
patch(
"leggend.api.routes.accounts.database_service.get_account_details_from_db",
"leggen.api.routes.accounts.database_service.get_account_details_from_db",
return_value=None,
),
):

View File

@@ -27,7 +27,7 @@ class TestBanksAPI:
return_value=httpx.Response(200, json=sample_bank_data)
)
with patch("leggend.config.config", mock_config):
with patch("leggen.utils.config.config", mock_config):
response = api_client.get("/api/v1/banks/institutions?country=PT")
assert response.status_code == 200
@@ -52,7 +52,7 @@ class TestBanksAPI:
return_value=httpx.Response(200, json=[])
)
with patch("leggend.config.config", mock_config):
with patch("leggen.utils.config.config", mock_config):
response = api_client.get("/api/v1/banks/institutions?country=XX")
# Should still work but return empty or filtered results
@@ -86,7 +86,7 @@ class TestBanksAPI:
"redirect_url": "http://localhost:8000/",
}
with patch("leggend.config.config", mock_config):
with patch("leggen.utils.config.config", mock_config):
response = api_client.post("/api/v1/banks/connect", json=request_data)
assert response.status_code == 200
@@ -122,7 +122,7 @@ class TestBanksAPI:
return_value=httpx.Response(200, json=requisitions_data)
)
with patch("leggend.config.config", mock_config):
with patch("leggen.utils.config.config", mock_config):
response = api_client.get("/api/v1/banks/status")
assert response.status_code == 200
@@ -155,7 +155,7 @@ class TestBanksAPI:
return_value=httpx.Response(401, json={"detail": "Invalid credentials"})
)
with patch("leggend.config.config", mock_config):
with patch("leggen.utils.config.config", mock_config):
response = api_client.get("/api/v1/banks/institutions")
assert response.status_code == 500

View File

@@ -5,16 +5,16 @@ import requests
import requests_mock
from unittest.mock import patch
from leggen.api_client import LeggendAPIClient
from leggen.api_client import LeggenAPIClient
@pytest.mark.cli
class TestLeggendAPIClient:
class TestLeggenAPIClient:
"""Test the CLI API client."""
def test_health_check_success(self):
"""Test successful health check."""
client = LeggendAPIClient("http://localhost:8000")
client = LeggenAPIClient("http://localhost:8000")
with requests_mock.Mocker() as m:
m.get("http://localhost:8000/health", json={"status": "healthy"})
@@ -24,7 +24,7 @@ class TestLeggendAPIClient:
def test_health_check_failure(self):
"""Test health check failure."""
client = LeggendAPIClient("http://localhost:8000")
client = LeggenAPIClient("http://localhost:8000")
with requests_mock.Mocker() as m:
m.get("http://localhost:8000/health", status_code=500)
@@ -34,7 +34,7 @@ class TestLeggendAPIClient:
def test_get_institutions_success(self, sample_bank_data):
"""Test getting institutions via API client."""
client = LeggendAPIClient("http://localhost:8000")
client = LeggenAPIClient("http://localhost:8000")
api_response = {
"success": True,
@@ -51,7 +51,7 @@ class TestLeggendAPIClient:
def test_get_accounts_success(self, sample_account_data):
"""Test getting accounts via API client."""
client = LeggendAPIClient("http://localhost:8000")
client = LeggenAPIClient("http://localhost:8000")
api_response = {
"success": True,
@@ -68,7 +68,7 @@ class TestLeggendAPIClient:
def test_trigger_sync_success(self):
"""Test triggering sync via API client."""
client = LeggendAPIClient("http://localhost:8000")
client = LeggenAPIClient("http://localhost:8000")
api_response = {
"success": True,
@@ -84,14 +84,14 @@ class TestLeggendAPIClient:
def test_connection_error_handling(self):
"""Test handling of connection errors."""
client = LeggendAPIClient("http://localhost:9999") # Non-existent service
client = LeggenAPIClient("http://localhost:9999") # Non-existent service
with pytest.raises((requests.ConnectionError, requests.RequestException)):
client.get_accounts()
def test_http_error_handling(self):
"""Test handling of HTTP errors."""
client = LeggendAPIClient("http://localhost:8000")
client = LeggenAPIClient("http://localhost:8000")
with requests_mock.Mocker() as m:
m.get(
@@ -106,19 +106,19 @@ class TestLeggendAPIClient:
def test_custom_api_url(self):
"""Test using custom API URL."""
custom_url = "http://custom-host:9000"
client = LeggendAPIClient(custom_url)
client = LeggenAPIClient(custom_url)
assert client.base_url == custom_url
def test_environment_variable_url(self):
"""Test using environment variable for API URL."""
with patch.dict("os.environ", {"LEGGEND_API_URL": "http://env-host:7000"}):
client = LeggendAPIClient()
with patch.dict("os.environ", {"LEGGEN_API_URL": "http://env-host:7000"}):
client = LeggenAPIClient()
assert client.base_url == "http://env-host:7000"
def test_sync_with_options(self):
"""Test sync with various options."""
client = LeggendAPIClient("http://localhost:8000")
client = LeggenAPIClient("http://localhost:8000")
api_response = {
"success": True,
@@ -135,7 +135,7 @@ class TestLeggendAPIClient:
def test_get_scheduler_config(self):
"""Test getting scheduler configuration."""
client = LeggendAPIClient("http://localhost:8000")
client = LeggenAPIClient("http://localhost:8000")
api_response = {
"success": True,

View File

@@ -43,13 +43,13 @@ class TestTransactionsAPI:
]
with (
patch("leggend.config.config", mock_config),
patch("leggen.utils.config.config", mock_config),
patch(
"leggend.api.routes.transactions.database_service.get_transactions_from_db",
"leggen.api.routes.transactions.database_service.get_transactions_from_db",
return_value=mock_transactions,
),
patch(
"leggend.api.routes.transactions.database_service.get_transaction_count_from_db",
"leggen.api.routes.transactions.database_service.get_transaction_count_from_db",
return_value=2,
),
):
@@ -90,13 +90,13 @@ class TestTransactionsAPI:
]
with (
patch("leggend.config.config", mock_config),
patch("leggen.utils.config.config", mock_config),
patch(
"leggend.api.routes.transactions.database_service.get_transactions_from_db",
"leggen.api.routes.transactions.database_service.get_transactions_from_db",
return_value=mock_transactions,
),
patch(
"leggend.api.routes.transactions.database_service.get_transaction_count_from_db",
"leggen.api.routes.transactions.database_service.get_transaction_count_from_db",
return_value=1,
),
):
@@ -135,13 +135,13 @@ class TestTransactionsAPI:
]
with (
patch("leggend.config.config", mock_config),
patch("leggen.utils.config.config", mock_config),
patch(
"leggend.api.routes.transactions.database_service.get_transactions_from_db",
"leggen.api.routes.transactions.database_service.get_transactions_from_db",
return_value=mock_transactions,
) as mock_get_transactions,
patch(
"leggend.api.routes.transactions.database_service.get_transaction_count_from_db",
"leggen.api.routes.transactions.database_service.get_transaction_count_from_db",
return_value=1,
),
):
@@ -178,13 +178,13 @@ class TestTransactionsAPI:
):
"""Test getting transactions when database returns empty result."""
with (
patch("leggend.config.config", mock_config),
patch("leggen.utils.config.config", mock_config),
patch(
"leggend.api.routes.transactions.database_service.get_transactions_from_db",
"leggen.api.routes.transactions.database_service.get_transactions_from_db",
return_value=[],
),
patch(
"leggend.api.routes.transactions.database_service.get_transaction_count_from_db",
"leggen.api.routes.transactions.database_service.get_transaction_count_from_db",
return_value=0,
),
):
@@ -203,9 +203,9 @@ class TestTransactionsAPI:
):
"""Test handling database error when getting transactions."""
with (
patch("leggend.config.config", mock_config),
patch("leggen.utils.config.config", mock_config),
patch(
"leggend.api.routes.transactions.database_service.get_transactions_from_db",
"leggen.api.routes.transactions.database_service.get_transactions_from_db",
side_effect=Exception("Database connection failed"),
),
):
@@ -243,9 +243,9 @@ class TestTransactionsAPI:
]
with (
patch("leggend.config.config", mock_config),
patch("leggen.utils.config.config", mock_config),
patch(
"leggend.api.routes.transactions.database_service.get_transactions_from_db",
"leggen.api.routes.transactions.database_service.get_transactions_from_db",
return_value=mock_transactions,
),
):
@@ -284,9 +284,9 @@ class TestTransactionsAPI:
]
with (
patch("leggend.config.config", mock_config),
patch("leggen.utils.config.config", mock_config),
patch(
"leggend.api.routes.transactions.database_service.get_transactions_from_db",
"leggen.api.routes.transactions.database_service.get_transactions_from_db",
return_value=mock_transactions,
) as mock_get_transactions,
):
@@ -306,9 +306,9 @@ class TestTransactionsAPI:
):
"""Test getting stats when no transactions match criteria."""
with (
patch("leggend.config.config", mock_config),
patch("leggen.utils.config.config", mock_config),
patch(
"leggend.api.routes.transactions.database_service.get_transactions_from_db",
"leggen.api.routes.transactions.database_service.get_transactions_from_db",
return_value=[],
),
):
@@ -331,9 +331,9 @@ class TestTransactionsAPI:
):
"""Test handling database error when getting stats."""
with (
patch("leggend.config.config", mock_config),
patch("leggen.utils.config.config", mock_config),
patch(
"leggend.api.routes.transactions.database_service.get_transactions_from_db",
"leggen.api.routes.transactions.database_service.get_transactions_from_db",
side_effect=Exception("Database connection failed"),
),
):
@@ -357,9 +357,9 @@ class TestTransactionsAPI:
]
with (
patch("leggend.config.config", mock_config),
patch("leggen.utils.config.config", mock_config),
patch(
"leggend.api.routes.transactions.database_service.get_transactions_from_db",
"leggen.api.routes.transactions.database_service.get_transactions_from_db",
return_value=mock_transactions,
) as mock_get_transactions,
):

View File

@@ -3,7 +3,7 @@
import pytest
from unittest.mock import patch
from leggend.config import Config
from leggen.utils.config import Config
@pytest.mark.unit

View File

@@ -14,7 +14,6 @@ class MockContext:
"""Mock context for testing."""
@pytest.mark.unit
class TestConfigurablePaths:
"""Test configurable path management."""

View File

@@ -4,7 +4,7 @@ import pytest
from unittest.mock import patch
from datetime import datetime
from leggend.services.database_service import DatabaseService
from leggen.services.database_service import DatabaseService
@pytest.fixture

View File

@@ -4,7 +4,7 @@ import pytest
from unittest.mock import patch, AsyncMock, MagicMock
from datetime import datetime
from leggend.background.scheduler import BackgroundScheduler
from leggen.background.scheduler import BackgroundScheduler
@pytest.mark.unit
@@ -20,8 +20,8 @@ class TestBackgroundScheduler:
def scheduler(self):
"""Create scheduler instance for testing."""
with (
patch("leggend.background.scheduler.SyncService"),
patch("leggend.background.scheduler.config") as mock_config,
patch("leggen.background.scheduler.SyncService"),
patch("leggen.background.scheduler.config") as mock_config,
):
mock_config.scheduler_config = {
"sync": {"enabled": True, "hour": 3, "minute": 0}
@@ -37,7 +37,7 @@ class TestBackgroundScheduler:
def test_scheduler_start_default_config(self, scheduler, mock_config):
"""Test starting scheduler with default configuration."""
with patch("leggend.config.config") as mock_config_obj:
with patch("leggen.utils.config.config") as mock_config_obj:
mock_config_obj.scheduler_config = mock_config
# Mock the job that gets added
@@ -58,7 +58,7 @@ class TestBackgroundScheduler:
with (
patch.object(scheduler, "scheduler") as mock_scheduler,
patch("leggend.background.scheduler.config") as mock_config_obj,
patch("leggen.background.scheduler.config") as mock_config_obj,
):
mock_config_obj.scheduler_config = disabled_config
mock_scheduler.running = False
@@ -79,7 +79,7 @@ class TestBackgroundScheduler:
}
}
with patch("leggend.config.config") as mock_config_obj:
with patch("leggen.utils.config.config") as mock_config_obj:
mock_config_obj.scheduler_config = cron_config
scheduler.start()
@@ -97,7 +97,7 @@ class TestBackgroundScheduler:
with (
patch.object(scheduler, "scheduler") as mock_scheduler,
patch("leggend.background.scheduler.config") as mock_config_obj,
patch("leggen.background.scheduler.config") as mock_config_obj,
):
mock_config_obj.scheduler_config = invalid_cron_config
mock_scheduler.running = False
@@ -187,7 +187,7 @@ class TestBackgroundScheduler:
def test_scheduler_job_max_instances(self, scheduler, mock_config):
"""Test that sync jobs have max_instances=1."""
with patch("leggend.config.config") as mock_config_obj:
with patch("leggen.utils.config.config") as mock_config_obj:
mock_config_obj.scheduler_config = mock_config
scheduler.start()