GrowWithTalha commited on
Commit
69be42f
·
1 Parent(s): c915a10
.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
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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