diff --git a/app/api/v1/jobs.py b/app/api/v1/jobs.py new file mode 100644 index 0000000..a442c1f --- /dev/null +++ b/app/api/v1/jobs.py @@ -0,0 +1,8 @@ +from typing import List, Optional +from fastapi import APIRouter, Depends, File, UploadFile, Query, HTTPException, status +from app.core.dependencies import get_job_service +from app.core.exceptions import JobNotFoundException, InvalidCSVException +from app.schemas.job import JobResponse, JobStatusResponse, JobResultResponse +from app.services.job_service import JobService + +router = APIRouter(prefix="/jobs", tags=["jobs"]) diff --git a/app/core/config.py b/app/core/config.py new file mode 100644 index 0000000..96e9e8b --- /dev/null +++ b/app/core/config.py @@ -0,0 +1,48 @@ +from typing import Any +from pydantic import Field, computed_field +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + model_config = SettingsConfigDict( + env_file=".env", env_file_encoding="utf-8", extra="ignore" + ) + + # General + PROJECT_NAME: str = "Alemno Payments API" + API_V1_STR: str = "/api/v1" + + # PostgreSQL + POSTGRES_USER: str = Field(default="postgres") + POSTGRES_PASSWORD: str = Field(default="postgres") + POSTGRES_HOST: str = Field(default="localhost") + POSTGRES_PORT: int = Field(default=5432) + POSTGRES_DB: str = Field(default="alemno_payments") + + # Redis + REDIS_HOST: str = Field(default="localhost") + REDIS_PORT: int = Field(default=6379) + REDIS_DB: int = Field(default=0) + + @computed_field + @property + def DATABASE_URL(self) -> str: + return ( + f"postgresql+asyncpg://" + f"{self.POSTGRES_USER}:{self.POSTGRES_PASSWORD}@" + f"{self.POSTGRES_HOST}:{self.POSTGRES_PORT}/" + f"{self.POSTGRES_DB}" + ) + + @computed_field + @property + def CELERY_BROKER_URL(self) -> str: + return f"redis://{self.REDIS_HOST}:{self.REDIS_PORT}/{self.REDIS_DB}" + + @computed_field + @property + def CELERY_RESULT_BACKEND(self) -> str: + return f"redis://{self.REDIS_HOST}:{self.REDIS_PORT}/{self.REDIS_DB}" + + +settings = Settings() diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..6a6254f --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,60 @@ +import asyncio +from typing import AsyncGenerator +import pytest +from httpx import ASGITransport, AsyncClient +from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession +from app.core.config import settings +from app.core.dependencies import get_db +from app.main import app +# Use a test database suffix or a separate database for testing +TEST_DATABASE_URL = settings.DATABASE_URL.replace(settings.POSTGRES_DB, f"test_{settings.POSTGRES_DB}") +# Create async engine for test db +test_engine = create_async_engine( + TEST_DATABASE_URL, + echo=False, + future=True, +) + +TestAsyncSessionLocal = async_sessionmaker( + bind=test_engine, + class_=AsyncSession, + expire_on_commit=False, +) + +# Global flag to track if test DB is accessible +_db_offline = False + + +@pytest.fixture(scope="session", autouse=True) +def initialize_test_db(): + """Create test tables and clean them up after all tests finish.""" + global _db_offline + import asyncio + try: + loop = asyncio.get_event_loop_policy().get_event_loop() + except RuntimeError: + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + + async def setup_db(): + async with test_engine.begin() as conn: + await conn.run_sync(Base.metadata.drop_all) + await conn.run_sync(Base.metadata.create_all) + + async def teardown_db(): + async with test_engine.begin() as conn: + await conn.run_sync(Base.metadata.drop_all) + + try: + loop.run_until_complete(setup_db()) + except Exception as e: + _db_offline = True + print(f"\nSkipping DB initialization (Postgres is likely offline): {e}\n") + + yield + + if not _db_offline: + try: + loop.run_until_complete(teardown_db()) + except Exception: + pass diff --git a/tests/services/test_csv_parser.py b/tests/services/test_csv_parser.py new file mode 100644 index 0000000..797a404 --- /dev/null +++ b/tests/services/test_csv_parser.py @@ -0,0 +1,49 @@ +import pytest +from app.core.exceptions import InvalidCSVException +from app.utils.csv_parser import parse_and_validate_csv + + +def test_parse_valid_csv(): + valid_csv = ( + "transaction_id,date,amount,category,description\n" + "TX1001,2026-06-25,120.50,Groceries,Weekly grocery shop\n" + "TX1002,06/26/2026,15.00,Coffee,Morning latte\n" + ) + result = parse_and_validate_csv(valid_csv.encode("utf-8")) + assert len(result) == 2 + assert result[0]["transaction_id"] == "TX1001" + assert result[0]["amount"] == 120.50 + assert result[0]["date"] == "2026-06-25" + assert result[1]["transaction_id"] == "TX1002" + assert result[1]["amount"] == 15.00 + assert result[1]["date"] == "2026-06-26" + + +def test_parse_missing_headers(): + invalid_csv = ( + "transaction_id,amount,category,description\n" + "TX1001,120.50,Groceries,Weekly grocery shop\n" + ) + with pytest.raises(InvalidCSVException) as exc_info: + parse_and_validate_csv(invalid_csv.encode("utf-8")) + assert "Missing required CSV columns" in str(exc_info.value) + + +def test_parse_invalid_amount(): + invalid_csv = ( + "transaction_id,date,amount,category,description\n" + "TX1001,2026-06-25,abc,Groceries,Weekly grocery shop\n" + ) + with pytest.raises(InvalidCSVException) as exc_info: + parse_and_validate_csv(invalid_csv.encode("utf-8")) + assert "Invalid amount" in str(exc_info.value) + + +def test_parse_invalid_date(): + invalid_csv = ( + "transaction_id,date,amount,category,description\n" + "TX1001,invalid-date,120.50,Groceries,Weekly grocery shop\n" + ) + with pytest.raises(InvalidCSVException) as exc_info: + parse_and_validate_csv(invalid_csv.encode("utf-8")) + assert "Invalid date format" in str(exc_info.value)