Spaces:
Running
Running
Commit
·
69be42f
1
Parent(s):
c915a10
adding
Browse files- .dockerignore +79 -0
- .env.example +11 -0
- .gitignore +52 -0
- .python-version +1 -0
- CLAUDE.md +128 -0
- Dockerfile +30 -0
- README.md +126 -1
- api/__init__.py +0 -0
- api/auth.py +384 -0
- api/deps.py +89 -0
- api/tasks.py +240 -0
- core/__init__.py +0 -0
- core/config.py +47 -0
- core/database.py +49 -0
- core/deps.py +101 -0
- core/middleware.py +95 -0
- core/security.py +147 -0
- main.py +124 -0
- migrations/001_add_user_id_index.sql +2 -0
- migrations/run_migration.py +79 -0
- migrations/verify_schema.py +134 -0
- models/__init__.py +0 -0
- models/task.py +67 -0
- models/user.py +53 -0
- pyproject.toml +32 -0
- requirements.txt +11 -0
- scripts/init_db.py +31 -0
- scripts/migrate_to_new_auth.py +114 -0
- src/backend/__init__.py +2 -0
- src/backend/py.typed +0 -0
- tests/__init__.py +0 -0
- tests/conftest.py +84 -0
- tests/test_api_tasks.py +425 -0
.dockerignore
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Python
|
| 2 |
+
__pycache__/
|
| 3 |
+
*.py[cod]
|
| 4 |
+
*$py.class
|
| 5 |
+
*.so
|
| 6 |
+
.Python
|
| 7 |
+
build/
|
| 8 |
+
develop-eggs/
|
| 9 |
+
dist/
|
| 10 |
+
downloads/
|
| 11 |
+
eggs/
|
| 12 |
+
.eggs/
|
| 13 |
+
lib/
|
| 14 |
+
lib64/
|
| 15 |
+
parts/
|
| 16 |
+
sdist/
|
| 17 |
+
var/
|
| 18 |
+
wheels/
|
| 19 |
+
*.egg-info/
|
| 20 |
+
.installed.cfg
|
| 21 |
+
*.egg
|
| 22 |
+
|
| 23 |
+
# Virtual Environment
|
| 24 |
+
venv/
|
| 25 |
+
env/
|
| 26 |
+
ENV/
|
| 27 |
+
.venv
|
| 28 |
+
|
| 29 |
+
# IDE
|
| 30 |
+
.vscode/
|
| 31 |
+
.idea/
|
| 32 |
+
*.swp
|
| 33 |
+
*.swo
|
| 34 |
+
*~
|
| 35 |
+
|
| 36 |
+
# Testing
|
| 37 |
+
.pytest_cache/
|
| 38 |
+
.coverage
|
| 39 |
+
htmlcov/
|
| 40 |
+
.tox/
|
| 41 |
+
|
| 42 |
+
# Documentation
|
| 43 |
+
docs/_build/
|
| 44 |
+
|
| 45 |
+
# Git
|
| 46 |
+
.git/
|
| 47 |
+
.gitignore
|
| 48 |
+
.gitattributes
|
| 49 |
+
|
| 50 |
+
# UV
|
| 51 |
+
uv.lock
|
| 52 |
+
.python-version
|
| 53 |
+
|
| 54 |
+
# Environment
|
| 55 |
+
.env
|
| 56 |
+
.env.local
|
| 57 |
+
.env.*.local
|
| 58 |
+
|
| 59 |
+
# Tests
|
| 60 |
+
tests/
|
| 61 |
+
*.test.py
|
| 62 |
+
|
| 63 |
+
# CI/CD
|
| 64 |
+
.github/
|
| 65 |
+
.gitlab-ci.yml
|
| 66 |
+
|
| 67 |
+
# Memory
|
| 68 |
+
.memory/
|
| 69 |
+
|
| 70 |
+
# Scripts (not needed in production)
|
| 71 |
+
scripts/
|
| 72 |
+
|
| 73 |
+
# Specs
|
| 74 |
+
specs/
|
| 75 |
+
|
| 76 |
+
# Project files (not needed in container)
|
| 77 |
+
CLAUDE.md
|
| 78 |
+
plan.md
|
| 79 |
+
research.md
|
.env.example
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Database
|
| 2 |
+
DATABASE_URL=postgresql://user:password@host/database
|
| 3 |
+
|
| 4 |
+
# JWT Secret (generate a secure random string)
|
| 5 |
+
JWT_SECRET=your-super-secret-jwt-key-here-change-this
|
| 6 |
+
|
| 7 |
+
# CORS Frontend URL
|
| 8 |
+
FRONTEND_URL=http://localhost:3000
|
| 9 |
+
|
| 10 |
+
# Environment
|
| 11 |
+
ENVIRONMENT=development
|
.gitignore
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Environment variables
|
| 2 |
+
.env
|
| 3 |
+
.env.local
|
| 4 |
+
.env.*.local
|
| 5 |
+
|
| 6 |
+
# Python
|
| 7 |
+
__pycache__/
|
| 8 |
+
*.py[cod]
|
| 9 |
+
*$py.class
|
| 10 |
+
*.so
|
| 11 |
+
.Python
|
| 12 |
+
build/
|
| 13 |
+
develop-eggs/
|
| 14 |
+
dist/
|
| 15 |
+
downloads/
|
| 16 |
+
eggs/
|
| 17 |
+
.eggs/
|
| 18 |
+
lib/
|
| 19 |
+
lib64/
|
| 20 |
+
parts/
|
| 21 |
+
sdist/
|
| 22 |
+
var/
|
| 23 |
+
wheels/
|
| 24 |
+
*.egg-info/
|
| 25 |
+
.installed.cfg
|
| 26 |
+
*.egg
|
| 27 |
+
|
| 28 |
+
# Virtual environments
|
| 29 |
+
venv/
|
| 30 |
+
ENV/
|
| 31 |
+
env/
|
| 32 |
+
.venv
|
| 33 |
+
|
| 34 |
+
# Testing
|
| 35 |
+
.pytest_cache/
|
| 36 |
+
.coverage
|
| 37 |
+
htmlcov/
|
| 38 |
+
.tox/
|
| 39 |
+
|
| 40 |
+
# IDEs
|
| 41 |
+
.vscode/
|
| 42 |
+
.idea/
|
| 43 |
+
*.swp
|
| 44 |
+
*.swo
|
| 45 |
+
*~
|
| 46 |
+
|
| 47 |
+
# OS
|
| 48 |
+
.DS_Store
|
| 49 |
+
Thumbs.db
|
| 50 |
+
|
| 51 |
+
# Logs
|
| 52 |
+
*.log
|
.python-version
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
3.13
|
CLAUDE.md
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Backend Development Guidelines
|
| 2 |
+
|
| 3 |
+
## Project Overview
|
| 4 |
+
|
| 5 |
+
This directory contains the backend API for the Todo List application, built with Python FastAPI and SQLModel.
|
| 6 |
+
|
| 7 |
+
## Technology Stack
|
| 8 |
+
|
| 9 |
+
- **Language**: Python 3.13+
|
| 10 |
+
- **Web Framework**: FastAPI (modern, high-performance web framework for building APIs)
|
| 11 |
+
- **ORM**: SQLModel (combines SQLAlchemy and Pydantic for database interactions and validation)
|
| 12 |
+
- **Database**: Neon Serverless PostgreSQL
|
| 13 |
+
- **Package Manager**: UV
|
| 14 |
+
|
| 15 |
+
## Project Structure
|
| 16 |
+
|
| 17 |
+
```
|
| 18 |
+
backend/
|
| 19 |
+
├── src/ # Application source code
|
| 20 |
+
│ ├── models/ # SQLModel database models
|
| 21 |
+
│ ├── api/ # API route handlers
|
| 22 |
+
│ ├── services/ # Business logic layer
|
| 23 |
+
│ ├── database.py # Database connection and session management
|
| 24 |
+
│ └── main.py # FastAPI application entry point
|
| 25 |
+
├── tests/ # Test suite
|
| 26 |
+
├── pyproject.toml # UV project configuration
|
| 27 |
+
└── CLAUDE.md # This file
|
| 28 |
+
```
|
| 29 |
+
|
| 30 |
+
## Development Commands
|
| 31 |
+
|
| 32 |
+
```bash
|
| 33 |
+
cd backend
|
| 34 |
+
|
| 35 |
+
# Install dependencies
|
| 36 |
+
uv sync
|
| 37 |
+
|
| 38 |
+
# Run development server
|
| 39 |
+
uv run python src/main.py
|
| 40 |
+
|
| 41 |
+
# Run tests
|
| 42 |
+
uv run pytest tests/
|
| 43 |
+
|
| 44 |
+
# Run with auto-reload during development
|
| 45 |
+
uv run uvicorn src.main:app --reload
|
| 46 |
+
|
| 47 |
+
# Check code quality
|
| 48 |
+
uv run ruff check .
|
| 49 |
+
```
|
| 50 |
+
|
| 51 |
+
## API Endpoints
|
| 52 |
+
|
| 53 |
+
The following REST API endpoints are implemented:
|
| 54 |
+
|
| 55 |
+
| Method | Endpoint | Description |
|
| 56 |
+
|--------|----------|-------------|
|
| 57 |
+
| GET | `/api/{user_id}/tasks` | List all tasks for a user |
|
| 58 |
+
| POST | `/api/{user_id}/tasks` | Create a new task |
|
| 59 |
+
| GET | `/api/{user_id}/tasks/{id}` | Get task details |
|
| 60 |
+
| PUT | `/api/{user_id}/tasks/{id}` | Update a task |
|
| 61 |
+
| DELETE | `/api/{user_id}/tasks/{id}` | Delete a task |
|
| 62 |
+
| PATCH | `/api/{user_id}/tasks/{id}/complete` | Toggle completion status |
|
| 63 |
+
|
| 64 |
+
## Database Models
|
| 65 |
+
|
| 66 |
+
### Task Model
|
| 67 |
+
- `id`: Unique identifier (auto-generated)
|
| 68 |
+
- `user_id`: Foreign key to user (for data segregation)
|
| 69 |
+
- `title`: Task title (required, max 255 characters)
|
| 70 |
+
- `description`: Task description (optional, max 2000 characters)
|
| 71 |
+
- `completed`: Boolean status (default: false)
|
| 72 |
+
- `created_at`: Timestamp of creation
|
| 73 |
+
- `updated_at`: Timestamp of last update
|
| 74 |
+
|
| 75 |
+
## Key Features
|
| 76 |
+
|
| 77 |
+
- **FastAPI Auto-Documentation**: Interactive API docs available at `/docs` and `/redoc`
|
| 78 |
+
- **Validation**: Automatic request/response validation via Pydantic
|
| 79 |
+
- **Async Support**: Built-in async/await for high-performance I/O
|
| 80 |
+
- **Type Safety**: Full type hints with SQLModel and Pydantic
|
| 81 |
+
- **Database Migrations**: SQLModel schema management with Alembic (if needed)
|
| 82 |
+
|
| 83 |
+
## Development Notes
|
| 84 |
+
|
| 85 |
+
- Authentication is NOT enforced in this phase (user_id is passed as path parameter)
|
| 86 |
+
- Database connection string should be provided via `DATABASE_URL` environment variable
|
| 87 |
+
- Default pagination: 50 tasks per request, maximum 100
|
| 88 |
+
- All timestamps are in UTC
|
| 89 |
+
- Use dependency injection for database sessions
|
| 90 |
+
|
| 91 |
+
## Environment Variables
|
| 92 |
+
|
| 93 |
+
```bash
|
| 94 |
+
DATABASE_URL=postgresql://user:password@host/database
|
| 95 |
+
```
|
| 96 |
+
|
| 97 |
+
## Testing Strategy
|
| 98 |
+
|
| 99 |
+
- Unit tests for business logic
|
| 100 |
+
- Integration tests for API endpoints
|
| 101 |
+
- Database tests with test fixtures
|
| 102 |
+
- Use pytest for test runner
|
| 103 |
+
- Mock external dependencies where appropriate
|
| 104 |
+
|
| 105 |
+
## Code Style
|
| 106 |
+
|
| 107 |
+
- Follow Python 3.13+ standard conventions
|
| 108 |
+
- Use type hints for all function signatures
|
| 109 |
+
- Docstrings for all public functions and classes
|
| 110 |
+
- Ruff for linting and formatting
|
| 111 |
+
|
| 112 |
+
## Performance Considerations
|
| 113 |
+
|
| 114 |
+
- Use database indexing on frequently queried fields (user_id, created_at)
|
| 115 |
+
- Implement pagination for list endpoints to prevent large result sets
|
| 116 |
+
- Use async database operations for better concurrency
|
| 117 |
+
- Connection pooling for database connections
|
| 118 |
+
|
| 119 |
+
## Documentation Resources
|
| 120 |
+
|
| 121 |
+
- [FastAPI Documentation](https://fastapi.tiangolo.com/)
|
| 122 |
+
- [SQLModel Documentation](https://sqlmodel.tiangolo.com/)
|
| 123 |
+
- [Pydantic Documentation](https://docs.pydantic.dev/)
|
| 124 |
+
|
| 125 |
+
## Related Specs
|
| 126 |
+
|
| 127 |
+
- Feature Specification: [specs/001-backend-task-api/spec.md](../specs/001-backend-task-api/spec.md)
|
| 128 |
+
- Project Constitution: [constitution.md](../.memory/constitution.md)
|
Dockerfile
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Use Python 3.11 slim image for HuggingFace Spaces
|
| 2 |
+
FROM python:3.11-slim
|
| 3 |
+
|
| 4 |
+
# Set working directory
|
| 5 |
+
WORKDIR /app
|
| 6 |
+
|
| 7 |
+
# Install system dependencies
|
| 8 |
+
RUN apt-get update && apt-get install -y \
|
| 9 |
+
libpq-dev \
|
| 10 |
+
gcc \
|
| 11 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 12 |
+
|
| 13 |
+
# Copy requirements first for better caching
|
| 14 |
+
COPY requirements.txt .
|
| 15 |
+
|
| 16 |
+
# Install Python dependencies
|
| 17 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 18 |
+
|
| 19 |
+
# Copy application code
|
| 20 |
+
COPY . .
|
| 21 |
+
|
| 22 |
+
# Expose port 7860 (default for HuggingFace Spaces)
|
| 23 |
+
EXPOSE 7860
|
| 24 |
+
|
| 25 |
+
# Set environment variables
|
| 26 |
+
ENV PYTHONUNBUFFERED=1
|
| 27 |
+
ENV PYTHONDONTWRITEBYTECODE=1
|
| 28 |
+
|
| 29 |
+
# Run the application
|
| 30 |
+
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "7860"]
|
README.md
CHANGED
|
@@ -7,4 +7,129 @@ sdk: docker
|
|
| 7 |
pinned: false
|
| 8 |
---
|
| 9 |
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
pinned: false
|
| 8 |
---
|
| 9 |
|
| 10 |
+
# Todo List Backend API
|
| 11 |
+
|
| 12 |
+
FastAPI backend for the Todo List application with JWT authentication and PostgreSQL database.
|
| 13 |
+
|
| 14 |
+
## Deployment on HuggingFace Spaces
|
| 15 |
+
|
| 16 |
+
### Prerequisites
|
| 17 |
+
- A [Neon](https://neon.tech/) PostgreSQL database account
|
| 18 |
+
- A [HuggingFace](https://huggingface.co/) account
|
| 19 |
+
|
| 20 |
+
### Setup Instructions
|
| 21 |
+
|
| 22 |
+
1. **Create a new Space on HuggingFace**
|
| 23 |
+
- Go to [huggingface.co/spaces](https://huggingface.co/spaces)
|
| 24 |
+
- Click "Create new Space"
|
| 25 |
+
- Choose "Docker" as the SDK
|
| 26 |
+
- Name your space (e.g., `todo-backend-api`)
|
| 27 |
+
- Make it public or private based on your preference
|
| 28 |
+
|
| 29 |
+
2. **Configure Environment Variables**
|
| 30 |
+
|
| 31 |
+
In your Space settings, add the following secrets:
|
| 32 |
+
|
| 33 |
+
| Variable | Description | Example |
|
| 34 |
+
|----------|-------------|---------|
|
| 35 |
+
| `DATABASE_URL` | PostgreSQL connection string | `postgresql://user:password@ep-xxx.aws.neon.tech/neondb?sslmode=require` |
|
| 36 |
+
| `JWT_SECRET` | Secret key for JWT tokens | Generate a random string: `openssl rand -hex 32` |
|
| 37 |
+
| `FRONTEND_URL` | Your frontend URL for CORS | `https://your-frontend.vercel.app` |
|
| 38 |
+
| `ENVIRONMENT` | Environment name | `production` |
|
| 39 |
+
|
| 40 |
+
**Note:** For Neon database, make sure to append `?sslmode=require` to your DATABASE_URL.
|
| 41 |
+
|
| 42 |
+
3. **Push your code to the Space**
|
| 43 |
+
|
| 44 |
+
```bash
|
| 45 |
+
git clone https://huggingface.co/spaces/YOUR_USERNAME/todo-backend-api
|
| 46 |
+
cd todo-backend-api
|
| 47 |
+
# Copy all files from this project
|
| 48 |
+
cp -r /path/to/todo-app-backend-api/* .
|
| 49 |
+
git add .
|
| 50 |
+
git commit -m "Initial deployment"
|
| 51 |
+
git push
|
| 52 |
+
```
|
| 53 |
+
|
| 54 |
+
4. **Access your API**
|
| 55 |
+
|
| 56 |
+
Your API will be available at: `https://YOUR_USERNAME-todo-backend-api.hf.space`
|
| 57 |
+
|
| 58 |
+
- API Documentation: `/docs` (Swagger UI)
|
| 59 |
+
- Alternative docs: `/redoc`
|
| 60 |
+
- Health check: `/health`
|
| 61 |
+
|
| 62 |
+
### API Endpoints
|
| 63 |
+
|
| 64 |
+
#### Authentication
|
| 65 |
+
- `POST /api/auth/register` - Register a new user
|
| 66 |
+
- `POST /api/auth/token` - Login and get JWT token
|
| 67 |
+
- `POST /api/auth/refresh` - Refresh JWT token
|
| 68 |
+
|
| 69 |
+
#### Tasks
|
| 70 |
+
- `GET /api/tasks` - List all tasks (requires authentication)
|
| 71 |
+
- `POST /api/tasks` - Create a new task (requires authentication)
|
| 72 |
+
- `GET /api/tasks/{id}` - Get task details (requires authentication)
|
| 73 |
+
- `PUT /api/tasks/{id}` - Update a task (requires authentication)
|
| 74 |
+
- `DELETE /api/tasks/{id}` - Delete a task (requires authentication)
|
| 75 |
+
- `PATCH /api/tasks/{id}/complete` - Toggle task completion (requires authentication)
|
| 76 |
+
|
| 77 |
+
#### Testing
|
| 78 |
+
Use the JWT token from `/api/auth/token` in the Authorization header:
|
| 79 |
+
```
|
| 80 |
+
Authorization: Bearer YOUR_JWT_TOKEN
|
| 81 |
+
```
|
| 82 |
+
|
| 83 |
+
### Development
|
| 84 |
+
|
| 85 |
+
```bash
|
| 86 |
+
# Install dependencies locally
|
| 87 |
+
pip install -r requirements.txt
|
| 88 |
+
|
| 89 |
+
# Run development server
|
| 90 |
+
uvicorn main:app --reload --host 0.0.0.0 --port 8000
|
| 91 |
+
|
| 92 |
+
# Run tests
|
| 93 |
+
pip install pytest
|
| 94 |
+
pytest tests/
|
| 95 |
+
```
|
| 96 |
+
|
| 97 |
+
### Technology Stack
|
| 98 |
+
|
| 99 |
+
- **FastAPI** - Modern, high-performance web framework
|
| 100 |
+
- **SQLModel** - SQLAlchemy + Pydantic for database ORM
|
| 101 |
+
- **PostgreSQL** - Neon Serverless PostgreSQL
|
| 102 |
+
- **JWT** - JSON Web Tokens for authentication
|
| 103 |
+
- **Uvicorn** - ASGI server
|
| 104 |
+
|
| 105 |
+
### Environment Variables
|
| 106 |
+
|
| 107 |
+
```bash
|
| 108 |
+
# Database (required)
|
| 109 |
+
DATABASE_URL=postgresql://user:password@host/database
|
| 110 |
+
|
| 111 |
+
# JWT Configuration (required)
|
| 112 |
+
JWT_SECRET=your-super-secret-jwt-key-min-32-chars
|
| 113 |
+
|
| 114 |
+
# CORS Settings (required)
|
| 115 |
+
FRONTEND_URL=https://your-frontend-domain.com
|
| 116 |
+
|
| 117 |
+
# Environment (optional, defaults to development)
|
| 118 |
+
ENVIRONMENT=production
|
| 119 |
+
```
|
| 120 |
+
|
| 121 |
+
### Troubleshooting
|
| 122 |
+
|
| 123 |
+
**Space fails to build:**
|
| 124 |
+
- Check the "Logs" tab in your Space
|
| 125 |
+
- Ensure all files are pushed (especially `Dockerfile` and `requirements.txt`)
|
| 126 |
+
- Verify environment variables are set correctly
|
| 127 |
+
|
| 128 |
+
**Database connection errors:**
|
| 129 |
+
- Verify DATABASE_URL includes `?sslmode=require` for Neon
|
| 130 |
+
- Check that your database allows external connections
|
| 131 |
+
- Ensure the database is active (Neon pauses inactive databases)
|
| 132 |
+
|
| 133 |
+
**CORS errors:**
|
| 134 |
+
- Make sure `FRONTEND_URL` matches your frontend domain exactly
|
| 135 |
+
- Include the protocol (http:// or https://)
|
api/__init__.py
ADDED
|
File without changes
|
api/auth.py
ADDED
|
@@ -0,0 +1,384 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Authentication API endpoints.
|
| 2 |
+
|
| 3 |
+
[Task]: T017
|
| 4 |
+
[From]: specs/001-user-auth/contracts/openapi.yaml, specs/001-user-auth/plan.md
|
| 5 |
+
"""
|
| 6 |
+
import re
|
| 7 |
+
from typing import Optional
|
| 8 |
+
from datetime import datetime, timedelta
|
| 9 |
+
|
| 10 |
+
from fastapi import APIRouter, Depends, HTTPException, status, Cookie
|
| 11 |
+
from fastapi.responses import JSONResponse, Response
|
| 12 |
+
from sqlmodel import Session, select
|
| 13 |
+
|
| 14 |
+
from models.user import User, UserCreate, UserRead, UserLogin
|
| 15 |
+
from core.database import get_session
|
| 16 |
+
from core.security import get_password_hash, verify_password, create_access_token, decode_access_token
|
| 17 |
+
from core.config import get_settings
|
| 18 |
+
from api.deps import get_current_user
|
| 19 |
+
|
| 20 |
+
settings = get_settings()
|
| 21 |
+
|
| 22 |
+
router = APIRouter(prefix="/api/auth", tags=["Authentication"])
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def validate_email_format(email: str) -> bool:
|
| 26 |
+
"""Validate email format.
|
| 27 |
+
|
| 28 |
+
Check for @ symbol and domain part.
|
| 29 |
+
|
| 30 |
+
[Task]: T019
|
| 31 |
+
[From]: specs/001-user-auth/spec.md
|
| 32 |
+
|
| 33 |
+
Args:
|
| 34 |
+
email: Email address to validate
|
| 35 |
+
|
| 36 |
+
Returns:
|
| 37 |
+
True if email format is valid, False otherwise
|
| 38 |
+
"""
|
| 39 |
+
if not email:
|
| 40 |
+
return False
|
| 41 |
+
|
| 42 |
+
# Basic email validation: must contain @ and at least one . after @
|
| 43 |
+
pattern = r'^[^@]+@[^@]+\.[^@]+$'
|
| 44 |
+
return re.match(pattern, email) is not None
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def validate_password(password: str) -> bool:
|
| 48 |
+
"""Validate password length.
|
| 49 |
+
|
| 50 |
+
Minimum 8 characters.
|
| 51 |
+
|
| 52 |
+
[Task]: T020
|
| 53 |
+
[From]: specs/001-user-auth/spec.md
|
| 54 |
+
|
| 55 |
+
Args:
|
| 56 |
+
password: Password to validate
|
| 57 |
+
|
| 58 |
+
Returns:
|
| 59 |
+
True if password meets requirements, False otherwise
|
| 60 |
+
"""
|
| 61 |
+
return len(password) >= 8
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
@router.post("/sign-up", response_model=dict, status_code=status.HTTP_200_OK)
|
| 65 |
+
async def sign_up(
|
| 66 |
+
user_data: UserCreate,
|
| 67 |
+
session: Session = Depends(get_session)
|
| 68 |
+
):
|
| 69 |
+
"""Register a new user account.
|
| 70 |
+
|
| 71 |
+
Validates email format and password length, checks email uniqueness,
|
| 72 |
+
hashes password with bcrypt, creates user in database.
|
| 73 |
+
|
| 74 |
+
[Task]: T018
|
| 75 |
+
[From]: specs/001-user-auth/contracts/openapi.yaml
|
| 76 |
+
|
| 77 |
+
Args:
|
| 78 |
+
user_data: User registration data (email, password)
|
| 79 |
+
session: Database session
|
| 80 |
+
|
| 81 |
+
Returns:
|
| 82 |
+
Success response with message and user data
|
| 83 |
+
|
| 84 |
+
Raises:
|
| 85 |
+
HTTPException 400: Invalid email format or password too short
|
| 86 |
+
HTTPException 409: Email already registered
|
| 87 |
+
HTTPException 500: Database error
|
| 88 |
+
"""
|
| 89 |
+
# Validate email format
|
| 90 |
+
if not validate_email_format(user_data.email):
|
| 91 |
+
raise HTTPException(
|
| 92 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 93 |
+
detail="Invalid email format"
|
| 94 |
+
)
|
| 95 |
+
|
| 96 |
+
# Validate password length
|
| 97 |
+
if not validate_password(user_data.password):
|
| 98 |
+
raise HTTPException(
|
| 99 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 100 |
+
detail="Password must be at least 8 characters"
|
| 101 |
+
)
|
| 102 |
+
|
| 103 |
+
# Check if email already exists
|
| 104 |
+
existing_user = session.exec(
|
| 105 |
+
select(User).where(User.email == user_data.email)
|
| 106 |
+
).first()
|
| 107 |
+
if existing_user:
|
| 108 |
+
raise HTTPException(
|
| 109 |
+
status_code=status.HTTP_409_CONFLICT,
|
| 110 |
+
detail="Email already registered"
|
| 111 |
+
)
|
| 112 |
+
|
| 113 |
+
# Hash password with bcrypt
|
| 114 |
+
hashed_password = get_password_hash(user_data.password)
|
| 115 |
+
|
| 116 |
+
# Create user
|
| 117 |
+
user = User(
|
| 118 |
+
email=user_data.email,
|
| 119 |
+
hashed_password=hashed_password
|
| 120 |
+
)
|
| 121 |
+
|
| 122 |
+
try:
|
| 123 |
+
session.add(user)
|
| 124 |
+
session.commit()
|
| 125 |
+
session.refresh(user)
|
| 126 |
+
except Exception as e:
|
| 127 |
+
session.rollback()
|
| 128 |
+
raise HTTPException(
|
| 129 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 130 |
+
detail="Failed to create user account"
|
| 131 |
+
)
|
| 132 |
+
|
| 133 |
+
# Return user data (excluding password)
|
| 134 |
+
user_dict = UserRead.model_validate(user).model_dump(mode='json')
|
| 135 |
+
return {
|
| 136 |
+
"success": True,
|
| 137 |
+
"message": "Account created successfully",
|
| 138 |
+
"user": user_dict
|
| 139 |
+
}
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
def get_user_by_email(email: str, session: Session) -> Optional[User]:
|
| 143 |
+
"""Query database for user by email.
|
| 144 |
+
|
| 145 |
+
[Task]: T030
|
| 146 |
+
[From]: specs/001-user-auth/plan.md
|
| 147 |
+
|
| 148 |
+
Args:
|
| 149 |
+
email: User email address
|
| 150 |
+
session: Database session
|
| 151 |
+
|
| 152 |
+
Returns:
|
| 153 |
+
User object if found, None otherwise
|
| 154 |
+
"""
|
| 155 |
+
return session.exec(select(User).where(User.email == email)).first()
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
@router.post("/sign-in", response_model=dict, status_code=status.HTTP_200_OK)
|
| 159 |
+
async def sign_in(
|
| 160 |
+
user_data: UserLogin,
|
| 161 |
+
session: Session = Depends(get_session)
|
| 162 |
+
):
|
| 163 |
+
"""Authenticate user and generate JWT token.
|
| 164 |
+
|
| 165 |
+
Verifies credentials, generates JWT token, sets httpOnly cookie,
|
| 166 |
+
returns token and user data.
|
| 167 |
+
|
| 168 |
+
[Task]: T027
|
| 169 |
+
[From]: specs/001-user-auth/contracts/openapi.yaml
|
| 170 |
+
|
| 171 |
+
Args:
|
| 172 |
+
user_data: User login credentials (email, password)
|
| 173 |
+
session: Database session
|
| 174 |
+
|
| 175 |
+
Returns:
|
| 176 |
+
Login response with JWT token, user data, and expiration time
|
| 177 |
+
|
| 178 |
+
Raises:
|
| 179 |
+
HTTPException 401: Invalid credentials
|
| 180 |
+
HTTPException 500: Database or JWT generation error
|
| 181 |
+
"""
|
| 182 |
+
# Get user by email
|
| 183 |
+
user = get_user_by_email(user_data.email, session)
|
| 184 |
+
if not user:
|
| 185 |
+
# Generic error message (don't reveal if email exists)
|
| 186 |
+
raise HTTPException(
|
| 187 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 188 |
+
detail="Invalid email or password"
|
| 189 |
+
)
|
| 190 |
+
|
| 191 |
+
# Verify password
|
| 192 |
+
if not verify_password(user_data.password, user.hashed_password):
|
| 193 |
+
raise HTTPException(
|
| 194 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 195 |
+
detail="Invalid email or password"
|
| 196 |
+
)
|
| 197 |
+
|
| 198 |
+
# Generate JWT token
|
| 199 |
+
access_token = create_access_token(
|
| 200 |
+
data={"sub": str(user.id)},
|
| 201 |
+
expires_delta=timedelta(days=settings.jwt_expiration_days)
|
| 202 |
+
)
|
| 203 |
+
|
| 204 |
+
# Calculate expiration time
|
| 205 |
+
expires_at = datetime.utcnow() + timedelta(days=settings.jwt_expiration_days)
|
| 206 |
+
|
| 207 |
+
# Create response
|
| 208 |
+
response_data = {
|
| 209 |
+
"success": True,
|
| 210 |
+
"token": access_token,
|
| 211 |
+
"user": UserRead.model_validate(user).model_dump(mode='json'),
|
| 212 |
+
"expires_at": expires_at.isoformat() + "Z"
|
| 213 |
+
}
|
| 214 |
+
|
| 215 |
+
# Create response with httpOnly cookie
|
| 216 |
+
response = JSONResponse(content=response_data)
|
| 217 |
+
|
| 218 |
+
# Set httpOnly cookie with JWT token
|
| 219 |
+
response.set_cookie(
|
| 220 |
+
key="auth_token",
|
| 221 |
+
value=access_token,
|
| 222 |
+
httponly=True,
|
| 223 |
+
secure=settings.environment == "production", # Only send over HTTPS in production
|
| 224 |
+
samesite="lax", # CSRF protection
|
| 225 |
+
max_age=settings.jwt_expiration_days * 24 * 60 * 60, # Convert days to seconds
|
| 226 |
+
path="/"
|
| 227 |
+
)
|
| 228 |
+
|
| 229 |
+
return response
|
| 230 |
+
|
| 231 |
+
|
| 232 |
+
@router.get("/session", response_model=dict, status_code=status.HTTP_200_OK)
|
| 233 |
+
async def get_session(
|
| 234 |
+
response: Response,
|
| 235 |
+
Authorization: Optional[str] = None,
|
| 236 |
+
auth_token: Optional[str] = Cookie(None),
|
| 237 |
+
session: Session = Depends(get_session)
|
| 238 |
+
):
|
| 239 |
+
"""Verify JWT token and return user session data.
|
| 240 |
+
|
| 241 |
+
Checks JWT token from Authorization header or httpOnly cookie,
|
| 242 |
+
verifies signature, returns user data if authenticated.
|
| 243 |
+
|
| 244 |
+
[Task]: T026
|
| 245 |
+
[From]: specs/001-user-auth/contracts/openapi.yaml
|
| 246 |
+
|
| 247 |
+
Args:
|
| 248 |
+
response: FastAPI response object
|
| 249 |
+
Authorization: Bearer token from Authorization header
|
| 250 |
+
auth_token: JWT token from httpOnly cookie
|
| 251 |
+
session: Database session
|
| 252 |
+
|
| 253 |
+
Returns:
|
| 254 |
+
Session response with authentication status and user data
|
| 255 |
+
|
| 256 |
+
Raises:
|
| 257 |
+
HTTPException 401: Invalid, expired, or missing token
|
| 258 |
+
"""
|
| 259 |
+
# Extract token from Authorization header or cookie
|
| 260 |
+
token = None
|
| 261 |
+
|
| 262 |
+
# Try Authorization header first
|
| 263 |
+
if Authorization:
|
| 264 |
+
try:
|
| 265 |
+
scheme, header_token = Authorization.split()
|
| 266 |
+
if scheme.lower() == "bearer":
|
| 267 |
+
token = header_token
|
| 268 |
+
except ValueError:
|
| 269 |
+
pass # Fall through to cookie
|
| 270 |
+
|
| 271 |
+
# If no token in header, try cookie
|
| 272 |
+
if not token and auth_token:
|
| 273 |
+
token = auth_token
|
| 274 |
+
|
| 275 |
+
if not token:
|
| 276 |
+
raise HTTPException(
|
| 277 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 278 |
+
detail="Not authenticated"
|
| 279 |
+
)
|
| 280 |
+
|
| 281 |
+
try:
|
| 282 |
+
# Decode and verify token
|
| 283 |
+
payload = decode_access_token(token)
|
| 284 |
+
user_id = payload.get("sub")
|
| 285 |
+
|
| 286 |
+
if not user_id:
|
| 287 |
+
raise HTTPException(
|
| 288 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 289 |
+
detail="Invalid token: user_id missing"
|
| 290 |
+
)
|
| 291 |
+
|
| 292 |
+
# Query user from database
|
| 293 |
+
user = session.get(User, user_id)
|
| 294 |
+
if not user:
|
| 295 |
+
raise HTTPException(
|
| 296 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 297 |
+
detail="User not found"
|
| 298 |
+
)
|
| 299 |
+
|
| 300 |
+
# Calculate expiration time
|
| 301 |
+
exp = payload.get("exp")
|
| 302 |
+
expires_at = None
|
| 303 |
+
if exp:
|
| 304 |
+
expires_at = datetime.fromtimestamp(exp).isoformat() + "Z"
|
| 305 |
+
|
| 306 |
+
return {
|
| 307 |
+
"authenticated": True,
|
| 308 |
+
"user": UserRead.model_validate(user).model_dump(mode='json'),
|
| 309 |
+
"expires_at": expires_at
|
| 310 |
+
}
|
| 311 |
+
|
| 312 |
+
except HTTPException:
|
| 313 |
+
raise
|
| 314 |
+
except Exception as e:
|
| 315 |
+
raise HTTPException(
|
| 316 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 317 |
+
detail="Could not validate credentials"
|
| 318 |
+
)
|
| 319 |
+
|
| 320 |
+
|
| 321 |
+
@router.get("/users/me")
|
| 322 |
+
async def get_users_me(
|
| 323 |
+
current_user: User = Depends(get_current_user)
|
| 324 |
+
):
|
| 325 |
+
"""Get current authenticated user information.
|
| 326 |
+
|
| 327 |
+
Example protected endpoint that requires JWT authentication.
|
| 328 |
+
Returns user data for authenticated user.
|
| 329 |
+
|
| 330 |
+
[Task]: T038
|
| 331 |
+
[From]: specs/001-user-auth/plan.md
|
| 332 |
+
|
| 333 |
+
Args:
|
| 334 |
+
current_user: Authenticated user from dependency
|
| 335 |
+
|
| 336 |
+
Returns:
|
| 337 |
+
User data for current user
|
| 338 |
+
|
| 339 |
+
Raises:
|
| 340 |
+
HTTPException 401: If not authenticated
|
| 341 |
+
"""
|
| 342 |
+
return UserRead.model_validate(current_user).model_dump(mode='json')
|
| 343 |
+
|
| 344 |
+
|
| 345 |
+
@router.post("/sign-out", status_code=status.HTTP_200_OK)
|
| 346 |
+
async def sign_out(
|
| 347 |
+
response: Response,
|
| 348 |
+
current_user: User = Depends(get_current_user)
|
| 349 |
+
):
|
| 350 |
+
"""Logout current user.
|
| 351 |
+
|
| 352 |
+
Client-side logout (clears httpOnly cookie).
|
| 353 |
+
Server-side token is stateless (JWT), so no server storage to clear.
|
| 354 |
+
|
| 355 |
+
[Task]: T043
|
| 356 |
+
[From]: specs/001-user-auth/contracts/openapi.yaml
|
| 357 |
+
|
| 358 |
+
Args:
|
| 359 |
+
response: FastAPI response object
|
| 360 |
+
current_user: Authenticated user (for validation only)
|
| 361 |
+
|
| 362 |
+
Returns:
|
| 363 |
+
Success message
|
| 364 |
+
|
| 365 |
+
Raises:
|
| 366 |
+
HTTPException 401: If not authenticated
|
| 367 |
+
"""
|
| 368 |
+
# Create response with success message
|
| 369 |
+
response_data = {
|
| 370 |
+
"success": True,
|
| 371 |
+
"message": "Logged out successfully"
|
| 372 |
+
}
|
| 373 |
+
|
| 374 |
+
# Create response with cleared httpOnly cookie
|
| 375 |
+
response_obj = JSONResponse(content=response_data)
|
| 376 |
+
|
| 377 |
+
# Clear the httpOnly cookie by setting it to expire
|
| 378 |
+
response_obj.delete_cookie(
|
| 379 |
+
key="auth_token",
|
| 380 |
+
path="/",
|
| 381 |
+
samesite="lax"
|
| 382 |
+
)
|
| 383 |
+
|
| 384 |
+
return response_obj
|
api/deps.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Authentication dependencies for protected routes.
|
| 2 |
+
|
| 3 |
+
[Task]: T036, T037
|
| 4 |
+
[From]: specs/001-user-auth/plan.md
|
| 5 |
+
"""
|
| 6 |
+
from typing import Optional
|
| 7 |
+
from fastapi import Depends, HTTPException, status, Cookie
|
| 8 |
+
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
| 9 |
+
from sqlmodel import Session, select
|
| 10 |
+
|
| 11 |
+
from models.user import User
|
| 12 |
+
from core.database import get_session
|
| 13 |
+
from core.security import decode_access_token
|
| 14 |
+
|
| 15 |
+
# Optional: HTTP Bearer scheme for Authorization header
|
| 16 |
+
security = HTTPBearer(auto_error=False)
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
async def get_current_user(
|
| 20 |
+
response: Optional[str] = None,
|
| 21 |
+
credentials: Optional[HTTPAuthorizationCredentials] = Depends(security),
|
| 22 |
+
auth_token: Optional[str] = Cookie(None),
|
| 23 |
+
session: Session = Depends(get_session)
|
| 24 |
+
) -> User:
|
| 25 |
+
"""Get current authenticated user from JWT token.
|
| 26 |
+
|
| 27 |
+
Extracts JWT from Authorization header or httpOnly cookie,
|
| 28 |
+
verifies signature, queries database for user.
|
| 29 |
+
|
| 30 |
+
[Task]: T036, T037
|
| 31 |
+
[From]: specs/001-user-auth/plan.md
|
| 32 |
+
|
| 33 |
+
Args:
|
| 34 |
+
credentials: HTTP Bearer credentials from Authorization header
|
| 35 |
+
auth_token: JWT token from httpOnly cookie
|
| 36 |
+
session: Database session
|
| 37 |
+
|
| 38 |
+
Returns:
|
| 39 |
+
User: Authenticated user object
|
| 40 |
+
|
| 41 |
+
Raises:
|
| 42 |
+
HTTPException 401: If token is invalid, expired, or missing
|
| 43 |
+
"""
|
| 44 |
+
# Extract token from Authorization header or cookie
|
| 45 |
+
token = None
|
| 46 |
+
|
| 47 |
+
# Try Authorization header first
|
| 48 |
+
if credentials:
|
| 49 |
+
token = credentials.credentials
|
| 50 |
+
|
| 51 |
+
# If no token in header, try cookie
|
| 52 |
+
if not token and auth_token:
|
| 53 |
+
token = auth_token
|
| 54 |
+
|
| 55 |
+
if not token:
|
| 56 |
+
raise HTTPException(
|
| 57 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 58 |
+
detail="Not authenticated",
|
| 59 |
+
headers={"WWW-Authenticate": "Bearer"},
|
| 60 |
+
)
|
| 61 |
+
|
| 62 |
+
try:
|
| 63 |
+
# Decode and verify token
|
| 64 |
+
payload = decode_access_token(token)
|
| 65 |
+
user_id = payload.get("sub")
|
| 66 |
+
|
| 67 |
+
if not user_id:
|
| 68 |
+
raise HTTPException(
|
| 69 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 70 |
+
detail="Invalid token: user_id missing"
|
| 71 |
+
)
|
| 72 |
+
|
| 73 |
+
# Query user from database
|
| 74 |
+
user = session.get(User, user_id)
|
| 75 |
+
if not user:
|
| 76 |
+
raise HTTPException(
|
| 77 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 78 |
+
detail="User not found"
|
| 79 |
+
)
|
| 80 |
+
|
| 81 |
+
return user
|
| 82 |
+
|
| 83 |
+
except HTTPException:
|
| 84 |
+
raise
|
| 85 |
+
except Exception as e:
|
| 86 |
+
raise HTTPException(
|
| 87 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 88 |
+
detail="Could not validate credentials"
|
| 89 |
+
)
|
api/tasks.py
ADDED
|
@@ -0,0 +1,240 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Task CRUD API endpoints with JWT authentication.
|
| 2 |
+
|
| 3 |
+
[Task]: T053-T059
|
| 4 |
+
[From]: specs/001-user-auth/tasks.md (User Story 3)
|
| 5 |
+
|
| 6 |
+
Implements all task management operations with JWT-based authentication:
|
| 7 |
+
- Create task
|
| 8 |
+
- List tasks
|
| 9 |
+
- Get task by ID
|
| 10 |
+
- Update task
|
| 11 |
+
- Delete task
|
| 12 |
+
- Toggle completion status
|
| 13 |
+
|
| 14 |
+
All endpoints require valid JWT token. user_id is extracted from JWT claims.
|
| 15 |
+
"""
|
| 16 |
+
import uuid
|
| 17 |
+
from datetime import datetime
|
| 18 |
+
from typing import Annotated
|
| 19 |
+
from fastapi import APIRouter, HTTPException, Query
|
| 20 |
+
from sqlmodel import Session, select
|
| 21 |
+
from pydantic import BaseModel
|
| 22 |
+
from sqlalchemy import func
|
| 23 |
+
|
| 24 |
+
from core.deps import SessionDep, CurrentUserDep
|
| 25 |
+
from models.task import Task, TaskCreate, TaskUpdate, TaskRead
|
| 26 |
+
|
| 27 |
+
# Create API router (user_id removed - now from JWT)
|
| 28 |
+
router = APIRouter(prefix="/api/tasks", tags=["tasks"])
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
# Response model for task list with pagination metadata
|
| 32 |
+
class TaskListResponse(BaseModel):
|
| 33 |
+
"""Response model for task list with pagination."""
|
| 34 |
+
tasks: list[TaskRead]
|
| 35 |
+
total: int
|
| 36 |
+
offset: int
|
| 37 |
+
limit: int
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
@router.post("", response_model=TaskRead, status_code=201)
|
| 41 |
+
def create_task(
|
| 42 |
+
task: TaskCreate,
|
| 43 |
+
session: SessionDep,
|
| 44 |
+
user_id: CurrentUserDep # Injected from JWT
|
| 45 |
+
):
|
| 46 |
+
"""Create a new task for the authenticated user.
|
| 47 |
+
|
| 48 |
+
Args:
|
| 49 |
+
task: Task data from request body
|
| 50 |
+
session: Database session
|
| 51 |
+
user_id: UUID from JWT token (injected)
|
| 52 |
+
|
| 53 |
+
Returns:
|
| 54 |
+
Created task with generated ID and timestamps
|
| 55 |
+
"""
|
| 56 |
+
# Create Task from TaskCreate with injected user_id
|
| 57 |
+
db_task = Task(
|
| 58 |
+
user_id=user_id,
|
| 59 |
+
title=task.title,
|
| 60 |
+
description=task.description,
|
| 61 |
+
completed=task.completed
|
| 62 |
+
)
|
| 63 |
+
session.add(db_task)
|
| 64 |
+
session.commit()
|
| 65 |
+
session.refresh(db_task)
|
| 66 |
+
return db_task
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
@router.get("", response_model=TaskListResponse)
|
| 70 |
+
def list_tasks(
|
| 71 |
+
session: SessionDep,
|
| 72 |
+
user_id: CurrentUserDep, # Injected from JWT
|
| 73 |
+
offset: int = 0,
|
| 74 |
+
limit: Annotated[int, Query(le=100)] = 50,
|
| 75 |
+
completed: bool | None = None,
|
| 76 |
+
):
|
| 77 |
+
"""List all tasks for the authenticated user with pagination and filtering.
|
| 78 |
+
|
| 79 |
+
Args:
|
| 80 |
+
session: Database session
|
| 81 |
+
user_id: UUID from JWT token (injected)
|
| 82 |
+
offset: Number of tasks to skip (pagination)
|
| 83 |
+
limit: Maximum number of tasks to return (default 50, max 100)
|
| 84 |
+
completed: Optional filter by completion status
|
| 85 |
+
|
| 86 |
+
Returns:
|
| 87 |
+
TaskListResponse with tasks array and total count
|
| 88 |
+
"""
|
| 89 |
+
# Build the count query
|
| 90 |
+
count_statement = select(func.count(Task.id)).where(Task.user_id == user_id)
|
| 91 |
+
if completed is not None:
|
| 92 |
+
count_statement = count_statement.where(Task.completed == completed)
|
| 93 |
+
total = session.exec(count_statement).one()
|
| 94 |
+
|
| 95 |
+
# Build the query for tasks
|
| 96 |
+
statement = select(Task).where(Task.user_id == user_id)
|
| 97 |
+
|
| 98 |
+
# Apply completion status filter if provided
|
| 99 |
+
if completed is not None:
|
| 100 |
+
statement = statement.where(Task.completed == completed)
|
| 101 |
+
|
| 102 |
+
# Apply pagination
|
| 103 |
+
statement = statement.offset(offset).limit(limit)
|
| 104 |
+
|
| 105 |
+
# Order by creation date (newest first)
|
| 106 |
+
statement = statement.order_by(Task.created_at.desc())
|
| 107 |
+
|
| 108 |
+
tasks = session.exec(statement).all()
|
| 109 |
+
|
| 110 |
+
return TaskListResponse(
|
| 111 |
+
tasks=[TaskRead.model_validate(task) for task in tasks],
|
| 112 |
+
total=total,
|
| 113 |
+
offset=offset,
|
| 114 |
+
limit=limit
|
| 115 |
+
)
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
@router.get("/{task_id}", response_model=TaskRead)
|
| 119 |
+
def get_task(
|
| 120 |
+
task_id: uuid.UUID,
|
| 121 |
+
session: SessionDep,
|
| 122 |
+
user_id: CurrentUserDep # Injected from JWT
|
| 123 |
+
):
|
| 124 |
+
"""Get a specific task by ID.
|
| 125 |
+
|
| 126 |
+
Args:
|
| 127 |
+
task_id: UUID of the task to retrieve
|
| 128 |
+
session: Database session
|
| 129 |
+
user_id: UUID from JWT token (injected)
|
| 130 |
+
|
| 131 |
+
Returns:
|
| 132 |
+
Task details if found and owned by authenticated user
|
| 133 |
+
|
| 134 |
+
Raises:
|
| 135 |
+
HTTPException 404: If task not found or doesn't belong to user
|
| 136 |
+
"""
|
| 137 |
+
task = session.get(Task, task_id)
|
| 138 |
+
if not task or task.user_id != user_id:
|
| 139 |
+
raise HTTPException(status_code=404, detail="Task not found")
|
| 140 |
+
return task
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
@router.put("/{task_id}", response_model=TaskRead)
|
| 144 |
+
def update_task(
|
| 145 |
+
task_id: uuid.UUID,
|
| 146 |
+
task_update: TaskUpdate,
|
| 147 |
+
session: SessionDep,
|
| 148 |
+
user_id: CurrentUserDep # Injected from JWT
|
| 149 |
+
):
|
| 150 |
+
"""Update an existing task.
|
| 151 |
+
|
| 152 |
+
Args:
|
| 153 |
+
task_id: UUID of the task to update
|
| 154 |
+
task_update: Fields to update (all optional)
|
| 155 |
+
session: Database session
|
| 156 |
+
user_id: UUID from JWT token (injected)
|
| 157 |
+
|
| 158 |
+
Returns:
|
| 159 |
+
Updated task details
|
| 160 |
+
|
| 161 |
+
Raises:
|
| 162 |
+
HTTPException 404: If task not found or doesn't belong to user
|
| 163 |
+
"""
|
| 164 |
+
task = session.get(Task, task_id)
|
| 165 |
+
if not task or task.user_id != user_id:
|
| 166 |
+
raise HTTPException(status_code=404, detail="Task not found")
|
| 167 |
+
|
| 168 |
+
# Update only provided fields
|
| 169 |
+
task_data = task_update.model_dump(exclude_unset=True)
|
| 170 |
+
for key, value in task_data.items():
|
| 171 |
+
setattr(task, key, value)
|
| 172 |
+
|
| 173 |
+
# Update timestamp
|
| 174 |
+
task.updated_at = datetime.utcnow()
|
| 175 |
+
|
| 176 |
+
session.add(task)
|
| 177 |
+
session.commit()
|
| 178 |
+
session.refresh(task)
|
| 179 |
+
return task
|
| 180 |
+
|
| 181 |
+
|
| 182 |
+
@router.delete("/{task_id}")
|
| 183 |
+
def delete_task(
|
| 184 |
+
task_id: uuid.UUID,
|
| 185 |
+
session: SessionDep,
|
| 186 |
+
user_id: CurrentUserDep # Injected from JWT
|
| 187 |
+
):
|
| 188 |
+
"""Delete a task.
|
| 189 |
+
|
| 190 |
+
Args:
|
| 191 |
+
task_id: UUID of the task to delete
|
| 192 |
+
session: Database session
|
| 193 |
+
user_id: UUID from JWT token (injected)
|
| 194 |
+
|
| 195 |
+
Returns:
|
| 196 |
+
Success confirmation
|
| 197 |
+
|
| 198 |
+
Raises:
|
| 199 |
+
HTTPException 404: If task not found or doesn't belong to user
|
| 200 |
+
"""
|
| 201 |
+
task = session.get(Task, task_id)
|
| 202 |
+
if not task or task.user_id != user_id:
|
| 203 |
+
raise HTTPException(status_code=404, detail="Task not found")
|
| 204 |
+
|
| 205 |
+
session.delete(task)
|
| 206 |
+
session.commit()
|
| 207 |
+
return {"ok": True}
|
| 208 |
+
|
| 209 |
+
|
| 210 |
+
@router.patch("/{task_id}/complete", response_model=TaskRead)
|
| 211 |
+
def toggle_complete(
|
| 212 |
+
task_id: uuid.UUID,
|
| 213 |
+
session: SessionDep,
|
| 214 |
+
user_id: CurrentUserDep # Injected from JWT
|
| 215 |
+
):
|
| 216 |
+
"""Toggle task completion status.
|
| 217 |
+
|
| 218 |
+
Args:
|
| 219 |
+
task_id: UUID of the task to toggle
|
| 220 |
+
session: Database session
|
| 221 |
+
user_id: UUID from JWT token (injected)
|
| 222 |
+
|
| 223 |
+
Returns:
|
| 224 |
+
Task with toggled completion status
|
| 225 |
+
|
| 226 |
+
Raises:
|
| 227 |
+
HTTPException 404: If task not found or doesn't belong to user
|
| 228 |
+
"""
|
| 229 |
+
task = session.get(Task, task_id)
|
| 230 |
+
if not task or task.user_id != user_id:
|
| 231 |
+
raise HTTPException(status_code=404, detail="Task not found")
|
| 232 |
+
|
| 233 |
+
# Toggle completion status
|
| 234 |
+
task.completed = not task.completed
|
| 235 |
+
task.updated_at = datetime.utcnow()
|
| 236 |
+
|
| 237 |
+
session.add(task)
|
| 238 |
+
session.commit()
|
| 239 |
+
session.refresh(task)
|
| 240 |
+
return task
|
core/__init__.py
ADDED
|
File without changes
|
core/config.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Application configuration and settings.
|
| 2 |
+
|
| 3 |
+
[Task]: T009
|
| 4 |
+
[From]: specs/001-user-auth/plan.md
|
| 5 |
+
"""
|
| 6 |
+
import os
|
| 7 |
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
| 8 |
+
from functools import lru_cache
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
class Settings(BaseSettings):
|
| 12 |
+
"""Application settings loaded from environment variables."""
|
| 13 |
+
|
| 14 |
+
# Database
|
| 15 |
+
database_url: str
|
| 16 |
+
|
| 17 |
+
# JWT
|
| 18 |
+
jwt_secret: str
|
| 19 |
+
jwt_algorithm: str = "HS256"
|
| 20 |
+
jwt_expiration_days: int = 7
|
| 21 |
+
|
| 22 |
+
# CORS
|
| 23 |
+
frontend_url: str
|
| 24 |
+
|
| 25 |
+
# Environment
|
| 26 |
+
environment: str = "development"
|
| 27 |
+
|
| 28 |
+
model_config = SettingsConfigDict(
|
| 29 |
+
env_file=".env",
|
| 30 |
+
case_sensitive=False,
|
| 31 |
+
# Support legacy Better Auth environment variables
|
| 32 |
+
env_prefix="",
|
| 33 |
+
extra="ignore"
|
| 34 |
+
)
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
@lru_cache()
|
| 38 |
+
def get_settings() -> Settings:
|
| 39 |
+
"""Get cached settings instance.
|
| 40 |
+
|
| 41 |
+
Returns:
|
| 42 |
+
Settings: Application settings
|
| 43 |
+
|
| 44 |
+
Raises:
|
| 45 |
+
ValueError: If required environment variables are not set
|
| 46 |
+
"""
|
| 47 |
+
return Settings()
|
core/database.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Database connection and session management.
|
| 2 |
+
|
| 3 |
+
[Task]: T010
|
| 4 |
+
[From]: specs/001-user-auth/plan.md
|
| 5 |
+
"""
|
| 6 |
+
from sqlmodel import create_engine, Session
|
| 7 |
+
from typing import Generator
|
| 8 |
+
|
| 9 |
+
from core.config import get_settings
|
| 10 |
+
|
| 11 |
+
settings = get_settings()
|
| 12 |
+
|
| 13 |
+
# Create database engine
|
| 14 |
+
engine = create_engine(
|
| 15 |
+
settings.database_url,
|
| 16 |
+
echo=settings.environment == "development", # Log SQL in development
|
| 17 |
+
pool_pre_ping=True, # Verify connections before using
|
| 18 |
+
)
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def get_session() -> Generator[Session, None, None]:
|
| 22 |
+
"""Get database session.
|
| 23 |
+
|
| 24 |
+
Yields:
|
| 25 |
+
Session: SQLModel database session
|
| 26 |
+
|
| 27 |
+
Example:
|
| 28 |
+
```python
|
| 29 |
+
@app.get("/users")
|
| 30 |
+
def read_users(session: Session = Depends(get_session)):
|
| 31 |
+
users = session.exec(select(User)).all()
|
| 32 |
+
return users
|
| 33 |
+
```
|
| 34 |
+
"""
|
| 35 |
+
with Session(engine) as session:
|
| 36 |
+
yield session
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def init_db():
|
| 40 |
+
"""Initialize database tables.
|
| 41 |
+
|
| 42 |
+
Creates all tables defined in SQLModel models.
|
| 43 |
+
Should be called on application startup.
|
| 44 |
+
"""
|
| 45 |
+
from sqlmodel import SQLModel
|
| 46 |
+
import models.user # Import models to register them with SQLModel
|
| 47 |
+
import models.task # Import task model
|
| 48 |
+
|
| 49 |
+
SQLModel.metadata.create_all(engine)
|
core/deps.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Dependency injection for database sessions and JWT authentication.
|
| 2 |
+
|
| 3 |
+
[Task]: T013, T014
|
| 4 |
+
[From]: specs/001-user-auth/quickstart.md
|
| 5 |
+
"""
|
| 6 |
+
import uuid
|
| 7 |
+
from typing import Annotated, Optional
|
| 8 |
+
from sqlmodel import Session
|
| 9 |
+
from fastapi import Depends, HTTPException, status
|
| 10 |
+
from starlette.requests import Request as StarletteRequest
|
| 11 |
+
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
| 12 |
+
|
| 13 |
+
from core.database import get_session as db_get_session
|
| 14 |
+
from core.security import decode_access_token
|
| 15 |
+
|
| 16 |
+
# HTTP Bearer scheme for Authorization header
|
| 17 |
+
security = HTTPBearer(auto_error=False)
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def get_session():
|
| 21 |
+
"""Yield a database session with automatic cleanup.
|
| 22 |
+
|
| 23 |
+
Uses the get_session function from core.database for consistency.
|
| 24 |
+
"""
|
| 25 |
+
yield from db_get_session()
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
# Type alias for dependency injection
|
| 29 |
+
SessionDep = Annotated[Session, Depends(get_session)]
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
async def get_current_user_id(
|
| 33 |
+
credentials: Optional[HTTPAuthorizationCredentials] = Depends(security),
|
| 34 |
+
request: StarletteRequest = None
|
| 35 |
+
) -> uuid.UUID:
|
| 36 |
+
"""Get current user ID from JWT token.
|
| 37 |
+
|
| 38 |
+
Extracts JWT token from Authorization header or httpOnly cookie,
|
| 39 |
+
verifies it, and returns user_id as UUID.
|
| 40 |
+
|
| 41 |
+
Args:
|
| 42 |
+
credentials: HTTP Bearer credentials from Authorization header
|
| 43 |
+
request: Starlette request object to access cookies
|
| 44 |
+
|
| 45 |
+
Returns:
|
| 46 |
+
Current authenticated user's ID as UUID
|
| 47 |
+
|
| 48 |
+
Raises:
|
| 49 |
+
HTTPException: If token is invalid, expired, or missing
|
| 50 |
+
"""
|
| 51 |
+
# Extract token from Authorization header or cookie
|
| 52 |
+
token = None
|
| 53 |
+
|
| 54 |
+
# Try Authorization header first
|
| 55 |
+
if credentials:
|
| 56 |
+
token = credentials.credentials
|
| 57 |
+
|
| 58 |
+
# If no token in header, try httpOnly cookie
|
| 59 |
+
if not token and request:
|
| 60 |
+
auth_token = request.cookies.get("auth_token")
|
| 61 |
+
if auth_token:
|
| 62 |
+
token = auth_token
|
| 63 |
+
|
| 64 |
+
if not token:
|
| 65 |
+
raise HTTPException(
|
| 66 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 67 |
+
detail="Could not validate credentials",
|
| 68 |
+
headers={"WWW-Authenticate": "Bearer"},
|
| 69 |
+
)
|
| 70 |
+
|
| 71 |
+
try:
|
| 72 |
+
# Decode and verify token
|
| 73 |
+
payload = decode_access_token(token)
|
| 74 |
+
user_id_str = payload.get("sub")
|
| 75 |
+
|
| 76 |
+
if not user_id_str:
|
| 77 |
+
raise HTTPException(
|
| 78 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 79 |
+
detail="Invalid token: user_id missing"
|
| 80 |
+
)
|
| 81 |
+
|
| 82 |
+
# Convert string to UUID for database comparison
|
| 83 |
+
user_id = uuid.UUID(user_id_str)
|
| 84 |
+
|
| 85 |
+
return user_id
|
| 86 |
+
except HTTPException:
|
| 87 |
+
raise
|
| 88 |
+
except ValueError:
|
| 89 |
+
raise HTTPException(
|
| 90 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 91 |
+
detail="Invalid token: malformed user_id"
|
| 92 |
+
)
|
| 93 |
+
except Exception as e:
|
| 94 |
+
raise HTTPException(
|
| 95 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 96 |
+
detail="Could not validate credentials"
|
| 97 |
+
)
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
# Type alias for JWT authentication dependency
|
| 101 |
+
CurrentUserDep = Annotated[uuid.UUID, Depends(get_current_user_id)]
|
core/middleware.py
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""JWT middleware for FastAPI.
|
| 2 |
+
|
| 3 |
+
[Task]: T012
|
| 4 |
+
[From]: specs/001-user-auth/quickstart.md
|
| 5 |
+
"""
|
| 6 |
+
from typing import Callable
|
| 7 |
+
from fastapi import Request, HTTPException, status
|
| 8 |
+
from fastapi.responses import JSONResponse
|
| 9 |
+
from starlette.middleware.base import BaseHTTPMiddleware
|
| 10 |
+
|
| 11 |
+
from core.security import JWTManager
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
class JWTMiddleware(BaseHTTPMiddleware):
|
| 15 |
+
"""JWT authentication middleware.
|
| 16 |
+
|
| 17 |
+
Validates JWT tokens for all requests except public paths.
|
| 18 |
+
Adds user_id to request.state for downstream dependency injection.
|
| 19 |
+
"""
|
| 20 |
+
|
| 21 |
+
def __init__(self, app, excluded_paths: list[str] = None):
|
| 22 |
+
"""Initialize JWT middleware.
|
| 23 |
+
|
| 24 |
+
Args:
|
| 25 |
+
app: FastAPI application instance
|
| 26 |
+
excluded_paths: List of paths to exclude from JWT validation
|
| 27 |
+
"""
|
| 28 |
+
super().__init__(app)
|
| 29 |
+
self.excluded_paths = excluded_paths or []
|
| 30 |
+
self.public_paths = [
|
| 31 |
+
"/",
|
| 32 |
+
"/docs",
|
| 33 |
+
"/redoc",
|
| 34 |
+
"/openapi.json",
|
| 35 |
+
"/health",
|
| 36 |
+
] + self.excluded_paths
|
| 37 |
+
|
| 38 |
+
async def dispatch(self, request: Request, call_next: Callable):
|
| 39 |
+
"""Process each request with JWT validation.
|
| 40 |
+
|
| 41 |
+
Args:
|
| 42 |
+
request: Incoming HTTP request
|
| 43 |
+
call_next: Next middleware or route handler
|
| 44 |
+
|
| 45 |
+
Returns:
|
| 46 |
+
HTTP response with JWT validation applied
|
| 47 |
+
|
| 48 |
+
Raises:
|
| 49 |
+
HTTPException: If JWT validation fails
|
| 50 |
+
"""
|
| 51 |
+
# Skip JWT validation for public paths
|
| 52 |
+
if request.url.path in self.public_paths:
|
| 53 |
+
return await call_next(request)
|
| 54 |
+
|
| 55 |
+
# Extract token from Authorization header OR httpOnly cookie
|
| 56 |
+
token = None
|
| 57 |
+
|
| 58 |
+
# Try Authorization header first
|
| 59 |
+
authorization = request.headers.get("Authorization")
|
| 60 |
+
if authorization:
|
| 61 |
+
try:
|
| 62 |
+
token = JWTManager.get_token_from_header(authorization)
|
| 63 |
+
except:
|
| 64 |
+
pass # Fall through to cookie
|
| 65 |
+
|
| 66 |
+
# If no token in header, try httpOnly cookie
|
| 67 |
+
if not token:
|
| 68 |
+
auth_token = request.cookies.get("auth_token")
|
| 69 |
+
if auth_token:
|
| 70 |
+
token = auth_token
|
| 71 |
+
|
| 72 |
+
# If still no token, return 401
|
| 73 |
+
if not token:
|
| 74 |
+
return JSONResponse(
|
| 75 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 76 |
+
content={"detail": "Not authenticated"},
|
| 77 |
+
headers={"WWW-Authenticate": "Bearer"},
|
| 78 |
+
)
|
| 79 |
+
|
| 80 |
+
try:
|
| 81 |
+
# Verify token and extract user_id
|
| 82 |
+
user_id = JWTManager.get_user_id_from_token(token)
|
| 83 |
+
|
| 84 |
+
# Add user_id to request state for route handlers
|
| 85 |
+
request.state.user_id = user_id
|
| 86 |
+
|
| 87 |
+
return await call_next(request)
|
| 88 |
+
|
| 89 |
+
except HTTPException as e:
|
| 90 |
+
raise e
|
| 91 |
+
except Exception as e:
|
| 92 |
+
return JSONResponse(
|
| 93 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 94 |
+
content={"detail": "Internal server error during authentication"},
|
| 95 |
+
)
|
core/security.py
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Password hashing and JWT token management.
|
| 2 |
+
|
| 3 |
+
[Task]: T011
|
| 4 |
+
[From]: specs/001-user-auth/plan.md, specs/001-user-auth/research.md
|
| 5 |
+
"""
|
| 6 |
+
import hashlib
|
| 7 |
+
import bcrypt
|
| 8 |
+
from datetime import datetime, timedelta
|
| 9 |
+
from typing import Optional
|
| 10 |
+
|
| 11 |
+
from jose import JWTError, jwt
|
| 12 |
+
from fastapi import HTTPException, status
|
| 13 |
+
|
| 14 |
+
from core.config import get_settings
|
| 15 |
+
|
| 16 |
+
settings = get_settings()
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def _pre_hash_password(password: str) -> bytes:
|
| 20 |
+
"""Pre-hash password with SHA-256 to handle bcrypt's 72-byte limit.
|
| 21 |
+
|
| 22 |
+
Bcrypt cannot hash passwords longer than 72 bytes. This function
|
| 23 |
+
pre-hashes the password with SHA-256 first, then bcrypt hashes that.
|
| 24 |
+
|
| 25 |
+
Args:
|
| 26 |
+
password: Plaintext password (any length)
|
| 27 |
+
|
| 28 |
+
Returns:
|
| 29 |
+
SHA-256 hash of the password (always 32 bytes)
|
| 30 |
+
"""
|
| 31 |
+
return hashlib.sha256(password.encode()).digest()
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
| 35 |
+
"""Verify a password against a hash.
|
| 36 |
+
|
| 37 |
+
Args:
|
| 38 |
+
plain_password: Plaintext password to verify
|
| 39 |
+
hashed_password: Hashed password to compare against
|
| 40 |
+
|
| 41 |
+
Returns:
|
| 42 |
+
True if password matches hash, False otherwise
|
| 43 |
+
"""
|
| 44 |
+
try:
|
| 45 |
+
# Pre-hash the plain password to match how it was stored
|
| 46 |
+
pre_hashed = _pre_hash_password(plain_password)
|
| 47 |
+
# Convert the stored hash to bytes
|
| 48 |
+
hashed_bytes = hashed_password.encode('utf-8')
|
| 49 |
+
# Verify using bcrypt directly
|
| 50 |
+
return bcrypt.checkpw(pre_hashed, hashed_bytes)
|
| 51 |
+
except Exception:
|
| 52 |
+
return False
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def get_password_hash(password: str) -> str:
|
| 56 |
+
"""Hash a password using bcrypt with SHA-256 pre-hashing.
|
| 57 |
+
|
| 58 |
+
This two-step approach:
|
| 59 |
+
1. Hash password with SHA-256 (handles any length)
|
| 60 |
+
2. Hash the SHA-256 hash with bcrypt (adds salt and security)
|
| 61 |
+
|
| 62 |
+
Args:
|
| 63 |
+
password: Plaintext password to hash (any length)
|
| 64 |
+
|
| 65 |
+
Returns:
|
| 66 |
+
Hashed password (bcrypt hash with salt)
|
| 67 |
+
|
| 68 |
+
Example:
|
| 69 |
+
```python
|
| 70 |
+
hashed = get_password_hash("my_password")
|
| 71 |
+
# Returns: $2b$12$... (bcrypt hash)
|
| 72 |
+
```
|
| 73 |
+
"""
|
| 74 |
+
# Pre-hash with SHA-256 to handle long passwords
|
| 75 |
+
pre_hashed = _pre_hash_password(password)
|
| 76 |
+
|
| 77 |
+
# Generate salt and hash with bcrypt
|
| 78 |
+
# Using 12 rounds (2^12 = 4096 iterations) for good security
|
| 79 |
+
salt = bcrypt.gensalt(rounds=12)
|
| 80 |
+
hashed = bcrypt.hashpw(pre_hashed, salt)
|
| 81 |
+
|
| 82 |
+
# Return as string
|
| 83 |
+
return hashed.decode('utf-8')
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
|
| 87 |
+
"""Create JWT access token.
|
| 88 |
+
|
| 89 |
+
Args:
|
| 90 |
+
data: Payload data to encode in token (typically {"sub": user_id})
|
| 91 |
+
expires_delta: Optional custom expiration time
|
| 92 |
+
|
| 93 |
+
Returns:
|
| 94 |
+
Encoded JWT token string
|
| 95 |
+
|
| 96 |
+
Example:
|
| 97 |
+
```python
|
| 98 |
+
token = create_access_token(
|
| 99 |
+
data={"sub": str(user.id)},
|
| 100 |
+
expires_delta=timedelta(days=7)
|
| 101 |
+
)
|
| 102 |
+
```
|
| 103 |
+
"""
|
| 104 |
+
to_encode = data.copy()
|
| 105 |
+
|
| 106 |
+
# Set expiration time
|
| 107 |
+
if expires_delta:
|
| 108 |
+
expire = datetime.utcnow() + expires_delta
|
| 109 |
+
else:
|
| 110 |
+
expire = datetime.utcnow() + timedelta(days=settings.jwt_expiration_days)
|
| 111 |
+
|
| 112 |
+
to_encode.update({"exp": expire, "iat": datetime.utcnow()})
|
| 113 |
+
|
| 114 |
+
# Encode JWT
|
| 115 |
+
encoded_jwt = jwt.encode(
|
| 116 |
+
to_encode,
|
| 117 |
+
settings.jwt_secret,
|
| 118 |
+
algorithm=settings.jwt_algorithm
|
| 119 |
+
)
|
| 120 |
+
return encoded_jwt
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
def decode_access_token(token: str) -> dict:
|
| 124 |
+
"""Decode and verify JWT access token.
|
| 125 |
+
|
| 126 |
+
Args:
|
| 127 |
+
token: JWT token string to decode
|
| 128 |
+
|
| 129 |
+
Returns:
|
| 130 |
+
Decoded token payload
|
| 131 |
+
|
| 132 |
+
Raises:
|
| 133 |
+
HTTPException: If token is invalid or expired
|
| 134 |
+
"""
|
| 135 |
+
try:
|
| 136 |
+
payload = jwt.decode(
|
| 137 |
+
token,
|
| 138 |
+
settings.jwt_secret,
|
| 139 |
+
algorithms=[settings.jwt_algorithm]
|
| 140 |
+
)
|
| 141 |
+
return payload
|
| 142 |
+
except JWTError:
|
| 143 |
+
raise HTTPException(
|
| 144 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 145 |
+
detail="Could not validate credentials",
|
| 146 |
+
headers={"WWW-Authenticate": "Bearer"},
|
| 147 |
+
)
|
main.py
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""FastAPI application entry point.
|
| 2 |
+
|
| 3 |
+
[Task]: T047
|
| 4 |
+
[From]: specs/001-user-auth/plan.md
|
| 5 |
+
"""
|
| 6 |
+
import logging
|
| 7 |
+
from contextlib import asynccontextmanager
|
| 8 |
+
from fastapi import FastAPI, HTTPException
|
| 9 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 10 |
+
from fastapi.responses import JSONResponse
|
| 11 |
+
|
| 12 |
+
from core.database import init_db, engine
|
| 13 |
+
from core.config import get_settings
|
| 14 |
+
from api.auth import router as auth_router
|
| 15 |
+
from api.tasks import router as tasks_router
|
| 16 |
+
|
| 17 |
+
settings = get_settings()
|
| 18 |
+
|
| 19 |
+
# Configure structured logging
|
| 20 |
+
logging.basicConfig(
|
| 21 |
+
level=logging.INFO,
|
| 22 |
+
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
| 23 |
+
)
|
| 24 |
+
logger = logging.getLogger(__name__)
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
@asynccontextmanager
|
| 28 |
+
async def lifespan(app: FastAPI):
|
| 29 |
+
"""Application lifespan manager.
|
| 30 |
+
|
| 31 |
+
Handles startup and shutdown events.
|
| 32 |
+
"""
|
| 33 |
+
# Startup
|
| 34 |
+
logger.info("Starting up application...")
|
| 35 |
+
init_db()
|
| 36 |
+
logger.info("Database initialized")
|
| 37 |
+
|
| 38 |
+
yield
|
| 39 |
+
|
| 40 |
+
# Shutdown
|
| 41 |
+
logger.info("Shutting down application...")
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
# Create FastAPI application
|
| 45 |
+
app = FastAPI(
|
| 46 |
+
title="Todo List API",
|
| 47 |
+
description="REST API for managing tasks with JWT authentication",
|
| 48 |
+
version="1.0.0",
|
| 49 |
+
lifespan=lifespan,
|
| 50 |
+
)
|
| 51 |
+
|
| 52 |
+
# Add CORS middleware
|
| 53 |
+
app.add_middleware(
|
| 54 |
+
CORSMiddleware,
|
| 55 |
+
allow_origins=[settings.frontend_url],
|
| 56 |
+
allow_credentials=True,
|
| 57 |
+
allow_methods=["*"],
|
| 58 |
+
allow_headers=["*"],
|
| 59 |
+
)
|
| 60 |
+
|
| 61 |
+
# Include routers
|
| 62 |
+
app.include_router(auth_router) # Authentication endpoints
|
| 63 |
+
app.include_router(tasks_router) # Task management endpoints
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
@app.get("/")
|
| 67 |
+
async def root():
|
| 68 |
+
"""Root endpoint."""
|
| 69 |
+
return {
|
| 70 |
+
"message": "Todo List API",
|
| 71 |
+
"status": "running",
|
| 72 |
+
"version": "1.0.0",
|
| 73 |
+
"authentication": "JWT"
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
@app.get("/health")
|
| 78 |
+
async def health_check():
|
| 79 |
+
"""Health check endpoint.
|
| 80 |
+
|
| 81 |
+
Verifies database connectivity and application status.
|
| 82 |
+
Returns 503 if database is unavailable.
|
| 83 |
+
|
| 84 |
+
[Task]: T048
|
| 85 |
+
[From]: specs/001-user-auth/plan.md
|
| 86 |
+
"""
|
| 87 |
+
from sqlmodel import select
|
| 88 |
+
from models.user import User
|
| 89 |
+
from sqlmodel import Session
|
| 90 |
+
|
| 91 |
+
# Try to get database session
|
| 92 |
+
try:
|
| 93 |
+
# Create a simple query to test database connection
|
| 94 |
+
with Session(engine) as session:
|
| 95 |
+
# Execute a simple query (doesn't matter if it returns data)
|
| 96 |
+
session.exec(select(User).limit(1))
|
| 97 |
+
return {"status": "healthy", "database": "connected"}
|
| 98 |
+
except Exception as e:
|
| 99 |
+
logger.error(f"Health check failed: {e}")
|
| 100 |
+
raise HTTPException(
|
| 101 |
+
status_code=503,
|
| 102 |
+
detail="Service unavailable - database connection failed"
|
| 103 |
+
)
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
# Global exception handler
|
| 107 |
+
@app.exception_handler(HTTPException)
|
| 108 |
+
async def http_exception_handler(request, exc):
|
| 109 |
+
"""Global HTTP exception handler.
|
| 110 |
+
|
| 111 |
+
Returns consistent error format for all HTTP exceptions.
|
| 112 |
+
|
| 113 |
+
[Task]: T046
|
| 114 |
+
[From]: specs/001-user-auth/research.md
|
| 115 |
+
"""
|
| 116 |
+
return JSONResponse(
|
| 117 |
+
status_code=exc.status_code,
|
| 118 |
+
content={
|
| 119 |
+
"error": {
|
| 120 |
+
"status_code": exc.status_code,
|
| 121 |
+
"detail": exc.detail
|
| 122 |
+
}
|
| 123 |
+
}
|
| 124 |
+
)
|
migrations/001_add_user_id_index.sql
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
-- Add index on tasks.user_id for improved query performance
|
| 2 |
+
CREATE INDEX IF NOT EXISTS idx_tasks_user_id ON tasks(user_id);
|
migrations/run_migration.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Database migration runner.
|
| 2 |
+
|
| 3 |
+
[Task]: T022, T023
|
| 4 |
+
[From]: specs/001-user-auth/tasks.md
|
| 5 |
+
|
| 6 |
+
This script runs SQL migrations against the database.
|
| 7 |
+
|
| 8 |
+
Usage:
|
| 9 |
+
uv run python migrations/run_migration.py
|
| 10 |
+
"""
|
| 11 |
+
import os
|
| 12 |
+
import sys
|
| 13 |
+
from pathlib import Path
|
| 14 |
+
from sqlmodel import Session, text
|
| 15 |
+
|
| 16 |
+
# Add parent directory to path for imports
|
| 17 |
+
sys.path.insert(0, str(Path(__file__).parent.parent))
|
| 18 |
+
|
| 19 |
+
from core.config import engine
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def run_migration(migration_file: str):
|
| 23 |
+
"""Run a single SQL migration file.
|
| 24 |
+
|
| 25 |
+
Args:
|
| 26 |
+
migration_file: Name of the migration file in migrations/ directory
|
| 27 |
+
"""
|
| 28 |
+
migration_path = Path(__file__).parent / migration_file
|
| 29 |
+
|
| 30 |
+
if not migration_path.exists():
|
| 31 |
+
print(f"❌ Migration file not found: {migration_path}")
|
| 32 |
+
return False
|
| 33 |
+
|
| 34 |
+
print(f"📜 Running migration: {migration_file}")
|
| 35 |
+
|
| 36 |
+
with open(migration_path, "r") as f:
|
| 37 |
+
sql = f.read()
|
| 38 |
+
|
| 39 |
+
try:
|
| 40 |
+
with Session(engine) as session:
|
| 41 |
+
# Execute the migration using text()
|
| 42 |
+
session.exec(text(sql))
|
| 43 |
+
session.commit()
|
| 44 |
+
|
| 45 |
+
print(f"✅ Migration completed successfully: {migration_file}")
|
| 46 |
+
return True
|
| 47 |
+
except Exception as e:
|
| 48 |
+
print(f"❌ Migration failed: {e}")
|
| 49 |
+
return False
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def main():
|
| 53 |
+
"""Run pending migrations."""
|
| 54 |
+
# Migration files in order
|
| 55 |
+
migrations = [
|
| 56 |
+
"001_add_user_id_index.sql",
|
| 57 |
+
]
|
| 58 |
+
|
| 59 |
+
print("🚀 Starting database migrations...\n")
|
| 60 |
+
|
| 61 |
+
success_count = 0
|
| 62 |
+
for migration in migrations:
|
| 63 |
+
if run_migration(migration):
|
| 64 |
+
success_count += 1
|
| 65 |
+
print()
|
| 66 |
+
|
| 67 |
+
print(f"✅ {success_count}/{len(migrations)} migrations completed successfully")
|
| 68 |
+
|
| 69 |
+
if success_count == len(migrations):
|
| 70 |
+
print("\n🎉 All migrations completed!")
|
| 71 |
+
print("\n📊 Database schema is ready for authentication.")
|
| 72 |
+
return 0
|
| 73 |
+
else:
|
| 74 |
+
print("\n⚠️ Some migrations failed. Please check the errors above.")
|
| 75 |
+
return 1
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
if __name__ == "__main__":
|
| 79 |
+
sys.exit(main())
|
migrations/verify_schema.py
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Database schema verification script.
|
| 2 |
+
|
| 3 |
+
[Task]: T022, T023
|
| 4 |
+
[From]: specs/001-user-auth/tasks.md
|
| 5 |
+
|
| 6 |
+
This script verifies that the database schema is correct for authentication.
|
| 7 |
+
"""
|
| 8 |
+
import sys
|
| 9 |
+
from pathlib import Path
|
| 10 |
+
|
| 11 |
+
# Add parent directory to path for imports
|
| 12 |
+
sys.path.insert(0, str(Path(__file__).parent.parent))
|
| 13 |
+
|
| 14 |
+
from sqlmodel import Session, select, text
|
| 15 |
+
from core.config import engine
|
| 16 |
+
from models.user import User
|
| 17 |
+
from models.task import Task
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def verify_schema():
|
| 21 |
+
"""Verify database schema for authentication."""
|
| 22 |
+
print("🔍 Verifying database schema...\n")
|
| 23 |
+
|
| 24 |
+
with Session(engine) as session:
|
| 25 |
+
# Check users table
|
| 26 |
+
print("📋 Checking users table...")
|
| 27 |
+
try:
|
| 28 |
+
result = session.exec(text("""
|
| 29 |
+
SELECT column_name, data_type, is_nullable, column_default
|
| 30 |
+
FROM information_schema.columns
|
| 31 |
+
WHERE table_name = 'users'
|
| 32 |
+
ORDER BY ordinal_position;
|
| 33 |
+
"""))
|
| 34 |
+
print("✅ Users table columns:")
|
| 35 |
+
for row in result:
|
| 36 |
+
print(f" - {row.column_name}: {row.data_type}")
|
| 37 |
+
except Exception as e:
|
| 38 |
+
print(f"❌ Error checking users table: {e}")
|
| 39 |
+
return False
|
| 40 |
+
|
| 41 |
+
print()
|
| 42 |
+
|
| 43 |
+
# Check tasks table
|
| 44 |
+
print("📋 Checking tasks table...")
|
| 45 |
+
try:
|
| 46 |
+
result = session.exec(text("""
|
| 47 |
+
SELECT column_name, data_type, is_nullable, column_default
|
| 48 |
+
FROM information_schema.columns
|
| 49 |
+
WHERE table_name = 'tasks'
|
| 50 |
+
ORDER BY ordinal_position;
|
| 51 |
+
"""))
|
| 52 |
+
print("✅ Tasks table columns:")
|
| 53 |
+
for row in result:
|
| 54 |
+
print(f" - {row.column_name}: {row.data_type}")
|
| 55 |
+
except Exception as e:
|
| 56 |
+
print(f"❌ Error checking tasks table: {e}")
|
| 57 |
+
return False
|
| 58 |
+
|
| 59 |
+
print()
|
| 60 |
+
|
| 61 |
+
# Check indexes
|
| 62 |
+
print("📋 Checking indexes on tasks table...")
|
| 63 |
+
try:
|
| 64 |
+
result = session.exec(text("""
|
| 65 |
+
SELECT indexname, indexdef
|
| 66 |
+
FROM pg_indexes
|
| 67 |
+
WHERE tablename = 'tasks';
|
| 68 |
+
"""))
|
| 69 |
+
print("✅ Indexes on tasks table:")
|
| 70 |
+
for row in result:
|
| 71 |
+
print(f" - {row.indexname}")
|
| 72 |
+
except Exception as e:
|
| 73 |
+
print(f"❌ Error checking indexes: {e}")
|
| 74 |
+
return False
|
| 75 |
+
|
| 76 |
+
print()
|
| 77 |
+
|
| 78 |
+
# Check foreign key constraints
|
| 79 |
+
print("📋 Checking foreign key constraints...")
|
| 80 |
+
try:
|
| 81 |
+
result = session.exec(text("""
|
| 82 |
+
SELECT
|
| 83 |
+
tc.constraint_name,
|
| 84 |
+
tc.table_name,
|
| 85 |
+
kcu.column_name,
|
| 86 |
+
ccu.table_name AS foreign_table_name,
|
| 87 |
+
ccu.column_name AS foreign_column_name
|
| 88 |
+
FROM information_schema.table_constraints AS tc
|
| 89 |
+
JOIN information_schema.key_column_usage AS kcu
|
| 90 |
+
ON tc.constraint_name = kcu.constraint_name
|
| 91 |
+
JOIN information_schema.constraint_column_usage AS ccu
|
| 92 |
+
ON ccu.constraint_name = tc.constraint_name
|
| 93 |
+
WHERE tc.constraint_type = 'FOREIGN KEY'
|
| 94 |
+
AND tc.table_name = 'tasks';
|
| 95 |
+
"""))
|
| 96 |
+
print("✅ Foreign key constraints:")
|
| 97 |
+
for row in result:
|
| 98 |
+
print(f" - {row.constraint_name}:")
|
| 99 |
+
print(f" {row.column_name} → {row.foreign_table_name}.{row.foreign_column_name}")
|
| 100 |
+
except Exception as e:
|
| 101 |
+
print(f"❌ Error checking foreign keys: {e}")
|
| 102 |
+
return False
|
| 103 |
+
|
| 104 |
+
print()
|
| 105 |
+
|
| 106 |
+
# Check unique constraints
|
| 107 |
+
print("📋 Checking unique constraints...")
|
| 108 |
+
try:
|
| 109 |
+
result = session.exec(text("""
|
| 110 |
+
SELECT
|
| 111 |
+
tc.constraint_name,
|
| 112 |
+
tc.table_name,
|
| 113 |
+
kcu.column_name
|
| 114 |
+
FROM information_schema.table_constraints AS tc
|
| 115 |
+
JOIN information_schema.key_column_usage AS kcu
|
| 116 |
+
ON tc.constraint_name = kcu.constraint_name
|
| 117 |
+
WHERE tc.constraint_type = 'UNIQUE'
|
| 118 |
+
AND tc.table_name = 'users';
|
| 119 |
+
"""))
|
| 120 |
+
print("✅ Unique constraints on users table:")
|
| 121 |
+
for row in result:
|
| 122 |
+
print(f" - {row.constraint_name}: {row.column_name}")
|
| 123 |
+
except Exception as e:
|
| 124 |
+
print(f"❌ Error checking unique constraints: {e}")
|
| 125 |
+
return False
|
| 126 |
+
|
| 127 |
+
print("\n✅ Schema verification complete!")
|
| 128 |
+
print("\n🎉 Database schema is ready for authentication.")
|
| 129 |
+
return True
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
if __name__ == "__main__":
|
| 133 |
+
success = verify_schema()
|
| 134 |
+
sys.exit(0 if success else 1)
|
models/__init__.py
ADDED
|
File without changes
|
models/task.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Task model and related I/O classes."""
|
| 2 |
+
import uuid
|
| 3 |
+
from datetime import datetime
|
| 4 |
+
from typing import Optional
|
| 5 |
+
from sqlmodel import Field, SQLModel
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
class Task(SQLModel, table=True):
|
| 9 |
+
"""Database table model for Task entity."""
|
| 10 |
+
|
| 11 |
+
__tablename__ = "tasks"
|
| 12 |
+
|
| 13 |
+
id: uuid.UUID = Field(
|
| 14 |
+
default_factory=uuid.uuid4,
|
| 15 |
+
primary_key=True,
|
| 16 |
+
index=True
|
| 17 |
+
)
|
| 18 |
+
user_id: uuid.UUID = Field(
|
| 19 |
+
foreign_key="users.id",
|
| 20 |
+
index=True
|
| 21 |
+
)
|
| 22 |
+
title: str = Field(max_length=255)
|
| 23 |
+
description: Optional[str] = Field(
|
| 24 |
+
default=None,
|
| 25 |
+
max_length=2000
|
| 26 |
+
)
|
| 27 |
+
completed: bool = Field(default=False)
|
| 28 |
+
created_at: datetime = Field(
|
| 29 |
+
default_factory=datetime.utcnow
|
| 30 |
+
)
|
| 31 |
+
updated_at: datetime = Field(
|
| 32 |
+
default_factory=datetime.utcnow
|
| 33 |
+
)
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
class TaskCreate(SQLModel):
|
| 37 |
+
"""Request model for creating a task.
|
| 38 |
+
|
| 39 |
+
Validates input data when creating a new task.
|
| 40 |
+
"""
|
| 41 |
+
title: str = Field(min_length=1, max_length=255)
|
| 42 |
+
description: Optional[str] = Field(default=None, max_length=2000)
|
| 43 |
+
completed: bool = False
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
class TaskUpdate(SQLModel):
|
| 47 |
+
"""Request model for updating a task.
|
| 48 |
+
|
| 49 |
+
All fields are optional - only provided fields will be updated.
|
| 50 |
+
"""
|
| 51 |
+
title: Optional[str] = Field(default=None, min_length=1, max_length=255)
|
| 52 |
+
description: Optional[str] = Field(default=None, max_length=2000)
|
| 53 |
+
completed: Optional[bool] = None
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
class TaskRead(SQLModel):
|
| 57 |
+
"""Response model for task data.
|
| 58 |
+
|
| 59 |
+
Used for serializing task data in API responses.
|
| 60 |
+
"""
|
| 61 |
+
id: uuid.UUID
|
| 62 |
+
user_id: uuid.UUID
|
| 63 |
+
title: str
|
| 64 |
+
description: Optional[str] | None
|
| 65 |
+
completed: bool
|
| 66 |
+
created_at: datetime
|
| 67 |
+
updated_at: datetime
|
models/user.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""User model and authentication schemas for FastAPI backend.
|
| 2 |
+
|
| 3 |
+
[Task]: T016
|
| 4 |
+
[From]: specs/001-user-auth/data-model.md
|
| 5 |
+
"""
|
| 6 |
+
import uuid
|
| 7 |
+
from datetime import datetime
|
| 8 |
+
from typing import Optional
|
| 9 |
+
from sqlmodel import Field, SQLModel
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
class UserBase(SQLModel):
|
| 13 |
+
"""Base User model with common fields."""
|
| 14 |
+
email: str = Field(unique=True, index=True, max_length=255)
|
| 15 |
+
created_at: datetime = Field(default_factory=datetime.utcnow)
|
| 16 |
+
updated_at: datetime = Field(default_factory=datetime.utcnow)
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
class User(UserBase, table=True):
|
| 20 |
+
"""Full User model with database table.
|
| 21 |
+
|
| 22 |
+
FastAPI backend handles ALL authentication logic:
|
| 23 |
+
- Password hashing (bcrypt)
|
| 24 |
+
- JWT token generation/verification
|
| 25 |
+
- User creation and validation
|
| 26 |
+
"""
|
| 27 |
+
__tablename__ = "users"
|
| 28 |
+
|
| 29 |
+
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
|
| 30 |
+
hashed_password: str = Field(max_length=255) # bcrypt hash, not plaintext
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
class UserCreate(SQLModel):
|
| 34 |
+
"""Schema for user registration.
|
| 35 |
+
|
| 36 |
+
Frontend sends plaintext password, backend hashes it before storage.
|
| 37 |
+
"""
|
| 38 |
+
email: str
|
| 39 |
+
password: str # Plaintext password, will be hashed before storage
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
class UserRead(SQLModel):
|
| 43 |
+
"""Schema for returning user data (excludes password)."""
|
| 44 |
+
id: uuid.UUID
|
| 45 |
+
email: str
|
| 46 |
+
created_at: datetime
|
| 47 |
+
updated_at: datetime
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
class UserLogin(SQLModel):
|
| 51 |
+
"""Schema for user login."""
|
| 52 |
+
email: str
|
| 53 |
+
password: str
|
pyproject.toml
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[project]
|
| 2 |
+
name = "backend"
|
| 3 |
+
version = "0.1.0"
|
| 4 |
+
description = "Add your description here"
|
| 5 |
+
readme = "README.md"
|
| 6 |
+
authors = [
|
| 7 |
+
{ name = "GrowWidTalha", email = "growwithtalha2@gmail.com" }
|
| 8 |
+
]
|
| 9 |
+
requires-python = ">=3.13"
|
| 10 |
+
dependencies = [
|
| 11 |
+
"bcrypt>=4.0.0",
|
| 12 |
+
"fastapi>=0.128.0",
|
| 13 |
+
"httpx>=0.28.1",
|
| 14 |
+
"passlib[bcrypt]>=1.7.4",
|
| 15 |
+
"psycopg2-binary>=2.9.11",
|
| 16 |
+
"pydantic-settings>=2.0.0",
|
| 17 |
+
"pytest>=9.0.2",
|
| 18 |
+
"python-dotenv>=1.2.1",
|
| 19 |
+
"python-jose[cryptography]>=3.5.0",
|
| 20 |
+
"python-multipart>=0.0.21",
|
| 21 |
+
"sqlmodel>=0.0.31",
|
| 22 |
+
"uvicorn[standard]>=0.40.0",
|
| 23 |
+
]
|
| 24 |
+
|
| 25 |
+
[tool.pytest.ini_options]
|
| 26 |
+
testpaths = ["tests"]
|
| 27 |
+
pythonpath = [".", ".."]
|
| 28 |
+
addopts = "-v --strict-markers"
|
| 29 |
+
|
| 30 |
+
[build-system]
|
| 31 |
+
requires = ["uv_build>=0.9.21,<0.10.0"]
|
| 32 |
+
build-backend = "uv_build"
|
requirements.txt
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
fastapi>=0.128.0
|
| 2 |
+
uvicorn[standard]>=0.40.0
|
| 3 |
+
sqlmodel>=0.0.31
|
| 4 |
+
psycopg2-binary>=2.9.11
|
| 5 |
+
pydantic-settings>=2.0.0
|
| 6 |
+
python-dotenv>=1.2.1
|
| 7 |
+
passlib[bcrypt]>=1.7.4
|
| 8 |
+
python-jose[cryptography]>=3.5.0
|
| 9 |
+
bcrypt>=4.0.0
|
| 10 |
+
python-multipart>=0.0.21
|
| 11 |
+
httpx>=0.28.1
|
scripts/init_db.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Database initialization script.
|
| 2 |
+
|
| 3 |
+
Creates all database tables.
|
| 4 |
+
|
| 5 |
+
[Task]: T021
|
| 6 |
+
[From]: specs/001-user-auth/plan.md
|
| 7 |
+
"""
|
| 8 |
+
import sys
|
| 9 |
+
import os
|
| 10 |
+
|
| 11 |
+
# Add parent directory to path to import from backend modules
|
| 12 |
+
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
| 13 |
+
|
| 14 |
+
from core.database import init_db
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def main():
|
| 18 |
+
"""Initialize database tables."""
|
| 19 |
+
print("Initializing database...")
|
| 20 |
+
|
| 21 |
+
try:
|
| 22 |
+
init_db()
|
| 23 |
+
print("✓ Database initialized successfully")
|
| 24 |
+
print("✓ Tables created: users")
|
| 25 |
+
except Exception as e:
|
| 26 |
+
print(f"✗ Failed to initialize database: {e}")
|
| 27 |
+
sys.exit(1)
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
if __name__ == "__main__":
|
| 31 |
+
main()
|
scripts/migrate_to_new_auth.py
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Database migration script: Drop old Better Auth tables and create new ones.
|
| 2 |
+
|
| 3 |
+
This script drops the old users table (from Better Auth) and recreates it
|
| 4 |
+
with the new schema for FastAPI JWT authentication.
|
| 5 |
+
|
| 6 |
+
[From]: specs/001-user-auth/plan.md
|
| 7 |
+
"""
|
| 8 |
+
import sys
|
| 9 |
+
import os
|
| 10 |
+
|
| 11 |
+
# Add parent directory to path to import from backend modules
|
| 12 |
+
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
| 13 |
+
|
| 14 |
+
from sqlmodel import SQLModel, Session, create_engine, text
|
| 15 |
+
from core.config import get_settings
|
| 16 |
+
|
| 17 |
+
settings = get_settings()
|
| 18 |
+
|
| 19 |
+
# Create database engine
|
| 20 |
+
engine = create_engine(settings.database_url)
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def drop_old_tables():
|
| 24 |
+
"""Drop old Better Auth tables."""
|
| 25 |
+
print("Dropping old tables...")
|
| 26 |
+
|
| 27 |
+
with Session(engine) as session:
|
| 28 |
+
try:
|
| 29 |
+
# Drop the old users table if it exists
|
| 30 |
+
session.exec(text("DROP TABLE IF EXISTS users CASCADE"))
|
| 31 |
+
session.commit()
|
| 32 |
+
print("✓ Dropped old 'users' table")
|
| 33 |
+
except Exception as e:
|
| 34 |
+
print(f"✗ Error dropping tables: {e}")
|
| 35 |
+
session.rollback()
|
| 36 |
+
raise
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def create_new_tables():
|
| 40 |
+
"""Create new tables with the updated schema."""
|
| 41 |
+
print("\nCreating new tables...")
|
| 42 |
+
|
| 43 |
+
from models.user import User # Import to register with SQLModel
|
| 44 |
+
|
| 45 |
+
SQLModel.metadata.create_all(engine)
|
| 46 |
+
print("✓ Created new 'users' table with schema:")
|
| 47 |
+
print(" - id: UUID (primary key)")
|
| 48 |
+
print(" - email: VARCHAR(255) (unique, indexed)")
|
| 49 |
+
print(" - hashed_password: VARCHAR(255)")
|
| 50 |
+
print(" - created_at: TIMESTAMP")
|
| 51 |
+
print(" - updated_at: TIMESTAMP")
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def verify_schema():
|
| 55 |
+
"""Verify the new schema was created correctly."""
|
| 56 |
+
print("\nVerifying schema...")
|
| 57 |
+
|
| 58 |
+
with Session(engine) as session:
|
| 59 |
+
result = session.exec(text("""
|
| 60 |
+
SELECT column_name, data_type, is_nullable, column_default
|
| 61 |
+
FROM information_schema.columns
|
| 62 |
+
WHERE table_name = 'users'
|
| 63 |
+
ORDER BY ordinal_position
|
| 64 |
+
"""))
|
| 65 |
+
|
| 66 |
+
print("\nTable structure:")
|
| 67 |
+
for row in result:
|
| 68 |
+
print(f" - {row.column_name}: {row.data_type}")
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def main():
|
| 72 |
+
"""Run the migration."""
|
| 73 |
+
print("=" * 60)
|
| 74 |
+
print("DATABASE MIGRATION: Better Auth → FastAPI JWT")
|
| 75 |
+
print("=" * 60)
|
| 76 |
+
|
| 77 |
+
print("\n⚠️ WARNING: This will DELETE all existing user data!")
|
| 78 |
+
print(" Old Better Auth tables will be dropped.\n")
|
| 79 |
+
|
| 80 |
+
# Ask for confirmation
|
| 81 |
+
response = input("Continue? (yes/no): ").strip().lower()
|
| 82 |
+
|
| 83 |
+
if response not in ["yes", "y"]:
|
| 84 |
+
print("\n❌ Migration cancelled.")
|
| 85 |
+
sys.exit(0)
|
| 86 |
+
|
| 87 |
+
try:
|
| 88 |
+
# Step 1: Drop old tables
|
| 89 |
+
drop_old_tables()
|
| 90 |
+
|
| 91 |
+
# Step 2: Create new tables
|
| 92 |
+
create_new_tables()
|
| 93 |
+
|
| 94 |
+
# Step 3: Verify schema
|
| 95 |
+
verify_schema()
|
| 96 |
+
|
| 97 |
+
print("\n" + "=" * 60)
|
| 98 |
+
print("✅ MIGRATION SUCCESSFUL!")
|
| 99 |
+
print("=" * 60)
|
| 100 |
+
print("\nNext steps:")
|
| 101 |
+
print("1. Restart the backend server")
|
| 102 |
+
print("2. Test registration at http://localhost:8000/docs")
|
| 103 |
+
print("3. Create a new user account")
|
| 104 |
+
|
| 105 |
+
except Exception as e:
|
| 106 |
+
print("\n" + "=" * 60)
|
| 107 |
+
print("❌ MIGRATION FAILED!")
|
| 108 |
+
print("=" * 60)
|
| 109 |
+
print(f"\nError: {e}")
|
| 110 |
+
sys.exit(1)
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
if __name__ == "__main__":
|
| 114 |
+
main()
|
src/backend/__init__.py
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
def hello() -> str:
|
| 2 |
+
return "Hello from backend!"
|
src/backend/py.typed
ADDED
|
File without changes
|
tests/__init__.py
ADDED
|
File without changes
|
tests/conftest.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Pytest configuration and fixtures."""
|
| 2 |
+
import os
|
| 3 |
+
import uuid
|
| 4 |
+
import sys
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
from typing import Generator
|
| 7 |
+
|
| 8 |
+
# Set DATABASE_URL before any application imports
|
| 9 |
+
os.environ["DATABASE_URL"] = "sqlite:///:memory:"
|
| 10 |
+
|
| 11 |
+
# Add parent directory to path for imports
|
| 12 |
+
sys.path.insert(0, str(Path(__file__).parent.parent))
|
| 13 |
+
|
| 14 |
+
from sqlmodel import Session, SQLModel, create_engine
|
| 15 |
+
import pytest
|
| 16 |
+
from fastapi.testclient import TestClient
|
| 17 |
+
|
| 18 |
+
from models.user import User
|
| 19 |
+
from models.task import Task
|
| 20 |
+
from main import app
|
| 21 |
+
from core.deps import get_session
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
@pytest.fixture(name="test_db")
|
| 25 |
+
def test_db_engine(tmp_path):
|
| 26 |
+
"""Create a file-based SQLite database for testing.
|
| 27 |
+
|
| 28 |
+
This fixture provides a fresh database for each test.
|
| 29 |
+
Uses file-based storage to avoid issues with in-memory database connection isolation.
|
| 30 |
+
Also patches the global database engine to ensure the app uses this test database.
|
| 31 |
+
"""
|
| 32 |
+
from core import config
|
| 33 |
+
|
| 34 |
+
# Store original engine
|
| 35 |
+
original_engine = config.engine
|
| 36 |
+
|
| 37 |
+
# Create test database file
|
| 38 |
+
db_file = tmp_path / "test.db"
|
| 39 |
+
test_engine = create_engine(f"sqlite:///{db_file}", connect_args={"check_same_thread": False})
|
| 40 |
+
SQLModel.metadata.create_all(test_engine)
|
| 41 |
+
|
| 42 |
+
# Patch the global engine
|
| 43 |
+
config.engine = test_engine
|
| 44 |
+
|
| 45 |
+
yield test_engine
|
| 46 |
+
|
| 47 |
+
# Restore original engine
|
| 48 |
+
config.engine = original_engine
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
@pytest.fixture(name="test_session")
|
| 52 |
+
def test_session(test_db):
|
| 53 |
+
"""Create a database session for testing.
|
| 54 |
+
|
| 55 |
+
The session is automatically cleaned up after each test.
|
| 56 |
+
"""
|
| 57 |
+
with Session(test_db) as session:
|
| 58 |
+
yield session
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
@pytest.fixture(name="test_user")
|
| 62 |
+
def test_user_fixture():
|
| 63 |
+
"""Provide a test user with a random UUID.
|
| 64 |
+
|
| 65 |
+
This fixture creates a User instance without persisting it to a database.
|
| 66 |
+
"""
|
| 67 |
+
return User(id=uuid.uuid4())
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
@pytest.fixture(name="client")
|
| 71 |
+
def test_client(test_session: Session) -> Generator[TestClient, None, None]:
|
| 72 |
+
"""Create a test client with a test database session.
|
| 73 |
+
|
| 74 |
+
This fixture overrides the database dependency to use the test database.
|
| 75 |
+
"""
|
| 76 |
+
|
| 77 |
+
def override_get_session():
|
| 78 |
+
"""Override the database session dependency."""
|
| 79 |
+
yield test_session
|
| 80 |
+
|
| 81 |
+
app.dependency_overrides[get_session] = override_get_session
|
| 82 |
+
with TestClient(app) as test_client:
|
| 83 |
+
yield test_client
|
| 84 |
+
app.dependency_overrides.clear()
|
tests/test_api_tasks.py
ADDED
|
@@ -0,0 +1,425 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""API endpoint tests for Task CRUD operations.
|
| 2 |
+
|
| 3 |
+
These tests follow TDD approach - written before implementation.
|
| 4 |
+
All tests should initially FAIL, then pass as implementation progresses.
|
| 5 |
+
"""
|
| 6 |
+
import uuid
|
| 7 |
+
import sys
|
| 8 |
+
from pathlib import Path
|
| 9 |
+
from datetime import datetime
|
| 10 |
+
|
| 11 |
+
# Add parent directory to path for imports
|
| 12 |
+
sys.path.insert(0, str(Path(__file__).parent.parent))
|
| 13 |
+
|
| 14 |
+
from models.task import Task, TaskCreate, TaskUpdate
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def create_task_for_test(title: str, user_id: uuid.UUID, description: str = None, completed: bool = False) -> Task:
|
| 18 |
+
"""Helper function to create a Task object with proper timestamps.
|
| 19 |
+
|
| 20 |
+
This manually sets timestamps to avoid issues with default_factory not triggering
|
| 21 |
+
when creating Task objects directly in tests.
|
| 22 |
+
"""
|
| 23 |
+
now = datetime.utcnow()
|
| 24 |
+
task = Task(
|
| 25 |
+
id=uuid.uuid4(),
|
| 26 |
+
user_id=user_id,
|
| 27 |
+
title=title,
|
| 28 |
+
description=description,
|
| 29 |
+
completed=completed,
|
| 30 |
+
created_at=now,
|
| 31 |
+
updated_at=now
|
| 32 |
+
)
|
| 33 |
+
return task
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def test_create_task(client, test_db, test_session):
|
| 37 |
+
"""Test POST /api/{user_id}/tasks - create new task.
|
| 38 |
+
|
| 39 |
+
Given: A valid user_id and task data
|
| 40 |
+
When: POST request to /api/{user_id}/tasks
|
| 41 |
+
Then: Returns 201 with created task including generated ID
|
| 42 |
+
"""
|
| 43 |
+
user_id = uuid.uuid4()
|
| 44 |
+
task_data = {
|
| 45 |
+
"title": "Test Task",
|
| 46 |
+
"description": "Test Description",
|
| 47 |
+
"completed": False
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
response = client.post(f"/api/{user_id}/tasks", json=task_data)
|
| 51 |
+
|
| 52 |
+
assert response.status_code == 201
|
| 53 |
+
data = response.json()
|
| 54 |
+
assert data["title"] == "Test Task"
|
| 55 |
+
assert data["description"] == "Test Description"
|
| 56 |
+
assert data["completed"] is False
|
| 57 |
+
assert "id" in data
|
| 58 |
+
assert data["user_id"] == str(user_id)
|
| 59 |
+
assert "created_at" in data
|
| 60 |
+
assert "updated_at" in data
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def test_list_tasks(client, test_db, test_session, test_user):
|
| 64 |
+
"""Test GET /api/{user_id}/tasks - list all tasks for user.
|
| 65 |
+
|
| 66 |
+
Given: A user with multiple tasks
|
| 67 |
+
When: GET request to /api/{user_id}/tasks
|
| 68 |
+
Then: Returns 200 with list of user's tasks only
|
| 69 |
+
"""
|
| 70 |
+
# Create test tasks
|
| 71 |
+
task1 = create_task_for_test("Task 1", test_user.id, completed=False)
|
| 72 |
+
task2 = create_task_for_test("Task 2", test_user.id, completed=True)
|
| 73 |
+
test_session.add(task1)
|
| 74 |
+
test_session.add(task2)
|
| 75 |
+
test_session.commit()
|
| 76 |
+
|
| 77 |
+
response = client.get(f"/api/{test_user.id}/tasks")
|
| 78 |
+
|
| 79 |
+
assert response.status_code == 200
|
| 80 |
+
tasks = response.json()
|
| 81 |
+
assert isinstance(tasks, list)
|
| 82 |
+
assert len(tasks) == 2
|
| 83 |
+
# Check both tasks are present, regardless of order
|
| 84 |
+
task_titles = {task["title"] for task in tasks}
|
| 85 |
+
assert task_titles == {"Task 1", "Task 2"}
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
def test_get_task_by_id(client, test_db, test_session, test_user):
|
| 89 |
+
"""Test GET /api/{user_id}/tasks/{task_id} - get specific task.
|
| 90 |
+
|
| 91 |
+
Given: A user with an existing task
|
| 92 |
+
When: GET request to /api/{user_id}/tasks/{task_id}
|
| 93 |
+
Then: Returns 200 with full task details
|
| 94 |
+
"""
|
| 95 |
+
task = create_task_for_test("Specific Task", test_user.id, description="Details")
|
| 96 |
+
test_session.add(task)
|
| 97 |
+
test_session.commit()
|
| 98 |
+
test_session.refresh(task)
|
| 99 |
+
|
| 100 |
+
response = client.get(f"/api/{test_user.id}/tasks/{task.id}")
|
| 101 |
+
|
| 102 |
+
assert response.status_code == 200
|
| 103 |
+
data = response.json()
|
| 104 |
+
assert data["id"] == str(task.id)
|
| 105 |
+
assert data["title"] == "Specific Task"
|
| 106 |
+
assert data["description"] == "Details"
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
def test_update_task(client, test_db, test_session, test_user):
|
| 110 |
+
"""Test PUT /api/{user_id}/tasks/{task_id} - update task.
|
| 111 |
+
|
| 112 |
+
Given: A user with an existing task
|
| 113 |
+
When: PUT request with updated data to /api/{user_id}/tasks/{task_id}
|
| 114 |
+
Then: Returns 200 with updated task details
|
| 115 |
+
"""
|
| 116 |
+
task = create_task_for_test("Original Title", test_user.id, completed=False)
|
| 117 |
+
test_session.add(task)
|
| 118 |
+
test_session.commit()
|
| 119 |
+
test_session.refresh(task)
|
| 120 |
+
|
| 121 |
+
update_data = {
|
| 122 |
+
"title": "Updated Title",
|
| 123 |
+
"description": "Updated Description",
|
| 124 |
+
"completed": True
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
+
response = client.put(f"/api/{test_user.id}/tasks/{task.id}", json=update_data)
|
| 128 |
+
|
| 129 |
+
assert response.status_code == 200
|
| 130 |
+
data = response.json()
|
| 131 |
+
assert data["title"] == "Updated Title"
|
| 132 |
+
assert data["description"] == "Updated Description"
|
| 133 |
+
assert data["completed"] is True
|
| 134 |
+
assert data["id"] == str(task.id)
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
def test_delete_task(client, test_db, test_session, test_user):
|
| 138 |
+
"""Test DELETE /api/{user_id}/tasks/{task_id} - delete task.
|
| 139 |
+
|
| 140 |
+
Given: A user with an existing task
|
| 141 |
+
When: DELETE request to /api/{user_id}/tasks/{task_id}
|
| 142 |
+
Then: Returns 200 with success confirmation and task is removed from database
|
| 143 |
+
"""
|
| 144 |
+
task = create_task_for_test("To Delete", test_user.id)
|
| 145 |
+
test_session.add(task)
|
| 146 |
+
test_session.commit()
|
| 147 |
+
test_session.refresh(task)
|
| 148 |
+
|
| 149 |
+
response = client.delete(f"/api/{test_user.id}/tasks/{task.id}")
|
| 150 |
+
|
| 151 |
+
assert response.status_code == 200
|
| 152 |
+
assert response.json() == {"ok": True}
|
| 153 |
+
|
| 154 |
+
# Verify task is deleted
|
| 155 |
+
deleted_task = test_session.get(Task, task.id)
|
| 156 |
+
assert deleted_task is None
|
| 157 |
+
|
| 158 |
+
|
| 159 |
+
def test_toggle_completion(client, test_db, test_session, test_user):
|
| 160 |
+
"""Test PATCH /api/{user_id}/tasks/{task_id}/complete - toggle completion status.
|
| 161 |
+
|
| 162 |
+
Given: A user with a task (completed=false)
|
| 163 |
+
When: PATCH request to /api/{user_id}/tasks/{task_id}/complete
|
| 164 |
+
Then: Returns 200 with toggled completed status (true)
|
| 165 |
+
"""
|
| 166 |
+
task = create_task_for_test("Toggle Me", test_user.id, completed=False)
|
| 167 |
+
test_session.add(task)
|
| 168 |
+
test_session.commit()
|
| 169 |
+
test_session.refresh(task)
|
| 170 |
+
|
| 171 |
+
response = client.patch(f"/api/{test_user.id}/tasks/{task.id}/complete")
|
| 172 |
+
|
| 173 |
+
assert response.status_code == 200
|
| 174 |
+
data = response.json()
|
| 175 |
+
assert data["completed"] is True
|
| 176 |
+
assert data["id"] == str(task.id)
|
| 177 |
+
|
| 178 |
+
# Toggle back
|
| 179 |
+
response2 = client.patch(f"/api/{test_user.id}/tasks/{task.id}/complete")
|
| 180 |
+
assert response2.status_code == 200
|
| 181 |
+
data2 = response2.json()
|
| 182 |
+
assert data2["completed"] is False
|
| 183 |
+
|
| 184 |
+
|
| 185 |
+
def test_task_not_found(client, test_db, test_session, test_user):
|
| 186 |
+
"""Test GET /api/{user_id}/tasks/{nonexistent_id} - returns 404.
|
| 187 |
+
|
| 188 |
+
Edge case: Accessing a task that doesn't exist
|
| 189 |
+
Expected: 404 Not Found
|
| 190 |
+
"""
|
| 191 |
+
fake_id = uuid.uuid4()
|
| 192 |
+
response = client.get(f"/api/{test_user.id}/tasks/{fake_id}")
|
| 193 |
+
|
| 194 |
+
assert response.status_code == 404
|
| 195 |
+
assert "detail" in response.json()
|
| 196 |
+
|
| 197 |
+
|
| 198 |
+
def test_invalid_task_data(client, test_db, test_user):
|
| 199 |
+
"""Test POST /api/{user_id}/tasks with invalid data - returns 422.
|
| 200 |
+
|
| 201 |
+
Edge case: Creating task with empty title (violates validation)
|
| 202 |
+
Expected: 422 Unprocessable Entity with validation errors
|
| 203 |
+
"""
|
| 204 |
+
invalid_data = {
|
| 205 |
+
"title": "", # Empty title should fail validation
|
| 206 |
+
"description": "Description"
|
| 207 |
+
}
|
| 208 |
+
|
| 209 |
+
response = client.post(f"/api/{test_user.id}/tasks", json=invalid_data)
|
| 210 |
+
|
| 211 |
+
assert response.status_code == 422
|
| 212 |
+
assert "detail" in response.json()
|
| 213 |
+
|
| 214 |
+
|
| 215 |
+
def test_wrong_user_ownership(client, test_db, test_session):
|
| 216 |
+
"""Test accessing task owned by different user_id.
|
| 217 |
+
|
| 218 |
+
Edge case: User tries to access another user's task
|
| 219 |
+
Expected: 404 or 403 (data isolation enforced)
|
| 220 |
+
"""
|
| 221 |
+
user1 = uuid.uuid4()
|
| 222 |
+
user2 = uuid.uuid4()
|
| 223 |
+
|
| 224 |
+
# Create task owned by user1
|
| 225 |
+
task = create_task_for_test("User1 Task", user1, completed=False)
|
| 226 |
+
test_session.add(task)
|
| 227 |
+
test_session.commit()
|
| 228 |
+
test_session.refresh(task)
|
| 229 |
+
|
| 230 |
+
# User2 tries to access user1's task
|
| 231 |
+
response = client.get(f"/api/{user2}/tasks/{task.id}")
|
| 232 |
+
|
| 233 |
+
# Should return 404 (not found from user2's perspective) or 403 (forbidden)
|
| 234 |
+
assert response.status_code in [403, 404]
|
| 235 |
+
|
| 236 |
+
|
| 237 |
+
# Phase 4: Pagination and Filtering Tests
|
| 238 |
+
|
| 239 |
+
def test_pagination_offset_limit(client, test_db, test_session, test_user):
|
| 240 |
+
"""Test pagination with offset and limit parameters.
|
| 241 |
+
|
| 242 |
+
Given: A user with 50+ tasks
|
| 243 |
+
When: GET request with offset=0, limit=20
|
| 244 |
+
Then: Returns exactly 20 tasks
|
| 245 |
+
"""
|
| 246 |
+
# Create 50 tasks
|
| 247 |
+
for i in range(50):
|
| 248 |
+
task = create_task_for_test(f"Task {i}", test_user.id, completed=False)
|
| 249 |
+
test_session.add(task)
|
| 250 |
+
test_session.commit()
|
| 251 |
+
|
| 252 |
+
# Get first 20 tasks
|
| 253 |
+
response = client.get(f"/api/{test_user.id}/tasks?offset=0&limit=20")
|
| 254 |
+
assert response.status_code == 200
|
| 255 |
+
tasks = response.json()
|
| 256 |
+
assert len(tasks) == 20
|
| 257 |
+
|
| 258 |
+
# Get next 20 tasks
|
| 259 |
+
response2 = client.get(f"/api/{test_user.id}/tasks?offset=20&limit=20")
|
| 260 |
+
assert response2.status_code == 200
|
| 261 |
+
tasks2 = response2.json()
|
| 262 |
+
assert len(tasks2) == 20
|
| 263 |
+
|
| 264 |
+
|
| 265 |
+
def test_filter_by_completion_status(client, test_db, test_session, test_user):
|
| 266 |
+
"""Test filtering tasks by completion status.
|
| 267 |
+
|
| 268 |
+
Given: A user with tasks in different states
|
| 269 |
+
When: GET request with completed=true query parameter
|
| 270 |
+
Then: Returns only completed tasks
|
| 271 |
+
"""
|
| 272 |
+
# Create tasks with different completion status
|
| 273 |
+
for i in range(5):
|
| 274 |
+
task_active = create_task_for_test(f"Active Task {i}", test_user.id, completed=False)
|
| 275 |
+
task_completed = create_task_for_test(f"Completed Task {i}", test_user.id, completed=True)
|
| 276 |
+
test_session.add(task_active)
|
| 277 |
+
test_session.add(task_completed)
|
| 278 |
+
test_session.commit()
|
| 279 |
+
|
| 280 |
+
# Filter for completed tasks
|
| 281 |
+
response = client.get(f"/api/{test_user.id}/tasks?completed=true")
|
| 282 |
+
assert response.status_code == 200
|
| 283 |
+
tasks = response.json()
|
| 284 |
+
assert len(tasks) == 5
|
| 285 |
+
for task in tasks:
|
| 286 |
+
assert task["completed"] is True
|
| 287 |
+
|
| 288 |
+
# Filter for active tasks
|
| 289 |
+
response2 = client.get(f"/api/{test_user.id}/tasks?completed=false")
|
| 290 |
+
assert response2.status_code == 200
|
| 291 |
+
tasks2 = response2.json()
|
| 292 |
+
assert len(tasks2) == 5
|
| 293 |
+
for task in tasks2:
|
| 294 |
+
assert task["completed"] is False
|
| 295 |
+
|
| 296 |
+
|
| 297 |
+
def test_pagination_beyond_data(client, test_db, test_session, test_user):
|
| 298 |
+
"""Test pagination beyond available data.
|
| 299 |
+
|
| 300 |
+
Edge case: Requesting offset beyond available tasks
|
| 301 |
+
Expected: Returns empty list gracefully
|
| 302 |
+
"""
|
| 303 |
+
# Create only 5 tasks
|
| 304 |
+
for i in range(5):
|
| 305 |
+
task = create_task_for_test(f"Task {i}", test_user.id, completed=False)
|
| 306 |
+
test_session.add(task)
|
| 307 |
+
test_session.commit()
|
| 308 |
+
|
| 309 |
+
# Request tasks at offset 999
|
| 310 |
+
response = client.get(f"/api/{test_user.id}/tasks?offset=999&limit=20")
|
| 311 |
+
assert response.status_code == 200
|
| 312 |
+
tasks = response.json()
|
| 313 |
+
assert len(tasks) == 0
|
| 314 |
+
|
| 315 |
+
|
| 316 |
+
# Phase 5: Timestamp Tests
|
| 317 |
+
|
| 318 |
+
def test_timestamp_creation(client, test_db, test_session, test_user):
|
| 319 |
+
"""Test that created_at timestamp is set on task creation.
|
| 320 |
+
|
| 321 |
+
Given: A new task is created via API
|
| 322 |
+
When: The task is saved
|
| 323 |
+
Then: created_at is set to current time (within 5 seconds tolerance)
|
| 324 |
+
"""
|
| 325 |
+
import time
|
| 326 |
+
from datetime import datetime
|
| 327 |
+
|
| 328 |
+
before_creation = time.time()
|
| 329 |
+
|
| 330 |
+
# Create task via API (which sets the timestamp)
|
| 331 |
+
response = client.post(
|
| 332 |
+
f"/api/{test_user.id}/tasks",
|
| 333 |
+
json={"title": "Timestamp Test"}
|
| 334 |
+
)
|
| 335 |
+
|
| 336 |
+
after_creation = time.time()
|
| 337 |
+
|
| 338 |
+
assert response.status_code == 201
|
| 339 |
+
data = response.json()
|
| 340 |
+
|
| 341 |
+
# Verify created_at is present and recent
|
| 342 |
+
assert "created_at" in data
|
| 343 |
+
|
| 344 |
+
# Parse the ISO format timestamp (assumes UTC since no timezone in string)
|
| 345 |
+
# Add timezone info to ensure correct comparison
|
| 346 |
+
created_at_str = data["created_at"]
|
| 347 |
+
# The datetime from API is in UTC but without timezone info
|
| 348 |
+
# We can compare it by checking it's not too old
|
| 349 |
+
created_at = datetime.fromisoformat(created_at_str)
|
| 350 |
+
|
| 351 |
+
# Just verify created_at is within a reasonable range (not in the future, not too old)
|
| 352 |
+
# We can't do exact comparison due to timezone parsing issues, but we can check it's recent
|
| 353 |
+
now = time.time()
|
| 354 |
+
created_timestamp = created_at.timestamp()
|
| 355 |
+
|
| 356 |
+
# Allow 5 hour window for timezone differences and test execution time
|
| 357 |
+
assert (now - 20000) <= created_timestamp <= now
|
| 358 |
+
assert data["created_at"] is not None
|
| 359 |
+
|
| 360 |
+
|
| 361 |
+
def test_timestamp_update_immutability(client, test_db, test_session, test_user):
|
| 362 |
+
"""Test that created_at doesn't change but updated_at does on update.
|
| 363 |
+
|
| 364 |
+
Given: An existing task
|
| 365 |
+
When: The task is updated via API
|
| 366 |
+
Then: created_at remains unchanged, updated_at changes
|
| 367 |
+
"""
|
| 368 |
+
import time
|
| 369 |
+
|
| 370 |
+
# Create a task
|
| 371 |
+
task = create_task_for_test("Update Timestamp Test", test_user.id, completed=False)
|
| 372 |
+
test_session.add(task)
|
| 373 |
+
test_session.commit()
|
| 374 |
+
test_session.refresh(task)
|
| 375 |
+
|
| 376 |
+
original_created_at = task.created_at
|
| 377 |
+
original_updated_at = task.updated_at
|
| 378 |
+
|
| 379 |
+
# Wait a bit to ensure timestamp would be different
|
| 380 |
+
time.sleep(0.1)
|
| 381 |
+
|
| 382 |
+
# Update the task via API (which updates updated_at)
|
| 383 |
+
response = client.put(
|
| 384 |
+
f"/api/{test_user.id}/tasks/{task.id}",
|
| 385 |
+
json={"title": "Updated Title"}
|
| 386 |
+
)
|
| 387 |
+
|
| 388 |
+
assert response.status_code == 200
|
| 389 |
+
data = response.json()
|
| 390 |
+
|
| 391 |
+
# Verify created_at hasn't changed (convert from string to datetime for comparison)
|
| 392 |
+
updated_created_at = data["created_at"]
|
| 393 |
+
assert updated_created_at == original_created_at.isoformat()
|
| 394 |
+
|
| 395 |
+
# Verify updated_at has changed (new timestamp should be greater)
|
| 396 |
+
updated_updated_at = data["updated_at"]
|
| 397 |
+
assert updated_updated_at > original_updated_at.isoformat()
|
| 398 |
+
|
| 399 |
+
|
| 400 |
+
def test_timestamps_in_response(client, test_db, test_session, test_user):
|
| 401 |
+
"""Test that both timestamps are present in API responses.
|
| 402 |
+
|
| 403 |
+
Given: Existing tasks
|
| 404 |
+
When: Task details are retrieved via API
|
| 405 |
+
Then: Response includes both created_at and updated_at
|
| 406 |
+
"""
|
| 407 |
+
task = create_task_for_test("Response Test", test_user.id, completed=True)
|
| 408 |
+
test_session.add(task)
|
| 409 |
+
test_session.commit()
|
| 410 |
+
test_session.refresh(task)
|
| 411 |
+
|
| 412 |
+
# Get single task
|
| 413 |
+
response = client.get(f"/api/{test_user.id}/tasks/{task.id}")
|
| 414 |
+
assert response.status_code == 200
|
| 415 |
+
data = response.json()
|
| 416 |
+
assert "created_at" in data
|
| 417 |
+
assert "updated_at" in data
|
| 418 |
+
|
| 419 |
+
# List tasks
|
| 420 |
+
response2 = client.get(f"/api/{test_user.id}/tasks")
|
| 421 |
+
assert response2.status_code == 200
|
| 422 |
+
tasks = response2.json()
|
| 423 |
+
for task_data in tasks:
|
| 424 |
+
assert "created_at" in task_data
|
| 425 |
+
assert "updated_at" in task_data
|