diff --git a/.gitignore b/.gitignore index 3a5c722..429e471 100644 --- a/.gitignore +++ b/.gitignore @@ -1,23 +1,21 @@ # Mac .DS_Store -# Application +# Project management +.my_project/ +.cursor/ real_data/ -my_notes.txt -notes.txt + +# Application *log.* -.vite/ -notes/ # Frontend frontend/coverage/ +.vite/ # CSV files - ignore purchases.csv but keep sample data *-purchases.csv -# Cursor -.cursor/ - # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] diff --git a/backend/pytest.ini b/backend/pytest.ini new file mode 100644 index 0000000..fcccae1 --- /dev/null +++ b/backend/pytest.ini @@ -0,0 +1,2 @@ +[pytest] +pythonpath = src diff --git a/backend/src/crud.py b/backend/src/crud.py index 9aea6ec..91cc24e 100644 --- a/backend/src/crud.py +++ b/backend/src/crud.py @@ -77,7 +77,7 @@ def get_users(db: Session, skip: int = 0, limit: int = 100) -> List[User]: def create_user(db: Session, user: UserCreate) -> User: # Sanitize input - user_data = user.dict() + user_data = user.model_dump() user_data['username'] = sanitize_input(user_data['username']) user_data['email'] = sanitize_input(user_data['email']) @@ -90,7 +90,7 @@ def create_user(db: Session, user: UserCreate) -> User: def update_user(db: Session, user_id: UUID, user: UserUpdate) -> Optional[User]: db_user = get_user(db, user_id) if db_user: - update_data = user.dict(exclude_unset=True) + update_data = user.model_dump(exclude_unset=True) for field, value in update_data.items(): setattr(db_user, field, value) db.commit() @@ -134,7 +134,7 @@ def get_or_create_category(db: Session, category_name: str) -> Category: def create_category(db: Session, category: CategoryCreate) -> Category: # Sanitize input - category_data = category.dict() + category_data = category.model_dump() category_data['category_name'] = sanitize_input(category_data['category_name']) db_category = Category(**category_data) @@ -146,7 +146,7 @@ def create_category(db: Session, category: CategoryCreate) -> Category: def update_category(db: Session, category_id: UUID, category: CategoryUpdate) -> Optional[Category]: db_category = get_category(db, category_id) if db_category: - update_data = category.dict(exclude_unset=True) + update_data = category.model_dump(exclude_unset=True) for field, value in update_data.items(): setattr(db_category, field, value) db.commit() @@ -181,7 +181,7 @@ def get_expenses( def create_expense(db: Session, expense: ExpenseCreate) -> Expense: # Sanitize input - expense_data = expense.dict() + expense_data = expense.model_dump() expense_data['item'] = sanitize_input(expense_data['item']) expense_data['vendor'] = sanitize_input(expense_data['vendor']) if expense_data.get('payment_method'): @@ -214,7 +214,7 @@ def create_expense(db: Session, expense: ExpenseCreate) -> Expense: def update_expense(db: Session, expense_id: UUID, expense: ExpenseUpdate) -> Optional[Expense]: db_expense = get_expense(db, expense_id) if db_expense: - update_data = expense.dict(exclude_unset=True) + update_data = expense.model_dump(exclude_unset=True) # Extract new categories before updating expense new_categories = update_data.pop('new_categories', []) or [] @@ -335,7 +335,7 @@ def get_wishlist_items( return query.order_by(Wishlist.priority).offset(skip).limit(limit).all() def create_wishlist_item(db: Session, wishlist_item: WishlistCreate) -> Wishlist: - db_wishlist_item = Wishlist(**wishlist_item.dict()) + db_wishlist_item = Wishlist(**wishlist_item.model_dump()) db.add(db_wishlist_item) db.commit() db.refresh(db_wishlist_item) @@ -344,7 +344,7 @@ def create_wishlist_item(db: Session, wishlist_item: WishlistCreate) -> Wishlist def update_wishlist_item(db: Session, wish_id: UUID, wishlist_item: WishlistUpdate) -> Optional[Wishlist]: db_wishlist_item = get_wishlist_item(db, wish_id) if db_wishlist_item: - update_data = wishlist_item.dict(exclude_unset=True) + update_data = wishlist_item.model_dump(exclude_unset=True) for field, value in update_data.items(): setattr(db_wishlist_item, field, value) db.commit() @@ -397,7 +397,7 @@ def create_budget(db: Session, budget: BudgetCreate) -> Budget: is_over_max = total_spend > budget.max_spend # Create budget with calculated values - budget_data = budget.dict() + budget_data = budget.model_dump() budget_data['current_spend'] = current_spend budget_data['future_spend'] = future_spend budget_data['is_over_max'] = is_over_max @@ -413,7 +413,7 @@ def create_budget(db: Session, budget: BudgetCreate) -> Budget: def update_budget(db: Session, budget_id: UUID, budget: BudgetUpdate) -> Optional[Budget]: db_budget = get_budget(db, budget_id) if db_budget: - update_data = budget.dict(exclude_unset=True) + update_data = budget.model_dump(exclude_unset=True) # Recalculate current spend if category or user changed if 'category_id' in update_data or 'user_id' in update_data: @@ -469,12 +469,18 @@ def get_total_expenses_in_date_range(db: Session, user_id: Optional[UUID] = None return result or 0.0 def get_expenses_by_category(db: Session, user_id: Optional[UUID] = None) -> List[dict]: - query = db.query( - Category.category_name, - func.sum(Expense.price).label('total') - ).join(ExpenseCategory).join(Expense) - + """Aggregate expenses by category with explicit join path to avoid ambiguity.""" + query = ( + db.query( + Category.category_name, + func.sum(Expense.price).label('total') + ) + .select_from(Category) + .join(ExpenseCategory, ExpenseCategory.category_id == Category.category_id) + .join(Expense, Expense.expense_id == ExpenseCategory.expense_id) + ) + if user_id: query = query.filter(Expense.user_id == user_id) - + return query.group_by(Category.category_id, Category.category_name).all() \ No newline at end of file diff --git a/backend/src/database.py b/backend/src/database.py index cfc278e..fa2e870 100644 --- a/backend/src/database.py +++ b/backend/src/database.py @@ -1,6 +1,5 @@ from sqlalchemy import create_engine -from sqlalchemy.ext.declarative import declarative_base -from sqlalchemy.orm import sessionmaker +from sqlalchemy.orm import sessionmaker, declarative_base import os # Database URL from environment variable diff --git a/backend/src/models.py b/backend/src/models.py index 7a817a6..fb64714 100644 --- a/backend/src/models.py +++ b/backend/src/models.py @@ -30,7 +30,7 @@ class Category(Base): # Relationships expense_categories = relationship("ExpenseCategory", back_populates="category") budgets = relationship("Budget", back_populates="category") - expenses = relationship("Expense", secondary="expense_categories", back_populates="categories") + expenses = relationship("Expense", secondary="expense_categories", back_populates="categories", overlaps="expense_categories") class Expense(Base): __tablename__ = "expenses" @@ -47,8 +47,8 @@ class Expense(Base): # Relationships user = relationship("User", back_populates="expenses") - expense_categories = relationship("ExpenseCategory", back_populates="expense") - categories = relationship("Category", secondary="expense_categories", back_populates="expenses") + expense_categories = relationship("ExpenseCategory", back_populates="expense", overlaps="expenses") + categories = relationship("Category", secondary="expense_categories", back_populates="expenses", overlaps="expense_categories") class ExpenseCategory(Base): __tablename__ = "expense_categories" diff --git a/backend/src/schemas.py b/backend/src/schemas.py index e3ebffb..343355d 100644 --- a/backend/src/schemas.py +++ b/backend/src/schemas.py @@ -1,4 +1,4 @@ -from pydantic import BaseModel, Field, validator, EmailStr +from pydantic import field_validator, ConfigDict, BaseModel, Field, validator, EmailStr from typing import Optional, List from datetime import datetime, date from uuid import UUID @@ -8,7 +8,7 @@ class UserBase(BaseModel): username: str = Field(..., min_length=3, max_length=50, pattern=r'^[a-zA-Z0-9_]+$') email: EmailStr - role: str = Field(..., pattern=r'^(regular|admin)$', strip_whitespace=True) + role: str = Field(..., pattern=r'^(regular|admin)$', json_schema_extra={'strip_whitespace': True}) class UserCreate(UserBase): password_hash: str = Field(..., min_length=60, max_length=73) # bcrypt hash length @@ -16,22 +16,21 @@ class UserCreate(UserBase): class UserUpdate(UserBase): username: Optional[str] = Field(None, min_length=3, max_length=50, pattern=r'^[a-zA-Z0-9_]+$') email: Optional[EmailStr] = None - role: Optional[str] = Field(None, pattern=r'^(regular|admin)$', strip_whitespace=True) + role: Optional[str] = Field(None, pattern=r'^(regular|admin)$', json_schema_extra={'strip_whitespace': True}) password_hash: Optional[str] = Field(None, min_length=60, max_length=73) class User(UserBase): user_id: UUID created_at: datetime last_login: datetime - - class Config: - from_attributes = True + model_config = ConfigDict(from_attributes=True) # Category schemas class CategoryBase(BaseModel): - category_name: str = Field(..., min_length=1, max_length=100, strip_whitespace=True) + category_name: str = Field(..., min_length=1, max_length=100, json_schema_extra={'strip_whitespace': True}) - @validator('category_name') + @field_validator('category_name') + @classmethod def validate_category_name(cls, v): # Remove any potentially dangerous characters v = re.sub(r'[<>"\']', '', v) @@ -41,24 +40,23 @@ class CategoryCreate(CategoryBase): pass class CategoryUpdate(CategoryBase): - category_name: Optional[str] = Field(None, min_length=1, max_length=100, strip_whitespace=True) + category_name: Optional[str] = Field(None, min_length=1, max_length=100, json_schema_extra={'strip_whitespace': True}) class Category(CategoryBase): category_id: UUID - - class Config: - from_attributes = True + model_config = ConfigDict(from_attributes=True) # Expense schemas class ExpenseBase(BaseModel): - item: str = Field(..., min_length=1, max_length=255, strip_whitespace=True) - vendor: str = Field(..., min_length=1, max_length=255, strip_whitespace=True) + item: str = Field(..., min_length=1, max_length=255, json_schema_extra={'strip_whitespace': True}) + vendor: str = Field(..., min_length=1, max_length=255, json_schema_extra={'strip_whitespace': True}) price: float = Field(..., gt=0, le=999999.99) # Positive price with reasonable limit date_purchased: date - payment_method: Optional[str] = Field(None, max_length=100, strip_whitespace=True) - notes: Optional[str] = Field(None, max_length=1000, strip_whitespace=True) + payment_method: Optional[str] = Field(None, max_length=100, json_schema_extra={'strip_whitespace': True}) + notes: Optional[str] = Field(None, max_length=1000, json_schema_extra={'strip_whitespace': True}) - @validator('item', 'vendor', 'payment_method', 'notes') + @field_validator('item', 'vendor', 'payment_method', 'notes') + @classmethod def validate_text_fields(cls, v): if v is not None: # Remove potentially dangerous characters @@ -71,12 +69,12 @@ class ExpenseCreate(ExpenseBase): new_categories: Optional[List[str]] = Field(None, description="List of new category names to create") class ExpenseUpdate(ExpenseBase): - item: Optional[str] = Field(None, min_length=1, max_length=255, strip_whitespace=True) - vendor: Optional[str] = Field(None, min_length=1, max_length=255, strip_whitespace=True) + item: Optional[str] = Field(None, min_length=1, max_length=255, json_schema_extra={'strip_whitespace': True}) + vendor: Optional[str] = Field(None, min_length=1, max_length=255, json_schema_extra={'strip_whitespace': True}) price: Optional[float] = Field(None, gt=0, le=999999.99) date_purchased: Optional[date] = None - payment_method: Optional[str] = Field(None, max_length=100, strip_whitespace=True) - notes: Optional[str] = Field(None, max_length=1000, strip_whitespace=True) + payment_method: Optional[str] = Field(None, max_length=100, json_schema_extra={'strip_whitespace': True}) + notes: Optional[str] = Field(None, max_length=1000, json_schema_extra={'strip_whitespace': True}) new_categories: Optional[List[str]] = Field(None, description="List of new category names to create") class Expense(ExpenseBase): @@ -84,9 +82,7 @@ class Expense(ExpenseBase): user_id: UUID created_at: datetime categories: Optional[List[Category]] = [] - - class Config: - from_attributes = True + model_config = ConfigDict(from_attributes=True) # ExpenseCategory schemas class ExpenseCategoryBase(BaseModel): @@ -97,20 +93,20 @@ class ExpenseCategoryCreate(ExpenseCategoryBase): pass class ExpenseCategory(ExpenseCategoryBase): - class Config: - from_attributes = True + model_config = ConfigDict(from_attributes=True) # Wishlist schemas class WishlistBase(BaseModel): - item: str = Field(..., min_length=1, max_length=255, strip_whitespace=True) - vendor: Optional[str] = Field(None, max_length=255, strip_whitespace=True) + item: str = Field(..., min_length=1, max_length=255, json_schema_extra={'strip_whitespace': True}) + vendor: Optional[str] = Field(None, max_length=255, json_schema_extra={'strip_whitespace': True}) price: float = Field(..., gt=0, le=999999.99) priority: int = Field(..., ge=1, le=10) # Priority 1-10 status: str = Field(..., pattern=r'^(wished|scheduled|bought)$') - notes: Optional[str] = Field(None, max_length=1000, strip_whitespace=True) + notes: Optional[str] = Field(None, max_length=1000, json_schema_extra={'strip_whitespace': True}) planned_date: Optional[date] = None - @validator('item', 'vendor', 'notes') + @field_validator('item', 'vendor', 'notes') + @classmethod def validate_text_fields(cls, v): if v is not None: v = re.sub(r'[<>"\']', '', v) @@ -121,12 +117,12 @@ class WishlistCreate(WishlistBase): user_id: UUID class WishlistUpdate(WishlistBase): - item: Optional[str] = Field(None, min_length=1, max_length=255, strip_whitespace=True) - vendor: Optional[str] = Field(None, max_length=255, strip_whitespace=True) + item: Optional[str] = Field(None, min_length=1, max_length=255, json_schema_extra={'strip_whitespace': True}) + vendor: Optional[str] = Field(None, max_length=255, json_schema_extra={'strip_whitespace': True}) price: Optional[float] = Field(None, gt=0, le=999999.99) priority: Optional[int] = Field(None, ge=1, le=10) status: Optional[str] = Field(None, pattern=r'^(wished|scheduled|bought)$') - notes: Optional[str] = Field(None, max_length=1000, strip_whitespace=True) + notes: Optional[str] = Field(None, max_length=1000, json_schema_extra={'strip_whitespace': True}) planned_date: Optional[date] = None class Wishlist(WishlistBase): @@ -134,9 +130,7 @@ class Wishlist(WishlistBase): user_id: UUID created_at: Optional[datetime] = None user: Optional[User] = None - - class Config: - from_attributes = True + model_config = ConfigDict(from_attributes=True) # Budget schemas class BudgetBase(BaseModel): @@ -148,13 +142,17 @@ class BudgetBase(BaseModel): timeframe_interval: Optional[int] = Field(None, ge=1, le=100) recurring_start_date: Optional[date] = None - @validator('end_date') + # TODO[pydantic]: We couldn't refactor the `validator`, please replace it by `field_validator` manually. + # Check https://docs.pydantic.dev/dev-v2/migration/#changes-to-validators for more information. + @field_validator('end_date') def validate_date_range(cls, v, values): if 'start_date' in values and v <= values['start_date']: raise ValueError('end_date must be after start_date') return v - @validator('timeframe_interval') + # TODO[pydantic]: We couldn't refactor the `validator`, please replace it by `field_validator` manually. + # Check https://docs.pydantic.dev/dev-v2/migration/#changes-to-validators for more information. + @field_validator('timeframe_interval') def validate_timeframe_interval(cls, v, values): if 'timeframe_type' in values and values['timeframe_type'] != 'custom' and v is None: raise ValueError('timeframe_interval is required for non-custom timeframes') @@ -181,6 +179,4 @@ class Budget(BudgetBase): category_id: UUID current_spend: float = Field(..., ge=0, le=999999.99) future_spend: float = Field(..., ge=0, le=999999.99) - - class Config: - from_attributes = True \ No newline at end of file + model_config = ConfigDict(from_attributes=True) \ No newline at end of file diff --git a/backend/src/test_api.py b/backend/src/test_api.py deleted file mode 100644 index 132d44e..0000000 --- a/backend/src/test_api.py +++ /dev/null @@ -1,116 +0,0 @@ -#!/usr/bin/env python3 -""" -Simple test script to verify the FastAPI endpoints -Run this after starting the services with: docker compose up -d -""" - -import requests -import json -from datetime import datetime, date -import uuid - -BASE_URL = "http://localhost:8001" - -def test_health(): - print("Testing health endpoint...") - response = requests.get(f"{BASE_URL}/health") - print(f"Status: {response.status_code}") - print(f"Response: {response.json()}") - print() - -def test_user(): - print("Testing user endpoints...") - user_data = { - "username": "testuser", - "email": "testuser@example.com", - "role": "regular", - "password_hash": "fakehash123" - } - response = requests.post(f"{BASE_URL}/users/", json=user_data) - print(f"Create user status: {response.status_code}") - if response.status_code == 201: - user = response.json() - print(f"Created user: {user}") - return user["user_id"] - else: - print(f"Error creating user: {response.text}") - return None - -def test_categories(): - print("Testing category endpoints...") - category_data = { - "category_name": "Food & Dining" - } - response = requests.post(f"{BASE_URL}/categories/", json=category_data) - print(f"Create category status: {response.status_code}") - if response.status_code == 201: - category = response.json() - print(f"Created category: {category}") - category_id = category['category_id'] - # Get all categories - response = requests.get(f"{BASE_URL}/categories/") - print(f"Get categories status: {response.status_code}") - print(f"Categories: {response.json()}") - # Update category - update_data = {"category_name": "Updated Food & Dining"} - response = requests.put(f"{BASE_URL}/categories/{category_id}", json=update_data) - print(f"Update category status: {response.status_code}") - return category_id - else: - print(f"Error creating category: {response.text}") - return None - -def test_expenses(user_id, category_id): - print("\nTesting expense endpoints...") - # Create an expense - expense_data = { - "item": "Lunch at Chipotle", - "vendor": "Chipotle", - "price": 25.50, - "date_purchased": date.today().isoformat(), - "user_id": user_id, - "payment_method": "credit card", - "notes": "Delicious burrito bowl" - } - response = requests.post(f"{BASE_URL}/expenses/", json=expense_data) - print(f"Create expense status: {response.status_code}") - if response.status_code == 201: - expense = response.json() - print(f"Created expense: {expense}") - expense_id = expense['expense_id'] - # Link expense to category - response = requests.post(f"{BASE_URL}/expenses/{expense_id}/categories/{category_id}") - print(f"Link expense to category status: {response.status_code}") - # Get all expenses - response = requests.get(f"{BASE_URL}/expenses/") - print(f"Get expenses status: {response.status_code}") - print(f"Expenses: {response.json()}") - # Get analytics - response = requests.get(f"{BASE_URL}/analytics/total") - print(f"Total expenses: {response.json()}") - response = requests.get(f"{BASE_URL}/analytics/by-category") - print(f"Expenses by category: {response.json()}") - return expense_id - else: - print(f"Error creating expense: {response.text}") - return None - -def main(): - print("Starting API tests...") - print("=" * 50) - try: - test_health() - user_id = test_user() - category_id = test_categories() - if user_id and category_id: - test_expenses(user_id, category_id) - print("=" * 50) - print("Tests completed!") - except requests.exceptions.ConnectionError: - print("Error: Could not connect to the API. Make sure the services are running with:") - print("docker compose up -d") - except Exception as e: - print(f"Error during testing: {e}") - -if __name__ == "__main__": - main() \ No newline at end of file diff --git a/backend/test/requirements.txt b/backend/test/requirements.txt new file mode 100644 index 0000000..bd5f874 --- /dev/null +++ b/backend/test/requirements.txt @@ -0,0 +1,29 @@ +annotated-types==0.7.0 +anyio==4.11.0 +certifi==2025.10.5 +charset-normalizer==3.4.3 +click==8.3.0 +dnspython==2.8.0 +email-validator==2.3.0 +fastapi==0.118.0 +greenlet==3.2.4 +h11==0.16.0 +httpcore==1.0.9 +httpx==0.28.1 +idna==3.10 +iniconfig==2.1.0 +packaging==25.0 +pluggy==1.6.0 +psycopg2-binary==2.9.10 +pydantic==2.12.0 +pydantic_core==2.41.1 +Pygments==2.19.2 +pytest==8.4.2 +requests==2.32.5 +sniffio==1.3.1 +SQLAlchemy==2.0.43 +starlette==0.48.0 +typing-inspection==0.4.2 +typing_extensions==4.15.0 +urllib3==2.5.0 +uvicorn==0.37.0 diff --git a/backend/test/test_api.py b/backend/test/test_api.py new file mode 100644 index 0000000..708a265 --- /dev/null +++ b/backend/test/test_api.py @@ -0,0 +1,185 @@ +#!/usr/bin/env python3 +""" +Pytest API tests using FastAPI TestClient bound to a test database on port 5433. +Requires the test Postgres in compose.test.yaml to be running (docker compose -f compose.test.yaml up -d). +""" + +import os +from datetime import date + +import pytest +from fastapi.testclient import TestClient +from sqlalchemy import create_engine, event +from sqlalchemy.orm import sessionmaker + +# Import app and DB utilities from the backend src +from main import app # ensure PYTHONPATH includes backend/src when running pytest +from database import Base, get_db + + +# Configure test database (port 5433) +POSTGRES_PASSWORD = os.getenv("POSTGRES_PASSWORD", "password") +TEST_DATABASE_URL = os.getenv( + "TEST_DATABASE_URL", + f"postgresql://postgres:{POSTGRES_PASSWORD}@localhost:5433/test_expenses_db", +) + +engine = create_engine(TEST_DATABASE_URL, pool_pre_ping=True, future=True) +TestingSessionLocal = sessionmaker(bind=engine, autocommit=False, autoflush=False) + + +@pytest.fixture(scope="session", autouse=True) +def _create_schema(): + Base.metadata.create_all(bind=engine) + yield + Base.metadata.drop_all(bind=engine) + + +@pytest.fixture() +def db_session(): + connection = engine.connect() + trans = connection.begin() + session = TestingSessionLocal(bind=connection) + + nested = connection.begin_nested() + + @event.listens_for(session, "after_transaction_end") + def _restart_savepoint(sess, transaction): + if transaction.nested and not transaction._parent.nested: + sess.connection().begin_nested() + + try: + yield session + finally: + session.close() + nested.rollback() + trans.rollback() + connection.close() + + +@pytest.fixture() +def client(db_session): + def override_get_db(): + try: + yield db_session + finally: + pass + + app.dependency_overrides[get_db] = override_get_db + # Use localhost as base_url so TrustedHostMiddleware allows requests + # with TestClient(app) as c: + with TestClient(app, base_url="http://localhost") as c: + yield c + app.dependency_overrides.clear() + + +common_data = { + "category_id": None, + "user_id": None, + "expense_id": None, +} + + +def test_health(client): + r = client.get("/health") + assert r.status_code == 200 + assert r.json()["status"] == "healthy" + + +def test_user_flow(client): + user_data = { + "username": "testuser", + "email": "testuser@example.com", + "role": "regular", + "password_hash": "x" * 60 + } + r = client.post("/users/", json=user_data) + assert r.status_code == 201 + user = r.json() + assert user["username"] == "testuser" + assert user["email"] == "testuser@example.com" + assert user["role"] == "regular" + common_data["user_id"] = user["user_id"] + + +def test_category_crud(client): + # Create + r = client.post("/categories/", json={"category_name": "Food & Dining"}) + assert r.status_code == 201 + category = r.json() + assert category["category_name"] == "Food & Dining" + common_data["category_id"] = category["category_id"] + + # List + r = client.get("/categories/") + assert r.status_code == 200 + names = [c["category_name"] for c in r.json()] + assert "Food & Dining" in names + + # Update + r = client.put( + f"/categories/{common_data['category_id']}", + json={"category_name": "Updated Food & Dining"}, + ) + assert r.status_code == 200 + assert r.json()["category_name"] == "Updated Food & Dining" + + +def test_expenses_flow(client): + # Ensure user and category exist + test_user_flow(client) + test_category_crud(client) + + # Create expense + r = client.post( + "/expenses/", + json={ + "item": "Lunch at Chipotle", + "vendor": "Chipotle", + "price": 25.50, + "date_purchased": date.today().isoformat(), + "user_id": common_data["user_id"], + "payment_method": "credit card", + "notes": "Delicious burrito bowl", + }, + ) + assert r.status_code == 201 + expense = r.json() + assert expense["item"] == "Lunch at Chipotle" + assert expense["vendor"] == "Chipotle" + assert expense["price"] == 25.50 + assert expense["date_purchased"] == date.today().isoformat() + assert expense["user_id"] == common_data["user_id"] + common_data["expense_id"] = expense["expense_id"] + + # Link expense to category + r = client.post( + f"/expenses/{common_data['expense_id']}/categories/{common_data['category_id']}" + ) + assert r.status_code == 200 + assert r.json()["category_id"] == common_data["category_id"] + + # List expenses + r = client.get("/expenses/") + assert r.status_code == 200 + assert isinstance(r.json(), list) + assert len(r.json()) > 0 + + +def test_analytics(client): + r = client.get("/analytics/total") + assert r.status_code == 200 + total = r.json() + assert "total" in total + assert isinstance(total["total"], float) + + r = client.get("/analytics/by-category") + assert r.status_code == 200 + data = r.json() + assert isinstance(data, list) + if data: + first = data[0] + assert "category" in first + assert "total" in first + assert isinstance(first["total"], float) + assert isinstance(first["category"], str) diff --git a/compose.test.yaml b/compose.test.yaml new file mode 100644 index 0000000..2825467 --- /dev/null +++ b/compose.test.yaml @@ -0,0 +1,17 @@ +name: "test-expense-tracking" +services: + db: + image: postgres:17-alpine + restart: always + environment: + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + POSTGRES_DB: test_expenses_db + ports: + - "5433:5432" + healthcheck: + test: ["CMD-SHELL", "pg_isready -d test_expenses_db -U postgres"] + interval: 1s + timeout: 5s + retries: 10 + volumes: + - ./scripts/sql/:/docker-entrypoint-initdb.d/ diff --git a/frontend/package.json b/frontend/package.json index 7c3b201..2c89c9e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -10,6 +10,7 @@ "preview": "vite preview", "test": "vitest", "test:ui": "vitest --ui", + "test:ui:coverage": "vitest --ui --coverage", "test:coverage": "vitest --coverage", "test:watch": "vitest --watch" },