Compare commits

..

2 Commits

Author SHA1 Message Date
Timothy Farrell
35314f4f8b Add POST payoff endpoint 2025-12-29 14:20:08 -06:00
Timothy Farrell
49ef7997a2 Add tests for health endpoint 2025-12-29 14:19:33 -06:00
10 changed files with 167 additions and 31 deletions

7
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,7 @@
{
"python.testing.pytestArgs": [
"tests"
],
"python.testing.unittestEnabled": false,
"python.testing.pytestEnabled": true
}

2
.vscode/tasks.json vendored
View File

@ -6,7 +6,7 @@
{ {
"label": "Run Mypy", "label": "Run Mypy",
"type": "shell", "type": "shell",
"command": "uv run mypy .", "command": "uv run mypy . --no-incremental",
"problemMatcher": [ "problemMatcher": [
"$python" "$python"
] ]

View File

@ -25,14 +25,10 @@ class Settings(BaseSettings):
dd_env: str = Field(default="development", alias="DD_ENV") dd_env: str = Field(default="development", alias="DD_ENV")
dd_version: str = Field(default="1.0.0", alias="DD_VERSION") dd_version: str = Field(default="1.0.0", alias="DD_VERSION")
# Rate limiting settings model_config = {
rate_limit_enabled: bool = Field(default=True, alias="RATE_LIMIT_ENABLED") "env_file": ".env",
rate_limit_requests: int = Field(default=100, alias="RATE_LIMIT_REQUESTS") "case_sensitive": False
rate_limit_period: str = Field(default="1/minute", alias="RATE_LIMIT_PERIOD") }
class Config:
env_file = ".env"
case_sensitive = False
settings = Settings() # type:ignore[call-arg] settings = Settings() # type:ignore[call-arg]

View File

@ -1,4 +1,4 @@
from typing import Callable from typing import Callable, Any
from fastapi import Request, Response from fastapi import Request, Response
from slowapi import Limiter, _rate_limit_exceeded_handler from slowapi import Limiter, _rate_limit_exceeded_handler
@ -12,13 +12,10 @@ from app.logging import get_logger
logger = get_logger(__name__) logger = get_logger(__name__)
# Rate limiter # Rate limiter
limiter = Limiter( limiter = Limiter(key_func=get_remote_address)
key_func=get_remote_address,
default_limits=[f"{settings.rate_limit_requests}/{settings.rate_limit_period}"] if settings.rate_limit_enabled else []
)
async def logging_middleware(request: Request, call_next: Callable) -> Response: async def logging_middleware(request: Request, call_next: Callable[[Request], Any]) -> Any:
"""Log all requests and responses.""" """Log all requests and responses."""
logger.debug( logger.debug(
"Request received", "Request received",

View File

@ -2,8 +2,10 @@
from fastapi import APIRouter from fastapi import APIRouter
from app.database import DatabaseService from app.database import DatabaseService
from app.logging import get_logger
router = APIRouter() router = APIRouter()
logger = get_logger(__name__)
@router.get("/health-check") @router.get("/health-check")
@ -11,7 +13,11 @@ async def health_check() -> dict[str, str]:
"""Comprehensive health check endpoint.""" """Comprehensive health check endpoint."""
# Check database connectivity # Check database connectivity
db_healthy = DatabaseService.health_check() try:
db_healthy = DatabaseService.health_check()
except Exception as e:
logger.error("Database health check failed", exc_info=e)
db_healthy = False
status = "healthy" if db_healthy else "unhealthy" status = "healthy" if db_healthy else "unhealthy"

73
app/resources/payoff.py Normal file
View File

@ -0,0 +1,73 @@
"""Pay-off endpoints."""
from datetime import date
from decimal import Decimal
from enum import Enum
from typing import Optional
from fastapi import APIRouter
from pydantic import BaseModel, Field, validator
from app.logging import get_logger
router = APIRouter()
logger = get_logger(__name__)
class PayoffStatus(str, Enum):
"""Enum for payoff status values."""
PROCESSED = "processed"
PENDING = "pending"
FAILED = "failed"
CANCELLED = "cancelled"
class PayoffRequest(BaseModel):
"""Request model for loan payoff."""
loan_id: str = Field(..., description="Loan identifier", min_length=1, max_length=50)
payoff_amount: Decimal = Field(..., description="Payoff amount", gt=0, decimal_places=2)
payoff_date: date = Field(..., description="Payoff date")
borrower_name: Optional[str] = Field(None, description="Borrower name", max_length=100)
payment_method: str = Field(..., description="Payment method", regex="^(check|wire|ach|cash)$")
notes: Optional[str] = Field(None, description="Additional notes", max_length=500)
@validator('payoff_date')
def validate_payoff_date(cls, v):
"""Validate payoff date is not in the future."""
from datetime import date
if v > date.today():
raise ValueError('Payoff date cannot be in the future')
return v
class PayoffResponse(BaseModel):
"""Response model for loan payoff."""
status: PayoffStatus
loan_id: str
payoff_amount: Decimal
payoff_date: date
transaction_id: str
@router.post("/payoff", response_model=PayoffResponse)
async def create_payoff(payoff_request: PayoffRequest) -> PayoffResponse:
"""Create a loan payoff record."""
logger.info(f"Processing payoff for loan {payoff_request.loan_id}")
# TODO: Add database logic to process payoff
# For now, return a mock response
import uuid
transaction_id = str(uuid.uuid4())
response = PayoffResponse(
status=PayoffStatus.PROCESSED,
loan_id=payoff_request.loan_id,
payoff_amount=payoff_request.payoff_amount,
payoff_date=payoff_request.payoff_date,
transaction_id=transaction_id
)
logger.info(f"Payoff processed successfully for loan {payoff_request.loan_id}, transaction {transaction_id}")
return response

17
main.py
View File

@ -5,14 +5,11 @@ import sentry_sdk
from ddtrace import patch_all from ddtrace import patch_all
from fastapi import APIRouter, FastAPI from fastapi import APIRouter, FastAPI
from sentry_sdk.integrations.fastapi import FastApiIntegration from sentry_sdk.integrations.fastapi import FastApiIntegration
from slowapi import _rate_limit_exceeded_handler
from slowapi.errors import RateLimitExceeded
from slowapi.middleware import SlowAPIMiddleware
from app.config import settings from app.config import settings
from app.logging import configure_logging, get_logger from app.logging import configure_logging, get_logger
from app.middleware import logging_middleware, limiter from app.middleware import logging_middleware
from app.resources import health from app.resources import health, payoff
def create_app() -> FastAPI: def create_app() -> FastAPI:
@ -44,17 +41,9 @@ def create_app() -> FastAPI:
redoc_url="/redoc", redoc_url="/redoc",
) )
# Add rate limiting middleware if enabled
if settings.rate_limit_enabled:
app.state.limiter = limiter
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
app.add_middleware(SlowAPIMiddleware)
# Add logging middleware
app.middleware("http")(logging_middleware)
# Include all endpoint routers # Include all endpoint routers
app.include_router(health.router, tags=["health"]) app.include_router(health.router, tags=["health"])
app.include_router(payoff.router, tags=["payoff"])
return app return app

View File

@ -37,6 +37,7 @@ strict = true
warn_return_any = true warn_return_any = true
warn_unused_configs = true warn_unused_configs = true
disallow_untyped_defs = true disallow_untyped_defs = true
exclude = ["tests"]
[tool.pylint.messages_control] [tool.pylint.messages_control]
max-line-length = 88 max-line-length = 88
@ -48,15 +49,17 @@ multi_line_output = 3
line_length = 88 line_length = 88
[tool.pytest.ini_options] [tool.pytest.ini_options]
asyncio_mode = "auto" pythonpath = ["."]
testpaths = ["tests"] testpaths = ["tests"]
[dependency-groups] [dependency-groups]
dev = [ dev = [
"autoflake>=2.3.1", "autoflake>=2.3.1",
"autopep8>=2.3.2", "autopep8>=2.3.2",
"httpx>=0.28.1",
"isort>=7.0.0", "isort>=7.0.0",
"mypy>=1.19.1", "mypy>=1.19.1",
"pylint-pydantic>=0.4.1", "pylint-pydantic>=0.4.1",
"pylint[pydantic]>=4.0.4", "pylint[pydantic]>=4.0.4",
"pytest[coverage]>=9.0.2",
] ]

61
tests/test_health.py Normal file
View File

@ -0,0 +1,61 @@
"""Tests for health check endpoints."""
import pytest
from fastapi.testclient import TestClient
from unittest.mock import patch
from main import create_app
class TestHealthCheck:
"""Test cases for health check endpoint."""
@pytest.fixture
def client(self) -> TestClient:
"""Create test client."""
app = create_app()
return TestClient(app)
@patch('app.database.DatabaseService.health_check')
def test_health_check_database_connected(self, mock_db_health, client) -> None:
"""Test health check when database is connected."""
# Mock database as healthy
mock_db_health.return_value = True
response = client.get("/health-check")
assert response.status_code == 200
data = response.json()
assert data["status"] == "healthy"
assert data["service"] == "loapi"
assert data["database"] == "connected"
mock_db_health.assert_called_once()
@patch('app.database.DatabaseService.health_check')
def test_health_check_database_disconnected(self, mock_db_health, client):
"""Test health check when database is disconnected."""
# Mock database as unhealthy
mock_db_health.return_value = False
response = client.get("/health-check")
assert response.status_code == 200
data = response.json()
assert data["status"] == "unhealthy"
assert data["service"] == "loapi"
assert data["database"] == "disconnected"
mock_db_health.assert_called_once()
@patch('app.database.DatabaseService.health_check')
def test_health_check_database_exception(self, mock_db_health, client):
"""Test health check when database check raises exception."""
# Mock database health check to raise exception
mock_db_health.side_effect = Exception("Database connection failed")
response = client.get("/health-check")
assert response.status_code == 200
data = response.json()
assert data["status"] == "unhealthy"
assert data["service"] == "loapi"
assert data["database"] == "disconnected"
mock_db_health.assert_called_once()

4
uv.lock generated
View File

@ -715,10 +715,12 @@ dev = [
dev = [ dev = [
{ name = "autoflake" }, { name = "autoflake" },
{ name = "autopep8" }, { name = "autopep8" },
{ name = "httpx" },
{ name = "isort" }, { name = "isort" },
{ name = "mypy" }, { name = "mypy" },
{ name = "pylint" }, { name = "pylint" },
{ name = "pylint-pydantic" }, { name = "pylint-pydantic" },
{ name = "pytest" },
] ]
[package.metadata] [package.metadata]
@ -750,10 +752,12 @@ provides-extras = ["dev"]
dev = [ dev = [
{ name = "autoflake", specifier = ">=2.3.1" }, { name = "autoflake", specifier = ">=2.3.1" },
{ name = "autopep8", specifier = ">=2.3.2" }, { name = "autopep8", specifier = ">=2.3.2" },
{ name = "httpx", specifier = ">=0.28.1" },
{ name = "isort", specifier = ">=7.0.0" }, { name = "isort", specifier = ">=7.0.0" },
{ name = "mypy", specifier = ">=1.19.1" }, { name = "mypy", specifier = ">=1.19.1" },
{ name = "pylint", extras = ["pydantic"], specifier = ">=4.0.4" }, { name = "pylint", extras = ["pydantic"], specifier = ">=4.0.4" },
{ name = "pylint-pydantic", specifier = ">=0.4.1" }, { name = "pylint-pydantic", specifier = ">=0.4.1" },
{ name = "pytest", extras = ["coverage"], specifier = ">=9.0.2" },
] ]
[[package]] [[package]]