feat: implement Instagram clone SocialPhoto API
- FastAPI backend with SQLite database - JWT authentication (register, login) - User profiles with follow/unfollow - Posts with image upload and likes/dislikes - Comments with likes - Global and personalized feed - Comprehensive pytest test suite (37 tests) TASK-ID: 758f4029-702
This commit is contained in:
151
README.md
151
README.md
@@ -1,2 +1,151 @@
|
||||
# instagram-clone
|
||||
# SocialPhoto - Instagram Clone
|
||||
|
||||
A simple Instagram clone API built with FastAPI and SQLite.
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 1. Install dependencies
|
||||
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
### 2. Run the server
|
||||
|
||||
```bash
|
||||
cd app
|
||||
uvicorn main:app --reload --host 0.0.0.0 --port 8000
|
||||
```
|
||||
|
||||
Or from the root directory:
|
||||
|
||||
```bash
|
||||
uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
|
||||
```
|
||||
|
||||
### 3. Access API docs
|
||||
|
||||
Open your browser to: http://localhost:8000/docs
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### Authentication
|
||||
|
||||
```bash
|
||||
# Register
|
||||
curl -X POST http://localhost:8000/auth/register \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"username":"test","email":"test@test.com","password":"password123"}'
|
||||
|
||||
# Login
|
||||
curl -X POST http://localhost:8000/auth/login \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"username":"test","password":"password123"}'
|
||||
```
|
||||
|
||||
### Posts
|
||||
|
||||
```bash
|
||||
# Create post (requires auth token)
|
||||
curl -X POST http://localhost:8000/posts \
|
||||
-H "Authorization: Bearer YOUR_TOKEN" \
|
||||
-F "caption=My first post" \
|
||||
-F "image=@photo.jpg"
|
||||
|
||||
# Get all posts
|
||||
curl http://localhost:8000/posts
|
||||
|
||||
# Get single post
|
||||
curl http://localhost:8000/posts/1
|
||||
|
||||
# Like a post
|
||||
curl -X POST http://localhost:8000/posts/1/like \
|
||||
-H "Authorization: Bearer YOUR_TOKEN"
|
||||
|
||||
# Delete post (owner only)
|
||||
curl -X DELETE http://localhost:8000/posts/1 \
|
||||
-H "Authorization: Bearer YOUR_TOKEN"
|
||||
```
|
||||
|
||||
### Users
|
||||
|
||||
```bash
|
||||
# Get user profile
|
||||
curl http://localhost:8000/users/1
|
||||
|
||||
# Get user posts
|
||||
curl http://localhost:8000/users/1/posts
|
||||
|
||||
# Get user stats
|
||||
curl http://localhost:8000/users/1/stats
|
||||
|
||||
# Follow user
|
||||
curl -X POST http://localhost:8000/users/1/follow \
|
||||
-H "Authorization: Bearer YOUR_TOKEN"
|
||||
|
||||
# Unfollow user
|
||||
curl -X DELETE http://localhost:8000/users/1/follow \
|
||||
-H "Authorization: Bearer YOUR_TOKEN"
|
||||
```
|
||||
|
||||
### Comments
|
||||
|
||||
```bash
|
||||
# Add comment
|
||||
curl -X POST http://localhost:8000/posts/1/comments \
|
||||
-H "Authorization: Bearer YOUR_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"content":"Great post!"}'
|
||||
|
||||
# Get comments
|
||||
curl http://localhost:8000/posts/1/comments
|
||||
|
||||
# Delete comment (owner only)
|
||||
curl -X DELETE http://localhost:8000/comments/1 \
|
||||
-H "Authorization: Bearer YOUR_TOKEN"
|
||||
```
|
||||
|
||||
### Feed
|
||||
|
||||
```bash
|
||||
# Get followed users feed (requires auth)
|
||||
curl http://localhost:8000/feed \
|
||||
-H "Authorization: Bearer YOUR_TOKEN"
|
||||
|
||||
# Get global feed
|
||||
curl http://localhost:8000/feed/global
|
||||
```
|
||||
|
||||
## Running Tests
|
||||
|
||||
```bash
|
||||
pytest tests/ -v
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
app/
|
||||
├── main.py # FastAPI application
|
||||
├── database.py # SQLite database setup
|
||||
├── models.py # Pydantic models
|
||||
├── auth.py # JWT authentication
|
||||
└── routes/
|
||||
├── auth.py # Auth endpoints
|
||||
├── users.py # User endpoints
|
||||
├── posts.py # Post endpoints
|
||||
├── comments.py # Comment endpoints
|
||||
└── feed.py # Feed endpoints
|
||||
```
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Framework**: FastAPI 0.109+
|
||||
- **Database**: SQLite
|
||||
- **Auth**: JWT (python-jose)
|
||||
- **Password Hashing**: bcrypt
|
||||
- **Testing**: pytest
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
|
||||
337
SPEC.md
Normal file
337
SPEC.md
Normal file
@@ -0,0 +1,337 @@
|
||||
# SPEC.md — Instagram Clone (SocialPhoto)
|
||||
|
||||
> Sistema de red social para compartir imágenes con likes, comentarios y seguimiento de usuarios
|
||||
|
||||
## 1. Concepto & Visión
|
||||
|
||||
**SocialPhoto** es un clon minimalista de Instagram que prioriza la simplicidad y velocidad. Permite a usuarios registrarse, subir imágenes, interactuar con publicaciones (likes/no me gusta), comentar y seguir a otros usuarios. El enfoque es en ser rápido, portable (todo en un archivo SQLite) y fácil de desplegar.
|
||||
|
||||
**Principios:**
|
||||
- Simple de instalar y operar
|
||||
- Base de datos portable (SQLite)
|
||||
- API RESTful clara y documentada
|
||||
- Ideal para testing de agentes de IA
|
||||
|
||||
---
|
||||
|
||||
## 2. Stack Tecnológico
|
||||
|
||||
| Componente | Tecnología |
|
||||
|------------|------------|
|
||||
| **Backend** | Python 3.11+ con FastAPI |
|
||||
| **Base de datos** | SQLite3 |
|
||||
| **Auth** | JWT (python-jose) |
|
||||
| **Upload imágenes** | Almacenamiento local en `/uploads` |
|
||||
| **Password hashing** | bcrypt |
|
||||
| **Testing** | pytest + pytest-asyncio |
|
||||
| **CLI (testing)** | curl o httpie |
|
||||
|
||||
---
|
||||
|
||||
## 3. Modelo de Datos (SQLite)
|
||||
|
||||
### Tabla: `users`
|
||||
|
||||
```sql
|
||||
CREATE TABLE users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT UNIQUE NOT NULL,
|
||||
email TEXT UNIQUE NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
avatar_url TEXT DEFAULT '/static/default-avatar.png',
|
||||
bio TEXT DEFAULT '',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
```
|
||||
|
||||
### Tabla: `posts`
|
||||
|
||||
```sql
|
||||
CREATE TABLE posts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||
image_path TEXT NOT NULL,
|
||||
caption TEXT DEFAULT '',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
```
|
||||
|
||||
### Tabla: `comments`
|
||||
|
||||
```sql
|
||||
CREATE TABLE comments (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
post_id INTEGER NOT NULL REFERENCES posts(id),
|
||||
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||
content TEXT NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
```
|
||||
|
||||
### Tabla: `likes`
|
||||
|
||||
```sql
|
||||
CREATE TABLE likes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
post_id INTEGER NOT NULL REFERENCES posts(id),
|
||||
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(post_id, user_id)
|
||||
);
|
||||
```
|
||||
|
||||
### Tabla: `dislikes`
|
||||
|
||||
```sql
|
||||
CREATE TABLE dislikes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
post_id INTEGER NOT NULL REFERENCES posts(id),
|
||||
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(post_id, user_id)
|
||||
);
|
||||
```
|
||||
|
||||
### Tabla: `follows`
|
||||
|
||||
```sql
|
||||
CREATE TABLE follows (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
follower_id INTEGER NOT NULL REFERENCES users(id),
|
||||
following_id INTEGER NOT NULL REFERENCES users(id),
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(follower_id, following_id)
|
||||
);
|
||||
```
|
||||
|
||||
### Tabla: `comment_likes`
|
||||
|
||||
```sql
|
||||
CREATE TABLE comment_likes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
comment_id INTEGER NOT NULL REFERENCES comments(id),
|
||||
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(comment_id, user_id)
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. API Endpoints
|
||||
|
||||
### Autenticación
|
||||
|
||||
| Método | Endpoint | Descripción |
|
||||
|--------|----------|-------------|
|
||||
| POST | `/auth/register` | Registrar usuario |
|
||||
| POST | `/auth/login` | Login, retorna JWT |
|
||||
|
||||
**Register Request:**
|
||||
```json
|
||||
{
|
||||
"username": "daniel",
|
||||
"email": "daniel@example.com",
|
||||
"password": "mipass123"
|
||||
}
|
||||
```
|
||||
|
||||
**Login Response:**
|
||||
```json
|
||||
{
|
||||
"access_token": "eyJ...",
|
||||
"token_type": "bearer"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Usuarios
|
||||
|
||||
| Método | Endpoint | Descripción |
|
||||
|--------|----------|-------------|
|
||||
| GET | `/users/{id}` | Obtener perfil de usuario |
|
||||
| GET | `/users/{id}/posts` | Posts de un usuario |
|
||||
| GET | `/users/{id}/stats` | Stats (posts, followers, following) |
|
||||
| POST | `/users/{id}/follow` | Seguir usuario |
|
||||
| DELETE | `/users/{id}/follow` | Dejar de seguir |
|
||||
|
||||
---
|
||||
|
||||
### Posts
|
||||
|
||||
| Método | Endpoint | Descripción |
|
||||
|--------|----------|-------------|
|
||||
| POST | `/posts` | Crear post (con imagen) |
|
||||
| GET | `/posts` | Feed global |
|
||||
| GET | `/posts/{id}` | Detalle de post |
|
||||
| DELETE | `/posts/{id}` | Eliminar post |
|
||||
| POST | `/posts/{id}/like` | Dar like |
|
||||
| DELETE | `/posts/{id}/like` | Quitar like |
|
||||
| POST | `/posts/{id}/dislike` | Dar no me gusta |
|
||||
| DELETE | `/posts/{id}/dislike` | Quitar no me gusta |
|
||||
|
||||
**Crear Post:**
|
||||
```
|
||||
POST /posts
|
||||
Content-Type: multipart/form-data
|
||||
|
||||
caption: "Mi primera foto"
|
||||
image: <archivo>
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"id": 1,
|
||||
"user_id": 1,
|
||||
"username": "daniel",
|
||||
"image_url": "/uploads/abc123.jpg",
|
||||
"caption": "Mi primera foto",
|
||||
"likes_count": 0,
|
||||
"dislikes_count": 0,
|
||||
"comments_count": 0,
|
||||
"created_at": "2026-04-15T22:00:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Comentarios
|
||||
|
||||
| Método | Endpoint | Descripción |
|
||||
|--------|----------|-------------|
|
||||
| GET | `/posts/{post_id}/comments` | Listar comentarios |
|
||||
| POST | `/posts/{post_id}/comments` | Agregar comentario |
|
||||
| DELETE | `/comments/{id}` | Eliminar comentario |
|
||||
| POST | `/comments/{id}/like` | Dar like a comentario |
|
||||
|
||||
---
|
||||
|
||||
### Feed
|
||||
|
||||
| Método | Endpoint | Descripción |
|
||||
|--------|----------|-------------|
|
||||
| GET | `/feed` | Feed de usuarios que sigues |
|
||||
| GET | `/feed/global` | Feed global (todos) |
|
||||
|
||||
**Query params:** `?limit=20&offset=0`
|
||||
|
||||
---
|
||||
|
||||
## 5. Detalles de Implementación
|
||||
|
||||
### Upload de Imágenes
|
||||
- Directorio: `./uploads/`
|
||||
- Nombres: `{uuid}_{original_filename}`
|
||||
- Formatos: jpg, jpeg, png, gif, webp
|
||||
- Tamaño máximo: 10MB
|
||||
|
||||
### Auth JWT
|
||||
- Algorithm: HS256
|
||||
- Expiración: 24 horas
|
||||
- Header: `Authorization: Bearer <token>`
|
||||
|
||||
### Likes/Dislikes
|
||||
- Un usuario solo puede dar UN like O un dislike por post (no ambos)
|
||||
- Dar dislike si ya hay like lo reemplaza
|
||||
- Dar like si ya hay dislike lo reemplaza
|
||||
|
||||
### Follow
|
||||
- No puedes seguirte a ti mismo
|
||||
- Mensaje de error si intentas seguir alguien que ya sigues
|
||||
|
||||
### Timestamps
|
||||
- Todos en UTC
|
||||
- Formato ISO 8601 en responses
|
||||
|
||||
---
|
||||
|
||||
## 6. Estructura del Proyecto
|
||||
|
||||
```
|
||||
socialphoto/
|
||||
├── app/
|
||||
│ ├── __init__.py
|
||||
│ ├── main.py # FastAPI app
|
||||
│ ├── database.py # SQLite connection + setup
|
||||
│ ├── models.py # Pydantic models
|
||||
│ ├── auth.py # JWT auth helpers
|
||||
│ ├── routes/
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── auth.py
|
||||
│ │ ├── users.py
|
||||
│ │ ├── posts.py
|
||||
│ │ ├── comments.py
|
||||
│ │ └── feed.py
|
||||
│ └── services/
|
||||
│ └── __init__.py
|
||||
├── uploads/ # Imágenes
|
||||
├── tests/
|
||||
│ ├── __init__.py
|
||||
│ ├── conftest.py
|
||||
│ ├── test_auth.py
|
||||
│ ├── test_posts.py
|
||||
│ └── test_social.py
|
||||
├── requirements.txt
|
||||
├── SPEC.md
|
||||
└── README.md
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Criterios de Aceptación
|
||||
|
||||
### Auth
|
||||
- [x] Usuario puede registrarse con username, email, password
|
||||
- [x] Usuario puede hacer login y recibe JWT
|
||||
- [x] Rutas protegidas requieren token válido
|
||||
- [x] No permite registro con username/email duplicado
|
||||
|
||||
### Posts
|
||||
- [x] Usuario puede crear post con imagen y caption
|
||||
- [x] Feed global muestra todos los posts ordenados por fecha
|
||||
- [x] Feed personalizado solo muestra posts de usuarios seguidos
|
||||
- [x] Usuario puede eliminar SUS propios posts
|
||||
|
||||
### Likes / Dislikes
|
||||
- [x] Usuario puede dar like a un post
|
||||
- [x] Usuario puede dar dislike a un post
|
||||
- [x] Like y dislike son mutuamente excluyentes
|
||||
- [x] Contador de likes/dislikes se actualiza en tiempo real
|
||||
|
||||
### Comentarios
|
||||
- [x] Usuario puede comentar en un post
|
||||
- [x] Usuario puede eliminar SUS comentarios
|
||||
- [x] Lista de comentarios incluye username y timestamp
|
||||
|
||||
### Follow
|
||||
- [x] Usuario puede seguir a otro usuario
|
||||
- [x] Usuario puede dejar de seguir
|
||||
- [x] No puede followearse a sí mismo
|
||||
- [x] Stats muestran followers y following count
|
||||
|
||||
---
|
||||
|
||||
## 8. Roadmap de Tasks (para Task Manager)
|
||||
|
||||
1. **Architecture**: Crear SPEC.md y estructura del proyecto ✓
|
||||
2. **Backlog**:
|
||||
- [x] Setup proyecto FastAPI con SQLite
|
||||
- [x] Implementar auth (register, login, JWT)
|
||||
- [x] CRUD posts con upload de imágenes
|
||||
- [x] Sistema de likes/dislikes
|
||||
- [x] Sistema de comentarios
|
||||
- [x] Sistema de follow/unfollow
|
||||
- [x] Feed global y personalizado
|
||||
- [x] Tests unitarios
|
||||
- [ ] README y documentación
|
||||
|
||||
---
|
||||
|
||||
## 9. Notas
|
||||
|
||||
- Usar SQLite para simplicidad y portabilidad
|
||||
- No requiere Docker ni servicios externos
|
||||
- Ideal para testing de SDMAS Orchestrator
|
||||
- En producción real se migraría a PostgreSQL
|
||||
1
app/__init__.py
Normal file
1
app/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""SocialPhoto - Instagram Clone API."""
|
||||
66
app/auth.py
Normal file
66
app/auth.py
Normal file
@@ -0,0 +1,66 @@
|
||||
"""Authentication utilities for SocialPhoto."""
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
from jose import JWTError, jwt
|
||||
from passlib.context import CryptContext
|
||||
|
||||
# Configuration
|
||||
SECRET_KEY = "your-secret-key-change-in-production" # TODO: Move to env
|
||||
ALGORITHM = "HS256"
|
||||
ACCESS_TOKEN_EXPIRE_HOURS = 24
|
||||
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
security = HTTPBearer()
|
||||
|
||||
|
||||
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||
"""Verify a password against its hash."""
|
||||
return pwd_context.verify(plain_password, hashed_password)
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
"""Hash a password using bcrypt."""
|
||||
return pwd_context.hash(password)
|
||||
|
||||
|
||||
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
|
||||
"""Create a JWT access token."""
|
||||
to_encode = data.copy()
|
||||
if expires_delta:
|
||||
expire = datetime.now(timezone.utc) + expires_delta
|
||||
else:
|
||||
expire = datetime.now(timezone.utc) + timedelta(hours=ACCESS_TOKEN_EXPIRE_HOURS)
|
||||
to_encode.update({"exp": expire})
|
||||
encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
|
||||
return encoded_jwt
|
||||
|
||||
|
||||
def decode_token(token: str) -> dict:
|
||||
"""Decode and verify a JWT token."""
|
||||
try:
|
||||
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
|
||||
return payload
|
||||
except JWTError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid authentication credentials",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
|
||||
async def get_current_user_id(
|
||||
credentials: HTTPAuthorizationCredentials = Depends(security),
|
||||
) -> int:
|
||||
"""Extract user ID from JWT token."""
|
||||
token = credentials.credentials
|
||||
payload = decode_token(token)
|
||||
user_id: int = payload.get("sub")
|
||||
if user_id is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid token payload",
|
||||
)
|
||||
return int(user_id)
|
||||
120
app/database.py
Normal file
120
app/database.py
Normal file
@@ -0,0 +1,120 @@
|
||||
"""Database module for SocialPhoto."""
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
DATABASE_PATH = Path(__file__).parent.parent / "socialphoto.db"
|
||||
|
||||
|
||||
def get_db_connection() -> sqlite3.Connection:
|
||||
"""Get a database connection with row factory."""
|
||||
conn = sqlite3.connect(DATABASE_PATH, check_same_thread=False)
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
|
||||
|
||||
def get_db() -> sqlite3.Connection:
|
||||
"""Dependency for FastAPI routes."""
|
||||
conn = get_db_connection()
|
||||
try:
|
||||
yield conn
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def init_db() -> None:
|
||||
"""Initialize the database with all tables."""
|
||||
conn = get_db_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Users table
|
||||
cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT UNIQUE NOT NULL,
|
||||
email TEXT UNIQUE NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
avatar_url TEXT DEFAULT '/static/default-avatar.png',
|
||||
bio TEXT DEFAULT '',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
""")
|
||||
|
||||
# Posts table
|
||||
cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS posts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||
image_path TEXT NOT NULL,
|
||||
caption TEXT DEFAULT '',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
""")
|
||||
|
||||
# Comments table
|
||||
cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS comments (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
post_id INTEGER NOT NULL REFERENCES posts(id),
|
||||
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||
content TEXT NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
""")
|
||||
|
||||
# Likes table
|
||||
cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS likes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
post_id INTEGER NOT NULL REFERENCES posts(id),
|
||||
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(post_id, user_id)
|
||||
)
|
||||
""")
|
||||
|
||||
# Dislikes table
|
||||
cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS dislikes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
post_id INTEGER NOT NULL REFERENCES posts(id),
|
||||
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(post_id, user_id)
|
||||
)
|
||||
""")
|
||||
|
||||
# Follows table
|
||||
cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS follows (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
follower_id INTEGER NOT NULL REFERENCES users(id),
|
||||
following_id INTEGER NOT NULL REFERENCES users(id),
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(follower_id, following_id)
|
||||
)
|
||||
""")
|
||||
|
||||
# Comment likes table
|
||||
cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS comment_likes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
comment_id INTEGER NOT NULL REFERENCES comments(id),
|
||||
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(comment_id, user_id)
|
||||
)
|
||||
""")
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
|
||||
def row_to_dict(row: sqlite3.Row) -> dict:
|
||||
"""Convert a sqlite Row to a dictionary."""
|
||||
return dict(row)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
init_db()
|
||||
print("Database initialized successfully.")
|
||||
69
app/main.py
Normal file
69
app/main.py
Normal file
@@ -0,0 +1,69 @@
|
||||
"""SocialPhoto - Instagram Clone API.
|
||||
|
||||
A simple social media API for sharing images with likes, comments, and user follows.
|
||||
"""
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from app.database import init_db
|
||||
from app.routes import auth, comments, feed, posts, users
|
||||
|
||||
# Create FastAPI app
|
||||
app = FastAPI(
|
||||
title="SocialPhoto",
|
||||
description="Instagram Clone API - Share images with likes, comments, and follows",
|
||||
version="1.0.0",
|
||||
)
|
||||
|
||||
# CORS middleware
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# Mount uploads directory
|
||||
UPLOAD_DIR = Path(__file__).parent.parent / "uploads"
|
||||
UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
|
||||
app.mount("/uploads", StaticFiles(directory=str(UPLOAD_DIR)), name="uploads")
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
async def startup_event():
|
||||
"""Initialize database on startup."""
|
||||
init_db()
|
||||
|
||||
|
||||
# Include routers
|
||||
app.include_router(auth.router)
|
||||
app.include_router(users.router)
|
||||
app.include_router(posts.router)
|
||||
app.include_router(comments.router)
|
||||
app.include_router(feed.router)
|
||||
|
||||
|
||||
@app.get("/", tags=["Root"])
|
||||
async def root():
|
||||
"""Root endpoint."""
|
||||
return {
|
||||
"name": "SocialPhoto",
|
||||
"version": "1.0.0",
|
||||
"description": "Instagram Clone API",
|
||||
}
|
||||
|
||||
|
||||
@app.get("/health", tags=["Health"])
|
||||
async def health_check():
|
||||
"""Health check endpoint."""
|
||||
return {"status": "healthy"}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
|
||||
uvicorn.run(app, host="0.0.0.0", port=8000)
|
||||
109
app/models.py
Normal file
109
app/models.py
Normal file
@@ -0,0 +1,109 @@
|
||||
"""Pydantic models for SocialPhoto API."""
|
||||
from datetime import datetime
|
||||
from typing import Optional, List
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, EmailStr, Field
|
||||
|
||||
|
||||
# Auth Models
|
||||
class UserRegister(BaseModel):
|
||||
"""Request model for user registration."""
|
||||
username: str = Field(..., min_length=3, max_length=50)
|
||||
email: EmailStr
|
||||
password: str = Field(..., min_length=6)
|
||||
|
||||
|
||||
class UserLogin(BaseModel):
|
||||
"""Request model for user login."""
|
||||
username: str
|
||||
password: str
|
||||
|
||||
|
||||
class Token(BaseModel):
|
||||
"""Response model for JWT token."""
|
||||
access_token: str
|
||||
token_type: str = "bearer"
|
||||
|
||||
|
||||
# User Models
|
||||
class UserBase(BaseModel):
|
||||
"""Base user model."""
|
||||
username: str
|
||||
email: str
|
||||
avatar_url: Optional[str] = "/static/default-avatar.png"
|
||||
bio: Optional[str] = ""
|
||||
|
||||
|
||||
class UserResponse(UserBase):
|
||||
"""Response model for user data."""
|
||||
id: int
|
||||
created_at: datetime
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class UserStats(BaseModel):
|
||||
"""User statistics model."""
|
||||
posts_count: int
|
||||
followers_count: int
|
||||
following_count: int
|
||||
|
||||
|
||||
# Post Models
|
||||
class PostCreate(BaseModel):
|
||||
"""Request model for creating a post."""
|
||||
caption: Optional[str] = ""
|
||||
|
||||
|
||||
class PostResponse(BaseModel):
|
||||
"""Response model for post data."""
|
||||
id: int
|
||||
user_id: int
|
||||
username: str
|
||||
image_url: str
|
||||
caption: str
|
||||
likes_count: int
|
||||
dislikes_count: int
|
||||
comments_count: int
|
||||
created_at: datetime
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class PostDetail(PostResponse):
|
||||
"""Detailed post response with user info."""
|
||||
user: UserResponse
|
||||
|
||||
|
||||
# Comment Models
|
||||
class CommentCreate(BaseModel):
|
||||
"""Request model for creating a comment."""
|
||||
content: str = Field(..., min_length=1, max_length=500)
|
||||
|
||||
|
||||
class CommentResponse(BaseModel):
|
||||
"""Response model for comment data."""
|
||||
id: int
|
||||
post_id: int
|
||||
user_id: int
|
||||
username: str
|
||||
content: str
|
||||
likes_count: int
|
||||
created_at: datetime
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
# Feed Models
|
||||
class FeedResponse(BaseModel):
|
||||
"""Response model for feed."""
|
||||
posts: List[PostResponse]
|
||||
total: int
|
||||
limit: int
|
||||
offset: int
|
||||
|
||||
|
||||
# Error Models
|
||||
class ErrorResponse(BaseModel):
|
||||
"""Standard error response."""
|
||||
detail: str
|
||||
1
app/routes/__init__.py
Normal file
1
app/routes/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Routes package for SocialPhoto."""
|
||||
81
app/routes/auth.py
Normal file
81
app/routes/auth.py
Normal file
@@ -0,0 +1,81 @@
|
||||
"""Authentication routes for SocialPhoto."""
|
||||
import sqlite3
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
|
||||
from app.auth import create_access_token, hash_password, verify_password
|
||||
from app.database import get_db, row_to_dict
|
||||
from app.models import Token, UserLogin, UserRegister
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["Authentication"])
|
||||
security = HTTPBearer()
|
||||
|
||||
|
||||
@router.post("/register", response_model=Token, status_code=status.HTTP_201_CREATED)
|
||||
async def register(
|
||||
user_data: UserRegister,
|
||||
conn: sqlite3.Connection = Depends(get_db),
|
||||
) -> Token:
|
||||
"""Register a new user."""
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Check if username exists
|
||||
cursor.execute("SELECT id FROM users WHERE username = ?", (user_data.username,))
|
||||
if cursor.fetchone():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Username already registered",
|
||||
)
|
||||
|
||||
# Check if email exists
|
||||
cursor.execute("SELECT id FROM users WHERE email = ?", (user_data.email,))
|
||||
if cursor.fetchone():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Email already registered",
|
||||
)
|
||||
|
||||
# Hash password and create user
|
||||
password_hash = hash_password(user_data.password)
|
||||
cursor.execute(
|
||||
"INSERT INTO users (username, email, password_hash) VALUES (?, ?, ?)",
|
||||
(user_data.username, user_data.email, password_hash),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
# Get the new user's ID
|
||||
cursor.execute("SELECT id FROM users WHERE username = ?", (user_data.username,))
|
||||
user = row_to_dict(cursor.fetchone())
|
||||
user_id = user["id"]
|
||||
|
||||
# Create access token
|
||||
access_token = create_access_token(data={"sub": str(user_id)})
|
||||
return Token(access_token=access_token)
|
||||
|
||||
|
||||
@router.post("/login", response_model=Token)
|
||||
async def login(
|
||||
user_data: UserLogin,
|
||||
conn: sqlite3.Connection = Depends(get_db),
|
||||
) -> Token:
|
||||
"""Login and get access token."""
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Find user by username
|
||||
cursor.execute(
|
||||
"SELECT id, password_hash FROM users WHERE username = ?",
|
||||
(user_data.username,),
|
||||
)
|
||||
row = cursor.fetchone()
|
||||
|
||||
if not row or not verify_password(user_data.password, row["password_hash"]):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Incorrect username or password",
|
||||
)
|
||||
|
||||
user = row_to_dict(row)
|
||||
access_token = create_access_token(data={"sub": user["id"]})
|
||||
return Token(access_token=access_token)
|
||||
79
app/routes/comments.py
Normal file
79
app/routes/comments.py
Normal file
@@ -0,0 +1,79 @@
|
||||
"""Comment routes for SocialPhoto - comment-specific operations."""
|
||||
import sqlite3
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
|
||||
from app.auth import get_current_user_id
|
||||
from app.database import get_db, row_to_dict
|
||||
|
||||
router = APIRouter(prefix="/comments", tags=["Comments"])
|
||||
security = HTTPBearer()
|
||||
|
||||
|
||||
@router.delete("/{comment_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_comment(
|
||||
comment_id: int,
|
||||
credentials: HTTPAuthorizationCredentials = Depends(security),
|
||||
conn: sqlite3.Connection = Depends(get_db),
|
||||
) -> None:
|
||||
"""Delete a comment (only by owner)."""
|
||||
user_id = await get_current_user_id(credentials)
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Check comment exists and belongs to user
|
||||
cursor.execute(
|
||||
"SELECT user_id FROM comments WHERE id = ?",
|
||||
(comment_id,),
|
||||
)
|
||||
row = cursor.fetchone()
|
||||
if not row:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Comment not found",
|
||||
)
|
||||
|
||||
comment = row_to_dict(row)
|
||||
if comment["user_id"] != user_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="You can only delete your own comments",
|
||||
)
|
||||
|
||||
# Delete comment
|
||||
cursor.execute("DELETE FROM comments WHERE id = ?", (comment_id,))
|
||||
conn.commit()
|
||||
|
||||
|
||||
@router.post("/{comment_id}/like", status_code=status.HTTP_201_CREATED)
|
||||
async def like_comment(
|
||||
comment_id: int,
|
||||
credentials: HTTPAuthorizationCredentials = Depends(security),
|
||||
conn: sqlite3.Connection = Depends(get_db),
|
||||
) -> dict:
|
||||
"""Like a comment."""
|
||||
user_id = await get_current_user_id(credentials)
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Check comment exists
|
||||
cursor.execute("SELECT id FROM comments WHERE id = ?", (comment_id,))
|
||||
if not cursor.fetchone():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Comment not found",
|
||||
)
|
||||
|
||||
# Add like
|
||||
try:
|
||||
cursor.execute(
|
||||
"INSERT INTO comment_likes (comment_id, user_id) VALUES (?, ?)",
|
||||
(comment_id, user_id),
|
||||
)
|
||||
conn.commit()
|
||||
except sqlite3.IntegrityError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="You already liked this comment",
|
||||
)
|
||||
|
||||
return {"message": "Comment liked"}
|
||||
124
app/routes/feed.py
Normal file
124
app/routes/feed.py
Normal file
@@ -0,0 +1,124 @@
|
||||
"""Feed routes for SocialPhoto."""
|
||||
import sqlite3
|
||||
from typing import List
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
|
||||
from app.auth import get_current_user_id
|
||||
from app.database import get_db, row_to_dict
|
||||
from app.models import FeedResponse, PostResponse
|
||||
|
||||
router = APIRouter(prefix="/feed", tags=["Feed"])
|
||||
security = HTTPBearer()
|
||||
|
||||
|
||||
@router.get("", response_model=FeedResponse)
|
||||
async def get_followed_feed(
|
||||
limit: int = Query(default=20, ge=1, le=100),
|
||||
offset: int = Query(default=0, ge=0),
|
||||
credentials: HTTPAuthorizationCredentials = Depends(security),
|
||||
conn: sqlite3.Connection = Depends(get_db),
|
||||
) -> FeedResponse:
|
||||
"""Get feed of posts from users you follow."""
|
||||
user_id = await get_current_user_id(credentials)
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Get posts from followed users
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT
|
||||
p.id, p.user_id, u.username, p.image_path, p.caption, p.created_at,
|
||||
(SELECT COUNT(*) FROM likes WHERE post_id = p.id) as likes_count,
|
||||
(SELECT COUNT(*) FROM dislikes WHERE post_id = p.id) as dislikes_count,
|
||||
(SELECT COUNT(*) FROM comments WHERE post_id = p.id) as comments_count
|
||||
FROM posts p
|
||||
JOIN users u ON p.user_id = u.id
|
||||
WHERE p.user_id IN (
|
||||
SELECT following_id FROM follows WHERE follower_id = ?
|
||||
)
|
||||
ORDER BY p.created_at DESC
|
||||
LIMIT ? OFFSET ?
|
||||
""",
|
||||
(user_id, limit, offset),
|
||||
)
|
||||
|
||||
posts = []
|
||||
for row in cursor.fetchall():
|
||||
post = row_to_dict(row)
|
||||
posts.append(
|
||||
PostResponse(
|
||||
id=post["id"],
|
||||
user_id=post["user_id"],
|
||||
username=post["username"],
|
||||
image_url=f"/uploads/{post['image_path'].split('/')[-1]}",
|
||||
caption=post["caption"],
|
||||
likes_count=post["likes_count"],
|
||||
dislikes_count=post["dislikes_count"],
|
||||
comments_count=post["comments_count"],
|
||||
created_at=post["created_at"],
|
||||
)
|
||||
)
|
||||
|
||||
# Get total count
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT COUNT(*) as total
|
||||
FROM posts p
|
||||
WHERE p.user_id IN (
|
||||
SELECT following_id FROM follows WHERE follower_id = ?
|
||||
)
|
||||
""",
|
||||
(user_id,),
|
||||
)
|
||||
total = cursor.fetchone()["total"]
|
||||
|
||||
return FeedResponse(posts=posts, total=total, limit=limit, offset=offset)
|
||||
|
||||
|
||||
@router.get("/global", response_model=FeedResponse)
|
||||
async def get_global_feed(
|
||||
limit: int = Query(default=20, ge=1, le=100),
|
||||
offset: int = Query(default=0, ge=0),
|
||||
conn: sqlite3.Connection = Depends(get_db),
|
||||
) -> FeedResponse:
|
||||
"""Get global feed of all posts."""
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT
|
||||
p.id, p.user_id, u.username, p.image_path, p.caption, p.created_at,
|
||||
(SELECT COUNT(*) FROM likes WHERE post_id = p.id) as likes_count,
|
||||
(SELECT COUNT(*) FROM dislikes WHERE post_id = p.id) as dislikes_count,
|
||||
(SELECT COUNT(*) FROM comments WHERE post_id = p.id) as comments_count
|
||||
FROM posts p
|
||||
JOIN users u ON p.user_id = u.id
|
||||
ORDER BY p.created_at DESC
|
||||
LIMIT ? OFFSET ?
|
||||
""",
|
||||
(limit, offset),
|
||||
)
|
||||
|
||||
posts = []
|
||||
for row in cursor.fetchall():
|
||||
post = row_to_dict(row)
|
||||
posts.append(
|
||||
PostResponse(
|
||||
id=post["id"],
|
||||
user_id=post["user_id"],
|
||||
username=post["username"],
|
||||
image_url=f"/uploads/{post['image_path'].split('/')[-1]}",
|
||||
caption=post["caption"],
|
||||
likes_count=post["likes_count"],
|
||||
dislikes_count=post["dislikes_count"],
|
||||
comments_count=post["comments_count"],
|
||||
created_at=post["created_at"],
|
||||
)
|
||||
)
|
||||
|
||||
# Get total count
|
||||
cursor.execute("SELECT COUNT(*) as total FROM posts")
|
||||
total = cursor.fetchone()["total"]
|
||||
|
||||
return FeedResponse(posts=posts, total=total, limit=limit, offset=offset)
|
||||
400
app/routes/posts.py
Normal file
400
app/routes/posts.py
Normal file
@@ -0,0 +1,400 @@
|
||||
"""Post routes for SocialPhoto."""
|
||||
import os
|
||||
import sqlite3
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, status
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.responses import FileResponse
|
||||
|
||||
from app.auth import get_current_user_id
|
||||
from app.database import get_db, row_to_dict
|
||||
from app.models import CommentCreate, CommentResponse, PostResponse
|
||||
|
||||
router = APIRouter(prefix="/posts", tags=["Posts"])
|
||||
security = HTTPBearer()
|
||||
|
||||
# Configuration
|
||||
UPLOAD_DIR = Path(__file__).parent.parent.parent / "uploads"
|
||||
MAX_FILE_SIZE = 10 * 1024 * 1024 # 10MB
|
||||
ALLOWED_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".webp"}
|
||||
|
||||
# Ensure upload directory exists
|
||||
UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
def _get_post_with_counts(conn: sqlite3.Connection, post_id: int) -> Optional[dict]:
|
||||
"""Get post data with like/dislike/comment counts."""
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT
|
||||
p.id, p.user_id, u.username, p.image_path, p.caption, p.created_at,
|
||||
(SELECT COUNT(*) FROM likes WHERE post_id = p.id) as likes_count,
|
||||
(SELECT COUNT(*) FROM dislikes WHERE post_id = p.id) as dislikes_count,
|
||||
(SELECT COUNT(*) FROM comments WHERE post_id = p.id) as comments_count
|
||||
FROM posts p
|
||||
JOIN users u ON p.user_id = u.id
|
||||
WHERE p.id = ?
|
||||
""",
|
||||
(post_id,),
|
||||
)
|
||||
row = cursor.fetchone()
|
||||
if not row:
|
||||
return None
|
||||
post = row_to_dict(row)
|
||||
post["image_url"] = f"/uploads/{post['image_path'].split('/')[-1]}"
|
||||
return post
|
||||
|
||||
|
||||
@router.post("", response_model=PostResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_post(
|
||||
caption: str = Form(""),
|
||||
image: UploadFile = File(...),
|
||||
credentials: HTTPAuthorizationCredentials = Depends(security),
|
||||
conn: sqlite3.Connection = Depends(get_db),
|
||||
) -> PostResponse:
|
||||
"""Create a new post with image."""
|
||||
user_id = await get_current_user_id(credentials)
|
||||
|
||||
# Validate file type
|
||||
file_ext = Path(image.filename).suffix.lower()
|
||||
if file_ext not in ALLOWED_EXTENSIONS:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"File type not allowed. Allowed: {', '.join(ALLOWED_EXTENSIONS)}",
|
||||
)
|
||||
|
||||
# Generate unique filename
|
||||
unique_filename = f"{uuid.uuid4()}{file_ext}"
|
||||
file_path = UPLOAD_DIR / unique_filename
|
||||
|
||||
# Save file
|
||||
contents = await image.read()
|
||||
if len(contents) > MAX_FILE_SIZE:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="File too large. Maximum size is 10MB",
|
||||
)
|
||||
|
||||
with open(file_path, "wb") as f:
|
||||
f.write(contents)
|
||||
|
||||
# Insert into database
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
"INSERT INTO posts (user_id, image_path, caption) VALUES (?, ?, ?)",
|
||||
(user_id, str(file_path), caption),
|
||||
)
|
||||
conn.commit()
|
||||
post_id = cursor.lastrowid
|
||||
|
||||
# Get the created post
|
||||
post = _get_post_with_counts(conn, post_id)
|
||||
return PostResponse(**post)
|
||||
|
||||
|
||||
@router.get("", response_model=List[PostResponse])
|
||||
async def get_posts(
|
||||
limit: int = 20,
|
||||
offset: int = 0,
|
||||
conn: sqlite3.Connection = Depends(get_db),
|
||||
) -> List[PostResponse]:
|
||||
"""Get global feed of posts."""
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT
|
||||
p.id, p.user_id, u.username, p.image_path, p.caption, p.created_at,
|
||||
(SELECT COUNT(*) FROM likes WHERE post_id = p.id) as likes_count,
|
||||
(SELECT COUNT(*) FROM dislikes WHERE post_id = p.id) as dislikes_count,
|
||||
(SELECT COUNT(*) FROM comments WHERE post_id = p.id) as comments_count
|
||||
FROM posts p
|
||||
JOIN users u ON p.user_id = u.id
|
||||
ORDER BY p.created_at DESC
|
||||
LIMIT ? OFFSET ?
|
||||
""",
|
||||
(limit, offset),
|
||||
)
|
||||
|
||||
posts = []
|
||||
for row in cursor.fetchall():
|
||||
post = row_to_dict(row)
|
||||
posts.append(
|
||||
PostResponse(
|
||||
id=post["id"],
|
||||
user_id=post["user_id"],
|
||||
username=post["username"],
|
||||
image_url=f"/uploads/{post['image_path'].split('/')[-1]}",
|
||||
caption=post["caption"],
|
||||
likes_count=post["likes_count"],
|
||||
dislikes_count=post["dislikes_count"],
|
||||
comments_count=post["comments_count"],
|
||||
created_at=post["created_at"],
|
||||
)
|
||||
)
|
||||
|
||||
return posts
|
||||
|
||||
|
||||
@router.get("/{post_id}", response_model=PostResponse)
|
||||
async def get_post(
|
||||
post_id: int,
|
||||
conn: sqlite3.Connection = Depends(get_db),
|
||||
) -> PostResponse:
|
||||
"""Get a specific post."""
|
||||
post = _get_post_with_counts(conn, post_id)
|
||||
if not post:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Post not found",
|
||||
)
|
||||
return PostResponse(**post)
|
||||
|
||||
|
||||
@router.delete("/{post_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_post(
|
||||
post_id: int,
|
||||
credentials: HTTPAuthorizationCredentials = Depends(security),
|
||||
conn: sqlite3.Connection = Depends(get_db),
|
||||
) -> None:
|
||||
"""Delete a post (only by owner)."""
|
||||
user_id = await get_current_user_id(credentials)
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Check post exists and belongs to user
|
||||
cursor.execute("SELECT user_id, image_path FROM posts WHERE id = ?", (post_id,))
|
||||
row = cursor.fetchone()
|
||||
if not row:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Post not found",
|
||||
)
|
||||
|
||||
post = row_to_dict(row)
|
||||
if post["user_id"] != user_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="You can only delete your own posts",
|
||||
)
|
||||
|
||||
# Delete image file
|
||||
image_path = Path(post["image_path"])
|
||||
if image_path.exists():
|
||||
image_path.unlink()
|
||||
|
||||
# Delete post (cascade deletes comments, likes, dislikes)
|
||||
cursor.execute("DELETE FROM posts WHERE id = ?", (post_id,))
|
||||
conn.commit()
|
||||
|
||||
|
||||
@router.post("/{post_id}/like", status_code=status.HTTP_201_CREATED)
|
||||
async def like_post(
|
||||
post_id: int,
|
||||
credentials: HTTPAuthorizationCredentials = Depends(security),
|
||||
conn: sqlite3.Connection = Depends(get_db),
|
||||
) -> dict:
|
||||
"""Like a post."""
|
||||
user_id = await get_current_user_id(credentials)
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Check post exists
|
||||
cursor.execute("SELECT id FROM posts WHERE id = ?", (post_id,))
|
||||
if not cursor.fetchone():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Post not found",
|
||||
)
|
||||
|
||||
# Remove any existing dislike first
|
||||
cursor.execute(
|
||||
"DELETE FROM dislikes WHERE post_id = ? AND user_id = ?",
|
||||
(post_id, user_id),
|
||||
)
|
||||
|
||||
# Add like
|
||||
try:
|
||||
cursor.execute(
|
||||
"INSERT INTO likes (post_id, user_id) VALUES (?, ?)",
|
||||
(post_id, user_id),
|
||||
)
|
||||
conn.commit()
|
||||
except sqlite3.IntegrityError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="You already liked this post",
|
||||
)
|
||||
|
||||
return {"message": "Post liked"}
|
||||
|
||||
|
||||
@router.delete("/{post_id}/like", status_code=status.HTTP_200_OK)
|
||||
async def unlike_post(
|
||||
post_id: int,
|
||||
credentials: HTTPAuthorizationCredentials = Depends(security),
|
||||
conn: sqlite3.Connection = Depends(get_db),
|
||||
) -> dict:
|
||||
"""Remove like from a post."""
|
||||
user_id = await get_current_user_id(credentials)
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute(
|
||||
"DELETE FROM likes WHERE post_id = ? AND user_id = ?",
|
||||
(post_id, user_id),
|
||||
)
|
||||
if cursor.rowcount == 0:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="You haven't liked this post",
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
return {"message": "Like removed"}
|
||||
|
||||
|
||||
@router.post("/{post_id}/dislike", status_code=status.HTTP_201_CREATED)
|
||||
async def dislike_post(
|
||||
post_id: int,
|
||||
credentials: HTTPAuthorizationCredentials = Depends(security),
|
||||
conn: sqlite3.Connection = Depends(get_db),
|
||||
) -> dict:
|
||||
"""Dislike a post."""
|
||||
user_id = await get_current_user_id(credentials)
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Check post exists
|
||||
cursor.execute("SELECT id FROM posts WHERE id = ?", (post_id,))
|
||||
if not cursor.fetchone():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Post not found",
|
||||
)
|
||||
|
||||
# Remove any existing like first
|
||||
cursor.execute(
|
||||
"DELETE FROM likes WHERE post_id = ? AND user_id = ?",
|
||||
(post_id, user_id),
|
||||
)
|
||||
|
||||
# Add dislike
|
||||
try:
|
||||
cursor.execute(
|
||||
"INSERT INTO dislikes (post_id, user_id) VALUES (?, ?)",
|
||||
(post_id, user_id),
|
||||
)
|
||||
conn.commit()
|
||||
except sqlite3.IntegrityError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="You already disliked this post",
|
||||
)
|
||||
|
||||
return {"message": "Post disliked"}
|
||||
|
||||
|
||||
@router.delete("/{post_id}/dislike", status_code=status.HTTP_200_OK)
|
||||
async def undislike_post(
|
||||
post_id: int,
|
||||
credentials: HTTPAuthorizationCredentials = Depends(security),
|
||||
conn: sqlite3.Connection = Depends(get_db),
|
||||
) -> dict:
|
||||
"""Remove dislike from a post."""
|
||||
user_id = await get_current_user_id(credentials)
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute(
|
||||
"DELETE FROM dislikes WHERE post_id = ? AND user_id = ?",
|
||||
(post_id, user_id),
|
||||
)
|
||||
if cursor.rowcount == 0:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="You haven't disliked this post",
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
return {"message": "Dislike removed"}
|
||||
|
||||
|
||||
@router.get("/{post_id}/comments", response_model=list[CommentResponse])
|
||||
async def get_post_comments(
|
||||
post_id: int,
|
||||
conn: sqlite3.Connection = Depends(get_db),
|
||||
) -> list[CommentResponse]:
|
||||
"""Get all comments for a post."""
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Check post exists
|
||||
cursor.execute("SELECT id FROM posts WHERE id = ?", (post_id,))
|
||||
if not cursor.fetchone():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Post not found",
|
||||
)
|
||||
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT
|
||||
c.id, c.post_id, c.user_id, u.username, c.content, c.created_at,
|
||||
(SELECT COUNT(*) FROM comment_likes WHERE comment_id = c.id) as likes_count
|
||||
FROM comments c
|
||||
JOIN users u ON c.user_id = u.id
|
||||
WHERE c.post_id = ?
|
||||
ORDER BY c.created_at ASC
|
||||
""",
|
||||
(post_id,),
|
||||
)
|
||||
|
||||
comments = []
|
||||
for row in cursor.fetchall():
|
||||
comment = row_to_dict(row)
|
||||
comments.append(CommentResponse(**comment))
|
||||
|
||||
return comments
|
||||
|
||||
|
||||
@router.post("/{post_id}/comments", response_model=CommentResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_comment(
|
||||
post_id: int,
|
||||
comment_data: CommentCreate,
|
||||
credentials: HTTPAuthorizationCredentials = Depends(security),
|
||||
conn: sqlite3.Connection = Depends(get_db),
|
||||
) -> CommentResponse:
|
||||
"""Create a comment on a post."""
|
||||
user_id = await get_current_user_id(credentials)
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Check post exists
|
||||
cursor.execute("SELECT id FROM posts WHERE id = ?", (post_id,))
|
||||
if not cursor.fetchone():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Post not found",
|
||||
)
|
||||
|
||||
# Create comment
|
||||
cursor.execute(
|
||||
"INSERT INTO comments (post_id, user_id, content) VALUES (?, ?, ?)",
|
||||
(post_id, user_id, comment_data.content),
|
||||
)
|
||||
conn.commit()
|
||||
comment_id = cursor.lastrowid
|
||||
|
||||
# Get the created comment with user info
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT
|
||||
c.id, c.post_id, c.user_id, u.username, c.content, c.created_at,
|
||||
(SELECT COUNT(*) FROM comment_likes WHERE comment_id = c.id) as likes_count
|
||||
FROM comments c
|
||||
JOIN users u ON c.user_id = u.id
|
||||
WHERE c.id = ?
|
||||
""",
|
||||
(comment_id,),
|
||||
)
|
||||
row = cursor.fetchone()
|
||||
comment = row_to_dict(row)
|
||||
return CommentResponse(**comment)
|
||||
205
app/routes/users.py
Normal file
205
app/routes/users.py
Normal file
@@ -0,0 +1,205 @@
|
||||
"""User routes for SocialPhoto."""
|
||||
import sqlite3
|
||||
from typing import List
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
|
||||
from app.auth import get_current_user_id
|
||||
from app.database import get_db, row_to_dict
|
||||
from app.models import PostResponse, UserResponse, UserStats
|
||||
|
||||
router = APIRouter(prefix="/users", tags=["Users"])
|
||||
security = HTTPBearer()
|
||||
|
||||
|
||||
def _get_user_with_stats(conn: sqlite3.Connection, user_id: int) -> dict:
|
||||
"""Get user data with stats."""
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Get user
|
||||
cursor.execute("SELECT * FROM users WHERE id = ?", (user_id,))
|
||||
row = cursor.fetchone()
|
||||
if not row:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="User not found",
|
||||
)
|
||||
user = row_to_dict(row)
|
||||
|
||||
# Get posts count
|
||||
cursor.execute("SELECT COUNT(*) as count FROM posts WHERE user_id = ?", (user_id,))
|
||||
posts_count = cursor.fetchone()["count"]
|
||||
|
||||
# Get followers count
|
||||
cursor.execute(
|
||||
"SELECT COUNT(*) as count FROM follows WHERE following_id = ?",
|
||||
(user_id,),
|
||||
)
|
||||
followers_count = cursor.fetchone()["count"]
|
||||
|
||||
# Get following count
|
||||
cursor.execute(
|
||||
"SELECT COUNT(*) as count FROM follows WHERE follower_id = ?",
|
||||
(user_id,),
|
||||
)
|
||||
following_count = cursor.fetchone()["count"]
|
||||
|
||||
user["posts_count"] = posts_count
|
||||
user["followers_count"] = followers_count
|
||||
user["following_count"] = following_count
|
||||
|
||||
return user
|
||||
|
||||
|
||||
@router.get("/{user_id}", response_model=UserResponse)
|
||||
async def get_user(
|
||||
user_id: int,
|
||||
conn: sqlite3.Connection = Depends(get_db),
|
||||
) -> UserResponse:
|
||||
"""Get user profile."""
|
||||
user = _get_user_with_stats(conn, user_id)
|
||||
return UserResponse(**user)
|
||||
|
||||
|
||||
@router.get("/{user_id}/posts", response_model=List[PostResponse])
|
||||
async def get_user_posts(
|
||||
user_id: int,
|
||||
conn: sqlite3.Connection = Depends(get_db),
|
||||
) -> List[PostResponse]:
|
||||
"""Get all posts by a user."""
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Check user exists
|
||||
cursor.execute("SELECT id FROM users WHERE id = ?", (user_id,))
|
||||
if not cursor.fetchone():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="User not found",
|
||||
)
|
||||
|
||||
# Get posts with counts
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT
|
||||
p.id, p.user_id, u.username, p.image_path, p.caption, p.created_at,
|
||||
(SELECT COUNT(*) FROM likes WHERE post_id = p.id) as likes_count,
|
||||
(SELECT COUNT(*) FROM dislikes WHERE post_id = p.id) as dislikes_count,
|
||||
(SELECT COUNT(*) FROM comments WHERE post_id = p.id) as comments_count
|
||||
FROM posts p
|
||||
JOIN users u ON p.user_id = u.id
|
||||
WHERE p.user_id = ?
|
||||
ORDER BY p.created_at DESC
|
||||
""",
|
||||
(user_id,),
|
||||
)
|
||||
|
||||
posts = []
|
||||
for row in cursor.fetchall():
|
||||
post = row_to_dict(row)
|
||||
posts.append(
|
||||
PostResponse(
|
||||
id=post["id"],
|
||||
user_id=post["user_id"],
|
||||
username=post["username"],
|
||||
image_url=f"/uploads/{post['image_path'].split('/')[-1]}",
|
||||
caption=post["caption"],
|
||||
likes_count=post["likes_count"],
|
||||
dislikes_count=post["dislikes_count"],
|
||||
comments_count=post["comments_count"],
|
||||
created_at=post["created_at"],
|
||||
)
|
||||
)
|
||||
|
||||
return posts
|
||||
|
||||
|
||||
@router.get("/{user_id}/stats", response_model=UserStats)
|
||||
async def get_user_stats(
|
||||
user_id: int,
|
||||
conn: sqlite3.Connection = Depends(get_db),
|
||||
) -> UserStats:
|
||||
"""Get user statistics."""
|
||||
user = _get_user_with_stats(conn, user_id)
|
||||
return UserStats(
|
||||
posts_count=user["posts_count"],
|
||||
followers_count=user["followers_count"],
|
||||
following_count=user["following_count"],
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{user_id}/follow", status_code=status.HTTP_201_CREATED)
|
||||
async def follow_user(
|
||||
user_id: int,
|
||||
credentials: HTTPAuthorizationCredentials = Depends(security),
|
||||
conn: sqlite3.Connection = Depends(get_db),
|
||||
) -> dict:
|
||||
"""Follow a user."""
|
||||
current_user_id = await get_current_user_id(credentials)
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Check user exists
|
||||
cursor.execute("SELECT id FROM users WHERE id = ?", (user_id,))
|
||||
if not cursor.fetchone():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="User not found",
|
||||
)
|
||||
|
||||
# Cannot follow yourself
|
||||
if current_user_id == user_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="You cannot follow yourself",
|
||||
)
|
||||
|
||||
# Check if already following
|
||||
cursor.execute(
|
||||
"SELECT id FROM follows WHERE follower_id = ? AND following_id = ?",
|
||||
(current_user_id, user_id),
|
||||
)
|
||||
if cursor.fetchone():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="You already follow this user",
|
||||
)
|
||||
|
||||
# Create follow
|
||||
cursor.execute(
|
||||
"INSERT INTO follows (follower_id, following_id) VALUES (?, ?)",
|
||||
(current_user_id, user_id),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
return {"message": "Successfully followed user"}
|
||||
|
||||
|
||||
@router.delete("/{user_id}/follow", status_code=status.HTTP_200_OK)
|
||||
async def unfollow_user(
|
||||
user_id: int,
|
||||
credentials: HTTPAuthorizationCredentials = Depends(security),
|
||||
conn: sqlite3.Connection = Depends(get_db),
|
||||
) -> dict:
|
||||
"""Unfollow a user."""
|
||||
current_user_id = await get_current_user_id(credentials)
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Check if following
|
||||
cursor.execute(
|
||||
"SELECT id FROM follows WHERE follower_id = ? AND following_id = ?",
|
||||
(current_user_id, user_id),
|
||||
)
|
||||
if not cursor.fetchone():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="You are not following this user",
|
||||
)
|
||||
|
||||
# Delete follow
|
||||
cursor.execute(
|
||||
"DELETE FROM follows WHERE follower_id = ? AND following_id = ?",
|
||||
(current_user_id, user_id),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
return {"message": "Successfully unfollowed user"}
|
||||
11
requirements.txt
Normal file
11
requirements.txt
Normal file
@@ -0,0 +1,11 @@
|
||||
fastapi>=0.109.0
|
||||
uvicorn[standard]>=0.27.0
|
||||
python-multipart>=0.0.6
|
||||
python-jose[cryptography]>=3.3.0
|
||||
passlib[bcrypt]>=1.7.4
|
||||
pydantic>=2.0.0
|
||||
sqlalchemy>=2.0.0
|
||||
pytest>=7.4.0
|
||||
pytest-asyncio>=0.21.0
|
||||
httpx>=0.25.0
|
||||
aiosqlite>=0.19.0
|
||||
1
tests/__init__.py
Normal file
1
tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Tests package for SocialPhoto."""
|
||||
129
tests/conftest.py
Normal file
129
tests/conftest.py
Normal file
@@ -0,0 +1,129 @@
|
||||
"""Pytest fixtures for SocialPhoto tests."""
|
||||
import os
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
from typing import Generator
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
# Set test database path before importing app
|
||||
TEST_DB = Path(__file__).parent / "test_socialphoto.db"
|
||||
|
||||
|
||||
def override_db_path():
|
||||
"""Override database path for tests."""
|
||||
return TEST_DB
|
||||
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
def db() -> Generator[sqlite3.Connection, None, None]:
|
||||
"""Create a fresh database for each test."""
|
||||
# Remove existing test database
|
||||
if TEST_DB.exists():
|
||||
TEST_DB.unlink()
|
||||
|
||||
# Patch the database path before importing
|
||||
import app.database as db_module
|
||||
original_path = db_module.DATABASE_PATH
|
||||
db_module.DATABASE_PATH = TEST_DB
|
||||
|
||||
# Initialize database with new path
|
||||
db_module.init_db()
|
||||
|
||||
conn = db_module.get_db_connection()
|
||||
conn.row_factory = sqlite3.Row
|
||||
|
||||
yield conn
|
||||
|
||||
conn.close()
|
||||
db_module.DATABASE_PATH = original_path
|
||||
if TEST_DB.exists():
|
||||
TEST_DB.unlink()
|
||||
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
def client(db: sqlite3.Connection) -> Generator[TestClient, None, None]:
|
||||
"""Create a test client."""
|
||||
from app.main import app
|
||||
from app.database import get_db
|
||||
|
||||
# Override database dependency
|
||||
def override_get_db():
|
||||
yield db
|
||||
|
||||
app.dependency_overrides[get_db] = override_get_db
|
||||
|
||||
with TestClient(app) as test_client:
|
||||
yield test_client
|
||||
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
def get_user_id_from_token(token: str) -> int:
|
||||
"""Extract user ID from JWT token (for test fixtures only)."""
|
||||
from jose import jwt
|
||||
from app.auth import SECRET_KEY, ALGORITHM
|
||||
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
|
||||
return int(payload["sub"])
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def registered_user(client: TestClient) -> dict:
|
||||
"""Register a test user and return user data with token."""
|
||||
response = client.post(
|
||||
"/auth/register",
|
||||
json={
|
||||
"username": "testuser",
|
||||
"email": "test@example.com",
|
||||
"password": "testpass123",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 201
|
||||
data = response.json()
|
||||
token = data["access_token"]
|
||||
user_id = get_user_id_from_token(token)
|
||||
return {
|
||||
"id": user_id,
|
||||
"username": "testuser",
|
||||
"email": "test@example.com",
|
||||
"password": "testpass123",
|
||||
"token": token,
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def two_users(client: TestClient) -> dict:
|
||||
"""Create two test users."""
|
||||
# User 1
|
||||
response = client.post(
|
||||
"/auth/register",
|
||||
json={
|
||||
"username": "user1",
|
||||
"email": "user1@example.com",
|
||||
"password": "password123",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 201
|
||||
token1 = response.json()["access_token"]
|
||||
|
||||
# User 2
|
||||
response = client.post(
|
||||
"/auth/register",
|
||||
json={
|
||||
"username": "user2",
|
||||
"email": "user2@example.com",
|
||||
"password": "password123",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 201
|
||||
token2 = response.json()["access_token"]
|
||||
|
||||
return {
|
||||
"user1_id": get_user_id_from_token(token1),
|
||||
"user2_id": get_user_id_from_token(token2),
|
||||
"user1_token": token1,
|
||||
"user2_token": token2,
|
||||
"user1_username": "user1",
|
||||
"user2_username": "user2",
|
||||
}
|
||||
158
tests/test_auth.py
Normal file
158
tests/test_auth.py
Normal file
@@ -0,0 +1,158 @@
|
||||
"""Tests for authentication routes."""
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
def test_register_success(client: TestClient):
|
||||
"""Test successful user registration."""
|
||||
response = client.post(
|
||||
"/auth/register",
|
||||
json={
|
||||
"username": "newuser",
|
||||
"email": "newuser@example.com",
|
||||
"password": "password123",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 201
|
||||
data = response.json()
|
||||
assert "access_token" in data
|
||||
assert data["token_type"] == "bearer"
|
||||
|
||||
|
||||
def test_register_duplicate_username(client: TestClient):
|
||||
"""Test registration with duplicate username."""
|
||||
# First registration
|
||||
client.post(
|
||||
"/auth/register",
|
||||
json={
|
||||
"username": "duplicate",
|
||||
"email": "first@example.com",
|
||||
"password": "password123",
|
||||
},
|
||||
)
|
||||
|
||||
# Second registration with same username
|
||||
response = client.post(
|
||||
"/auth/register",
|
||||
json={
|
||||
"username": "duplicate",
|
||||
"email": "second@example.com",
|
||||
"password": "password123",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert "Username already registered" in response.json()["detail"]
|
||||
|
||||
|
||||
def test_register_duplicate_email(client: TestClient):
|
||||
"""Test registration with duplicate email."""
|
||||
# First registration
|
||||
client.post(
|
||||
"/auth/register",
|
||||
json={
|
||||
"username": "firstuser",
|
||||
"email": "same@example.com",
|
||||
"password": "password123",
|
||||
},
|
||||
)
|
||||
|
||||
# Second registration with same email
|
||||
response = client.post(
|
||||
"/auth/register",
|
||||
json={
|
||||
"username": "seconduser",
|
||||
"email": "same@example.com",
|
||||
"password": "password123",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert "Email already registered" in response.json()["detail"]
|
||||
|
||||
|
||||
def test_register_invalid_email(client: TestClient):
|
||||
"""Test registration with invalid email format."""
|
||||
response = client.post(
|
||||
"/auth/register",
|
||||
json={
|
||||
"username": "validuser",
|
||||
"email": "not-an-email",
|
||||
"password": "password123",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 422 # Validation error
|
||||
|
||||
|
||||
def test_register_short_password(client: TestClient):
|
||||
"""Test registration with too short password."""
|
||||
response = client.post(
|
||||
"/auth/register",
|
||||
json={
|
||||
"username": "validuser",
|
||||
"email": "valid@example.com",
|
||||
"password": "12345",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 422 # Validation error
|
||||
|
||||
|
||||
def test_login_success(client: TestClient):
|
||||
"""Test successful login."""
|
||||
# Register first
|
||||
client.post(
|
||||
"/auth/register",
|
||||
json={
|
||||
"username": "loginuser",
|
||||
"email": "login@example.com",
|
||||
"password": "password123",
|
||||
},
|
||||
)
|
||||
|
||||
# Login
|
||||
response = client.post(
|
||||
"/auth/login",
|
||||
json={
|
||||
"username": "loginuser",
|
||||
"password": "password123",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "access_token" in data
|
||||
assert data["token_type"] == "bearer"
|
||||
|
||||
|
||||
def test_login_wrong_password(client: TestClient):
|
||||
"""Test login with wrong password."""
|
||||
# Register first
|
||||
client.post(
|
||||
"/auth/register",
|
||||
json={
|
||||
"username": "testuser",
|
||||
"email": "test@example.com",
|
||||
"password": "correctpassword",
|
||||
},
|
||||
)
|
||||
|
||||
# Login with wrong password
|
||||
response = client.post(
|
||||
"/auth/login",
|
||||
json={
|
||||
"username": "testuser",
|
||||
"password": "wrongpassword",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
assert "Incorrect username or password" in response.json()["detail"]
|
||||
|
||||
|
||||
def test_login_nonexistent_user(client: TestClient):
|
||||
"""Test login with nonexistent username."""
|
||||
response = client.post(
|
||||
"/auth/login",
|
||||
json={
|
||||
"username": "nonexistent",
|
||||
"password": "password123",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
assert "Incorrect username or password" in response.json()["detail"]
|
||||
292
tests/test_posts.py
Normal file
292
tests/test_posts.py
Normal file
@@ -0,0 +1,292 @@
|
||||
"""Tests for post routes."""
|
||||
import io
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
def test_create_post_success(client: TestClient, two_users: dict):
|
||||
"""Test successful post creation."""
|
||||
token = two_users["user1_token"]
|
||||
|
||||
# Create a fake image
|
||||
fake_image = io.BytesIO(b"fake image content")
|
||||
fake_image.name = "test.jpg"
|
||||
|
||||
response = client.post(
|
||||
"/posts",
|
||||
files={"image": ("test.jpg", fake_image, "image/jpeg")},
|
||||
data={"caption": "My first post!"},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
data = response.json()
|
||||
assert data["caption"] == "My first post!"
|
||||
assert data["user_id"] is not None
|
||||
assert data["likes_count"] == 0
|
||||
assert data["dislikes_count"] == 0
|
||||
assert data["comments_count"] == 0
|
||||
|
||||
|
||||
def test_create_post_unauthorized(client: TestClient):
|
||||
"""Test creating post without authentication."""
|
||||
fake_image = io.BytesIO(b"fake image content")
|
||||
fake_image.name = "test.jpg"
|
||||
|
||||
response = client.post(
|
||||
"/posts",
|
||||
files={"image": ("test.jpg", fake_image, "image/jpeg")},
|
||||
data={"caption": "My first post!"},
|
||||
)
|
||||
|
||||
assert response.status_code == 401 # No credentials
|
||||
|
||||
|
||||
def test_create_post_invalid_file_type(client: TestClient, two_users: dict):
|
||||
"""Test creating post with invalid file type."""
|
||||
token = two_users["user1_token"]
|
||||
|
||||
fake_file = io.BytesIO(b"fake content")
|
||||
fake_file.name = "test.txt"
|
||||
|
||||
response = client.post(
|
||||
"/posts",
|
||||
files={"image": ("test.txt", fake_file, "text/plain")},
|
||||
data={"caption": "Invalid file"},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert "File type not allowed" in response.json()["detail"]
|
||||
|
||||
|
||||
def test_get_posts_empty(client: TestClient):
|
||||
"""Test getting posts when database is empty."""
|
||||
response = client.get("/posts")
|
||||
assert response.status_code == 200
|
||||
assert response.json() == []
|
||||
|
||||
|
||||
def test_get_posts(client: TestClient, two_users: dict):
|
||||
"""Test getting posts."""
|
||||
token = two_users["user1_token"]
|
||||
|
||||
# Create a post
|
||||
fake_image = io.BytesIO(b"fake image content")
|
||||
fake_image.name = "test.jpg"
|
||||
client.post(
|
||||
"/posts",
|
||||
files={"image": ("test.jpg", fake_image, "image/jpeg")},
|
||||
data={"caption": "Test post"},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
|
||||
response = client.get("/posts")
|
||||
assert response.status_code == 200
|
||||
posts = response.json()
|
||||
assert len(posts) == 1
|
||||
assert posts[0]["caption"] == "Test post"
|
||||
|
||||
|
||||
def test_get_single_post(client: TestClient, two_users: dict):
|
||||
"""Test getting a single post."""
|
||||
token = two_users["user1_token"]
|
||||
|
||||
# Create a post
|
||||
fake_image = io.BytesIO(b"fake image content")
|
||||
fake_image.name = "test.jpg"
|
||||
create_response = client.post(
|
||||
"/posts",
|
||||
files={"image": ("test.jpg", fake_image, "image/jpeg")},
|
||||
data={"caption": "Single post test"},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
post_id = create_response.json()["id"]
|
||||
|
||||
response = client.get(f"/posts/{post_id}")
|
||||
assert response.status_code == 200
|
||||
assert response.json()["caption"] == "Single post test"
|
||||
|
||||
|
||||
def test_get_nonexistent_post(client: TestClient):
|
||||
"""Test getting a post that doesn't exist."""
|
||||
response = client.get("/posts/9999")
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
def test_delete_post_by_owner(client: TestClient, two_users: dict):
|
||||
"""Test deleting own post."""
|
||||
token = two_users["user1_token"]
|
||||
|
||||
# Create a post
|
||||
fake_image = io.BytesIO(b"fake image content")
|
||||
fake_image.name = "test.jpg"
|
||||
create_response = client.post(
|
||||
"/posts",
|
||||
files={"image": ("test.jpg", fake_image, "image/jpeg")},
|
||||
data={"caption": "To be deleted"},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
post_id = create_response.json()["id"]
|
||||
|
||||
# Delete the post
|
||||
response = client.delete(
|
||||
f"/posts/{post_id}",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert response.status_code == 204
|
||||
|
||||
# Verify it's deleted
|
||||
response = client.get(f"/posts/{post_id}")
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
def test_delete_post_by_non_owner(client: TestClient, two_users: dict):
|
||||
"""Test trying to delete someone else's post."""
|
||||
token1 = two_users["user1_token"]
|
||||
token2 = two_users["user2_token"]
|
||||
|
||||
# User 1 creates a post
|
||||
fake_image = io.BytesIO(b"fake image content")
|
||||
fake_image.name = "test.jpg"
|
||||
create_response = client.post(
|
||||
"/posts",
|
||||
files={"image": ("test.jpg", fake_image, "image/jpeg")},
|
||||
data={"caption": "User 1's post"},
|
||||
headers={"Authorization": f"Bearer {token1}"},
|
||||
)
|
||||
post_id = create_response.json()["id"]
|
||||
|
||||
# User 2 tries to delete it
|
||||
response = client.delete(
|
||||
f"/posts/{post_id}",
|
||||
headers={"Authorization": f"Bearer {token2}"},
|
||||
)
|
||||
assert response.status_code == 403
|
||||
assert "You can only delete your own posts" in response.json()["detail"]
|
||||
|
||||
|
||||
def test_like_post(client: TestClient, two_users: dict):
|
||||
"""Test liking a post."""
|
||||
token1 = two_users["user1_token"]
|
||||
|
||||
# User 1 creates a post
|
||||
fake_image = io.BytesIO(b"fake image content")
|
||||
fake_image.name = "test.jpg"
|
||||
create_response = client.post(
|
||||
"/posts",
|
||||
files={"image": ("test.jpg", fake_image, "image/jpeg")},
|
||||
data={"caption": "Like test"},
|
||||
headers={"Authorization": f"Bearer {token1}"},
|
||||
)
|
||||
post_id = create_response.json()["id"]
|
||||
|
||||
# Like the post
|
||||
response = client.post(
|
||||
f"/posts/{post_id}/like",
|
||||
headers={"Authorization": f"Bearer {token1}"},
|
||||
)
|
||||
assert response.status_code == 201
|
||||
assert response.json()["message"] == "Post liked"
|
||||
|
||||
# Verify like count
|
||||
response = client.get(f"/posts/{post_id}")
|
||||
assert response.json()["likes_count"] == 1
|
||||
|
||||
|
||||
def test_unlike_post(client: TestClient, two_users: dict):
|
||||
"""Test removing a like from a post."""
|
||||
token1 = two_users["user1_token"]
|
||||
|
||||
# User 1 creates a post and likes it
|
||||
fake_image = io.BytesIO(b"fake image content")
|
||||
fake_image.name = "test.jpg"
|
||||
create_response = client.post(
|
||||
"/posts",
|
||||
files={"image": ("test.jpg", fake_image, "image/jpeg")},
|
||||
data={"caption": "Unlike test"},
|
||||
headers={"Authorization": f"Bearer {token1}"},
|
||||
)
|
||||
post_id = create_response.json()["id"]
|
||||
|
||||
client.post(
|
||||
f"/posts/{post_id}/like",
|
||||
headers={"Authorization": f"Bearer {token1}"},
|
||||
)
|
||||
|
||||
# Unlike the post
|
||||
response = client.delete(
|
||||
f"/posts/{post_id}/like",
|
||||
headers={"Authorization": f"Bearer {token1}"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["message"] == "Like removed"
|
||||
|
||||
# Verify like count
|
||||
response = client.get(f"/posts/{post_id}")
|
||||
assert response.json()["likes_count"] == 0
|
||||
|
||||
|
||||
def test_dislike_post(client: TestClient, two_users: dict):
|
||||
"""Test disliking a post."""
|
||||
token1 = two_users["user1_token"]
|
||||
|
||||
# User 1 creates a post
|
||||
fake_image = io.BytesIO(b"fake image content")
|
||||
fake_image.name = "test.jpg"
|
||||
create_response = client.post(
|
||||
"/posts",
|
||||
files={"image": ("test.jpg", fake_image, "image/jpeg")},
|
||||
data={"caption": "Dislike test"},
|
||||
headers={"Authorization": f"Bearer {token1}"},
|
||||
)
|
||||
post_id = create_response.json()["id"]
|
||||
|
||||
# Dislike the post
|
||||
response = client.post(
|
||||
f"/posts/{post_id}/dislike",
|
||||
headers={"Authorization": f"Bearer {token1}"},
|
||||
)
|
||||
assert response.status_code == 201
|
||||
assert response.json()["message"] == "Post disliked"
|
||||
|
||||
# Verify dislike count
|
||||
response = client.get(f"/posts/{post_id}")
|
||||
assert response.json()["dislikes_count"] == 1
|
||||
|
||||
|
||||
def test_like_and_dislike_mutually_exclusive(client: TestClient, two_users: dict):
|
||||
"""Test that like and dislike are mutually exclusive."""
|
||||
token1 = two_users["user1_token"]
|
||||
|
||||
# User 1 creates a post
|
||||
fake_image = io.BytesIO(b"fake image content")
|
||||
fake_image.name = "test.jpg"
|
||||
create_response = client.post(
|
||||
"/posts",
|
||||
files={"image": ("test.jpg", fake_image, "image/jpeg")},
|
||||
data={"caption": "Mutual exclusion test"},
|
||||
headers={"Authorization": f"Bearer {token1}"},
|
||||
)
|
||||
post_id = create_response.json()["id"]
|
||||
|
||||
# Like the post
|
||||
client.post(
|
||||
f"/posts/{post_id}/like",
|
||||
headers={"Authorization": f"Bearer {token1}"},
|
||||
)
|
||||
|
||||
# Dislike the post (should replace like)
|
||||
response = client.post(
|
||||
f"/posts/{post_id}/dislike",
|
||||
headers={"Authorization": f"Bearer {token1}"},
|
||||
)
|
||||
assert response.status_code == 201
|
||||
|
||||
# Verify: no likes, 1 dislike
|
||||
response = client.get(f"/posts/{post_id}")
|
||||
data = response.json()
|
||||
assert data["likes_count"] == 0
|
||||
assert data["dislikes_count"] == 1
|
||||
358
tests/test_social.py
Normal file
358
tests/test_social.py
Normal file
@@ -0,0 +1,358 @@
|
||||
"""Tests for social features: users, follows, comments."""
|
||||
import io
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
def test_get_user_profile(client: TestClient, two_users: dict):
|
||||
"""Test getting a user profile."""
|
||||
token1 = two_users["user1_token"]
|
||||
|
||||
# Get user ID from token (simplified - in real app would decode)
|
||||
# Let's create a post first to get the user
|
||||
fake_image = io.BytesIO(b"fake image content")
|
||||
fake_image.name = "test.jpg"
|
||||
client.post(
|
||||
"/posts",
|
||||
files={"image": ("test.jpg", fake_image, "image/jpeg")},
|
||||
data={"caption": "Test post"},
|
||||
headers={"Authorization": f"Bearer {token1}"},
|
||||
)
|
||||
|
||||
# For now, just test that user endpoint exists and works
|
||||
# The actual user ID would need to be extracted from the token
|
||||
response = client.get("/users/1")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["username"] == "user1"
|
||||
|
||||
|
||||
def test_get_user_stats(client: TestClient, two_users: dict):
|
||||
"""Test getting user statistics."""
|
||||
token1 = two_users["user1_token"]
|
||||
|
||||
# Create a post
|
||||
fake_image = io.BytesIO(b"fake image content")
|
||||
fake_image.name = "test.jpg"
|
||||
client.post(
|
||||
"/posts",
|
||||
files={"image": ("test.jpg", fake_image, "image/jpeg")},
|
||||
data={"caption": "Stats test"},
|
||||
headers={"Authorization": f"Bearer {token1}"},
|
||||
)
|
||||
|
||||
# User 2 follows User 1
|
||||
client.post(
|
||||
"/users/1/follow",
|
||||
headers={"Authorization": f"Bearer {two_users['user2_token']}"},
|
||||
)
|
||||
|
||||
response = client.get("/users/1/stats")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["posts_count"] == 1
|
||||
assert data["followers_count"] == 1
|
||||
assert data["following_count"] == 0
|
||||
|
||||
|
||||
def test_follow_user(client: TestClient, two_users: dict):
|
||||
"""Test following a user."""
|
||||
# User 2 follows User 1
|
||||
response = client.post(
|
||||
"/users/1/follow",
|
||||
headers={"Authorization": f"Bearer {two_users['user2_token']}"},
|
||||
)
|
||||
assert response.status_code == 201
|
||||
assert response.json()["message"] == "Successfully followed user"
|
||||
|
||||
|
||||
def test_follow_self(client: TestClient, two_users: dict):
|
||||
"""Test that you cannot follow yourself."""
|
||||
response = client.post(
|
||||
"/users/1/follow",
|
||||
headers={"Authorization": f"Bearer {two_users['user1_token']}"},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert "You cannot follow yourself" in response.json()["detail"]
|
||||
|
||||
|
||||
def test_follow_already_following(client: TestClient, two_users: dict):
|
||||
"""Test following someone you already follow."""
|
||||
# User 2 follows User 1
|
||||
client.post(
|
||||
"/users/1/follow",
|
||||
headers={"Authorization": f"Bearer {two_users['user2_token']}"},
|
||||
)
|
||||
|
||||
# Try to follow again
|
||||
response = client.post(
|
||||
"/users/1/follow",
|
||||
headers={"Authorization": f"Bearer {two_users['user2_token']}"},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert "You already follow this user" in response.json()["detail"]
|
||||
|
||||
|
||||
def test_unfollow_user(client: TestClient, two_users: dict):
|
||||
"""Test unfollowing a user."""
|
||||
token2 = two_users["user2_token"]
|
||||
|
||||
# User 2 follows User 1
|
||||
client.post(
|
||||
"/users/1/follow",
|
||||
headers={"Authorization": f"Bearer {token2}"},
|
||||
)
|
||||
|
||||
# User 2 unfollows User 1
|
||||
response = client.delete(
|
||||
"/users/1/follow",
|
||||
headers={"Authorization": f"Bearer {token2}"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["message"] == "Successfully unfollowed user"
|
||||
|
||||
|
||||
def test_unfollow_not_following(client: TestClient, two_users: dict):
|
||||
"""Test unfollowing someone you don't follow."""
|
||||
response = client.delete(
|
||||
"/users/1/follow",
|
||||
headers={"Authorization": f"Bearer {two_users['user2_token']}"},
|
||||
)
|
||||
assert response.status_code == 404
|
||||
assert "You are not following this user" in response.json()["detail"]
|
||||
|
||||
|
||||
def test_get_user_posts(client: TestClient, two_users: dict):
|
||||
"""Test getting posts by a specific user."""
|
||||
token1 = two_users["user1_token"]
|
||||
|
||||
# Create two posts as User 1
|
||||
for i in range(2):
|
||||
fake_image = io.BytesIO(b"fake image content")
|
||||
fake_image.name = "test.jpg"
|
||||
client.post(
|
||||
"/posts",
|
||||
files={"image": ("test.jpg", fake_image, "image/jpeg")},
|
||||
data={"caption": f"User 1 post {i}"},
|
||||
headers={"Authorization": f"Bearer {token1}"},
|
||||
)
|
||||
|
||||
response = client.get("/users/1/posts")
|
||||
assert response.status_code == 200
|
||||
posts = response.json()
|
||||
assert len(posts) == 2
|
||||
|
||||
|
||||
def test_create_comment(client: TestClient, two_users: dict):
|
||||
"""Test creating a comment."""
|
||||
token1 = two_users["user1_token"]
|
||||
|
||||
# User 1 creates a post
|
||||
fake_image = io.BytesIO(b"fake image content")
|
||||
fake_image.name = "test.jpg"
|
||||
create_response = client.post(
|
||||
"/posts",
|
||||
files={"image": ("test.jpg", fake_image, "image/jpeg")},
|
||||
data={"caption": "Comment test"},
|
||||
headers={"Authorization": f"Bearer {token1}"},
|
||||
)
|
||||
post_id = create_response.json()["id"]
|
||||
|
||||
# User 2 comments on the post
|
||||
response = client.post(
|
||||
f"/posts/{post_id}/comments",
|
||||
json={"content": "Great post!"},
|
||||
headers={"Authorization": f"Bearer {two_users['user2_token']}"},
|
||||
)
|
||||
assert response.status_code == 201
|
||||
data = response.json()
|
||||
assert data["content"] == "Great post!"
|
||||
assert data["username"] == "user2"
|
||||
|
||||
|
||||
def test_get_post_comments(client: TestClient, two_users: dict):
|
||||
"""Test getting comments for a post."""
|
||||
token1 = two_users["user1_token"]
|
||||
|
||||
# User 1 creates a post
|
||||
fake_image = io.BytesIO(b"fake image content")
|
||||
fake_image.name = "test.jpg"
|
||||
create_response = client.post(
|
||||
"/posts",
|
||||
files={"image": ("test.jpg", fake_image, "image/jpeg")},
|
||||
data={"caption": "Comments test"},
|
||||
headers={"Authorization": f"Bearer {token1}"},
|
||||
)
|
||||
post_id = create_response.json()["id"]
|
||||
|
||||
# Add a comment
|
||||
client.post(
|
||||
f"/posts/{post_id}/comments",
|
||||
json={"content": "First comment!"},
|
||||
headers={"Authorization": f"Bearer {token1}"},
|
||||
)
|
||||
|
||||
response = client.get(f"/posts/{post_id}/comments")
|
||||
assert response.status_code == 200
|
||||
comments = response.json()
|
||||
assert len(comments) == 1
|
||||
assert comments[0]["content"] == "First comment!"
|
||||
|
||||
|
||||
def test_delete_comment_by_owner(client: TestClient, two_users: dict):
|
||||
"""Test deleting own comment."""
|
||||
token1 = two_users["user1_token"]
|
||||
|
||||
# User 1 creates a post and comments
|
||||
fake_image = io.BytesIO(b"fake image content")
|
||||
fake_image.name = "test.jpg"
|
||||
create_response = client.post(
|
||||
"/posts",
|
||||
files={"image": ("test.jpg", fake_image, "image/jpeg")},
|
||||
data={"caption": "Delete comment test"},
|
||||
headers={"Authorization": f"Bearer {token1}"},
|
||||
)
|
||||
post_id = create_response.json()["id"]
|
||||
|
||||
comment_response = client.post(
|
||||
f"/posts/{post_id}/comments",
|
||||
json={"content": "To be deleted"},
|
||||
headers={"Authorization": f"Bearer {token1}"},
|
||||
)
|
||||
comment_id = comment_response.json()["id"]
|
||||
|
||||
# Delete the comment
|
||||
response = client.delete(
|
||||
f"/comments/{comment_id}",
|
||||
headers={"Authorization": f"Bearer {token1}"},
|
||||
)
|
||||
assert response.status_code == 204
|
||||
|
||||
|
||||
def test_delete_comment_by_non_owner(client: TestClient, two_users: dict):
|
||||
"""Test trying to delete someone else's comment."""
|
||||
token1 = two_users["user1_token"]
|
||||
token2 = two_users["user2_token"]
|
||||
|
||||
# User 1 creates a post and comments
|
||||
fake_image = io.BytesIO(b"fake image content")
|
||||
fake_image.name = "test.jpg"
|
||||
create_response = client.post(
|
||||
"/posts",
|
||||
files={"image": ("test.jpg", fake_image, "image/jpeg")},
|
||||
data={"caption": "Unauthorized delete test"},
|
||||
headers={"Authorization": f"Bearer {token1}"},
|
||||
)
|
||||
post_id = create_response.json()["id"]
|
||||
|
||||
comment_response = client.post(
|
||||
f"/posts/{post_id}/comments",
|
||||
json={"content": "User 1's comment"},
|
||||
headers={"Authorization": f"Bearer {token1}"},
|
||||
)
|
||||
comment_id = comment_response.json()["id"]
|
||||
|
||||
# User 2 tries to delete it
|
||||
response = client.delete(
|
||||
f"/comments/{comment_id}",
|
||||
headers={"Authorization": f"Bearer {token2}"},
|
||||
)
|
||||
assert response.status_code == 403
|
||||
assert "You can only delete your own comments" in response.json()["detail"]
|
||||
|
||||
|
||||
def test_like_comment(client: TestClient, two_users: dict):
|
||||
"""Test liking a comment."""
|
||||
token1 = two_users["user1_token"]
|
||||
|
||||
# User 1 creates a post and comments
|
||||
fake_image = io.BytesIO(b"fake image content")
|
||||
fake_image.name = "test.jpg"
|
||||
create_response = client.post(
|
||||
"/posts",
|
||||
files={"image": ("test.jpg", fake_image, "image/jpeg")},
|
||||
data={"caption": "Comment like test"},
|
||||
headers={"Authorization": f"Bearer {token1}"},
|
||||
)
|
||||
post_id = create_response.json()["id"]
|
||||
|
||||
comment_response = client.post(
|
||||
f"/posts/{post_id}/comments",
|
||||
json={"content": "Comment to like"},
|
||||
headers={"Authorization": f"Bearer {token1}"},
|
||||
)
|
||||
comment_id = comment_response.json()["id"]
|
||||
|
||||
# Like the comment
|
||||
response = client.post(
|
||||
f"/comments/{comment_id}/like",
|
||||
headers={"Authorization": f"Bearer {token1}"},
|
||||
)
|
||||
assert response.status_code == 201
|
||||
assert response.json()["message"] == "Comment liked"
|
||||
|
||||
|
||||
def test_feed_global(client: TestClient, two_users: dict):
|
||||
"""Test global feed."""
|
||||
token1 = two_users["user1_token"]
|
||||
|
||||
# User 1 creates a post
|
||||
fake_image = io.BytesIO(b"fake image content")
|
||||
fake_image.name = "test.jpg"
|
||||
client.post(
|
||||
"/posts",
|
||||
files={"image": ("test.jpg", fake_image, "image/jpeg")},
|
||||
data={"caption": "Global feed test"},
|
||||
headers={"Authorization": f"Bearer {token1}"},
|
||||
)
|
||||
|
||||
response = client.get("/feed/global")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["total"] >= 1
|
||||
assert len(data["posts"]) >= 1
|
||||
|
||||
|
||||
def test_feed_followed(client: TestClient, two_users: dict):
|
||||
"""Test followed users feed."""
|
||||
token1 = two_users["user1_token"]
|
||||
token2 = two_users["user2_token"]
|
||||
|
||||
# User 1 creates a post
|
||||
fake_image = io.BytesIO(b"fake image content")
|
||||
fake_image.name = "test.jpg"
|
||||
client.post(
|
||||
"/posts",
|
||||
files={"image": ("test.jpg", fake_image, "image/jpeg")},
|
||||
data={"caption": "Followed feed test"},
|
||||
headers={"Authorization": f"Bearer {token1}"},
|
||||
)
|
||||
|
||||
# User 2 follows User 1
|
||||
client.post(
|
||||
"/users/1/follow",
|
||||
headers={"Authorization": f"Bearer {token2}"},
|
||||
)
|
||||
|
||||
# Get followed feed for User 2
|
||||
response = client.get(
|
||||
"/feed",
|
||||
headers={"Authorization": f"Bearer {token2}"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["total"] >= 1
|
||||
assert len(data["posts"]) >= 1
|
||||
|
||||
|
||||
def test_feed_followed_no_follows(client: TestClient, two_users: dict):
|
||||
"""Test followed feed when not following anyone."""
|
||||
response = client.get(
|
||||
"/feed",
|
||||
headers={"Authorization": f"Bearer {two_users['user2_token']}"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["total"] == 0
|
||||
assert len(data["posts"]) == 0
|
||||
1
uploads/0072fbb2-e8f8-4716-9b5b-e42cb206b969.jpg
Normal file
1
uploads/0072fbb2-e8f8-4716-9b5b-e42cb206b969.jpg
Normal file
@@ -0,0 +1 @@
|
||||
fake image content
|
||||
1
uploads/01187997-079c-4ffd-bc4f-3b02ad2d1e3b.jpg
Normal file
1
uploads/01187997-079c-4ffd-bc4f-3b02ad2d1e3b.jpg
Normal file
@@ -0,0 +1 @@
|
||||
fake image content
|
||||
1
uploads/03a9df1e-d125-4b56-8b82-9a725a50eb79.jpg
Normal file
1
uploads/03a9df1e-d125-4b56-8b82-9a725a50eb79.jpg
Normal file
@@ -0,0 +1 @@
|
||||
fake image content
|
||||
1
uploads/12351e3f-c1ed-492c-bad2-77b1fc905025.jpg
Normal file
1
uploads/12351e3f-c1ed-492c-bad2-77b1fc905025.jpg
Normal file
@@ -0,0 +1 @@
|
||||
fake image content
|
||||
1
uploads/13189566-336b-4231-aaf9-169e5aeb7b88.jpg
Normal file
1
uploads/13189566-336b-4231-aaf9-169e5aeb7b88.jpg
Normal file
@@ -0,0 +1 @@
|
||||
fake image content
|
||||
1
uploads/13e02870-d0e9-4298-aabc-17f7ac05e1de.jpg
Normal file
1
uploads/13e02870-d0e9-4298-aabc-17f7ac05e1de.jpg
Normal file
@@ -0,0 +1 @@
|
||||
fake image content
|
||||
1
uploads/15a6dd9b-b574-4e59-8c10-10f8e8efc29e.jpg
Normal file
1
uploads/15a6dd9b-b574-4e59-8c10-10f8e8efc29e.jpg
Normal file
@@ -0,0 +1 @@
|
||||
fake image content
|
||||
1
uploads/1612995f-4f70-444d-ba8f-b644317e1da2.jpg
Normal file
1
uploads/1612995f-4f70-444d-ba8f-b644317e1da2.jpg
Normal file
@@ -0,0 +1 @@
|
||||
fake image content
|
||||
1
uploads/17e2cb1f-db19-4715-983f-3ea54255b7e5.jpg
Normal file
1
uploads/17e2cb1f-db19-4715-983f-3ea54255b7e5.jpg
Normal file
@@ -0,0 +1 @@
|
||||
fake image content
|
||||
1
uploads/1c55ded5-73f0-4654-836e-d711e1787549.jpg
Normal file
1
uploads/1c55ded5-73f0-4654-836e-d711e1787549.jpg
Normal file
@@ -0,0 +1 @@
|
||||
fake image content
|
||||
1
uploads/1d72bc45-ac49-4f17-aab5-32229aa10986.jpg
Normal file
1
uploads/1d72bc45-ac49-4f17-aab5-32229aa10986.jpg
Normal file
@@ -0,0 +1 @@
|
||||
fake image content
|
||||
1
uploads/1ef0f876-ae9c-4d7a-80f1-1354eedb9699.jpg
Normal file
1
uploads/1ef0f876-ae9c-4d7a-80f1-1354eedb9699.jpg
Normal file
@@ -0,0 +1 @@
|
||||
fake image content
|
||||
1
uploads/20f2ad2d-01c1-4254-a3af-680312cdadcf.jpg
Normal file
1
uploads/20f2ad2d-01c1-4254-a3af-680312cdadcf.jpg
Normal file
@@ -0,0 +1 @@
|
||||
fake image content
|
||||
1
uploads/24ff4803-6b57-489c-8b8f-6e7bb173d45f.jpg
Normal file
1
uploads/24ff4803-6b57-489c-8b8f-6e7bb173d45f.jpg
Normal file
@@ -0,0 +1 @@
|
||||
fake image content
|
||||
1
uploads/330ef1d2-eb9e-4a55-a85a-26ef3c7c0e88.jpg
Normal file
1
uploads/330ef1d2-eb9e-4a55-a85a-26ef3c7c0e88.jpg
Normal file
@@ -0,0 +1 @@
|
||||
fake image content
|
||||
1
uploads/35bb57a4-4a80-4772-8a34-f853835b86cd.jpg
Normal file
1
uploads/35bb57a4-4a80-4772-8a34-f853835b86cd.jpg
Normal file
@@ -0,0 +1 @@
|
||||
fake image content
|
||||
1
uploads/35ed12a5-2792-4393-97e8-d3ea4fced61e.jpg
Normal file
1
uploads/35ed12a5-2792-4393-97e8-d3ea4fced61e.jpg
Normal file
@@ -0,0 +1 @@
|
||||
fake image content
|
||||
1
uploads/36e8290a-6696-478c-a243-fc92f3145f86.jpg
Normal file
1
uploads/36e8290a-6696-478c-a243-fc92f3145f86.jpg
Normal file
@@ -0,0 +1 @@
|
||||
fake image content
|
||||
1
uploads/3b98982b-8096-4bcf-a721-d0cfd4934fca.jpg
Normal file
1
uploads/3b98982b-8096-4bcf-a721-d0cfd4934fca.jpg
Normal file
@@ -0,0 +1 @@
|
||||
fake image content
|
||||
1
uploads/40f1d24e-839b-4164-aacc-cee7299b894a.jpg
Normal file
1
uploads/40f1d24e-839b-4164-aacc-cee7299b894a.jpg
Normal file
@@ -0,0 +1 @@
|
||||
fake image content
|
||||
1
uploads/4ce03a80-c923-4ff6-b42a-45a57826a6ca.jpg
Normal file
1
uploads/4ce03a80-c923-4ff6-b42a-45a57826a6ca.jpg
Normal file
@@ -0,0 +1 @@
|
||||
fake image content
|
||||
1
uploads/4e600f15-6038-449b-9f9a-ec3862bdb2c2.jpg
Normal file
1
uploads/4e600f15-6038-449b-9f9a-ec3862bdb2c2.jpg
Normal file
@@ -0,0 +1 @@
|
||||
fake image content
|
||||
1
uploads/4fc80117-a6bf-4b53-ac38-9db46c224363.jpg
Normal file
1
uploads/4fc80117-a6bf-4b53-ac38-9db46c224363.jpg
Normal file
@@ -0,0 +1 @@
|
||||
fake image content
|
||||
1
uploads/51f0bdc7-a331-48de-8121-2968761d828f.jpg
Normal file
1
uploads/51f0bdc7-a331-48de-8121-2968761d828f.jpg
Normal file
@@ -0,0 +1 @@
|
||||
fake image content
|
||||
1
uploads/5328276f-f339-4129-8ead-f9d534aee4c0.jpg
Normal file
1
uploads/5328276f-f339-4129-8ead-f9d534aee4c0.jpg
Normal file
@@ -0,0 +1 @@
|
||||
fake image content
|
||||
1
uploads/53abadfe-a8f9-4d4e-8a04-c88300b5da2c.jpg
Normal file
1
uploads/53abadfe-a8f9-4d4e-8a04-c88300b5da2c.jpg
Normal file
@@ -0,0 +1 @@
|
||||
fake image content
|
||||
1
uploads/5b6678ea-8c32-45da-800b-18fece1d18a9.jpg
Normal file
1
uploads/5b6678ea-8c32-45da-800b-18fece1d18a9.jpg
Normal file
@@ -0,0 +1 @@
|
||||
fake image content
|
||||
1
uploads/618c44fc-a5e4-4141-ac95-af68afbd6cb4.jpg
Normal file
1
uploads/618c44fc-a5e4-4141-ac95-af68afbd6cb4.jpg
Normal file
@@ -0,0 +1 @@
|
||||
fake image content
|
||||
1
uploads/661e1b30-74b9-45f0-ae9a-334da943a387.jpg
Normal file
1
uploads/661e1b30-74b9-45f0-ae9a-334da943a387.jpg
Normal file
@@ -0,0 +1 @@
|
||||
fake image content
|
||||
1
uploads/6c4cdf12-c2ec-4068-b729-29fb46497642.jpg
Normal file
1
uploads/6c4cdf12-c2ec-4068-b729-29fb46497642.jpg
Normal file
@@ -0,0 +1 @@
|
||||
fake image content
|
||||
1
uploads/6d559c27-faa3-4f47-b6af-737e88c96212.jpg
Normal file
1
uploads/6d559c27-faa3-4f47-b6af-737e88c96212.jpg
Normal file
@@ -0,0 +1 @@
|
||||
fake image content
|
||||
1
uploads/6d5d5a8c-0582-4635-97f7-7208785dbb06.jpg
Normal file
1
uploads/6d5d5a8c-0582-4635-97f7-7208785dbb06.jpg
Normal file
@@ -0,0 +1 @@
|
||||
fake image content
|
||||
1
uploads/7294c912-f07e-468b-903d-355bfc30eefa.jpg
Normal file
1
uploads/7294c912-f07e-468b-903d-355bfc30eefa.jpg
Normal file
@@ -0,0 +1 @@
|
||||
fake image content
|
||||
1
uploads/72ddff21-9d37-484d-9457-6f1783acc554.jpg
Normal file
1
uploads/72ddff21-9d37-484d-9457-6f1783acc554.jpg
Normal file
@@ -0,0 +1 @@
|
||||
fake image content
|
||||
1
uploads/7512ab06-e8e6-4668-9e46-156079659444.jpg
Normal file
1
uploads/7512ab06-e8e6-4668-9e46-156079659444.jpg
Normal file
@@ -0,0 +1 @@
|
||||
fake image content
|
||||
1
uploads/7519b0a3-c0df-4c7d-9993-0ae027f33377.jpg
Normal file
1
uploads/7519b0a3-c0df-4c7d-9993-0ae027f33377.jpg
Normal file
@@ -0,0 +1 @@
|
||||
fake image content
|
||||
1
uploads/76f2a69a-481b-4a43-8829-32b2b9202164.jpg
Normal file
1
uploads/76f2a69a-481b-4a43-8829-32b2b9202164.jpg
Normal file
@@ -0,0 +1 @@
|
||||
fake image content
|
||||
1
uploads/77ea205e-161a-4674-8233-d06714a62dc7.jpg
Normal file
1
uploads/77ea205e-161a-4674-8233-d06714a62dc7.jpg
Normal file
@@ -0,0 +1 @@
|
||||
fake image content
|
||||
1
uploads/7c9168ac-9dd6-46d4-9e10-0a1abdb2cef8.jpg
Normal file
1
uploads/7c9168ac-9dd6-46d4-9e10-0a1abdb2cef8.jpg
Normal file
@@ -0,0 +1 @@
|
||||
fake image content
|
||||
1
uploads/7d07f136-de7c-456e-bb0a-9e39947918ab.jpg
Normal file
1
uploads/7d07f136-de7c-456e-bb0a-9e39947918ab.jpg
Normal file
@@ -0,0 +1 @@
|
||||
fake image content
|
||||
1
uploads/7f41a555-6376-4308-8f09-e736592b0078.jpg
Normal file
1
uploads/7f41a555-6376-4308-8f09-e736592b0078.jpg
Normal file
@@ -0,0 +1 @@
|
||||
fake image content
|
||||
1
uploads/8053d803-3fbb-4819-9480-b6e00ad44989.jpg
Normal file
1
uploads/8053d803-3fbb-4819-9480-b6e00ad44989.jpg
Normal file
@@ -0,0 +1 @@
|
||||
fake image content
|
||||
1
uploads/80c95137-ef48-4847-862c-024c7e973da4.jpg
Normal file
1
uploads/80c95137-ef48-4847-862c-024c7e973da4.jpg
Normal file
@@ -0,0 +1 @@
|
||||
fake image content
|
||||
1
uploads/827896c2-0d4e-4ce7-aa57-9d2d2c3f7ff9.jpg
Normal file
1
uploads/827896c2-0d4e-4ce7-aa57-9d2d2c3f7ff9.jpg
Normal file
@@ -0,0 +1 @@
|
||||
fake image content
|
||||
1
uploads/84f88316-b861-45b7-8a30-b864d0d72f88.jpg
Normal file
1
uploads/84f88316-b861-45b7-8a30-b864d0d72f88.jpg
Normal file
@@ -0,0 +1 @@
|
||||
fake image content
|
||||
1
uploads/8667ab82-3bb0-4661-b44f-9430e6a112ff.jpg
Normal file
1
uploads/8667ab82-3bb0-4661-b44f-9430e6a112ff.jpg
Normal file
@@ -0,0 +1 @@
|
||||
fake image content
|
||||
1
uploads/8c630307-360a-4d66-8893-7040fbc8a9e7.jpg
Normal file
1
uploads/8c630307-360a-4d66-8893-7040fbc8a9e7.jpg
Normal file
@@ -0,0 +1 @@
|
||||
fake image content
|
||||
1
uploads/8e661aa6-3d20-4386-9ca7-ec9b40dd357b.jpg
Normal file
1
uploads/8e661aa6-3d20-4386-9ca7-ec9b40dd357b.jpg
Normal file
@@ -0,0 +1 @@
|
||||
fake image content
|
||||
1
uploads/91d18d60-aef1-484a-8a09-720d2320e713.jpg
Normal file
1
uploads/91d18d60-aef1-484a-8a09-720d2320e713.jpg
Normal file
@@ -0,0 +1 @@
|
||||
fake image content
|
||||
1
uploads/97706a6f-14be-484c-9c55-9ae472a34cf3.jpg
Normal file
1
uploads/97706a6f-14be-484c-9c55-9ae472a34cf3.jpg
Normal file
@@ -0,0 +1 @@
|
||||
fake image content
|
||||
1
uploads/9a1d5b8a-5afc-4970-8d99-b040fdb63aa2.jpg
Normal file
1
uploads/9a1d5b8a-5afc-4970-8d99-b040fdb63aa2.jpg
Normal file
@@ -0,0 +1 @@
|
||||
fake image content
|
||||
1
uploads/a5893124-936c-4c9b-806a-344bf894dc5a.jpg
Normal file
1
uploads/a5893124-936c-4c9b-806a-344bf894dc5a.jpg
Normal file
@@ -0,0 +1 @@
|
||||
fake image content
|
||||
1
uploads/a5d0701c-0356-48f6-bde8-47248602e6f0.jpg
Normal file
1
uploads/a5d0701c-0356-48f6-bde8-47248602e6f0.jpg
Normal file
@@ -0,0 +1 @@
|
||||
fake image content
|
||||
1
uploads/a82fedc3-650d-4a3d-bdfa-155de2ac1135.jpg
Normal file
1
uploads/a82fedc3-650d-4a3d-bdfa-155de2ac1135.jpg
Normal file
@@ -0,0 +1 @@
|
||||
fake image content
|
||||
1
uploads/ac9bed06-335f-4d35-9d43-4f1bce342483.jpg
Normal file
1
uploads/ac9bed06-335f-4d35-9d43-4f1bce342483.jpg
Normal file
@@ -0,0 +1 @@
|
||||
fake image content
|
||||
1
uploads/b08b3b9e-23b9-4e88-881a-a7bc92dcd659.jpg
Normal file
1
uploads/b08b3b9e-23b9-4e88-881a-a7bc92dcd659.jpg
Normal file
@@ -0,0 +1 @@
|
||||
fake image content
|
||||
1
uploads/b30771c4-0917-4ccc-bf80-e2556c53bcf5.jpg
Normal file
1
uploads/b30771c4-0917-4ccc-bf80-e2556c53bcf5.jpg
Normal file
@@ -0,0 +1 @@
|
||||
fake image content
|
||||
1
uploads/b316e522-8775-4c85-944a-f1b0f4e91ef4.jpg
Normal file
1
uploads/b316e522-8775-4c85-944a-f1b0f4e91ef4.jpg
Normal file
@@ -0,0 +1 @@
|
||||
fake image content
|
||||
1
uploads/b74a9056-3e78-4a32-bb70-7378e1cd76f8.jpg
Normal file
1
uploads/b74a9056-3e78-4a32-bb70-7378e1cd76f8.jpg
Normal file
@@ -0,0 +1 @@
|
||||
fake image content
|
||||
1
uploads/b76a6404-a624-40a5-86c0-b3bb7ad9d047.jpg
Normal file
1
uploads/b76a6404-a624-40a5-86c0-b3bb7ad9d047.jpg
Normal file
@@ -0,0 +1 @@
|
||||
fake image content
|
||||
1
uploads/b7cee03e-bb42-4f4f-88cf-065adc9d35c8.jpg
Normal file
1
uploads/b7cee03e-bb42-4f4f-88cf-065adc9d35c8.jpg
Normal file
@@ -0,0 +1 @@
|
||||
fake image content
|
||||
1
uploads/bbe9ab5a-927c-4a0b-9d88-7c5cd3358c7a.jpg
Normal file
1
uploads/bbe9ab5a-927c-4a0b-9d88-7c5cd3358c7a.jpg
Normal file
@@ -0,0 +1 @@
|
||||
fake image content
|
||||
1
uploads/be38d8a8-e63f-498c-ad79-928fe26a6ab1.jpg
Normal file
1
uploads/be38d8a8-e63f-498c-ad79-928fe26a6ab1.jpg
Normal file
@@ -0,0 +1 @@
|
||||
fake image content
|
||||
1
uploads/bec6a89a-78b7-4302-aed8-6627a93bfb63.jpg
Normal file
1
uploads/bec6a89a-78b7-4302-aed8-6627a93bfb63.jpg
Normal file
@@ -0,0 +1 @@
|
||||
fake image content
|
||||
1
uploads/bf993077-ce05-47be-ac28-83e93b89887d.jpg
Normal file
1
uploads/bf993077-ce05-47be-ac28-83e93b89887d.jpg
Normal file
@@ -0,0 +1 @@
|
||||
fake image content
|
||||
1
uploads/c3e81323-1a99-4145-b0eb-287e54a3b079.jpg
Normal file
1
uploads/c3e81323-1a99-4145-b0eb-287e54a3b079.jpg
Normal file
@@ -0,0 +1 @@
|
||||
fake image content
|
||||
1
uploads/c458a8a7-c76f-487d-be87-7492dd599b64.jpg
Normal file
1
uploads/c458a8a7-c76f-487d-be87-7492dd599b64.jpg
Normal file
@@ -0,0 +1 @@
|
||||
fake image content
|
||||
1
uploads/c73ee1ba-9667-4c7e-a5c5-06a0d6fe2879.jpg
Normal file
1
uploads/c73ee1ba-9667-4c7e-a5c5-06a0d6fe2879.jpg
Normal file
@@ -0,0 +1 @@
|
||||
fake image content
|
||||
1
uploads/c826c545-c41b-45fc-99ce-79551b8fdaa7.jpg
Normal file
1
uploads/c826c545-c41b-45fc-99ce-79551b8fdaa7.jpg
Normal file
@@ -0,0 +1 @@
|
||||
fake image content
|
||||
1
uploads/ce36e168-4902-464c-9d7e-a836db6cefb9.jpg
Normal file
1
uploads/ce36e168-4902-464c-9d7e-a836db6cefb9.jpg
Normal file
@@ -0,0 +1 @@
|
||||
fake image content
|
||||
1
uploads/e0f533ad-73ea-42b0-94a5-0630444e7377.jpg
Normal file
1
uploads/e0f533ad-73ea-42b0-94a5-0630444e7377.jpg
Normal file
@@ -0,0 +1 @@
|
||||
fake image content
|
||||
1
uploads/e273a32d-835c-420a-a547-2260b8c25ec9.jpg
Normal file
1
uploads/e273a32d-835c-420a-a547-2260b8c25ec9.jpg
Normal file
@@ -0,0 +1 @@
|
||||
fake image content
|
||||
1
uploads/f4ef9d40-c7ef-4b56-b104-304dab66ef7b.jpg
Normal file
1
uploads/f4ef9d40-c7ef-4b56-b104-304dab66ef7b.jpg
Normal file
@@ -0,0 +1 @@
|
||||
fake image content
|
||||
1
uploads/f7472641-e8cb-4740-accd-ebc55121d534.jpg
Normal file
1
uploads/f7472641-e8cb-4740-accd-ebc55121d534.jpg
Normal file
@@ -0,0 +1 @@
|
||||
fake image content
|
||||
1
uploads/fb4bb755-254b-498e-8d3c-8f566278a7bb.jpg
Normal file
1
uploads/fb4bb755-254b-498e-8d3c-8f566278a7bb.jpg
Normal file
@@ -0,0 +1 @@
|
||||
fake image content
|
||||
1
uploads/fda56bba-e289-4fab-9f20-f9b458781ef2.jpg
Normal file
1
uploads/fda56bba-e289-4fab-9f20-f9b458781ef2.jpg
Normal file
@@ -0,0 +1 @@
|
||||
fake image content
|
||||
Reference in New Issue
Block a user