mirror of
https://github.com/elisiariocouto/leggen.git
synced 2025-12-13 09:02:23 +00:00
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:
committed by
Elisiário Couto
parent
0e645d9bae
commit
318ca517f7
@@ -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
|
||||||
|
|||||||
@@ -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`
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
29
README.md
29
README.md
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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:**
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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:
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import click
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@click.command()
|
@click.command()
|
||||||
@click.option(
|
@click.option(
|
||||||
"--database",
|
"--database",
|
||||||
|
|||||||
@@ -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()
|
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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}")
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
@@ -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:
|
||||||
@@ -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:
|
||||||
@@ -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()
|
||||||
|
|||||||
@@ -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()
|
|
||||||
@@ -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",
|
|
||||||
}
|
|
||||||
@@ -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"]
|
||||||
|
|||||||
@@ -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__":
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
)
|
||||||
|
|||||||
@@ -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,
|
||||||
),
|
),
|
||||||
):
|
):
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
):
|
):
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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."""
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user