From a3eca3b7daa61bb9c52352ff74a6d78d7d73b751 Mon Sep 17 00:00:00 2001 From: OpenClaw Agent Date: Thu, 16 Apr 2026 03:20:48 +0000 Subject: [PATCH] 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 --- README.md | 151 ++++++- SPEC.md | 337 +++++++++++++++ app/__init__.py | 1 + app/auth.py | 66 +++ app/database.py | 120 ++++++ app/main.py | 69 +++ app/models.py | 109 +++++ app/routes/__init__.py | 1 + app/routes/auth.py | 81 ++++ app/routes/comments.py | 79 ++++ app/routes/feed.py | 124 ++++++ app/routes/posts.py | 400 ++++++++++++++++++ app/routes/users.py | 205 +++++++++ requirements.txt | 11 + tests/__init__.py | 1 + tests/conftest.py | 129 ++++++ tests/test_auth.py | 158 +++++++ tests/test_posts.py | 292 +++++++++++++ tests/test_social.py | 358 ++++++++++++++++ .../0072fbb2-e8f8-4716-9b5b-e42cb206b969.jpg | 1 + .../01187997-079c-4ffd-bc4f-3b02ad2d1e3b.jpg | 1 + .../03a9df1e-d125-4b56-8b82-9a725a50eb79.jpg | 1 + .../12351e3f-c1ed-492c-bad2-77b1fc905025.jpg | 1 + .../13189566-336b-4231-aaf9-169e5aeb7b88.jpg | 1 + .../13e02870-d0e9-4298-aabc-17f7ac05e1de.jpg | 1 + .../15a6dd9b-b574-4e59-8c10-10f8e8efc29e.jpg | 1 + .../1612995f-4f70-444d-ba8f-b644317e1da2.jpg | 1 + .../17e2cb1f-db19-4715-983f-3ea54255b7e5.jpg | 1 + .../1c55ded5-73f0-4654-836e-d711e1787549.jpg | 1 + .../1d72bc45-ac49-4f17-aab5-32229aa10986.jpg | 1 + .../1ef0f876-ae9c-4d7a-80f1-1354eedb9699.jpg | 1 + .../20f2ad2d-01c1-4254-a3af-680312cdadcf.jpg | 1 + .../24ff4803-6b57-489c-8b8f-6e7bb173d45f.jpg | 1 + .../330ef1d2-eb9e-4a55-a85a-26ef3c7c0e88.jpg | 1 + .../35bb57a4-4a80-4772-8a34-f853835b86cd.jpg | 1 + .../35ed12a5-2792-4393-97e8-d3ea4fced61e.jpg | 1 + .../36e8290a-6696-478c-a243-fc92f3145f86.jpg | 1 + .../3b98982b-8096-4bcf-a721-d0cfd4934fca.jpg | 1 + .../40f1d24e-839b-4164-aacc-cee7299b894a.jpg | 1 + .../4ce03a80-c923-4ff6-b42a-45a57826a6ca.jpg | 1 + .../4e600f15-6038-449b-9f9a-ec3862bdb2c2.jpg | 1 + .../4fc80117-a6bf-4b53-ac38-9db46c224363.jpg | 1 + .../51f0bdc7-a331-48de-8121-2968761d828f.jpg | 1 + .../5328276f-f339-4129-8ead-f9d534aee4c0.jpg | 1 + .../53abadfe-a8f9-4d4e-8a04-c88300b5da2c.jpg | 1 + .../5b6678ea-8c32-45da-800b-18fece1d18a9.jpg | 1 + .../618c44fc-a5e4-4141-ac95-af68afbd6cb4.jpg | 1 + .../661e1b30-74b9-45f0-ae9a-334da943a387.jpg | 1 + .../6c4cdf12-c2ec-4068-b729-29fb46497642.jpg | 1 + .../6d559c27-faa3-4f47-b6af-737e88c96212.jpg | 1 + .../6d5d5a8c-0582-4635-97f7-7208785dbb06.jpg | 1 + .../7294c912-f07e-468b-903d-355bfc30eefa.jpg | 1 + .../72ddff21-9d37-484d-9457-6f1783acc554.jpg | 1 + .../7512ab06-e8e6-4668-9e46-156079659444.jpg | 1 + .../7519b0a3-c0df-4c7d-9993-0ae027f33377.jpg | 1 + .../76f2a69a-481b-4a43-8829-32b2b9202164.jpg | 1 + .../77ea205e-161a-4674-8233-d06714a62dc7.jpg | 1 + .../7c9168ac-9dd6-46d4-9e10-0a1abdb2cef8.jpg | 1 + .../7d07f136-de7c-456e-bb0a-9e39947918ab.jpg | 1 + .../7f41a555-6376-4308-8f09-e736592b0078.jpg | 1 + .../8053d803-3fbb-4819-9480-b6e00ad44989.jpg | 1 + .../80c95137-ef48-4847-862c-024c7e973da4.jpg | 1 + .../827896c2-0d4e-4ce7-aa57-9d2d2c3f7ff9.jpg | 1 + .../84f88316-b861-45b7-8a30-b864d0d72f88.jpg | 1 + .../8667ab82-3bb0-4661-b44f-9430e6a112ff.jpg | 1 + .../8c630307-360a-4d66-8893-7040fbc8a9e7.jpg | 1 + .../8e661aa6-3d20-4386-9ca7-ec9b40dd357b.jpg | 1 + .../91d18d60-aef1-484a-8a09-720d2320e713.jpg | 1 + .../97706a6f-14be-484c-9c55-9ae472a34cf3.jpg | 1 + .../9a1d5b8a-5afc-4970-8d99-b040fdb63aa2.jpg | 1 + .../a5893124-936c-4c9b-806a-344bf894dc5a.jpg | 1 + .../a5d0701c-0356-48f6-bde8-47248602e6f0.jpg | 1 + .../a82fedc3-650d-4a3d-bdfa-155de2ac1135.jpg | 1 + .../ac9bed06-335f-4d35-9d43-4f1bce342483.jpg | 1 + .../b08b3b9e-23b9-4e88-881a-a7bc92dcd659.jpg | 1 + .../b30771c4-0917-4ccc-bf80-e2556c53bcf5.jpg | 1 + .../b316e522-8775-4c85-944a-f1b0f4e91ef4.jpg | 1 + .../b74a9056-3e78-4a32-bb70-7378e1cd76f8.jpg | 1 + .../b76a6404-a624-40a5-86c0-b3bb7ad9d047.jpg | 1 + .../b7cee03e-bb42-4f4f-88cf-065adc9d35c8.jpg | 1 + .../bbe9ab5a-927c-4a0b-9d88-7c5cd3358c7a.jpg | 1 + .../be38d8a8-e63f-498c-ad79-928fe26a6ab1.jpg | 1 + .../bec6a89a-78b7-4302-aed8-6627a93bfb63.jpg | 1 + .../bf993077-ce05-47be-ac28-83e93b89887d.jpg | 1 + .../c3e81323-1a99-4145-b0eb-287e54a3b079.jpg | 1 + .../c458a8a7-c76f-487d-be87-7492dd599b64.jpg | 1 + .../c73ee1ba-9667-4c7e-a5c5-06a0d6fe2879.jpg | 1 + .../c826c545-c41b-45fc-99ce-79551b8fdaa7.jpg | 1 + .../ce36e168-4902-464c-9d7e-a836db6cefb9.jpg | 1 + .../e0f533ad-73ea-42b0-94a5-0630444e7377.jpg | 1 + .../e273a32d-835c-420a-a547-2260b8c25ec9.jpg | 1 + .../f4ef9d40-c7ef-4b56-b104-304dab66ef7b.jpg | 1 + .../f7472641-e8cb-4740-accd-ebc55121d534.jpg | 1 + .../fb4bb755-254b-498e-8d3c-8f566278a7bb.jpg | 1 + .../fda56bba-e289-4fab-9f20-f9b458781ef2.jpg | 1 + 95 files changed, 2767 insertions(+), 1 deletion(-) create mode 100644 SPEC.md create mode 100644 app/__init__.py create mode 100644 app/auth.py create mode 100644 app/database.py create mode 100644 app/main.py create mode 100644 app/models.py create mode 100644 app/routes/__init__.py create mode 100644 app/routes/auth.py create mode 100644 app/routes/comments.py create mode 100644 app/routes/feed.py create mode 100644 app/routes/posts.py create mode 100644 app/routes/users.py create mode 100644 requirements.txt create mode 100644 tests/__init__.py create mode 100644 tests/conftest.py create mode 100644 tests/test_auth.py create mode 100644 tests/test_posts.py create mode 100644 tests/test_social.py create mode 100644 uploads/0072fbb2-e8f8-4716-9b5b-e42cb206b969.jpg create mode 100644 uploads/01187997-079c-4ffd-bc4f-3b02ad2d1e3b.jpg create mode 100644 uploads/03a9df1e-d125-4b56-8b82-9a725a50eb79.jpg create mode 100644 uploads/12351e3f-c1ed-492c-bad2-77b1fc905025.jpg create mode 100644 uploads/13189566-336b-4231-aaf9-169e5aeb7b88.jpg create mode 100644 uploads/13e02870-d0e9-4298-aabc-17f7ac05e1de.jpg create mode 100644 uploads/15a6dd9b-b574-4e59-8c10-10f8e8efc29e.jpg create mode 100644 uploads/1612995f-4f70-444d-ba8f-b644317e1da2.jpg create mode 100644 uploads/17e2cb1f-db19-4715-983f-3ea54255b7e5.jpg create mode 100644 uploads/1c55ded5-73f0-4654-836e-d711e1787549.jpg create mode 100644 uploads/1d72bc45-ac49-4f17-aab5-32229aa10986.jpg create mode 100644 uploads/1ef0f876-ae9c-4d7a-80f1-1354eedb9699.jpg create mode 100644 uploads/20f2ad2d-01c1-4254-a3af-680312cdadcf.jpg create mode 100644 uploads/24ff4803-6b57-489c-8b8f-6e7bb173d45f.jpg create mode 100644 uploads/330ef1d2-eb9e-4a55-a85a-26ef3c7c0e88.jpg create mode 100644 uploads/35bb57a4-4a80-4772-8a34-f853835b86cd.jpg create mode 100644 uploads/35ed12a5-2792-4393-97e8-d3ea4fced61e.jpg create mode 100644 uploads/36e8290a-6696-478c-a243-fc92f3145f86.jpg create mode 100644 uploads/3b98982b-8096-4bcf-a721-d0cfd4934fca.jpg create mode 100644 uploads/40f1d24e-839b-4164-aacc-cee7299b894a.jpg create mode 100644 uploads/4ce03a80-c923-4ff6-b42a-45a57826a6ca.jpg create mode 100644 uploads/4e600f15-6038-449b-9f9a-ec3862bdb2c2.jpg create mode 100644 uploads/4fc80117-a6bf-4b53-ac38-9db46c224363.jpg create mode 100644 uploads/51f0bdc7-a331-48de-8121-2968761d828f.jpg create mode 100644 uploads/5328276f-f339-4129-8ead-f9d534aee4c0.jpg create mode 100644 uploads/53abadfe-a8f9-4d4e-8a04-c88300b5da2c.jpg create mode 100644 uploads/5b6678ea-8c32-45da-800b-18fece1d18a9.jpg create mode 100644 uploads/618c44fc-a5e4-4141-ac95-af68afbd6cb4.jpg create mode 100644 uploads/661e1b30-74b9-45f0-ae9a-334da943a387.jpg create mode 100644 uploads/6c4cdf12-c2ec-4068-b729-29fb46497642.jpg create mode 100644 uploads/6d559c27-faa3-4f47-b6af-737e88c96212.jpg create mode 100644 uploads/6d5d5a8c-0582-4635-97f7-7208785dbb06.jpg create mode 100644 uploads/7294c912-f07e-468b-903d-355bfc30eefa.jpg create mode 100644 uploads/72ddff21-9d37-484d-9457-6f1783acc554.jpg create mode 100644 uploads/7512ab06-e8e6-4668-9e46-156079659444.jpg create mode 100644 uploads/7519b0a3-c0df-4c7d-9993-0ae027f33377.jpg create mode 100644 uploads/76f2a69a-481b-4a43-8829-32b2b9202164.jpg create mode 100644 uploads/77ea205e-161a-4674-8233-d06714a62dc7.jpg create mode 100644 uploads/7c9168ac-9dd6-46d4-9e10-0a1abdb2cef8.jpg create mode 100644 uploads/7d07f136-de7c-456e-bb0a-9e39947918ab.jpg create mode 100644 uploads/7f41a555-6376-4308-8f09-e736592b0078.jpg create mode 100644 uploads/8053d803-3fbb-4819-9480-b6e00ad44989.jpg create mode 100644 uploads/80c95137-ef48-4847-862c-024c7e973da4.jpg create mode 100644 uploads/827896c2-0d4e-4ce7-aa57-9d2d2c3f7ff9.jpg create mode 100644 uploads/84f88316-b861-45b7-8a30-b864d0d72f88.jpg create mode 100644 uploads/8667ab82-3bb0-4661-b44f-9430e6a112ff.jpg create mode 100644 uploads/8c630307-360a-4d66-8893-7040fbc8a9e7.jpg create mode 100644 uploads/8e661aa6-3d20-4386-9ca7-ec9b40dd357b.jpg create mode 100644 uploads/91d18d60-aef1-484a-8a09-720d2320e713.jpg create mode 100644 uploads/97706a6f-14be-484c-9c55-9ae472a34cf3.jpg create mode 100644 uploads/9a1d5b8a-5afc-4970-8d99-b040fdb63aa2.jpg create mode 100644 uploads/a5893124-936c-4c9b-806a-344bf894dc5a.jpg create mode 100644 uploads/a5d0701c-0356-48f6-bde8-47248602e6f0.jpg create mode 100644 uploads/a82fedc3-650d-4a3d-bdfa-155de2ac1135.jpg create mode 100644 uploads/ac9bed06-335f-4d35-9d43-4f1bce342483.jpg create mode 100644 uploads/b08b3b9e-23b9-4e88-881a-a7bc92dcd659.jpg create mode 100644 uploads/b30771c4-0917-4ccc-bf80-e2556c53bcf5.jpg create mode 100644 uploads/b316e522-8775-4c85-944a-f1b0f4e91ef4.jpg create mode 100644 uploads/b74a9056-3e78-4a32-bb70-7378e1cd76f8.jpg create mode 100644 uploads/b76a6404-a624-40a5-86c0-b3bb7ad9d047.jpg create mode 100644 uploads/b7cee03e-bb42-4f4f-88cf-065adc9d35c8.jpg create mode 100644 uploads/bbe9ab5a-927c-4a0b-9d88-7c5cd3358c7a.jpg create mode 100644 uploads/be38d8a8-e63f-498c-ad79-928fe26a6ab1.jpg create mode 100644 uploads/bec6a89a-78b7-4302-aed8-6627a93bfb63.jpg create mode 100644 uploads/bf993077-ce05-47be-ac28-83e93b89887d.jpg create mode 100644 uploads/c3e81323-1a99-4145-b0eb-287e54a3b079.jpg create mode 100644 uploads/c458a8a7-c76f-487d-be87-7492dd599b64.jpg create mode 100644 uploads/c73ee1ba-9667-4c7e-a5c5-06a0d6fe2879.jpg create mode 100644 uploads/c826c545-c41b-45fc-99ce-79551b8fdaa7.jpg create mode 100644 uploads/ce36e168-4902-464c-9d7e-a836db6cefb9.jpg create mode 100644 uploads/e0f533ad-73ea-42b0-94a5-0630444e7377.jpg create mode 100644 uploads/e273a32d-835c-420a-a547-2260b8c25ec9.jpg create mode 100644 uploads/f4ef9d40-c7ef-4b56-b104-304dab66ef7b.jpg create mode 100644 uploads/f7472641-e8cb-4740-accd-ebc55121d534.jpg create mode 100644 uploads/fb4bb755-254b-498e-8d3c-8f566278a7bb.jpg create mode 100644 uploads/fda56bba-e289-4fab-9f20-f9b458781ef2.jpg diff --git a/README.md b/README.md index 0f4182e..690a022 100644 --- a/README.md +++ b/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 diff --git a/SPEC.md b/SPEC.md new file mode 100644 index 0000000..a9be8d3 --- /dev/null +++ b/SPEC.md @@ -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: +``` + +**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 ` + +### 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 diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..31bf417 --- /dev/null +++ b/app/__init__.py @@ -0,0 +1 @@ +"""SocialPhoto - Instagram Clone API.""" diff --git a/app/auth.py b/app/auth.py new file mode 100644 index 0000000..56ed8dd --- /dev/null +++ b/app/auth.py @@ -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) diff --git a/app/database.py b/app/database.py new file mode 100644 index 0000000..63e6cc3 --- /dev/null +++ b/app/database.py @@ -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.") diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..1d2221f --- /dev/null +++ b/app/main.py @@ -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) diff --git a/app/models.py b/app/models.py new file mode 100644 index 0000000..f7e3c3f --- /dev/null +++ b/app/models.py @@ -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 diff --git a/app/routes/__init__.py b/app/routes/__init__.py new file mode 100644 index 0000000..b3fbbdf --- /dev/null +++ b/app/routes/__init__.py @@ -0,0 +1 @@ +"""Routes package for SocialPhoto.""" diff --git a/app/routes/auth.py b/app/routes/auth.py new file mode 100644 index 0000000..1f787f5 --- /dev/null +++ b/app/routes/auth.py @@ -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) diff --git a/app/routes/comments.py b/app/routes/comments.py new file mode 100644 index 0000000..c31ca8c --- /dev/null +++ b/app/routes/comments.py @@ -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"} diff --git a/app/routes/feed.py b/app/routes/feed.py new file mode 100644 index 0000000..8a04caf --- /dev/null +++ b/app/routes/feed.py @@ -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) diff --git a/app/routes/posts.py b/app/routes/posts.py new file mode 100644 index 0000000..bdc6cc6 --- /dev/null +++ b/app/routes/posts.py @@ -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) diff --git a/app/routes/users.py b/app/routes/users.py new file mode 100644 index 0000000..d7b2411 --- /dev/null +++ b/app/routes/users.py @@ -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"} diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..e3ed876 --- /dev/null +++ b/requirements.txt @@ -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 diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..f54ecb3 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +"""Tests package for SocialPhoto.""" diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..e80f1a0 --- /dev/null +++ b/tests/conftest.py @@ -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", + } diff --git a/tests/test_auth.py b/tests/test_auth.py new file mode 100644 index 0000000..463678d --- /dev/null +++ b/tests/test_auth.py @@ -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"] diff --git a/tests/test_posts.py b/tests/test_posts.py new file mode 100644 index 0000000..023b56b --- /dev/null +++ b/tests/test_posts.py @@ -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 diff --git a/tests/test_social.py b/tests/test_social.py new file mode 100644 index 0000000..28ade15 --- /dev/null +++ b/tests/test_social.py @@ -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 diff --git a/uploads/0072fbb2-e8f8-4716-9b5b-e42cb206b969.jpg b/uploads/0072fbb2-e8f8-4716-9b5b-e42cb206b969.jpg new file mode 100644 index 0000000..81a1e3b --- /dev/null +++ b/uploads/0072fbb2-e8f8-4716-9b5b-e42cb206b969.jpg @@ -0,0 +1 @@ +fake image content \ No newline at end of file diff --git a/uploads/01187997-079c-4ffd-bc4f-3b02ad2d1e3b.jpg b/uploads/01187997-079c-4ffd-bc4f-3b02ad2d1e3b.jpg new file mode 100644 index 0000000..81a1e3b --- /dev/null +++ b/uploads/01187997-079c-4ffd-bc4f-3b02ad2d1e3b.jpg @@ -0,0 +1 @@ +fake image content \ No newline at end of file diff --git a/uploads/03a9df1e-d125-4b56-8b82-9a725a50eb79.jpg b/uploads/03a9df1e-d125-4b56-8b82-9a725a50eb79.jpg new file mode 100644 index 0000000..81a1e3b --- /dev/null +++ b/uploads/03a9df1e-d125-4b56-8b82-9a725a50eb79.jpg @@ -0,0 +1 @@ +fake image content \ No newline at end of file diff --git a/uploads/12351e3f-c1ed-492c-bad2-77b1fc905025.jpg b/uploads/12351e3f-c1ed-492c-bad2-77b1fc905025.jpg new file mode 100644 index 0000000..81a1e3b --- /dev/null +++ b/uploads/12351e3f-c1ed-492c-bad2-77b1fc905025.jpg @@ -0,0 +1 @@ +fake image content \ No newline at end of file diff --git a/uploads/13189566-336b-4231-aaf9-169e5aeb7b88.jpg b/uploads/13189566-336b-4231-aaf9-169e5aeb7b88.jpg new file mode 100644 index 0000000..81a1e3b --- /dev/null +++ b/uploads/13189566-336b-4231-aaf9-169e5aeb7b88.jpg @@ -0,0 +1 @@ +fake image content \ No newline at end of file diff --git a/uploads/13e02870-d0e9-4298-aabc-17f7ac05e1de.jpg b/uploads/13e02870-d0e9-4298-aabc-17f7ac05e1de.jpg new file mode 100644 index 0000000..81a1e3b --- /dev/null +++ b/uploads/13e02870-d0e9-4298-aabc-17f7ac05e1de.jpg @@ -0,0 +1 @@ +fake image content \ No newline at end of file diff --git a/uploads/15a6dd9b-b574-4e59-8c10-10f8e8efc29e.jpg b/uploads/15a6dd9b-b574-4e59-8c10-10f8e8efc29e.jpg new file mode 100644 index 0000000..81a1e3b --- /dev/null +++ b/uploads/15a6dd9b-b574-4e59-8c10-10f8e8efc29e.jpg @@ -0,0 +1 @@ +fake image content \ No newline at end of file diff --git a/uploads/1612995f-4f70-444d-ba8f-b644317e1da2.jpg b/uploads/1612995f-4f70-444d-ba8f-b644317e1da2.jpg new file mode 100644 index 0000000..81a1e3b --- /dev/null +++ b/uploads/1612995f-4f70-444d-ba8f-b644317e1da2.jpg @@ -0,0 +1 @@ +fake image content \ No newline at end of file diff --git a/uploads/17e2cb1f-db19-4715-983f-3ea54255b7e5.jpg b/uploads/17e2cb1f-db19-4715-983f-3ea54255b7e5.jpg new file mode 100644 index 0000000..81a1e3b --- /dev/null +++ b/uploads/17e2cb1f-db19-4715-983f-3ea54255b7e5.jpg @@ -0,0 +1 @@ +fake image content \ No newline at end of file diff --git a/uploads/1c55ded5-73f0-4654-836e-d711e1787549.jpg b/uploads/1c55ded5-73f0-4654-836e-d711e1787549.jpg new file mode 100644 index 0000000..81a1e3b --- /dev/null +++ b/uploads/1c55ded5-73f0-4654-836e-d711e1787549.jpg @@ -0,0 +1 @@ +fake image content \ No newline at end of file diff --git a/uploads/1d72bc45-ac49-4f17-aab5-32229aa10986.jpg b/uploads/1d72bc45-ac49-4f17-aab5-32229aa10986.jpg new file mode 100644 index 0000000..81a1e3b --- /dev/null +++ b/uploads/1d72bc45-ac49-4f17-aab5-32229aa10986.jpg @@ -0,0 +1 @@ +fake image content \ No newline at end of file diff --git a/uploads/1ef0f876-ae9c-4d7a-80f1-1354eedb9699.jpg b/uploads/1ef0f876-ae9c-4d7a-80f1-1354eedb9699.jpg new file mode 100644 index 0000000..81a1e3b --- /dev/null +++ b/uploads/1ef0f876-ae9c-4d7a-80f1-1354eedb9699.jpg @@ -0,0 +1 @@ +fake image content \ No newline at end of file diff --git a/uploads/20f2ad2d-01c1-4254-a3af-680312cdadcf.jpg b/uploads/20f2ad2d-01c1-4254-a3af-680312cdadcf.jpg new file mode 100644 index 0000000..81a1e3b --- /dev/null +++ b/uploads/20f2ad2d-01c1-4254-a3af-680312cdadcf.jpg @@ -0,0 +1 @@ +fake image content \ No newline at end of file diff --git a/uploads/24ff4803-6b57-489c-8b8f-6e7bb173d45f.jpg b/uploads/24ff4803-6b57-489c-8b8f-6e7bb173d45f.jpg new file mode 100644 index 0000000..81a1e3b --- /dev/null +++ b/uploads/24ff4803-6b57-489c-8b8f-6e7bb173d45f.jpg @@ -0,0 +1 @@ +fake image content \ No newline at end of file diff --git a/uploads/330ef1d2-eb9e-4a55-a85a-26ef3c7c0e88.jpg b/uploads/330ef1d2-eb9e-4a55-a85a-26ef3c7c0e88.jpg new file mode 100644 index 0000000..81a1e3b --- /dev/null +++ b/uploads/330ef1d2-eb9e-4a55-a85a-26ef3c7c0e88.jpg @@ -0,0 +1 @@ +fake image content \ No newline at end of file diff --git a/uploads/35bb57a4-4a80-4772-8a34-f853835b86cd.jpg b/uploads/35bb57a4-4a80-4772-8a34-f853835b86cd.jpg new file mode 100644 index 0000000..81a1e3b --- /dev/null +++ b/uploads/35bb57a4-4a80-4772-8a34-f853835b86cd.jpg @@ -0,0 +1 @@ +fake image content \ No newline at end of file diff --git a/uploads/35ed12a5-2792-4393-97e8-d3ea4fced61e.jpg b/uploads/35ed12a5-2792-4393-97e8-d3ea4fced61e.jpg new file mode 100644 index 0000000..81a1e3b --- /dev/null +++ b/uploads/35ed12a5-2792-4393-97e8-d3ea4fced61e.jpg @@ -0,0 +1 @@ +fake image content \ No newline at end of file diff --git a/uploads/36e8290a-6696-478c-a243-fc92f3145f86.jpg b/uploads/36e8290a-6696-478c-a243-fc92f3145f86.jpg new file mode 100644 index 0000000..81a1e3b --- /dev/null +++ b/uploads/36e8290a-6696-478c-a243-fc92f3145f86.jpg @@ -0,0 +1 @@ +fake image content \ No newline at end of file diff --git a/uploads/3b98982b-8096-4bcf-a721-d0cfd4934fca.jpg b/uploads/3b98982b-8096-4bcf-a721-d0cfd4934fca.jpg new file mode 100644 index 0000000..81a1e3b --- /dev/null +++ b/uploads/3b98982b-8096-4bcf-a721-d0cfd4934fca.jpg @@ -0,0 +1 @@ +fake image content \ No newline at end of file diff --git a/uploads/40f1d24e-839b-4164-aacc-cee7299b894a.jpg b/uploads/40f1d24e-839b-4164-aacc-cee7299b894a.jpg new file mode 100644 index 0000000..81a1e3b --- /dev/null +++ b/uploads/40f1d24e-839b-4164-aacc-cee7299b894a.jpg @@ -0,0 +1 @@ +fake image content \ No newline at end of file diff --git a/uploads/4ce03a80-c923-4ff6-b42a-45a57826a6ca.jpg b/uploads/4ce03a80-c923-4ff6-b42a-45a57826a6ca.jpg new file mode 100644 index 0000000..81a1e3b --- /dev/null +++ b/uploads/4ce03a80-c923-4ff6-b42a-45a57826a6ca.jpg @@ -0,0 +1 @@ +fake image content \ No newline at end of file diff --git a/uploads/4e600f15-6038-449b-9f9a-ec3862bdb2c2.jpg b/uploads/4e600f15-6038-449b-9f9a-ec3862bdb2c2.jpg new file mode 100644 index 0000000..81a1e3b --- /dev/null +++ b/uploads/4e600f15-6038-449b-9f9a-ec3862bdb2c2.jpg @@ -0,0 +1 @@ +fake image content \ No newline at end of file diff --git a/uploads/4fc80117-a6bf-4b53-ac38-9db46c224363.jpg b/uploads/4fc80117-a6bf-4b53-ac38-9db46c224363.jpg new file mode 100644 index 0000000..81a1e3b --- /dev/null +++ b/uploads/4fc80117-a6bf-4b53-ac38-9db46c224363.jpg @@ -0,0 +1 @@ +fake image content \ No newline at end of file diff --git a/uploads/51f0bdc7-a331-48de-8121-2968761d828f.jpg b/uploads/51f0bdc7-a331-48de-8121-2968761d828f.jpg new file mode 100644 index 0000000..81a1e3b --- /dev/null +++ b/uploads/51f0bdc7-a331-48de-8121-2968761d828f.jpg @@ -0,0 +1 @@ +fake image content \ No newline at end of file diff --git a/uploads/5328276f-f339-4129-8ead-f9d534aee4c0.jpg b/uploads/5328276f-f339-4129-8ead-f9d534aee4c0.jpg new file mode 100644 index 0000000..81a1e3b --- /dev/null +++ b/uploads/5328276f-f339-4129-8ead-f9d534aee4c0.jpg @@ -0,0 +1 @@ +fake image content \ No newline at end of file diff --git a/uploads/53abadfe-a8f9-4d4e-8a04-c88300b5da2c.jpg b/uploads/53abadfe-a8f9-4d4e-8a04-c88300b5da2c.jpg new file mode 100644 index 0000000..81a1e3b --- /dev/null +++ b/uploads/53abadfe-a8f9-4d4e-8a04-c88300b5da2c.jpg @@ -0,0 +1 @@ +fake image content \ No newline at end of file diff --git a/uploads/5b6678ea-8c32-45da-800b-18fece1d18a9.jpg b/uploads/5b6678ea-8c32-45da-800b-18fece1d18a9.jpg new file mode 100644 index 0000000..81a1e3b --- /dev/null +++ b/uploads/5b6678ea-8c32-45da-800b-18fece1d18a9.jpg @@ -0,0 +1 @@ +fake image content \ No newline at end of file diff --git a/uploads/618c44fc-a5e4-4141-ac95-af68afbd6cb4.jpg b/uploads/618c44fc-a5e4-4141-ac95-af68afbd6cb4.jpg new file mode 100644 index 0000000..81a1e3b --- /dev/null +++ b/uploads/618c44fc-a5e4-4141-ac95-af68afbd6cb4.jpg @@ -0,0 +1 @@ +fake image content \ No newline at end of file diff --git a/uploads/661e1b30-74b9-45f0-ae9a-334da943a387.jpg b/uploads/661e1b30-74b9-45f0-ae9a-334da943a387.jpg new file mode 100644 index 0000000..81a1e3b --- /dev/null +++ b/uploads/661e1b30-74b9-45f0-ae9a-334da943a387.jpg @@ -0,0 +1 @@ +fake image content \ No newline at end of file diff --git a/uploads/6c4cdf12-c2ec-4068-b729-29fb46497642.jpg b/uploads/6c4cdf12-c2ec-4068-b729-29fb46497642.jpg new file mode 100644 index 0000000..81a1e3b --- /dev/null +++ b/uploads/6c4cdf12-c2ec-4068-b729-29fb46497642.jpg @@ -0,0 +1 @@ +fake image content \ No newline at end of file diff --git a/uploads/6d559c27-faa3-4f47-b6af-737e88c96212.jpg b/uploads/6d559c27-faa3-4f47-b6af-737e88c96212.jpg new file mode 100644 index 0000000..81a1e3b --- /dev/null +++ b/uploads/6d559c27-faa3-4f47-b6af-737e88c96212.jpg @@ -0,0 +1 @@ +fake image content \ No newline at end of file diff --git a/uploads/6d5d5a8c-0582-4635-97f7-7208785dbb06.jpg b/uploads/6d5d5a8c-0582-4635-97f7-7208785dbb06.jpg new file mode 100644 index 0000000..81a1e3b --- /dev/null +++ b/uploads/6d5d5a8c-0582-4635-97f7-7208785dbb06.jpg @@ -0,0 +1 @@ +fake image content \ No newline at end of file diff --git a/uploads/7294c912-f07e-468b-903d-355bfc30eefa.jpg b/uploads/7294c912-f07e-468b-903d-355bfc30eefa.jpg new file mode 100644 index 0000000..81a1e3b --- /dev/null +++ b/uploads/7294c912-f07e-468b-903d-355bfc30eefa.jpg @@ -0,0 +1 @@ +fake image content \ No newline at end of file diff --git a/uploads/72ddff21-9d37-484d-9457-6f1783acc554.jpg b/uploads/72ddff21-9d37-484d-9457-6f1783acc554.jpg new file mode 100644 index 0000000..81a1e3b --- /dev/null +++ b/uploads/72ddff21-9d37-484d-9457-6f1783acc554.jpg @@ -0,0 +1 @@ +fake image content \ No newline at end of file diff --git a/uploads/7512ab06-e8e6-4668-9e46-156079659444.jpg b/uploads/7512ab06-e8e6-4668-9e46-156079659444.jpg new file mode 100644 index 0000000..81a1e3b --- /dev/null +++ b/uploads/7512ab06-e8e6-4668-9e46-156079659444.jpg @@ -0,0 +1 @@ +fake image content \ No newline at end of file diff --git a/uploads/7519b0a3-c0df-4c7d-9993-0ae027f33377.jpg b/uploads/7519b0a3-c0df-4c7d-9993-0ae027f33377.jpg new file mode 100644 index 0000000..81a1e3b --- /dev/null +++ b/uploads/7519b0a3-c0df-4c7d-9993-0ae027f33377.jpg @@ -0,0 +1 @@ +fake image content \ No newline at end of file diff --git a/uploads/76f2a69a-481b-4a43-8829-32b2b9202164.jpg b/uploads/76f2a69a-481b-4a43-8829-32b2b9202164.jpg new file mode 100644 index 0000000..81a1e3b --- /dev/null +++ b/uploads/76f2a69a-481b-4a43-8829-32b2b9202164.jpg @@ -0,0 +1 @@ +fake image content \ No newline at end of file diff --git a/uploads/77ea205e-161a-4674-8233-d06714a62dc7.jpg b/uploads/77ea205e-161a-4674-8233-d06714a62dc7.jpg new file mode 100644 index 0000000..81a1e3b --- /dev/null +++ b/uploads/77ea205e-161a-4674-8233-d06714a62dc7.jpg @@ -0,0 +1 @@ +fake image content \ No newline at end of file diff --git a/uploads/7c9168ac-9dd6-46d4-9e10-0a1abdb2cef8.jpg b/uploads/7c9168ac-9dd6-46d4-9e10-0a1abdb2cef8.jpg new file mode 100644 index 0000000..81a1e3b --- /dev/null +++ b/uploads/7c9168ac-9dd6-46d4-9e10-0a1abdb2cef8.jpg @@ -0,0 +1 @@ +fake image content \ No newline at end of file diff --git a/uploads/7d07f136-de7c-456e-bb0a-9e39947918ab.jpg b/uploads/7d07f136-de7c-456e-bb0a-9e39947918ab.jpg new file mode 100644 index 0000000..81a1e3b --- /dev/null +++ b/uploads/7d07f136-de7c-456e-bb0a-9e39947918ab.jpg @@ -0,0 +1 @@ +fake image content \ No newline at end of file diff --git a/uploads/7f41a555-6376-4308-8f09-e736592b0078.jpg b/uploads/7f41a555-6376-4308-8f09-e736592b0078.jpg new file mode 100644 index 0000000..81a1e3b --- /dev/null +++ b/uploads/7f41a555-6376-4308-8f09-e736592b0078.jpg @@ -0,0 +1 @@ +fake image content \ No newline at end of file diff --git a/uploads/8053d803-3fbb-4819-9480-b6e00ad44989.jpg b/uploads/8053d803-3fbb-4819-9480-b6e00ad44989.jpg new file mode 100644 index 0000000..81a1e3b --- /dev/null +++ b/uploads/8053d803-3fbb-4819-9480-b6e00ad44989.jpg @@ -0,0 +1 @@ +fake image content \ No newline at end of file diff --git a/uploads/80c95137-ef48-4847-862c-024c7e973da4.jpg b/uploads/80c95137-ef48-4847-862c-024c7e973da4.jpg new file mode 100644 index 0000000..81a1e3b --- /dev/null +++ b/uploads/80c95137-ef48-4847-862c-024c7e973da4.jpg @@ -0,0 +1 @@ +fake image content \ No newline at end of file diff --git a/uploads/827896c2-0d4e-4ce7-aa57-9d2d2c3f7ff9.jpg b/uploads/827896c2-0d4e-4ce7-aa57-9d2d2c3f7ff9.jpg new file mode 100644 index 0000000..81a1e3b --- /dev/null +++ b/uploads/827896c2-0d4e-4ce7-aa57-9d2d2c3f7ff9.jpg @@ -0,0 +1 @@ +fake image content \ No newline at end of file diff --git a/uploads/84f88316-b861-45b7-8a30-b864d0d72f88.jpg b/uploads/84f88316-b861-45b7-8a30-b864d0d72f88.jpg new file mode 100644 index 0000000..81a1e3b --- /dev/null +++ b/uploads/84f88316-b861-45b7-8a30-b864d0d72f88.jpg @@ -0,0 +1 @@ +fake image content \ No newline at end of file diff --git a/uploads/8667ab82-3bb0-4661-b44f-9430e6a112ff.jpg b/uploads/8667ab82-3bb0-4661-b44f-9430e6a112ff.jpg new file mode 100644 index 0000000..81a1e3b --- /dev/null +++ b/uploads/8667ab82-3bb0-4661-b44f-9430e6a112ff.jpg @@ -0,0 +1 @@ +fake image content \ No newline at end of file diff --git a/uploads/8c630307-360a-4d66-8893-7040fbc8a9e7.jpg b/uploads/8c630307-360a-4d66-8893-7040fbc8a9e7.jpg new file mode 100644 index 0000000..81a1e3b --- /dev/null +++ b/uploads/8c630307-360a-4d66-8893-7040fbc8a9e7.jpg @@ -0,0 +1 @@ +fake image content \ No newline at end of file diff --git a/uploads/8e661aa6-3d20-4386-9ca7-ec9b40dd357b.jpg b/uploads/8e661aa6-3d20-4386-9ca7-ec9b40dd357b.jpg new file mode 100644 index 0000000..81a1e3b --- /dev/null +++ b/uploads/8e661aa6-3d20-4386-9ca7-ec9b40dd357b.jpg @@ -0,0 +1 @@ +fake image content \ No newline at end of file diff --git a/uploads/91d18d60-aef1-484a-8a09-720d2320e713.jpg b/uploads/91d18d60-aef1-484a-8a09-720d2320e713.jpg new file mode 100644 index 0000000..81a1e3b --- /dev/null +++ b/uploads/91d18d60-aef1-484a-8a09-720d2320e713.jpg @@ -0,0 +1 @@ +fake image content \ No newline at end of file diff --git a/uploads/97706a6f-14be-484c-9c55-9ae472a34cf3.jpg b/uploads/97706a6f-14be-484c-9c55-9ae472a34cf3.jpg new file mode 100644 index 0000000..81a1e3b --- /dev/null +++ b/uploads/97706a6f-14be-484c-9c55-9ae472a34cf3.jpg @@ -0,0 +1 @@ +fake image content \ No newline at end of file diff --git a/uploads/9a1d5b8a-5afc-4970-8d99-b040fdb63aa2.jpg b/uploads/9a1d5b8a-5afc-4970-8d99-b040fdb63aa2.jpg new file mode 100644 index 0000000..81a1e3b --- /dev/null +++ b/uploads/9a1d5b8a-5afc-4970-8d99-b040fdb63aa2.jpg @@ -0,0 +1 @@ +fake image content \ No newline at end of file diff --git a/uploads/a5893124-936c-4c9b-806a-344bf894dc5a.jpg b/uploads/a5893124-936c-4c9b-806a-344bf894dc5a.jpg new file mode 100644 index 0000000..81a1e3b --- /dev/null +++ b/uploads/a5893124-936c-4c9b-806a-344bf894dc5a.jpg @@ -0,0 +1 @@ +fake image content \ No newline at end of file diff --git a/uploads/a5d0701c-0356-48f6-bde8-47248602e6f0.jpg b/uploads/a5d0701c-0356-48f6-bde8-47248602e6f0.jpg new file mode 100644 index 0000000..81a1e3b --- /dev/null +++ b/uploads/a5d0701c-0356-48f6-bde8-47248602e6f0.jpg @@ -0,0 +1 @@ +fake image content \ No newline at end of file diff --git a/uploads/a82fedc3-650d-4a3d-bdfa-155de2ac1135.jpg b/uploads/a82fedc3-650d-4a3d-bdfa-155de2ac1135.jpg new file mode 100644 index 0000000..81a1e3b --- /dev/null +++ b/uploads/a82fedc3-650d-4a3d-bdfa-155de2ac1135.jpg @@ -0,0 +1 @@ +fake image content \ No newline at end of file diff --git a/uploads/ac9bed06-335f-4d35-9d43-4f1bce342483.jpg b/uploads/ac9bed06-335f-4d35-9d43-4f1bce342483.jpg new file mode 100644 index 0000000..81a1e3b --- /dev/null +++ b/uploads/ac9bed06-335f-4d35-9d43-4f1bce342483.jpg @@ -0,0 +1 @@ +fake image content \ No newline at end of file diff --git a/uploads/b08b3b9e-23b9-4e88-881a-a7bc92dcd659.jpg b/uploads/b08b3b9e-23b9-4e88-881a-a7bc92dcd659.jpg new file mode 100644 index 0000000..81a1e3b --- /dev/null +++ b/uploads/b08b3b9e-23b9-4e88-881a-a7bc92dcd659.jpg @@ -0,0 +1 @@ +fake image content \ No newline at end of file diff --git a/uploads/b30771c4-0917-4ccc-bf80-e2556c53bcf5.jpg b/uploads/b30771c4-0917-4ccc-bf80-e2556c53bcf5.jpg new file mode 100644 index 0000000..81a1e3b --- /dev/null +++ b/uploads/b30771c4-0917-4ccc-bf80-e2556c53bcf5.jpg @@ -0,0 +1 @@ +fake image content \ No newline at end of file diff --git a/uploads/b316e522-8775-4c85-944a-f1b0f4e91ef4.jpg b/uploads/b316e522-8775-4c85-944a-f1b0f4e91ef4.jpg new file mode 100644 index 0000000..81a1e3b --- /dev/null +++ b/uploads/b316e522-8775-4c85-944a-f1b0f4e91ef4.jpg @@ -0,0 +1 @@ +fake image content \ No newline at end of file diff --git a/uploads/b74a9056-3e78-4a32-bb70-7378e1cd76f8.jpg b/uploads/b74a9056-3e78-4a32-bb70-7378e1cd76f8.jpg new file mode 100644 index 0000000..81a1e3b --- /dev/null +++ b/uploads/b74a9056-3e78-4a32-bb70-7378e1cd76f8.jpg @@ -0,0 +1 @@ +fake image content \ No newline at end of file diff --git a/uploads/b76a6404-a624-40a5-86c0-b3bb7ad9d047.jpg b/uploads/b76a6404-a624-40a5-86c0-b3bb7ad9d047.jpg new file mode 100644 index 0000000..81a1e3b --- /dev/null +++ b/uploads/b76a6404-a624-40a5-86c0-b3bb7ad9d047.jpg @@ -0,0 +1 @@ +fake image content \ No newline at end of file diff --git a/uploads/b7cee03e-bb42-4f4f-88cf-065adc9d35c8.jpg b/uploads/b7cee03e-bb42-4f4f-88cf-065adc9d35c8.jpg new file mode 100644 index 0000000..81a1e3b --- /dev/null +++ b/uploads/b7cee03e-bb42-4f4f-88cf-065adc9d35c8.jpg @@ -0,0 +1 @@ +fake image content \ No newline at end of file diff --git a/uploads/bbe9ab5a-927c-4a0b-9d88-7c5cd3358c7a.jpg b/uploads/bbe9ab5a-927c-4a0b-9d88-7c5cd3358c7a.jpg new file mode 100644 index 0000000..81a1e3b --- /dev/null +++ b/uploads/bbe9ab5a-927c-4a0b-9d88-7c5cd3358c7a.jpg @@ -0,0 +1 @@ +fake image content \ No newline at end of file diff --git a/uploads/be38d8a8-e63f-498c-ad79-928fe26a6ab1.jpg b/uploads/be38d8a8-e63f-498c-ad79-928fe26a6ab1.jpg new file mode 100644 index 0000000..81a1e3b --- /dev/null +++ b/uploads/be38d8a8-e63f-498c-ad79-928fe26a6ab1.jpg @@ -0,0 +1 @@ +fake image content \ No newline at end of file diff --git a/uploads/bec6a89a-78b7-4302-aed8-6627a93bfb63.jpg b/uploads/bec6a89a-78b7-4302-aed8-6627a93bfb63.jpg new file mode 100644 index 0000000..81a1e3b --- /dev/null +++ b/uploads/bec6a89a-78b7-4302-aed8-6627a93bfb63.jpg @@ -0,0 +1 @@ +fake image content \ No newline at end of file diff --git a/uploads/bf993077-ce05-47be-ac28-83e93b89887d.jpg b/uploads/bf993077-ce05-47be-ac28-83e93b89887d.jpg new file mode 100644 index 0000000..81a1e3b --- /dev/null +++ b/uploads/bf993077-ce05-47be-ac28-83e93b89887d.jpg @@ -0,0 +1 @@ +fake image content \ No newline at end of file diff --git a/uploads/c3e81323-1a99-4145-b0eb-287e54a3b079.jpg b/uploads/c3e81323-1a99-4145-b0eb-287e54a3b079.jpg new file mode 100644 index 0000000..81a1e3b --- /dev/null +++ b/uploads/c3e81323-1a99-4145-b0eb-287e54a3b079.jpg @@ -0,0 +1 @@ +fake image content \ No newline at end of file diff --git a/uploads/c458a8a7-c76f-487d-be87-7492dd599b64.jpg b/uploads/c458a8a7-c76f-487d-be87-7492dd599b64.jpg new file mode 100644 index 0000000..81a1e3b --- /dev/null +++ b/uploads/c458a8a7-c76f-487d-be87-7492dd599b64.jpg @@ -0,0 +1 @@ +fake image content \ No newline at end of file diff --git a/uploads/c73ee1ba-9667-4c7e-a5c5-06a0d6fe2879.jpg b/uploads/c73ee1ba-9667-4c7e-a5c5-06a0d6fe2879.jpg new file mode 100644 index 0000000..81a1e3b --- /dev/null +++ b/uploads/c73ee1ba-9667-4c7e-a5c5-06a0d6fe2879.jpg @@ -0,0 +1 @@ +fake image content \ No newline at end of file diff --git a/uploads/c826c545-c41b-45fc-99ce-79551b8fdaa7.jpg b/uploads/c826c545-c41b-45fc-99ce-79551b8fdaa7.jpg new file mode 100644 index 0000000..81a1e3b --- /dev/null +++ b/uploads/c826c545-c41b-45fc-99ce-79551b8fdaa7.jpg @@ -0,0 +1 @@ +fake image content \ No newline at end of file diff --git a/uploads/ce36e168-4902-464c-9d7e-a836db6cefb9.jpg b/uploads/ce36e168-4902-464c-9d7e-a836db6cefb9.jpg new file mode 100644 index 0000000..81a1e3b --- /dev/null +++ b/uploads/ce36e168-4902-464c-9d7e-a836db6cefb9.jpg @@ -0,0 +1 @@ +fake image content \ No newline at end of file diff --git a/uploads/e0f533ad-73ea-42b0-94a5-0630444e7377.jpg b/uploads/e0f533ad-73ea-42b0-94a5-0630444e7377.jpg new file mode 100644 index 0000000..81a1e3b --- /dev/null +++ b/uploads/e0f533ad-73ea-42b0-94a5-0630444e7377.jpg @@ -0,0 +1 @@ +fake image content \ No newline at end of file diff --git a/uploads/e273a32d-835c-420a-a547-2260b8c25ec9.jpg b/uploads/e273a32d-835c-420a-a547-2260b8c25ec9.jpg new file mode 100644 index 0000000..81a1e3b --- /dev/null +++ b/uploads/e273a32d-835c-420a-a547-2260b8c25ec9.jpg @@ -0,0 +1 @@ +fake image content \ No newline at end of file diff --git a/uploads/f4ef9d40-c7ef-4b56-b104-304dab66ef7b.jpg b/uploads/f4ef9d40-c7ef-4b56-b104-304dab66ef7b.jpg new file mode 100644 index 0000000..81a1e3b --- /dev/null +++ b/uploads/f4ef9d40-c7ef-4b56-b104-304dab66ef7b.jpg @@ -0,0 +1 @@ +fake image content \ No newline at end of file diff --git a/uploads/f7472641-e8cb-4740-accd-ebc55121d534.jpg b/uploads/f7472641-e8cb-4740-accd-ebc55121d534.jpg new file mode 100644 index 0000000..81a1e3b --- /dev/null +++ b/uploads/f7472641-e8cb-4740-accd-ebc55121d534.jpg @@ -0,0 +1 @@ +fake image content \ No newline at end of file diff --git a/uploads/fb4bb755-254b-498e-8d3c-8f566278a7bb.jpg b/uploads/fb4bb755-254b-498e-8d3c-8f566278a7bb.jpg new file mode 100644 index 0000000..81a1e3b --- /dev/null +++ b/uploads/fb4bb755-254b-498e-8d3c-8f566278a7bb.jpg @@ -0,0 +1 @@ +fake image content \ No newline at end of file diff --git a/uploads/fda56bba-e289-4fab-9f20-f9b458781ef2.jpg b/uploads/fda56bba-e289-4fab-9f20-f9b458781ef2.jpg new file mode 100644 index 0000000..81a1e3b --- /dev/null +++ b/uploads/fda56bba-e289-4fab-9f20-f9b458781ef2.jpg @@ -0,0 +1 @@ +fake image content \ No newline at end of file