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: hooks:
- id: mypy - id: mypy
name: Static type check with mypy name: Static type check with mypy
entry: uv run mypy leggen leggend --check-untyped-defs entry: uv run mypy leggen --check-untyped-defs
files: "^leggen(d)?/.*" files: "^leggen/.*"
language: "system" language: "system"
types: ["python"] types: ["python"]
always_run: true 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: 4. Start the API server:
```bash ```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` - API will be available at `http://localhost:8000` with docs at `http://localhost:8000/docs`
### Start the Frontend ### Start the Frontend
@@ -60,7 +60,7 @@ The command outputs instructions for setting the required environment variable t
### Backend (Python) ### Backend (Python)
- **Lint**: `uv run ruff check .` - **Lint**: `uv run ruff check .`
- **Format**: `uv run ruff format .` - **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` - **All checks**: `uv run pre-commit run --all-files`
- **Run all tests**: `uv run pytest` - **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` - **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.source="https://github.com/elisiariocouto/leggen"
LABEL org.opencontainers.image.authors="Elisiário Couto <elisiario@couto.io>" LABEL org.opencontainers.image.authors="Elisiário Couto <elisiario@couto.io>"
LABEL org.opencontainers.image.licenses="MIT" 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.description="Open Banking API for Leggen"
LABEL org.opencontainers.image.url="https://github.com/elisiariocouto/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 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. 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. 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 ## 🛠️ Technologies
### 🔌 API & Backend ### 🔌 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 - [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 - [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 . uv sync # or pip install -e .
# Start the API service # 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) # Use the CLI (in another terminal)
uv run leggen --help uv run leggen --help
@@ -152,19 +152,19 @@ case-sensitive = ["SpecificStore"]
## 📖 Usage ## 📖 Usage
### API Service (`leggend`) ### API Service (`leggen server`)
Start the FastAPI backend service: Start the FastAPI backend service:
```bash ```bash
# Production mode # Production mode
leggend leggen server
# Development mode with auto-reload # Development mode with auto-reload
leggend --reload leggen server --reload
# Custom host and port # 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. **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 leggen --api-url http://localhost:8080 status
# Set via environment variable # Set via environment variable
export LEGGEND_API_URL=http://localhost:8080 export LEGGEN_API_URL=http://localhost:8080
leggen status leggen status
``` ```
@@ -223,7 +223,7 @@ docker compose -f compose.dev.yml ps
# Check logs # Check logs
docker compose -f compose.dev.yml logs frontend 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 # Stop development services
docker compose -f compose.dev.yml down docker compose -f compose.dev.yml down
@@ -239,7 +239,7 @@ docker compose ps
# Check logs # Check logs
docker compose logs frontend docker compose logs frontend
docker compose logs leggend docker compose logs leggen-server
# Access the web interface at http://localhost:3000 # Access the web interface at http://localhost:3000
# API documentation at http://localhost:8000/docs # API documentation at http://localhost:8000/docs
@@ -290,7 +290,7 @@ cd leggen
uv sync uv sync
# Start API service with auto-reload # Start API service with auto-reload
uv run leggend --reload uv run leggen server --reload
# Use CLI commands # Use CLI commands
uv run leggen status uv run leggen status
@@ -333,13 +333,10 @@ The test suite includes:
leggen/ # CLI application leggen/ # CLI application
├── commands/ # CLI command implementations ├── commands/ # CLI command implementations
├── utils/ # Shared utilities ├── utils/ # Shared utilities
── api_client.py # API client for leggend service ── api/ # FastAPI API routes and models
leggend/ # FastAPI backend service
├── api/ # API routes and models
├── services/ # Business logic ├── services/ # Business logic
├── background/ # Background job scheduler ├── background/ # Background job scheduler
└── main.py # FastAPI application └── api_client.py # API client for server communication
tests/ # Test suite tests/ # Test suite
├── conftest.py # Shared test fixtures ├── conftest.py # Shared test fixtures

View File

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

View File

@@ -6,11 +6,11 @@ services:
ports: ports:
- "127.0.0.1:3000:80" - "127.0.0.1:3000:80"
depends_on: depends_on:
leggend: leggen-server:
condition: service_healthy condition: service_healthy
# FastAPI backend service # FastAPI backend service
leggend: leggen-server:
image: ghcr.io/elisiariocouto/leggen:latest image: ghcr.io/elisiariocouto/leggen:latest
restart: "unless-stopped" restart: "unless-stopped"
ports: 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 COPY default.conf.template /etc/nginx/templates/default.conf.template
# Set default API backend URL (can be overridden at runtime) # 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 port 80
EXPOSE 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 - Uses relative URLs (`/api/v1`) that nginx proxies to the backend
- Configure nginx proxy target via `API_BACKEND_URL` environment variable - Configure nginx proxy target via `API_BACKEND_URL` environment variable
- Default: `http://leggend:8000` - Default: `http://leggen-server:8000`
**Docker Compose:** **Docker Compose:**

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,7 +1,7 @@
import click import click
from leggen.main import cli 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 from leggen.utils.text import datefmt, print_table
@@ -11,12 +11,12 @@ def balances(ctx: click.Context):
""" """
List balances of all connected accounts 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(): if not api_client.health_check():
click.echo( 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 return

View File

@@ -1,7 +1,7 @@
import click import click
from leggen.main import cli 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.disk import save_file
from leggen.utils.text import info, print_table, warning, success from leggen.utils.text import info, print_table, warning, success
@@ -12,12 +12,12 @@ def add(ctx):
""" """
Connect to a bank 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(): if not api_client.health_check():
click.echo( 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 return

View File

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

View File

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

View File

@@ -1,7 +1,7 @@
import click import click
from leggen.main import cli 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 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 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(): if not api_client.health_check():
click.echo( 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 return

View File

@@ -1,7 +1,7 @@
import click import click
from leggen.main import cli 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 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 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(): 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 return
try: try:

View File

@@ -1,7 +1,7 @@
import click import click
from leggen.main import cli 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 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. 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(): if not api_client.health_check():
click.echo( 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 return

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,9 +1,146 @@
import os
import sys import sys
import tomllib import tomllib
import tomli_w
from pathlib import Path
from typing import Dict, Any, Optional
import click import click
from loguru import logger
from leggen.utils.text import error 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): 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." "Configuration file not found. Provide a valid configuration file path with leggen --config <path> or LEGGEN_CONFIG=<path> environment variable."
) )
sys.exit(1) 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] [project.scripts]
leggen = "leggen.main:cli" leggen = "leggen.main:cli"
leggend = "leggend.main:main"
[dependency-groups] [dependency-groups]
dev = [ dev = [
@@ -58,10 +57,10 @@ dev = [
] ]
[tool.hatch.build.targets.sdist] [tool.hatch.build.targets.sdist]
include = ["leggen", "leggend"] include = ["leggen"]
[tool.hatch.build.targets.wheel] [tool.hatch.build.targets.wheel]
include = ["leggen", "leggend"] include = ["leggen"]
[build-system] [build-system]
requires = ["hatchling"] requires = ["hatchling"]

View File

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

View File

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

View File

@@ -5,8 +5,8 @@ from datetime import datetime, timedelta
from unittest.mock import Mock, AsyncMock, patch from unittest.mock import Mock, AsyncMock, patch
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from leggend.main import create_app from leggen.commands.server import create_app
from leggend.services.database_service import DatabaseService from leggen.services.database_service import DatabaseService
class TestAnalyticsFix: class TestAnalyticsFix:
@@ -27,78 +27,112 @@ class TestAnalyticsFix:
# Mock data for 600 transactions (simulating the issue) # Mock data for 600 transactions (simulating the issue)
mock_transactions = [] mock_transactions = []
for i in range(600): for i in range(600):
mock_transactions.append({ mock_transactions.append(
"transactionId": f"txn-{i}", {
"transactionDate": (datetime.now() - timedelta(days=i % 365)).isoformat(), "transactionId": f"txn-{i}",
"description": f"Transaction {i}", "transactionDate": (
"transactionValue": 10.0 if i % 2 == 0 else -5.0, datetime.now() - timedelta(days=i % 365)
"transactionCurrency": "EUR", ).isoformat(),
"transactionStatus": "booked", "description": f"Transaction {i}",
"accountId": f"account-{i % 3}", "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 # 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() app = create_app()
client = TestClient(app) client = TestClient(app)
response = client.get("/api/v1/transactions/stats?days=365") response = client.get("/api/v1/transactions/stats?days=365")
assert response.status_code == 200 assert response.status_code == 200
data = response.json() data = response.json()
# Verify that limit=None was passed to get all transactions # Verify that limit=None was passed to get all transactions
mock_database_service.get_transactions_from_db.assert_called_once() mock_database_service.get_transactions_from_db.assert_called_once()
call_args = mock_database_service.get_transactions_from_db.call_args 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 # Verify that the response contains stats for all 600 transactions
assert data["success"] is True assert data["success"] is True
stats = data["data"] 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 # Verify calculations are correct for all transactions
expected_income = sum(txn["transactionValue"] for txn in mock_transactions if txn["transactionValue"] > 0) expected_income = sum(
expected_expenses = sum(abs(txn["transactionValue"]) for txn in mock_transactions if txn["transactionValue"] < 0) 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_income"] == expected_income
assert stats["total_expenses"] == expected_expenses assert stats["total_expenses"] == expected_expenses
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_analytics_endpoint_returns_all_transactions(self, mock_database_service): async def test_analytics_endpoint_returns_all_transactions(
self, mock_database_service
):
"""Test that the new analytics endpoint returns all transactions without pagination""" """Test that the new analytics endpoint returns all transactions without pagination"""
# Mock data for 600 transactions # Mock data for 600 transactions
mock_transactions = [] mock_transactions = []
for i in range(600): for i in range(600):
mock_transactions.append({ mock_transactions.append(
"transactionId": f"txn-{i}", {
"transactionDate": (datetime.now() - timedelta(days=i % 365)).isoformat(), "transactionId": f"txn-{i}",
"description": f"Transaction {i}", "transactionDate": (
"transactionValue": 10.0 if i % 2 == 0 else -5.0, datetime.now() - timedelta(days=i % 365)
"transactionCurrency": "EUR", ).isoformat(),
"transactionStatus": "booked", "description": f"Transaction {i}",
"accountId": f"account-{i % 3}", "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() app = create_app()
client = TestClient(app) client = TestClient(app)
response = client.get("/api/v1/transactions/analytics?days=365") response = client.get("/api/v1/transactions/analytics?days=365")
assert response.status_code == 200 assert response.status_code == 200
data = response.json() data = response.json()
# Verify that limit=None was passed to get all transactions # Verify that limit=None was passed to get all transactions
mock_database_service.get_transactions_from_db.assert_called_once() mock_database_service.get_transactions_from_db.assert_called_once()
call_args = mock_database_service.get_transactions_from_db.call_args 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 # Verify that all 600 transactions are returned
assert data["success"] is True assert data["success"] is True
transactions_data = data["data"] 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 ( with (
patch("leggend.config.config", mock_config), patch("leggen.utils.config.config", mock_config),
patch( 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, return_value=mock_accounts,
), ),
patch( 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, return_value=mock_balances,
), ),
): ):
@@ -98,13 +98,13 @@ class TestAccountsAPI:
] ]
with ( with (
patch("leggend.config.config", mock_config), patch("leggen.utils.config.config", mock_config),
patch( 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, return_value=mock_account,
), ),
patch( 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, return_value=mock_balances,
), ),
): ):
@@ -148,9 +148,9 @@ class TestAccountsAPI:
] ]
with ( with (
patch("leggend.config.config", mock_config), patch("leggen.utils.config.config", mock_config),
patch( 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, return_value=mock_balances,
), ),
): ):
@@ -191,13 +191,13 @@ class TestAccountsAPI:
] ]
with ( with (
patch("leggend.config.config", mock_config), patch("leggen.utils.config.config", mock_config),
patch( 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, return_value=mock_transactions,
), ),
patch( 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, return_value=1,
), ),
): ):
@@ -243,13 +243,13 @@ class TestAccountsAPI:
] ]
with ( with (
patch("leggend.config.config", mock_config), patch("leggen.utils.config.config", mock_config),
patch( 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, return_value=mock_transactions,
), ),
patch( 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, return_value=1,
), ),
): ):
@@ -273,9 +273,9 @@ class TestAccountsAPI:
): ):
"""Test handling of non-existent account.""" """Test handling of non-existent account."""
with ( with (
patch("leggend.config.config", mock_config), patch("leggen.utils.config.config", mock_config),
patch( 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, return_value=None,
), ),
): ):

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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