diff --git a/.gitignore b/.gitignore index 36b13f1..10b84f4 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,3 @@ -# ---> Python # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] @@ -21,15 +20,11 @@ parts/ sdist/ var/ wheels/ -share/python-wheels/ *.egg-info/ .installed.cfg *.egg -MANIFEST # PyInstaller -# Usually these files are written by a python script from a template -# before PyInstaller builds the exe, so as to inject date/other infos into it. *.manifest *.spec @@ -47,87 +42,12 @@ htmlcov/ nosetests.xml coverage.xml *.cover -*.py,cover -.hypothesis/ -.pytest_cache/ -cover/ +*.pytest_cache/ # Translations *.mo *.pot -# Django stuff: -*.log -local_settings.py -db.sqlite3 -db.sqlite3-journal - -# Flask stuff: -instance/ -.webassets-cache - -# Scrapy stuff: -.scrapy - -# Sphinx documentation -docs/_build/ - -# PyBuilder -.pybuilder/ -target/ - -# Jupyter Notebook -.ipynb_checkpoints - -# IPython -profile_default/ -ipython_config.py - -# pyenv -# For a library or package, you might want to ignore these files since the code is -# intended to run in multiple environments; otherwise, check them in: -# .python-version - -# pipenv -# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. -# However, in case of collaboration, if having platform-specific dependencies or dependencies -# having no cross-platform support, pipenv may install dependencies that don't work, or not -# install all needed dependencies. -#Pipfile.lock - -# UV -# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. -# This is especially recommended for binary packages to ensure reproducibility, and is more -# commonly ignored for libraries. -#uv.lock - -# poetry -# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. -# This is especially recommended for binary packages to ensure reproducibility, and is more -# commonly ignored for libraries. -# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control -#poetry.lock - -# pdm -# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. -#pdm.lock -# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it -# in version control. -# https://pdm.fming.dev/latest/usage/project/#working-with-version-control -.pdm.toml -.pdm-python -.pdm-build/ - -# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm -__pypackages__/ - -# Celery stuff -celerybeat-schedule -celerybeat.pid - -# SageMath parsed files -*.sage.py - # Environments .env .venv @@ -137,40 +57,17 @@ ENV/ env.bak/ venv.bak/ -# Spyder project settings -.spyderproject -.spyproject +# IDE +.idea/ +.vscode/ +*.swp +*.swo +*~ -# Rope project settings -.ropeproject - -# mkdocs documentation -/site - -# mypy -.mypy_cache/ -.dmypy.json -dmypy.json - -# Pyre type checker -.pyre/ - -# pytype static type analyzer -.pytype/ - -# Cython debug symbols -cython_debug/ - -# PyCharm -# JetBrains specific template is maintained in a separate JetBrains.gitignore that can -# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore -# and can be added to the global gitignore or merged into this file. For a more nuclear -# option (not recommended) you can uncomment the following to ignore the entire idea folder. -#.idea/ - -# Ruff stuff: -.ruff_cache/ - -# PyPI configuration file -.pypirc +# Database +*.db +*.sqlite +*.sqlite3 +# Alembic +alembic/versions/*.pyc diff --git a/README.md b/README.md index 690a022..7873372 100644 --- a/README.md +++ b/README.md @@ -1,151 +1,62 @@ -# SocialPhoto - Instagram Clone +# Instagram Clone API -A simple Instagram clone API built with FastAPI and SQLite. - -## Quick Start - -### 1. Install dependencies - -```bash -pip install -r requirements.txt -``` - -### 2. Run the server - -```bash -cd app -uvicorn main:app --reload --host 0.0.0.0 --port 8000 -``` - -Or from the root directory: - -```bash -uvicorn app.main:app --reload --host 0.0.0.0 --port 8000 -``` - -### 3. Access API docs - -Open your browser to: http://localhost:8000/docs - -## API Endpoints - -### Authentication - -```bash -# Register -curl -X POST http://localhost:8000/auth/register \ - -H "Content-Type: application/json" \ - -d '{"username":"test","email":"test@test.com","password":"password123"}' - -# Login -curl -X POST http://localhost:8000/auth/login \ - -H "Content-Type: application/json" \ - -d '{"username":"test","password":"password123"}' -``` - -### Posts - -```bash -# Create post (requires auth token) -curl -X POST http://localhost:8000/posts \ - -H "Authorization: Bearer YOUR_TOKEN" \ - -F "caption=My first post" \ - -F "image=@photo.jpg" - -# Get all posts -curl http://localhost:8000/posts - -# Get single post -curl http://localhost:8000/posts/1 - -# Like a post -curl -X POST http://localhost:8000/posts/1/like \ - -H "Authorization: Bearer YOUR_TOKEN" - -# Delete post (owner only) -curl -X DELETE http://localhost:8000/posts/1 \ - -H "Authorization: Bearer YOUR_TOKEN" -``` - -### Users - -```bash -# Get user profile -curl http://localhost:8000/users/1 - -# Get user posts -curl http://localhost:8000/users/1/posts - -# Get user stats -curl http://localhost:8000/users/1/stats - -# Follow user -curl -X POST http://localhost:8000/users/1/follow \ - -H "Authorization: Bearer YOUR_TOKEN" - -# Unfollow user -curl -X DELETE http://localhost:8000/users/1/follow \ - -H "Authorization: Bearer YOUR_TOKEN" -``` - -### Comments - -```bash -# Add comment -curl -X POST http://localhost:8000/posts/1/comments \ - -H "Authorization: Bearer YOUR_TOKEN" \ - -H "Content-Type: application/json" \ - -d '{"content":"Great post!"}' - -# Get comments -curl http://localhost:8000/posts/1/comments - -# Delete comment (owner only) -curl -X DELETE http://localhost:8000/comments/1 \ - -H "Authorization: Bearer YOUR_TOKEN" -``` - -### Feed - -```bash -# Get followed users feed (requires auth) -curl http://localhost:8000/feed \ - -H "Authorization: Bearer YOUR_TOKEN" - -# Get global feed -curl http://localhost:8000/feed/global -``` - -## Running Tests - -```bash -pytest tests/ -v -``` - -## Project Structure - -``` -app/ -├── main.py # FastAPI application -├── database.py # SQLite database setup -├── models.py # Pydantic models -├── auth.py # JWT authentication -└── routes/ - ├── auth.py # Auth endpoints - ├── users.py # User endpoints - ├── posts.py # Post endpoints - ├── comments.py # Comment endpoints - └── feed.py # Feed endpoints -``` +A FastAPI-based Instagram clone API with SQLAlchemy 2.0 and Alembic migrations. ## Tech Stack - **Framework**: FastAPI 0.109+ -- **Database**: SQLite -- **Auth**: JWT (python-jose) -- **Password Hashing**: bcrypt -- **Testing**: pytest +- **ORM**: SQLAlchemy 2.0 +- **Database**: SQLite (dev), PostgreSQL (prod) +- **Migrations**: Alembic +- **Testing**: pytest + httpx -## License +## Project Structure -MIT +``` +instagram-clone/ +├── app/ +│ ├── api/ +│ │ ├── endpoints/ # API route handlers +│ │ └── dependencies/ # FastAPI dependencies +│ ├── core/ # Configuration and settings +│ ├── db/ # Database connection and models +│ ├── models/ # SQLAlchemy models +│ ├── schemas/ # Pydantic schemas +│ ├── services/ # Business logic +│ └── utils/ # Utility functions +├── alembic/ # Database migrations +├── tests/ +│ ├── unit/ # Unit tests +│ └── integration/ # Integration tests +├── pyproject.toml +└── alembic.ini +``` + +## Setup + +```bash +# Install dependencies +pip install -e ".[dev]" + +# Run database migrations +alembic upgrade head + +# Run the application +uvicorn app.main:app --reload +``` + +## Testing + +```bash +# Run all tests +pytest + +# Run with coverage +pytest --cov=app tests/ +``` + +## API Documentation + +Once running, visit: +- Swagger UI: http://localhost:8000/docs +- ReDoc: http://localhost:8000/redoc diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000..7f8e35e --- /dev/null +++ b/alembic.ini @@ -0,0 +1,42 @@ +# Alembic configuration file + +[alembic] +script_location = alembic +prepend_sys_path = . +sqlalchemy.url = sqlite:///./instagram_clone.db + +[post_write_hooks] + +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/alembic/env.py b/alembic/env.py new file mode 100644 index 0000000..196e320 --- /dev/null +++ b/alembic/env.py @@ -0,0 +1,61 @@ +"""Alembic environment configuration.""" + +from logging.config import fileConfig +from sqlalchemy import engine_from_config, pool +from alembic import context +import sys +from pathlib import Path + +# Add app to path +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from app.db.database import Base +from app.core.config import settings + +# this is the Alembic Config object +config = context.config + +# Set sqlalchemy.url from settings +config.set_main_option("sqlalchemy.url", settings.DATABASE_URL) + +# Interpret the config file for Python logging +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +# Model metadata +target_metadata = Base.metadata + + +def run_migrations_offline() -> None: + """Run migrations in 'offline' mode.""" + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online() -> None: + """Run migrations in 'online' mode.""" + connectable = engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + + with connectable.connect() as connection: + context.configure(connection=connection, target_metadata=target_metadata) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/alembic/script.py.mako b/alembic/script.py.mako new file mode 100644 index 0000000..04bdcae --- /dev/null +++ b/alembic/script.py.mako @@ -0,0 +1,25 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from typing import Sequence, Union +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision: str = ${repr(up_revision)} +down_revision: Union[str, None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} diff --git a/app/__init__.py b/app/__init__.py index 31bf417..18b665e 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -1 +1 @@ -"""SocialPhoto - Instagram Clone API.""" +"""Application package.""" diff --git a/app/api/__init__.py b/app/api/__init__.py new file mode 100644 index 0000000..dff53e5 --- /dev/null +++ b/app/api/__init__.py @@ -0,0 +1 @@ +"""API package.""" diff --git a/app/api/dependencies/__init__.py b/app/api/dependencies/__init__.py new file mode 100644 index 0000000..ae3434b --- /dev/null +++ b/app/api/dependencies/__init__.py @@ -0,0 +1 @@ +"""Dependencies package.""" diff --git a/app/api/endpoints/__init__.py b/app/api/endpoints/__init__.py new file mode 100644 index 0000000..0ea0a1f --- /dev/null +++ b/app/api/endpoints/__init__.py @@ -0,0 +1 @@ +"""Endpoints package.""" diff --git a/app/core/__init__.py b/app/core/__init__.py new file mode 100644 index 0000000..384bb6e --- /dev/null +++ b/app/core/__init__.py @@ -0,0 +1 @@ +"""Core module package.""" diff --git a/app/core/config.py b/app/core/config.py new file mode 100644 index 0000000..d63b5c5 --- /dev/null +++ b/app/core/config.py @@ -0,0 +1,26 @@ +"""Application configuration settings.""" + +from pydantic_settings import BaseSettings +from typing import Optional + + +class Settings(BaseSettings): + """Application settings loaded from environment variables.""" + + # Application + APP_NAME: str = "Instagram Clone" + APP_VERSION: str = "0.1.0" + DEBUG: bool = False + + # Database + DATABASE_URL: str = "sqlite:///./instagram_clone.db" + + # Security + SECRET_KEY: Optional[str] = None + + class Config: + env_file = ".env" + case_sensitive = True + + +settings = Settings() diff --git a/app/db/__init__.py b/app/db/__init__.py new file mode 100644 index 0000000..f73ec37 --- /dev/null +++ b/app/db/__init__.py @@ -0,0 +1 @@ +"""Database module package.""" diff --git a/app/db/database.py b/app/db/database.py new file mode 100644 index 0000000..a70739e --- /dev/null +++ b/app/db/database.py @@ -0,0 +1,28 @@ +"""Database connection and session management.""" + +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker, DeclarativeBase +from app.core.config import settings + +# Create engine +engine = create_engine( + settings.DATABASE_URL, + connect_args={"check_same_thread": False} if "sqlite" in settings.DATABASE_URL else {}, +) + +# Create session factory +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + + +class Base(DeclarativeBase): + """SQLAlchemy declarative base class.""" + pass + + +def get_db(): + """Dependency to get database session.""" + db = SessionLocal() + try: + yield db + finally: + db.close() diff --git a/app/main.py b/app/main.py index 1d2221f..7941e45 100644 --- a/app/main.py +++ b/app/main.py @@ -1,21 +1,15 @@ -"""SocialPhoto - Instagram Clone API. - -A simple social media API for sharing images with likes, comments, and user follows. -""" -from pathlib import Path +"""FastAPI application entry point.""" from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware -from fastapi.staticfiles import StaticFiles - -from app.database import init_db -from app.routes import auth, comments, feed, posts, users +from app.core.config import settings +from app.db.database import engine, Base # Create FastAPI app app = FastAPI( - title="SocialPhoto", - description="Instagram Clone API - Share images with likes, comments, and follows", - version="1.0.0", + title=settings.APP_NAME, + version=settings.APP_VERSION, + debug=settings.DEBUG, ) # CORS middleware @@ -27,43 +21,14 @@ app.add_middleware( allow_headers=["*"], ) -# Mount uploads directory -UPLOAD_DIR = Path(__file__).parent.parent / "uploads" -UPLOAD_DIR.mkdir(parents=True, exist_ok=True) -app.mount("/uploads", StaticFiles(directory=str(UPLOAD_DIR)), name="uploads") - -@app.on_event("startup") -async def startup_event(): - """Initialize database on startup.""" - init_db() - - -# Include routers -app.include_router(auth.router) -app.include_router(users.router) -app.include_router(posts.router) -app.include_router(comments.router) -app.include_router(feed.router) - - -@app.get("/", tags=["Root"]) +@app.get("/") async def root(): """Root endpoint.""" - return { - "name": "SocialPhoto", - "version": "1.0.0", - "description": "Instagram Clone API", - } + return {"message": f"Welcome to {settings.APP_NAME}", "version": settings.APP_VERSION} -@app.get("/health", tags=["Health"]) +@app.get("/health") async def health_check(): """Health check endpoint.""" return {"status": "healthy"} - - -if __name__ == "__main__": - import uvicorn - - uvicorn.run(app, host="0.0.0.0", port=8000) diff --git a/app/models/__init__.py b/app/models/__init__.py new file mode 100644 index 0000000..53f3a7c --- /dev/null +++ b/app/models/__init__.py @@ -0,0 +1 @@ +"""Models package.""" diff --git a/app/schemas/__init__.py b/app/schemas/__init__.py new file mode 100644 index 0000000..3a8b2f5 --- /dev/null +++ b/app/schemas/__init__.py @@ -0,0 +1 @@ +"""Schemas package.""" diff --git a/app/services/__init__.py b/app/services/__init__.py new file mode 100644 index 0000000..c7775ec --- /dev/null +++ b/app/services/__init__.py @@ -0,0 +1 @@ +"""Services package.""" diff --git a/app/utils/__init__.py b/app/utils/__init__.py new file mode 100644 index 0000000..f9a8320 --- /dev/null +++ b/app/utils/__init__.py @@ -0,0 +1 @@ +"""Utils package.""" diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..6d4fef3 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,44 @@ +[project] +name = "instagram-clone" +version = "0.1.0" +description = "Instagram Clone API" +requires-python = ">=3.11" +dependencies = [ + "fastapi>=0.109.0", + "uvicorn[standard]>=0.27.0", + "sqlalchemy>=2.0.0", + "alembic>=1.13.0", + "pydantic>=2.5.0", + "pydantic-settings>=2.1.0", + "python-multipart>=0.0.6", + "python-jose[cryptography]>=3.3.0", + "passlib[bcrypt]>=1.7.4", + "psycopg2-binary>=2.9.9", +] + +[project.optional-dependencies] +dev = [ + "pytest>=7.4.0", + "pytest-asyncio>=0.23.0", + "httpx>=0.26.0", + "black>=24.0.0", + "ruff>=0.1.0", + "mypy>=1.8.0", +] + +[build-system] +requires = ["setuptools>=68.0.0", "wheel"] +build-backend = "setuptools.build_meta" + +[tool.black] +line-length = 88 +target-version = ['py311'] + +[tool.ruff] +line-length = 88 +select = ["E", "F", "I", "N", "W"] +fixable = ["ALL"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +asyncio_mode = "auto" diff --git a/tests/__init__.py b/tests/__init__.py index f54ecb3..46816dd 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -1 +1 @@ -"""Tests package for SocialPhoto.""" +"""Tests package.""" diff --git a/tests/conftest.py b/tests/conftest.py index e80f1a0..7fb5338 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,129 +1,49 @@ -"""Pytest fixtures for SocialPhoto tests.""" -import os -import sqlite3 -from pathlib import Path -from typing import Generator +"""Pytest configuration and fixtures.""" import pytest from fastapi.testclient import TestClient +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker +from sqlalchemy.pool import StaticPool -# Set test database path before importing app -TEST_DB = Path(__file__).parent / "test_socialphoto.db" +from app.main import app +from app.db.database import Base, get_db -def override_db_path(): - """Override database path for tests.""" - return TEST_DB +# Create in-memory SQLite database for testing +SQLALCHEMY_DATABASE_URL = "sqlite:///:memory:" + +engine = create_engine( + SQLALCHEMY_DATABASE_URL, + connect_args={"check_same_thread": False}, + poolclass=StaticPool, +) +TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) @pytest.fixture(scope="function") -def db() -> Generator[sqlite3.Connection, None, None]: +def db_session(): """Create a fresh database for each test.""" - # Remove existing test database - if TEST_DB.exists(): - TEST_DB.unlink() - - # Patch the database path before importing - import app.database as db_module - original_path = db_module.DATABASE_PATH - db_module.DATABASE_PATH = TEST_DB - - # Initialize database with new path - db_module.init_db() - - conn = db_module.get_db_connection() - conn.row_factory = sqlite3.Row - - yield conn - - conn.close() - db_module.DATABASE_PATH = original_path - if TEST_DB.exists(): - TEST_DB.unlink() + Base.metadata.create_all(bind=engine) + db = TestingSessionLocal() + try: + yield db + finally: + db.close() + Base.metadata.drop_all(bind=engine) @pytest.fixture(scope="function") -def client(db: sqlite3.Connection) -> Generator[TestClient, None, None]: - """Create a test client.""" - from app.main import app - from app.database import get_db +def client(db_session): + """Create a test client with fresh database.""" - # Override database dependency def override_get_db(): - yield db + try: + yield db_session + finally: + pass app.dependency_overrides[get_db] = override_get_db - with TestClient(app) as test_client: yield test_client - app.dependency_overrides.clear() - - -def get_user_id_from_token(token: str) -> int: - """Extract user ID from JWT token (for test fixtures only).""" - from jose import jwt - from app.auth import SECRET_KEY, ALGORITHM - payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) - return int(payload["sub"]) - - -@pytest.fixture -def registered_user(client: TestClient) -> dict: - """Register a test user and return user data with token.""" - response = client.post( - "/auth/register", - json={ - "username": "testuser", - "email": "test@example.com", - "password": "testpass123", - }, - ) - assert response.status_code == 201 - data = response.json() - token = data["access_token"] - user_id = get_user_id_from_token(token) - return { - "id": user_id, - "username": "testuser", - "email": "test@example.com", - "password": "testpass123", - "token": token, - } - - -@pytest.fixture -def two_users(client: TestClient) -> dict: - """Create two test users.""" - # User 1 - response = client.post( - "/auth/register", - json={ - "username": "user1", - "email": "user1@example.com", - "password": "password123", - }, - ) - assert response.status_code == 201 - token1 = response.json()["access_token"] - - # User 2 - response = client.post( - "/auth/register", - json={ - "username": "user2", - "email": "user2@example.com", - "password": "password123", - }, - ) - assert response.status_code == 201 - token2 = response.json()["access_token"] - - return { - "user1_id": get_user_id_from_token(token1), - "user2_id": get_user_id_from_token(token2), - "user1_token": token1, - "user2_token": token2, - "user1_username": "user1", - "user2_username": "user2", - } diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py new file mode 100644 index 0000000..c66cd71 --- /dev/null +++ b/tests/integration/__init__.py @@ -0,0 +1 @@ +"""Integration tests package.""" diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py new file mode 100644 index 0000000..ea3f8b9 --- /dev/null +++ b/tests/unit/__init__.py @@ -0,0 +1 @@ +"""Unit tests package.""" diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py new file mode 100644 index 0000000..690301b --- /dev/null +++ b/tests/unit/test_config.py @@ -0,0 +1,18 @@ +"""Unit tests for core configuration.""" + +import pytest +from app.core.config import Settings + + +def test_settings_default_values(): + """Test default settings values.""" + settings = Settings() + assert settings.APP_NAME == "Instagram Clone" + assert settings.APP_VERSION == "0.1.0" + assert settings.DEBUG is False + + +def test_settings_database_url_default(): + """Test default database URL.""" + settings = Settings() + assert "sqlite" in settings.DATABASE_URL diff --git a/tests/unit/test_database.py b/tests/unit/test_database.py new file mode 100644 index 0000000..4e5fde0 --- /dev/null +++ b/tests/unit/test_database.py @@ -0,0 +1,17 @@ +"""Unit tests for database module.""" + +import pytest +from app.db.database import Base, get_db + + +def test_base_declarative(): + """Test Base is a DeclarativeBase.""" + from sqlalchemy.orm import DeclarativeBase + assert issubclass(Base, DeclarativeBase) + + +def test_get_db_yields_session(db_session): + """Test get_db dependency yields database session.""" + gen = get_db() + db = next(gen) + assert db is not None diff --git a/tests/unit/test_main.py b/tests/unit/test_main.py new file mode 100644 index 0000000..b98746e --- /dev/null +++ b/tests/unit/test_main.py @@ -0,0 +1,19 @@ +"""Unit tests for main application endpoints.""" + +import pytest + + +def test_root_endpoint(client): + """Test root endpoint returns welcome message.""" + response = client.get("/") + assert response.status_code == 200 + data = response.json() + assert "message" in data + assert "version" in data + + +def test_health_check_endpoint(client): + """Test health check endpoint.""" + response = client.get("/health") + assert response.status_code == 200 + assert response.json() == {"status": "healthy"}