diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 016eec5..89efbdd 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -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 diff --git a/AGENTS.md b/AGENTS.md index 545add0..e9743f3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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` diff --git a/CLAUDE.md b/CLAUDE.md new file mode 120000 index 0000000..47dc3e3 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 6b11fb0..1b7e252 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 " 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"] diff --git a/README.md b/README.md index 32e0877..c392e73 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/compose.dev.yml b/compose.dev.yml index ff7fd05..38e6870 100644 --- a/compose.dev.yml +++ b/compose.dev.yml @@ -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 diff --git a/compose.yml b/compose.yml index d55fba3..4a51922 100644 --- a/compose.yml +++ b/compose.yml @@ -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: diff --git a/frontend/Dockerfile b/frontend/Dockerfile index 33d7f98..af4a743 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -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 diff --git a/frontend/README.md b/frontend/README.md index 2504b93..076e9cc 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -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:** diff --git a/leggend/api/models/accounts.py b/leggen/api/models/accounts.py similarity index 100% rename from leggend/api/models/accounts.py rename to leggen/api/models/accounts.py diff --git a/leggend/api/models/banks.py b/leggen/api/models/banks.py similarity index 100% rename from leggend/api/models/banks.py rename to leggen/api/models/banks.py diff --git a/leggend/api/models/common.py b/leggen/api/models/common.py similarity index 100% rename from leggend/api/models/common.py rename to leggen/api/models/common.py diff --git a/leggend/api/models/notifications.py b/leggen/api/models/notifications.py similarity index 100% rename from leggend/api/models/notifications.py rename to leggen/api/models/notifications.py diff --git a/leggend/api/models/sync.py b/leggen/api/models/sync.py similarity index 100% rename from leggend/api/models/sync.py rename to leggen/api/models/sync.py diff --git a/leggend/api/routes/accounts.py b/leggen/api/routes/accounts.py similarity index 97% rename from leggend/api/routes/accounts.py rename to leggen/api/routes/accounts.py index c05e2d1..1bad617 100644 --- a/leggend/api/routes/accounts.py +++ b/leggen/api/routes/accounts.py @@ -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: diff --git a/leggend/api/routes/banks.py b/leggen/api/routes/banks.py similarity index 96% rename from leggend/api/routes/banks.py rename to leggen/api/routes/banks.py index 7368fea..1063767 100644 --- a/leggend/api/routes/banks.py +++ b/leggen/api/routes/banks.py @@ -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() diff --git a/leggend/api/routes/notifications.py b/leggen/api/routes/notifications.py similarity index 97% rename from leggend/api/routes/notifications.py rename to leggen/api/routes/notifications.py index bbf48b7..5ab7f3d 100644 --- a/leggend/api/routes/notifications.py +++ b/leggen/api/routes/notifications.py @@ -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() diff --git a/leggend/api/routes/sync.py b/leggen/api/routes/sync.py similarity index 96% rename from leggend/api/routes/sync.py rename to leggen/api/routes/sync.py index 0de823f..f154d69 100644 --- a/leggend/api/routes/sync.py +++ b/leggen/api/routes/sync.py @@ -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() diff --git a/leggend/api/routes/transactions.py b/leggen/api/routes/transactions.py similarity index 97% rename from leggend/api/routes/transactions.py rename to leggen/api/routes/transactions.py index bb202ec..c25c186 100644 --- a/leggend/api/routes/transactions.py +++ b/leggen/api/routes/transactions.py @@ -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 - - diff --git a/leggen/api_client.py b/leggen/api_client.py index b0a2d8d..a8fd089 100644 --- a/leggen/api_client.py +++ b/leggen/api_client.py @@ -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" diff --git a/leggend/background/scheduler.py b/leggen/background/scheduler.py similarity index 97% rename from leggend/background/scheduler.py rename to leggen/background/scheduler.py index e2a9648..17ea397 100644 --- a/leggend/background/scheduler.py +++ b/leggen/background/scheduler.py @@ -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: diff --git a/leggen/commands/balances.py b/leggen/commands/balances.py index 202c304..47b86c1 100644 --- a/leggen/commands/balances.py +++ b/leggen/commands/balances.py @@ -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 diff --git a/leggen/commands/bank/add.py b/leggen/commands/bank/add.py index 0c66c31..ead9033 100644 --- a/leggen/commands/bank/add.py +++ b/leggen/commands/bank/add.py @@ -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 diff --git a/leggen/commands/generate_sample_db.py b/leggen/commands/generate_sample_db.py index 11fb3fc..519d076 100644 --- a/leggen/commands/generate_sample_db.py +++ b/leggen/commands/generate_sample_db.py @@ -4,7 +4,6 @@ import click from pathlib import Path - @click.command() @click.option( "--database", diff --git a/leggend/main.py b/leggen/commands/server.py similarity index 64% rename from leggend/main.py rename to leggen/commands/server.py index 33c3544..14cc57f 100644 --- a/leggend/main.py +++ b/leggen/commands/server.py @@ -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: /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() diff --git a/leggen/commands/status.py b/leggen/commands/status.py index be41fab..a8de114 100644 --- a/leggen/commands/status.py +++ b/leggen/commands/status.py @@ -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 diff --git a/leggen/commands/sync.py b/leggen/commands/sync.py index acc09d0..73300a5 100644 --- a/leggen/commands/sync.py +++ b/leggen/commands/sync.py @@ -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: diff --git a/leggen/commands/transactions.py b/leggen/commands/transactions.py index c293ef4..3d4f3fc 100644 --- a/leggen/commands/transactions.py +++ b/leggen/commands/transactions.py @@ -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 diff --git a/leggen/database/sqlite.py b/leggen/database/sqlite.py index d88cd97..9ada689 100644 --- a/leggen/database/sqlite.py +++ b/leggen/database/sqlite.py @@ -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 diff --git a/leggen/main.py b/leggen/main.py index 9bfa692..b4302c3 100644 --- a/leggen/main.py +++ b/leggen/main.py @@ -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, diff --git a/leggend/services/database_service.py b/leggen/services/database_service.py similarity index 99% rename from leggend/services/database_service.py rename to leggen/services/database_service.py index 93a83cd..035defc 100644 --- a/leggend/services/database_service.py +++ b/leggen/services/database_service.py @@ -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}") diff --git a/leggend/services/gocardless_service.py b/leggen/services/gocardless_service.py similarity index 99% rename from leggend/services/gocardless_service.py rename to leggen/services/gocardless_service.py index 58afecb..1353cc4 100644 --- a/leggend/services/gocardless_service.py +++ b/leggen/services/gocardless_service.py @@ -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 diff --git a/leggend/services/notification_service.py b/leggen/services/notification_service.py similarity index 99% rename from leggend/services/notification_service.py rename to leggen/services/notification_service.py index 30ec502..681110e 100644 --- a/leggend/services/notification_service.py +++ b/leggen/services/notification_service.py @@ -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: diff --git a/leggend/services/sync_service.py b/leggen/services/sync_service.py similarity index 96% rename from leggend/services/sync_service.py rename to leggen/services/sync_service.py index cd9e69c..3ac0d75 100644 --- a/leggend/services/sync_service.py +++ b/leggen/services/sync_service.py @@ -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: diff --git a/leggen/utils/config.py b/leggen/utils/config.py index 5f647df..27e4686 100644 --- a/leggen/utils/config.py +++ b/leggen/utils/config.py @@ -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 or LEGGEN_CONFIG= environment variable." ) sys.exit(1) + + +# Global singleton instance +config = Config() diff --git a/leggend/__init__.py b/leggend/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/leggend/config.py b/leggend/config.py deleted file mode 100644 index ea12e40..0000000 --- a/leggend/config.py +++ /dev/null @@ -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() diff --git a/leggend/utils/gocardless.py b/leggend/utils/gocardless.py deleted file mode 100644 index 13db2d3..0000000 --- a/leggend/utils/gocardless.py +++ /dev/null @@ -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", -} diff --git a/pyproject.toml b/pyproject.toml index 8d3139e..beb13c4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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"] diff --git a/scripts/generate_sample_db.py b/scripts/generate_sample_db.py index 1088c9a..0f03466 100755 --- a/scripts/generate_sample_db.py +++ b/scripts/generate_sample_db.py @@ -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__": diff --git a/tests/conftest.py b/tests/conftest.py index a686cf9..c34cd29 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -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 diff --git a/tests/unit/test_analytics_fix.py b/tests/unit/test_analytics_fix.py index 4456bb6..0a88ecf 100644 --- a/tests/unit/test_analytics_fix.py +++ b/tests/unit/test_analytics_fix.py @@ -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" \ No newline at end of file + assert len(transactions_data) == 600, ( + "Analytics endpoint should return all 600 transactions" + ) diff --git a/tests/unit/test_api_accounts.py b/tests/unit/test_api_accounts.py index bc708aa..480066b 100644 --- a/tests/unit/test_api_accounts.py +++ b/tests/unit/test_api_accounts.py @@ -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, ), ): diff --git a/tests/unit/test_api_banks.py b/tests/unit/test_api_banks.py index b3bfea7..075d439 100644 --- a/tests/unit/test_api_banks.py +++ b/tests/unit/test_api_banks.py @@ -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 diff --git a/tests/unit/test_api_client.py b/tests/unit/test_api_client.py index c1d4282..7f39e24 100644 --- a/tests/unit/test_api_client.py +++ b/tests/unit/test_api_client.py @@ -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, diff --git a/tests/unit/test_api_transactions.py b/tests/unit/test_api_transactions.py index 0e2d643..8b3db2d 100644 --- a/tests/unit/test_api_transactions.py +++ b/tests/unit/test_api_transactions.py @@ -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, ): diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py index 445e440..2a37255 100644 --- a/tests/unit/test_config.py +++ b/tests/unit/test_config.py @@ -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 diff --git a/tests/unit/test_configurable_paths.py b/tests/unit/test_configurable_paths.py index f889448..452aeb7 100644 --- a/tests/unit/test_configurable_paths.py +++ b/tests/unit/test_configurable_paths.py @@ -14,7 +14,6 @@ class MockContext: """Mock context for testing.""" - @pytest.mark.unit class TestConfigurablePaths: """Test configurable path management.""" diff --git a/tests/unit/test_database_service.py b/tests/unit/test_database_service.py index 0594dff..8061159 100644 --- a/tests/unit/test_database_service.py +++ b/tests/unit/test_database_service.py @@ -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 diff --git a/tests/unit/test_scheduler.py b/tests/unit/test_scheduler.py index cb05d72..1a05eaf 100644 --- a/tests/unit/test_scheduler.py +++ b/tests/unit/test_scheduler.py @@ -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()