TASK-001: Setup FastAPI project structure
This commit is contained in:
129
.gitignore
vendored
129
.gitignore
vendored
@@ -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
|
||||
|
||||
199
README.md
199
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
|
||||
|
||||
42
alembic.ini
Normal file
42
alembic.ini
Normal file
@@ -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
|
||||
61
alembic/env.py
Normal file
61
alembic/env.py
Normal file
@@ -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()
|
||||
25
alembic/script.py.mako
Normal file
25
alembic/script.py.mako
Normal file
@@ -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"}
|
||||
@@ -1 +1 @@
|
||||
"""SocialPhoto - Instagram Clone API."""
|
||||
"""Application package."""
|
||||
|
||||
1
app/api/__init__.py
Normal file
1
app/api/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""API package."""
|
||||
1
app/api/dependencies/__init__.py
Normal file
1
app/api/dependencies/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Dependencies package."""
|
||||
1
app/api/endpoints/__init__.py
Normal file
1
app/api/endpoints/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Endpoints package."""
|
||||
1
app/core/__init__.py
Normal file
1
app/core/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Core module package."""
|
||||
26
app/core/config.py
Normal file
26
app/core/config.py
Normal file
@@ -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()
|
||||
1
app/db/__init__.py
Normal file
1
app/db/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Database module package."""
|
||||
28
app/db/database.py
Normal file
28
app/db/database.py
Normal file
@@ -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()
|
||||
53
app/main.py
53
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)
|
||||
|
||||
1
app/models/__init__.py
Normal file
1
app/models/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Models package."""
|
||||
1
app/schemas/__init__.py
Normal file
1
app/schemas/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Schemas package."""
|
||||
1
app/services/__init__.py
Normal file
1
app/services/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Services package."""
|
||||
1
app/utils/__init__.py
Normal file
1
app/utils/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Utils package."""
|
||||
44
pyproject.toml
Normal file
44
pyproject.toml
Normal file
@@ -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"
|
||||
@@ -1 +1 @@
|
||||
"""Tests package for SocialPhoto."""
|
||||
"""Tests package."""
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
1
tests/integration/__init__.py
Normal file
1
tests/integration/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Integration tests package."""
|
||||
1
tests/unit/__init__.py
Normal file
1
tests/unit/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Unit tests package."""
|
||||
18
tests/unit/test_config.py
Normal file
18
tests/unit/test_config.py
Normal file
@@ -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
|
||||
17
tests/unit/test_database.py
Normal file
17
tests/unit/test_database.py
Normal file
@@ -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
|
||||
19
tests/unit/test_main.py
Normal file
19
tests/unit/test_main.py
Normal file
@@ -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"}
|
||||
Reference in New Issue
Block a user