diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..7c0535fd7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,66 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# Virtual environments +venv/ +env/ +ENV/ + +# IDE +.vscode/ +.idea/ +*.swp +*.swo + +# Database +*.db +*.sqlite +*.sqlite3 +data/*.db +data/*.sqlite +data/*.sqlite3 + +# Logs +*.log + +# Environment variables +.env +.env.local + +# Docker +.dockerignore + +# OS +.DS_Store +Thumbs.db + +# Testes +.pytest_cache/ +.coverage +htmlcov/ +.tox/ +.cache +nosetests.xml +coverage.xml +*.cover +.hypothesis/ \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 000000000..ba8d4648d --- /dev/null +++ b/Dockerfile @@ -0,0 +1,26 @@ +# Usar imagem oficial do Python +FROM python:3.11-slim + +# Definir diretório de trabalho +WORKDIR /app + +# Copiar arquivo de dependências +COPY requirements.txt . + +# Instalar dependências +RUN pip install --no-cache-dir -r requirements.txt + +# Copiar código da aplicação +COPY . . + +# Tornar o script executável +RUN chmod +x start.sh + +# Expor porta 8000 +EXPOSE 8000 + +# Comando para executar a aplicação +CMD ["./start.sh"] + +# Comando alternativo para executar testes +# CMD ["pytest", "-v"] \ No newline at end of file diff --git a/README.md b/README.md index 5c3393a97..469f6b67e 100644 --- a/README.md +++ b/README.md @@ -1,34 +1,245 @@ ![WATTIO](http://wattio.com.br/web/image/1204-212f47c3/Logo%20Wattio.png) -#### Descrição +## 👨‍💻 Desenvolvedor -O desafio consiste em implementar um CRUD de filmes, utilizando [python](https://www.python.org/ "python") integrando com uma API REST e uma possível persistência de dados. +**Bruno Ricardo Alves Pultz** - Desenvolvimento completo do CRUD de filmes com FastAPI -Rotas da API: +## 📋 Sobre o Projeto - - `/filmes` - [GET] deve retornar todos os filmes cadastrados. - - `/filmes` - [POST] deve cadastrar um novo filme. - - `/filmes/{id}` - [GET] deve retornar o filme com ID especificado. +Este é um fork do desafio WATT Filmes, implementando um CRUD completo com autenticação JWT, testes unitários e containerização Docker. + +**Repositório**: [https://github.com/BrunoPultz/watt-backend](https://github.com/BrunoPultz/watt-backend) + +#### Description + +The challenge consists of implementing a movie CRUD using [python](https://www.python.org/ "python") integrating with a REST API and possible data persistence. + +API Routes: + +- `/movies` - [GET] should return all registered movies. +- `/movies` - [POST] should register a new movie. +- `/movies/{id}` - [GET] should return the movie with specified ID. O Objetivo é te desafiar e reconhecer seu esforço para aprender e se adaptar. Qualquer código enviado, ficaremos muito felizes e avaliaremos com toda atenção! -#### Sugestão de Ferramentas -Não é obrigatório utilizar todas as as tecnologias sugeridas, mas será um diferencial =] +#### Suggested Tools + +It's not mandatory to use all suggested technologies, but it will be a differential =] + +- Object-oriented programming (use objects, classes to manipulate movies) +- [FastAPI](https://fastapi.tiangolo.com/) (API with auto-generated documentation) +- [Docker](https://www.docker.com/) / [Docker-compose](https://docs.docker.com/compose/install/) (Application should be in a docker container, and start should be with the command `docker-compose up` +- Database integration (persist information in json (beginner) /[SqLite](https://www.sqlite.org/index.html) / [SQLAlchemy](https://fastapi.tiangolo.com/tutorial/sql-databases/#sql-relational-databases) / other DBs) + +## 🚀 How to run the application + +### Prerequisites + +- Docker and Docker Compose installed +- Git + +### Steps to run + +1. **Clone the repository** + +```bash +git clone +cd watt-backend +``` + +2. **Run with Docker Compose** + +**Windows (PowerShell):** + +**Option 1 - Automatic script:** + +```powershell +powershell -ExecutionPolicy Bypass -File run.ps1 +``` + +**Option 2 - Manual:** + +```powershell +# Add Docker to current session PATH +$env:PATH += ";C:\Program Files\Docker\Docker\resources\bin" + +# Run the application +docker compose up --build +``` + +**Option 3 - Full path:** + +```powershell +& "C:\Program Files\Docker\Docker\resources\bin\docker.exe" compose up --build +``` + +**Linux/Mac:** + +```bash +docker-compose up +``` + +3. **Access the API** + +- **API**: http://localhost:8000 +- **Swagger Documentation**: http://localhost:8000/docs +- **ReDoc Documentation**: http://localhost:8000/redoc + +### 🎯 API Routes + +#### Authentication + +- `POST /auth/register` - Register new user +- `POST /auth/login` - Login (returns JWT token) +- `GET /auth/me` - View logged user data + +#### Users (CRUD) + +- `GET /users` - List all users (protected) +- `GET /users/{id}` - View specific user (protected) +- `PUT /users/{id}` - Update user (protected - own profile only) +- `DELETE /users/{id}` - Remove user (protected - own profile only) + +#### Movies (CRUD) + +- `GET /movies` - List all movies (public) +- `POST /movies` - Register new movie (protected) +- `GET /movies/{id}` - View specific movie (public) +- `PUT /movies/{id}` - Update movie (protected) +- `DELETE /movies/{id}` - Remove movie (protected) + +### 🔐 Autenticação + +Para acessar rotas protegidas, use o token JWT no header: + +``` +Authorization: Bearer +``` + +### 📝 Exemplo de uso + +1. **Cadastrar usuário:** + +```bash +curl -X POST "http://localhost:8000/auth/register" \ + -H "Content-Type: application/json" \ + -d '{"email": "usuario@exemplo.com", "password": "123456", "name": "João"}' +``` + +2. **Fazer login:** + +```bash +curl -X POST "http://localhost:8000/auth/login" \ + -H "Content-Type: application/json" \ + -d '{"email": "usuario@exemplo.com", "password": "123456"}' +``` + +3. **Cadastrar filme (com token):** + +```bash +curl -X POST "http://localhost:8000/movies" \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{"title": "The Godfather", "year": 1972, "director": "Francis Ford Coppola", "genre": "Drama", "duration": 175, "rating": 9.2}' +``` + +4. **Listar todos os usuários (com token):** + +```bash +curl -X GET "http://localhost:8000/users" \ + -H "Authorization: Bearer " +``` + +5. **Atualizar usuário (com token):** + +```bash +curl -X PUT "http://localhost:8000/users/1" \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{"name": "João Silva", "email": "joao.silva@exemplo.com"}' +``` + +6. **Deletar usuário (com token):** + +```bash +curl -X DELETE "http://localhost:8000/users/1" \ + -H "Authorization: Bearer " +``` + +### 🧪 Executando os Testes + +Para executar os testes unitários: + +```bash +# Executar todos os testes +pytest + +# Executar com mais detalhes +pytest -v + +# Executar testes específicos +pytest tests/test_auth.py + +# Executar com cobertura (se instalado) +pytest --cov=app + +# Executar apenas testes rápidos +pytest -m "not slow" +``` + +**Cobertura de Testes:** + +- ✅ **Rotas de Autenticação** - Registro, login, verificação de usuário +- ✅ **Rotas de Filmes** - CRUD completo com autenticação +- ✅ **Rotas de Usuários** - CRUD completo com proteções de segurança +- ✅ **Operações CRUD** - Testes unitários para todas as operações +- ✅ **Autenticação JWT** - Criação, verificação e manipulação de tokens +- ✅ **Validação de Dados** - Schemas Pydantic e validações +- ✅ **Segurança** - Hash de senhas, proteções de acesso -- Orientação a objetos (utilizar objetos, classes para manipular os filmes) -- [FastAPI](https://fastapi.tiangolo.com/) (API com documentação auto gerada) -- [Docker](https://www.docker.com/) / [Docker-compose](https://docs.docker.com/compose/install/) (Aplicação deverá ficar em um container docker, e o start deverá seer com o comando ``` docker-compose up ``` -- Integração com banco de dados (persistir as informações em json (iniciante) /[SqLite](https://www.sqlite.org/index.html) / [SQLAlchemy](https://fastapi.tiangolo.com/tutorial/sql-databases/#sql-relational-databases) / outros DB) +### 🛠️ Tecnologias utilizadas +- **Python 3.11** - Linguagem principal +- **FastAPI** - Framework web com documentação automática +- **SQLAlchemy** - ORM para banco de dados +- **SQLite** - Banco de dados +- **Alembic** - Controle de versão do banco (migrações) +- **JWT** - Autenticação com tokens +- **Docker** - Containerização +- **Pydantic** - Validação de dados +- **Pytest** - Framework de testes unitários -#### Como começar? +### 📁 Estrutura do projeto -- Fork do repositório -- Criar branch com seu nome ``` git checkout -b feature/ana ``` -- Faça os commits de suas alterações ``` git commit -m "[ADD] Funcionalidade" ``` -- Envie a branch para seu repositório ``` git push origin feature/ana ``` -- Navegue até o [Github](https://github.com/), crie seu Pull Request apontando para a branch **```main```** -- Atualize o README.md descrevendo como subir sua aplicação +``` +watt-backend/ +├── app/ +│ ├── main.py # Aplicação FastAPI +│ ├── models/ # Modelos SQLAlchemy +│ ├── schemas/ # Schemas Pydantic +│ ├── crud/ # Operações CRUD +│ ├── database/ # Configuração do banco +│ ├── routes/ # Rotas da API +│ ├── auth/ # Autenticação JWT +│ └── middleware/ # Middleware de autenticação +├── tests/ # Testes unitários +│ ├── conftest.py # Configuração do pytest +│ ├── test_auth.py # Testes de autenticação +│ ├── test_filmes.py # Testes de filmes +│ ├── test_usuarios.py # Testes de usuários +│ ├── test_crud.py # Testes CRUD +│ └── test_auth_jwt.py # Testes JWT +├── alembic/ # Migrações do banco +│ ├── versions/ # Arquivos de migração +│ ├── env.py # Configuração do Alembic +│ └── script.py.mako # Template de migração +├── data/ # Banco SQLite +├── Dockerfile # Container da aplicação +├── docker-compose.yml # Orquestração +├── alembic.ini # Configuração do Alembic +├── pytest.ini # Configuração do pytest +└── requirements.txt # Dependências Python +``` #### Dúvidas? diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 000000000..bb916aac9 --- /dev/null +++ b/alembic.ini @@ -0,0 +1,107 @@ +# A generic, single database configuration. + +[alembic] +# path to migration scripts +script_location = alembic + +# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s +# Uncomment the line below if you want the files to be prepended with date and time +# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s + +# sys.path path, will be prepended to sys.path if present. +# defaults to the current working directory. +prepend_sys_path = . + +# timezone to use when rendering the date within the migration file +# as well as the filename. +# If specified, requires the python-dateutil library that can be +# installed by adding `alembic[tz]` to the pip requirements +# string value is passed to dateutil.tz.gettz() +# leave blank for localtime +# timezone = + +# max length of characters to apply to the +# "slug" field +# truncate_slug_length = 40 + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + +# set to 'true' to allow .pyc and .pyo files without +# a source .py file to be detected as revisions in the +# versions/ directory +# sourceless = false + +# version number format +version_num_format = %%(version_num)04d + +# version path separator; As mentioned above, this is the character used to split +# version_locations. The default within new alembic.ini files is "os", which uses +# os.pathsep. If this key is omitted entirely, it falls back to the legacy +# behavior of splitting on spaces and/or commas. +# Valid values for version_path_separator are: +# +# version_path_separator = : +# version_path_separator = ; +# version_path_separator = space +version_path_separator = os + +# the output encoding used when revision files +# are written from script.py.mako +# output_encoding = utf-8 + +sqlalchemy.url = sqlite:///watt_filmes.db + + +[post_write_hooks] +# post_write_hooks defines scripts or Python functions that are run +# on newly generated revision scripts. See the documentation for further +# detail and examples + +# format using "black" - use the console_scripts runner, against the "black" entrypoint +# hooks = black +# black.type = console_scripts +# black.entrypoint = black +# black.options = -l 79 REVISION_SCRIPT_FILENAME + +# lint with attempts to fix using "ruff" - use the exec runner, execute a binary +# hooks = ruff +# ruff.type = exec +# ruff.executable = %(here)s/.venv/bin/ruff +# ruff.options = --fix REVISION_SCRIPT_FILENAME + +# Logging configuration +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S \ No newline at end of file diff --git a/alembic/env.py b/alembic/env.py new file mode 100644 index 000000000..9b90ec3c6 --- /dev/null +++ b/alembic/env.py @@ -0,0 +1,79 @@ +from logging.config import fileConfig + +from sqlalchemy import engine_from_config +from sqlalchemy import pool + +from alembic import context + +# Importar os modelos para que o Alembic possa detectá-los +from app.models.filme import Movie +from app.models.usuario import User +from app.database.connection import Base + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config + +# Interpret the config file for Python logging. +# This line sets up loggers basically. +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +# add your model's MetaData object here +# for 'autogenerate' support +target_metadata = Base.metadata + +# other values from the config, defined by the needs of env.py, +# can be acquired: +# my_important_option = config.get_main_option("my_important_option") +# ... etc. + + +def run_migrations_offline() -> None: + """Run migrations in 'offline' mode. + + This configures the context with just a URL + and not an Engine, though an Engine is acceptable + here as well. By skipping the Engine creation + we don't even need a DBAPI to be available. + + Calls to context.execute() here emit the given string to the + script output. + + """ + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online() -> None: + """Run migrations in 'online' mode. + + In this scenario we need to create an Engine + and associate a connection with the context. + + """ + connectable = engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + + with connectable.connect() as connection: + context.configure(connection=connection, target_metadata=target_metadata) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/alembic/script.py.mako b/alembic/script.py.mako new file mode 100644 index 000000000..b5bd51672 --- /dev/null +++ b/alembic/script.py.mako @@ -0,0 +1,24 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision = ${repr(up_revision)} +down_revision = ${repr(down_revision)} +branch_labels = ${repr(branch_labels)} +depends_on = ${repr(depends_on)} + + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} \ No newline at end of file diff --git a/alembic/versions/001_initial_migration.py b/alembic/versions/001_initial_migration.py new file mode 100644 index 000000000..83c8e54a9 --- /dev/null +++ b/alembic/versions/001_initial_migration.py @@ -0,0 +1,62 @@ +"""Initial migration + +Revision ID: 001 +Revises: +Create Date: 2025-08-02 17:57:00.000000 + +""" + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = "001" +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # Create users table + op.create_table( + "users", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("email", sa.String(length=255), nullable=False), + sa.Column("password_hash", sa.String(length=255), nullable=False), + sa.Column("name", sa.String(length=255), nullable=True), + sa.Column("created_at", sa.DateTime(), nullable=True), + sa.Column("updated_at", sa.DateTime(), nullable=True), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("email"), + ) + + # Create movies table + op.create_table( + "movies", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("title", sa.String(length=200), nullable=False), + sa.Column("year", sa.Integer(), nullable=True), + sa.Column("director", sa.String(length=200), nullable=True), + sa.Column("genre", sa.String(length=100), nullable=True), + sa.Column("synopsis", sa.Text(), nullable=True), + sa.Column("duration", sa.Integer(), nullable=True), + sa.Column("rating", sa.Float(), nullable=True), + sa.Column("created_at", sa.DateTime(), nullable=True), + sa.Column("updated_at", sa.DateTime(), nullable=True), + sa.PrimaryKeyConstraint("id"), + ) + + # Create indexes + op.create_index(op.f("ix_users_email"), "users", ["email"], unique=True) + op.create_index(op.f("ix_movies_title"), "movies", ["title"], unique=False) + + +def downgrade() -> None: + # Drop indexes + op.drop_index(op.f("ix_movies_title"), table_name="movies") + op.drop_index(op.f("ix_users_email"), table_name="users") + + # Drop tables + op.drop_table("movies") + op.drop_table("users") diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 000000000..ee55bd579 --- /dev/null +++ b/app/__init__.py @@ -0,0 +1 @@ +# Módulo principal da aplicação \ No newline at end of file diff --git a/app/auth/__init__.py b/app/auth/__init__.py new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/app/auth/__init__.py @@ -0,0 +1 @@ + diff --git a/app/auth/jwt_handler.py b/app/auth/jwt_handler.py new file mode 100644 index 000000000..e9de61286 --- /dev/null +++ b/app/auth/jwt_handler.py @@ -0,0 +1,43 @@ +from datetime import datetime, timedelta +from jose import JWTError, jwt +from passlib.context import CryptContext +from app.schemas.token import TokenData + + +SECRET_KEY = "watt_filmes_secret_key_2024" +ALGORITHM = "HS256" +ACCESS_TOKEN_EXPIRE_MINUTES = 30 + + +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") + + +def verify_password(plain_password: str, hashed_password: str) -> bool: + return pwd_context.verify(plain_password, hashed_password) + + +def get_password_hash(password: str) -> str: + return pwd_context.hash(password) + + +def create_access_token(data: dict, expires_delta: timedelta = None): + to_encode = data.copy() + if expires_delta: + expire = datetime.utcnow() + expires_delta + else: + expire = datetime.utcnow() + timedelta(minutes=15) + to_encode.update({"exp": expire}) + encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM) + return encoded_jwt + + +def verify_token(token: str) -> TokenData: + try: + payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) + email: str = payload.get("sub") + if email is None: + return None + token_data = TokenData(email=email) + return token_data + except JWTError: + return None diff --git a/app/crud/__init__.py b/app/crud/__init__.py new file mode 100644 index 000000000..19a8d50ba --- /dev/null +++ b/app/crud/__init__.py @@ -0,0 +1,6 @@ +# CRUD Module - Database operations + +from .filmes import movie_crud +from .usuarios import user_crud + +__all__ = ["movie_crud", "user_crud"] \ No newline at end of file diff --git a/app/crud/filmes.py b/app/crud/filmes.py new file mode 100644 index 000000000..9da4d3979 --- /dev/null +++ b/app/crud/filmes.py @@ -0,0 +1,57 @@ +from sqlalchemy.orm import Session +from typing import List, Optional +from app.models.filme import Movie +from app.schemas.filme import MovieCreate, MovieUpdate + + +class CRUDMovie: + + def get(self, db: Session, movie_id: int) -> Optional[Movie]: + return db.query(Movie).filter(Movie.id == movie_id).first() + + def get_all(self, db: Session, skip: int = 0, limit: int = 100) -> List[Movie]: + return db.query(Movie).offset(skip).limit(limit).all() + + def create(self, db: Session, movie: MovieCreate) -> Movie: + db_movie = Movie(**movie.dict()) + db.add(db_movie) + db.commit() + db.refresh(db_movie) + return db_movie + + def update( + self, db: Session, movie_id: int, movie_update: MovieUpdate + ) -> Optional[Movie]: + db_movie = db.query(Movie).filter(Movie.id == movie_id).first() + if db_movie: + update_data = movie_update.dict(exclude_unset=True) + for field, value in update_data.items(): + setattr(db_movie, field, value) + db.commit() + db.refresh(db_movie) + return db_movie + + def delete(self, db: Session, movie_id: int) -> bool: + db_movie = db.query(Movie).filter(Movie.id == movie_id).first() + if db_movie: + db.delete(db_movie) + db.commit() + return True + return False + + def get_by_title(self, db: Session, title: str) -> Optional[Movie]: + return db.query(Movie).filter(Movie.title == title).first() + + def get_by_genre( + self, db: Session, genre: str, skip: int = 0, limit: int = 100 + ) -> List[Movie]: + return ( + db.query(Movie) + .filter(Movie.genre == genre) + .offset(skip) + .limit(limit) + .all() + ) + + +movie_crud = CRUDMovie() diff --git a/app/crud/usuarios.py b/app/crud/usuarios.py new file mode 100644 index 000000000..03532422d --- /dev/null +++ b/app/crud/usuarios.py @@ -0,0 +1,64 @@ +from sqlalchemy.orm import Session +from typing import Optional +from app.models.usuario import User +from app.schemas.usuario import UserCreate, UserUpdate +from app.auth.jwt_handler import get_password_hash, verify_password + + +class CRUDUser: + + def get(self, db: Session, user_id: int) -> Optional[User]: + return db.query(User).filter(User.id == user_id).first() + + def get_by_email(self, db: Session, email: str) -> Optional[User]: + return db.query(User).filter(User.email == email).first() + + def get_all(self, db: Session, skip: int = 0, limit: int = 100): + return db.query(User).offset(skip).limit(limit).all() + + def create(self, db: Session, user: UserCreate) -> User: + hashed_password = get_password_hash(user.password) + db_user = User(email=user.email, password_hash=hashed_password, name=user.name) + db.add(db_user) + db.commit() + db.refresh(db_user) + return db_user + + def authenticate(self, db: Session, email: str, password: str) -> Optional[User]: + user = self.get_by_email(db, email=email) + if not user: + return None + if not verify_password(password, user.password_hash): + return None + return user + + def update( + self, db: Session, user_id: int, user_update: UserUpdate + ) -> Optional[User]: + db_user = db.query(User).filter(User.id == user_id).first() + if db_user: + update_data = user_update.dict(exclude_unset=True) + + if "password" in update_data: + update_data["password_hash"] = get_password_hash( + update_data.pop("password") + ) + + for field, value in update_data.items(): + if hasattr(db_user, field): + setattr(db_user, field, value) + + db.commit() + db.refresh(db_user) + return db_user + + def delete(self, db: Session, user_id: int) -> bool: + db_user = db.query(User).filter(User.id == user_id).first() + if db_user: + db.delete(db_user) + db.commit() + return True + return False + + +user_crud = CRUDUser() diff --git a/app/database/__init__.py b/app/database/__init__.py new file mode 100644 index 000000000..b7be0c6af --- /dev/null +++ b/app/database/__init__.py @@ -0,0 +1 @@ +# Módulo de banco de dados diff --git a/app/database/connection.py b/app/database/connection.py new file mode 100644 index 000000000..3f4f95e45 --- /dev/null +++ b/app/database/connection.py @@ -0,0 +1,25 @@ +from sqlalchemy import create_engine +from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy.orm import sessionmaker + + +SQLALCHEMY_DATABASE_URL = "sqlite:///./watt_filmes.db" + + +engine = create_engine( + SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False} +) + + +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + + +Base = declarative_base() + + +def get_db(): + db = SessionLocal() + try: + yield db + finally: + db.close() diff --git a/app/main.py b/app/main.py new file mode 100644 index 000000000..3029fe978 --- /dev/null +++ b/app/main.py @@ -0,0 +1,48 @@ +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from app.database.connection import engine, Base +from app.models.filme import Movie +from app.models.usuario import User +from app.routes import auth, filmes as movies, usuarios as users + +# As tabelas serão criadas via Alembic migrations +# Base.metadata.create_all(bind=engine) + +# Criar aplicação FastAPI +app = FastAPI( + title="WATT Movies API", + description="REST API for movie management with JWT authentication", + version="1.0.0", + docs_url="/docs", + redoc_url="/redoc", +) + +# Configurar CORS +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# Include routes +app.include_router(auth.router) +app.include_router(movies.router) +app.include_router(users.router) + + +@app.get("/") +def read_root(): + """API root endpoint""" + return { + "message": "Welcome to WATT Movies API!", + "docs": "/docs", + "redoc": "/redoc", + } + + +@app.get("/health") +def health_check(): + """API health check""" + return {"status": "healthy", "message": "API is running normally"} diff --git a/app/middleware/__init__.py b/app/middleware/__init__.py new file mode 100644 index 000000000..eecac31cc --- /dev/null +++ b/app/middleware/__init__.py @@ -0,0 +1 @@ +# Módulo de middleware diff --git a/app/middleware/auth_middleware.py b/app/middleware/auth_middleware.py new file mode 100644 index 000000000..90c053027 --- /dev/null +++ b/app/middleware/auth_middleware.py @@ -0,0 +1,30 @@ +from fastapi import Depends, HTTPException, status +from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials +from sqlalchemy.orm import Session +from app.database.connection import get_db +from app.models.usuario import User +from app.auth.jwt_handler import verify_token +from app.crud import user_crud + +security = HTTPBearer() + + +def get_current_user( + credentials: HTTPAuthorizationCredentials = Depends(security), + db: Session = Depends(get_db), +) -> User: + credentials_exception = HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid credentials", + headers={"WWW-Authenticate": "Bearer"}, + ) + + token_data = verify_token(credentials.credentials) + if token_data is None: + raise credentials_exception + + user = user_crud.get_by_email(db, email=token_data.email) + if user is None: + raise credentials_exception + + return user diff --git a/app/models/__init__.py b/app/models/__init__.py new file mode 100644 index 000000000..ec1ba1c9e --- /dev/null +++ b/app/models/__init__.py @@ -0,0 +1,6 @@ +# Models module + +from .filme import Movie +from .usuario import User + +__all__ = ["Movie", "User"] diff --git a/app/models/filme.py b/app/models/filme.py new file mode 100644 index 000000000..b99abf05c --- /dev/null +++ b/app/models/filme.py @@ -0,0 +1,18 @@ +from sqlalchemy import Column, Integer, String, Float, Text, DateTime +from sqlalchemy.sql import func +from app.database.connection import Base + + +class Movie(Base): + __tablename__ = "movies" + + id = Column(Integer, primary_key=True, index=True) + title = Column(String(200), nullable=False, index=True) + year = Column(Integer) + director = Column(String(100)) + genre = Column(String(50)) + synopsis = Column(Text) + duration = Column(Integer) + rating = Column(Float) + created_at = Column(DateTime(timezone=True), default=func.now()) + updated_at = Column(DateTime(timezone=True), onupdate=func.now()) diff --git a/app/models/usuario.py b/app/models/usuario.py new file mode 100644 index 000000000..f2c038d3d --- /dev/null +++ b/app/models/usuario.py @@ -0,0 +1,14 @@ +from sqlalchemy import Column, Integer, String, DateTime +from sqlalchemy.sql import func +from app.database.connection import Base + + +class User(Base): + __tablename__ = "users" + + id = Column(Integer, primary_key=True, index=True) + email = Column(String(100), unique=True, nullable=False, index=True) + password_hash = Column(String(255), nullable=False) + name = Column(String(100)) + created_at = Column(DateTime(timezone=True), default=func.now()) + updated_at = Column(DateTime(timezone=True), onupdate=func.now()) diff --git a/app/routes/__init__.py b/app/routes/__init__.py new file mode 100644 index 000000000..11023938e --- /dev/null +++ b/app/routes/__init__.py @@ -0,0 +1 @@ +# Routes module diff --git a/app/routes/auth.py b/app/routes/auth.py new file mode 100644 index 000000000..ad9da1772 --- /dev/null +++ b/app/routes/auth.py @@ -0,0 +1,47 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.orm import Session +from datetime import timedelta +from app.database.connection import get_db +from app.models.usuario import User +from app.schemas.usuario import UserCreate, UserLogin, User as UserSchema +from app.schemas.token import Token +from app.auth.jwt_handler import ( + create_access_token, + ACCESS_TOKEN_EXPIRE_MINUTES, +) +from app.middleware.auth_middleware import get_current_user +from app.crud import user_crud + +router = APIRouter(prefix="/auth", tags=["authentication"]) + + +@router.post("/register", response_model=UserSchema) +def register(user: UserCreate, db: Session = Depends(get_db)): + db_user = user_crud.get_by_email(db, email=user.email) + if db_user: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, detail="Email already registered" + ) + + return user_crud.create(db, user=user) + + +@router.post("/login", response_model=Token) +def login(user: UserLogin, db: Session = Depends(get_db)): + db_user = user_crud.authenticate(db, email=user.email, password=user.password) + if not db_user: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Incorrect email or password", + ) + access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) + access_token = create_access_token( + data={"sub": db_user.email}, expires_delta=access_token_expires + ) + + return {"access_token": access_token, "token_type": "bearer"} + + +@router.get("/me", response_model=UserSchema) +def get_current_user_info(current_user: User = Depends(get_current_user)): + return current_user diff --git a/app/routes/filmes.py b/app/routes/filmes.py new file mode 100644 index 000000000..cddb98a89 --- /dev/null +++ b/app/routes/filmes.py @@ -0,0 +1,63 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.orm import Session +from typing import List +from app.database.connection import get_db +from app.models.usuario import User +from app.schemas.filme import MovieCreate, MovieUpdate, Movie as MovieSchema +from app.middleware.auth_middleware import get_current_user +from app.crud import movie_crud + +router = APIRouter(prefix="/movies", tags=["movies"]) + + +@router.get("/", response_model=List[MovieSchema]) +def get_movies(skip: int = 0, limit: int = 100, db: Session = Depends(get_db)): + return movie_crud.get_all(db, skip=skip, limit=limit) + + +@router.post("/", response_model=MovieSchema) +def create_movie( + movie: MovieCreate, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + return movie_crud.create(db, movie=movie) + + +@router.get("/{movie_id}", response_model=MovieSchema) +def get_movie(movie_id: int, db: Session = Depends(get_db)): + movie = movie_crud.get(db, movie_id=movie_id) + if movie is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Movie not found" + ) + return movie + + +@router.put("/{movie_id}", response_model=MovieSchema) +def update_movie( + movie_id: int, + movie_update: MovieUpdate, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + db_movie = movie_crud.update(db, movie_id=movie_id, movie_update=movie_update) + if db_movie is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Movie not found" + ) + return db_movie + + +@router.delete("/{movie_id}") +def delete_movie( + movie_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + success = movie_crud.delete(db, movie_id=movie_id) + if not success: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Movie not found" + ) + return {"message": "Movie removed successfully"} diff --git a/app/routes/usuarios.py b/app/routes/usuarios.py new file mode 100644 index 000000000..08de06e3f --- /dev/null +++ b/app/routes/usuarios.py @@ -0,0 +1,83 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.orm import Session +from typing import List +from app.database.connection import get_db +from app.models.usuario import User +from app.schemas.usuario import UserUpdate, User as UserSchema +from app.middleware.auth_middleware import get_current_user +from app.crud import user_crud + +router = APIRouter(prefix="/users", tags=["users"]) + + +@router.get("/", response_model=List[UserSchema]) +def get_users( + skip: int = 0, + limit: int = 100, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + return user_crud.get_all(db, skip=skip, limit=limit) + + +@router.get("/{user_id}", response_model=UserSchema) +def get_user( + user_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + user = user_crud.get(db, user_id=user_id) + if user is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="User not found" + ) + return user + + +@router.put("/{user_id}", response_model=UserSchema) +def update_user( + user_id: int, + user_update: UserUpdate, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + if current_user.id != user_id: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="You can only update your own profile", + ) + + if user_update.email: + existing_user = user_crud.get_by_email(db, email=user_update.email) + if existing_user and existing_user.id != user_id: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Email already registered", + ) + + db_user = user_crud.update(db, user_id=user_id, user_update=user_update) + if db_user is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="User not found" + ) + return db_user + + +@router.delete("/{user_id}") +def delete_user( + user_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + if current_user.id != user_id: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="You can only delete your own profile", + ) + + success = user_crud.delete(db, user_id=user_id) + if not success: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="User not found" + ) + return {"message": "User removed successfully"} diff --git a/app/schemas/__init__.py b/app/schemas/__init__.py new file mode 100644 index 000000000..c38b28c91 --- /dev/null +++ b/app/schemas/__init__.py @@ -0,0 +1,21 @@ +# Pydantic schemas module + +from .filme import MovieBase, MovieCreate, MovieUpdate, Movie +from .usuario import UserBase, UserCreate, UserLogin, User +from .token import Token, TokenData + +__all__ = [ + # Movie schemas + "MovieBase", + "MovieCreate", + "MovieUpdate", + "Movie", + # User schemas + "UserBase", + "UserCreate", + "UserLogin", + "User", + # Token schemas + "Token", + "TokenData", +] diff --git a/app/schemas/filme.py b/app/schemas/filme.py new file mode 100644 index 000000000..b4ec64db4 --- /dev/null +++ b/app/schemas/filme.py @@ -0,0 +1,36 @@ +from pydantic import BaseModel +from typing import Optional +from datetime import datetime + + +class MovieBase(BaseModel): + title: str + year: Optional[int] = None + director: Optional[str] = None + genre: Optional[str] = None + synopsis: Optional[str] = None + duration: Optional[int] = None + rating: Optional[float] = None + + +class MovieCreate(MovieBase): + pass + + +class MovieUpdate(BaseModel): + title: Optional[str] = None + year: Optional[int] = None + director: Optional[str] = None + genre: Optional[str] = None + synopsis: Optional[str] = None + duration: Optional[int] = None + rating: Optional[float] = None + + +class Movie(MovieBase): + id: int + created_at: Optional[datetime] = None + updated_at: Optional[datetime] = None + + class Config: + from_attributes = True diff --git a/app/schemas/token.py b/app/schemas/token.py new file mode 100644 index 000000000..5cc8c5600 --- /dev/null +++ b/app/schemas/token.py @@ -0,0 +1,11 @@ +from pydantic import BaseModel +from typing import Optional + + +class Token(BaseModel): + access_token: str + token_type: str + + +class TokenData(BaseModel): + email: Optional[str] = None diff --git a/app/schemas/usuario.py b/app/schemas/usuario.py new file mode 100644 index 000000000..26adf8626 --- /dev/null +++ b/app/schemas/usuario.py @@ -0,0 +1,32 @@ +from pydantic import BaseModel, EmailStr +from typing import Optional +from datetime import datetime + + +class UserBase(BaseModel): + email: EmailStr + name: Optional[str] = None + + +class UserCreate(UserBase): + password: str + + +class UserLogin(BaseModel): + email: EmailStr + password: str + + +class UserUpdate(BaseModel): + email: Optional[EmailStr] = None + name: Optional[str] = None + password: Optional[str] = None + + +class User(UserBase): + id: int + created_at: Optional[datetime] = None + updated_at: Optional[datetime] = None + + class Config: + from_attributes = True diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 000000000..431eeed6d --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,25 @@ +version: '3.8' + +services: + watt-api: + build: . + ports: + - "8000:8000" + volumes: + - .:/app + - ./data:/app/data + environment: + - DATABASE_URL=sqlite:///./data/watt_filmes.db + command: ./start.sh + restart: unless-stopped + + watt-tests: + build: . + volumes: + - .:/app + - ./data:/app/data + environment: + - DATABASE_URL=sqlite:///./data/watt_filmes.db + command: python -m pytest -v + depends_on: + - watt-api \ No newline at end of file diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 000000000..10e42868b --- /dev/null +++ b/pytest.ini @@ -0,0 +1,15 @@ +[tool:pytest] +testpaths = tests +python_files = test_*.py +python_classes = Test* +python_functions = test_* +addopts = + -v + --tb=short + --strict-markers + --disable-warnings + --color=yes +markers = + slow: marks tests as slow (deselect with '-m "not slow"') + integration: marks tests as integration tests + unit: marks tests as unit tests \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 000000000..8f004e33d --- /dev/null +++ b/requirements.txt @@ -0,0 +1,14 @@ +fastapi==0.104.1 +uvicorn[standard]==0.24.0 +sqlalchemy==2.0.23 +pydantic==2.5.0 +python-jose[cryptography]==3.3.0 +passlib[bcrypt]==1.7.4 +bcrypt==4.0.1 +python-multipart==0.0.6 +email-validator==2.1.0 +alembic==1.13.1 +pytest==7.4.3 +pytest-asyncio==0.21.1 +httpx==0.25.2 +pytest-mock==3.12.0 \ No newline at end of file diff --git a/run_tests_docker.py b/run_tests_docker.py new file mode 100644 index 000000000..fbdc34b2c --- /dev/null +++ b/run_tests_docker.py @@ -0,0 +1,80 @@ +#!/usr/bin/env python3 +""" +Script para executar testes da API WATT Filmes usando Docker +""" + +import subprocess +import sys +import os + + +def run_docker_command(command, description): + """Executa um comando Docker e exibe o resultado""" + print(f"\n{'='*60}") + print(f"🚀 {description}") + print(f"{'='*60}") + + try: + result = subprocess.run(command, shell=True, check=True, capture_output=True, text=True) + print(result.stdout) + return True + except subprocess.CalledProcessError as e: + print(f"❌ Erro ao executar: {e}") + print(f"Stderr: {e.stderr}") + return False + + +def main(): + """Função principal""" + print("🧪 EXECUTOR DE TESTES - WATT FILMES API (DOCKER)") + print("=" * 60) + + # Verificar se estamos no diretório correto + if not os.path.exists("tests"): + print("❌ Diretório 'tests' não encontrado!") + print(" Execute este script no diretório raiz do projeto.") + sys.exit(1) + + # Verificar se o Docker está disponível + try: + subprocess.run(["docker", "--version"], check=True, capture_output=True) + except (subprocess.CalledProcessError, FileNotFoundError): + print("❌ Docker não encontrado! Instale o Docker primeiro.") + sys.exit(1) + + # Lista de comandos para executar + commands = [ + ("docker compose run --rm watt-tests python -m pytest --version", "Verificando versão do pytest"), + ("docker compose run --rm watt-tests python -m pytest -v --tb=short", "Executando todos os testes"), + ("docker compose run --rm watt-tests python -m pytest tests/test_auth.py -v", "Testes de autenticação"), + ("docker compose run --rm watt-tests python -m pytest tests/test_filmes.py -v", "Testes de filmes"), + ("docker compose run --rm watt-tests python -m pytest tests/test_usuarios.py -v", "Testes de usuários"), + ("docker compose run --rm watt-tests python -m pytest tests/test_crud.py -v", "Testes CRUD"), + ("docker compose run --rm watt-tests python -m pytest tests/test_auth_jwt.py -v", "Testes JWT"), + ] + + success_count = 0 + total_commands = len(commands) + + for command, description in commands: + if run_docker_command(command, description): + success_count += 1 + else: + print(f"⚠️ Falha em: {description}") + + # Resumo final + print(f"\n{'='*60}") + print("📊 RESUMO DOS TESTES") + print(f"{'='*60}") + print(f"✅ Comandos executados com sucesso: {success_count}/{total_commands}") + + if success_count == total_commands: + print("🎉 Todos os testes foram executados com sucesso!") + sys.exit(0) + else: + print("⚠️ Alguns testes falharam. Verifique os erros acima.") + sys.exit(1) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/start.bat b/start.bat new file mode 100644 index 000000000..f5ee53b02 --- /dev/null +++ b/start.bat @@ -0,0 +1,23 @@ +@echo off +echo 🚀 Iniciando WATT Filmes API... +echo. + +REM Adicionar Docker ao PATH +set PATH=%PATH%;C:\Program Files\Docker\Docker\resources\bin + +REM Verificar se Docker está funcionando +docker --version >nul 2>&1 +if %errorlevel% neq 0 ( + echo ❌ Docker não encontrado! + pause + exit /b 1 +) + +echo ✅ Docker funcionando! +echo 📦 Executando docker compose up --build... +echo. + +REM Executar docker compose +docker compose up --build + +pause \ No newline at end of file diff --git a/start.sh b/start.sh new file mode 100644 index 000000000..86764bae7 --- /dev/null +++ b/start.sh @@ -0,0 +1,12 @@ +#!/bin/bash + +# Criar diretório de dados se não existir +mkdir -p /app/data + +# Executar migrações do Alembic +echo "Executando migrações do banco de dados..." +alembic upgrade head + +# Iniciar a aplicação +echo "Iniciando a aplicação..." +exec uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload \ No newline at end of file diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 000000000..34ce92c9b --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +# Módulo de testes \ No newline at end of file diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 000000000..48e6d19c7 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,131 @@ +import pytest +from datetime import datetime +from fastapi.testclient import TestClient +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker +from sqlalchemy.pool import StaticPool +from unittest.mock import Mock + +from app.main import app +from app.database.connection import get_db, Base +from app.models.filme import Movie +from app.models.usuario import User +from app.schemas.filme import MovieCreate +from app.schemas.usuario import UserCreate +from app.auth.jwt_handler import create_access_token + + +SQLALCHEMY_DATABASE_URL = "sqlite:///:memory:" + +engine = create_engine( + SQLALCHEMY_DATABASE_URL, + connect_args={"check_same_thread": False}, + poolclass=StaticPool, +) +TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + + +def override_get_db(): + + try: + db = TestingSessionLocal() + yield db + finally: + db.close() + + +@pytest.fixture(scope="function") +def db_session(): + Base.metadata.create_all(bind=engine) + db = TestingSessionLocal() + try: + yield db + finally: + db.close() + Base.metadata.drop_all(bind=engine) + + +@pytest.fixture(scope="function") +def client(db_session): + app.dependency_overrides[get_db] = lambda: db_session + with TestClient(app) as test_client: + yield test_client + app.dependency_overrides.clear() + + +@pytest.fixture +def mock_user(): + from app.auth.jwt_handler import get_password_hash + + return User( + id=1, + email="test@example.com", + password_hash=get_password_hash("123456"), + name="Test User", + created_at=datetime(2024, 1, 15, 10, 0, 0), + updated_at=None, + ) + + +@pytest.fixture +def mock_movie(): + return Movie( + id=1, + title="Interstellar", + year=2014, + director="Christopher Nolan", + genre="Science Fiction", + synopsis="An astronaut travels through a wormhole", + duration=169, + rating=8.6, + created_at=datetime(2024, 1, 15, 10, 0, 0), + updated_at=None, + ) + + +@pytest.fixture +def token_jwt(mock_user): + return create_access_token(data={"sub": mock_user.email}) + + +@pytest.fixture +def headers_auth(mock_usuario): + token = create_access_token(data={"sub": mock_usuario.email}) + return {"Authorization": f"Bearer {token}"} + + +@pytest.fixture +def user_create_data(): + return {"email": "new@example.com", "password": "123456", "name": "New User"} + + +@pytest.fixture +def mock_usuario(): + from app.auth.jwt_handler import get_password_hash + + return User( + id=1, + email="test@example.com", + password_hash=get_password_hash("123456"), + name="Test User", + created_at=datetime(2024, 1, 15, 10, 0, 0), + updated_at=None, + ) + + +@pytest.fixture +def usuario_create_data(): + return {"email": "new@example.com", "password": "123456", "name": "New User"} + + +@pytest.fixture +def movie_create_data(): + return { + "title": "The Godfather", + "year": 1972, + "director": "Francis Ford Coppola", + "genre": "Drama", + "synopsis": "The story of the Corleone family", + "duration": 175, + "rating": 9.2, + } diff --git a/tests/test_auth.py b/tests/test_auth.py new file mode 100644 index 000000000..41a538b4e --- /dev/null +++ b/tests/test_auth.py @@ -0,0 +1,99 @@ +import pytest +from unittest.mock import patch, MagicMock +from fastapi import status +from app.models.usuario import User + + +class TestAuthRoutes: + + def test_register_success(self, client, usuario_create_data): + + response = client.post("/auth/register", json=usuario_create_data) + + assert response.status_code == status.HTTP_200_OK + data = response.json() + assert data["email"] == usuario_create_data["email"] + assert data["name"] == usuario_create_data["name"] + assert "id" in data + assert "password" not in data + + def test_register_duplicate_email(self, client, db_session, mock_usuario): + db_session.add(mock_usuario) + db_session.commit() + + response = client.post( + "/auth/register", + json={ + "email": mock_usuario.email, + "password": "123456", + "name": "Outro Usuário", + }, + ) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert "Email already registered" in response.json()["detail"] + + def test_register_invalid_email(self, client): + response = client.post( + "/auth/register", + json={"email": "email-invalido", "senha": "123456", "nome": "Usuário"}, + ) + + assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY + + def test_login_success(self, client, db_session, mock_usuario): + db_session.add(mock_usuario) + db_session.commit() + + response = client.post( + "/auth/login", json={"email": mock_usuario.email, "password": "123456"} + ) + + assert response.status_code == status.HTTP_200_OK + data = response.json() + assert "access_token" in data + assert data["token_type"] == "bearer" + + def test_login_invalid_credentials(self, client, db_session, mock_usuario): + db_session.add(mock_usuario) + db_session.commit() + + response = client.post( + "/auth/login", json={"email": mock_usuario.email, "password": "senha_errada"} + ) + + assert response.status_code == status.HTTP_401_UNAUTHORIZED + assert "Incorrect email or password" in response.json()["detail"] + + def test_login_user_not_found(self, client): + response = client.post( + "/auth/login", json={"email": "naoexiste@exemplo.com", "password": "123456"} + ) + + assert response.status_code == status.HTTP_401_UNAUTHORIZED + assert "Incorrect email or password" in response.json()["detail"] + + def test_get_current_user_success( + self, client, db_session, mock_usuario, headers_auth + ): + db_session.add(mock_usuario) + db_session.commit() + + response = client.get("/auth/me", headers=headers_auth) + + assert response.status_code == status.HTTP_200_OK + data = response.json() + assert data["email"] == mock_usuario.email + assert data["name"] == mock_usuario.name + + def test_get_current_user_invalid_token(self, client): + response = client.get( + "/auth/me", headers={"Authorization": "Bearer token_invalido"} + ) + + assert response.status_code == status.HTTP_401_UNAUTHORIZED + + def test_get_current_user_no_token(self, client): + response = client.get("/auth/me") + + assert response.status_code == status.HTTP_403_FORBIDDEN diff --git a/tests/test_auth_jwt.py b/tests/test_auth_jwt.py new file mode 100644 index 000000000..ac63b2563 --- /dev/null +++ b/tests/test_auth_jwt.py @@ -0,0 +1,152 @@ +import pytest +from unittest.mock import patch, MagicMock +from datetime import datetime, timedelta +from jose import jwt +from app.auth.jwt_handler import ( + create_access_token, + verify_token, + get_password_hash, + verify_password, + SECRET_KEY, + ALGORITHM, +) +from app.schemas.token import TokenData + + +class TestJWTHandler: + + def test_create_access_token(self): + data = {"sub": "teste@exemplo.com"} + + token = create_access_token(data=data) + + assert token is not None + assert isinstance(token, str) + + payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) + assert payload["sub"] == data["sub"] + + def test_create_access_token_with_expires(self): + data = {"sub": "teste@exemplo.com"} + expires_delta = timedelta(minutes=60) + + token = create_access_token(data=data, expires_delta=expires_delta) + + assert token is not None + + payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) + assert payload["sub"] == data["sub"] + + exp_timestamp = payload["exp"] + exp_datetime = datetime.utcfromtimestamp(exp_timestamp) + now = datetime.utcnow() + + time_diff = exp_datetime - now + assert 59 <= time_diff.total_seconds() / 60 <= 61 + + def test_verify_token_valid(self): + data = {"sub": "teste@exemplo.com"} + token = create_access_token(data=data) + + token_data = verify_token(token) + + assert token_data is not None + assert isinstance(token_data, TokenData) + assert token_data.email == data["sub"] + + def test_verify_token_invalid(self): + token = "token_invalido" + + token_data = verify_token(token) + + assert token_data is None + + def test_verify_token_expired(self): + data = { + "sub": "teste@exemplo.com", + "exp": datetime.utcnow() - timedelta(hours=1), + } + token = jwt.encode(data, SECRET_KEY, algorithm=ALGORITHM) + + token_data = verify_token(token) + + assert token_data is None + + def test_verify_token_wrong_secret(self): + data = {"sub": "teste@exemplo.com"} + token = jwt.encode(data, "wrong_secret", algorithm=ALGORITHM) + + token_data = verify_token(token) + + assert token_data is None + + def test_verify_token_no_subject(self): + data = {"user_id": 123} + token = jwt.encode(data, SECRET_KEY, algorithm=ALGORITHM) + + token_data = verify_token(token) + + assert token_data is None + + +class TestPasswordHandler: + + def test_get_password_hash(self): + password = "minha_senha_123" + + hashed = get_password_hash(password) + + assert hashed is not None + assert isinstance(hashed, str) + assert hashed != password + assert len(hashed) > len(password) + + def test_verify_password_correct(self): + password = "minha_senha_123" + hashed = get_password_hash(password) + + is_valid = verify_password(password, hashed) + + assert is_valid is True + + def test_verify_password_incorrect(self): + password = "minha_senha_123" + wrong_password = "senha_errada" + hashed = get_password_hash(password) + + is_valid = verify_password(wrong_password, hashed) + + assert is_valid is False + + def test_verify_password_empty(self): + password = "" + hashed = get_password_hash(password) + + is_valid = verify_password(password, hashed) + + assert is_valid is True + + def test_password_hash_consistency(self): + password = "minha_senha_123" + + hash1 = get_password_hash(password) + hash2 = get_password_hash(password) + + assert hash1 != hash2 + assert verify_password(password, hash1) is True + assert verify_password(password, hash2) is True + + +class TestTokenData: + + def test_token_data_creation(self): + email = "teste@exemplo.com" + + token_data = TokenData(email=email) + + assert token_data.email == email + + def test_token_data_optional_email(self): + token_data = TokenData(email=None) + + assert token_data.email is None diff --git a/tests/test_crud.py b/tests/test_crud.py new file mode 100644 index 000000000..c5949147f --- /dev/null +++ b/tests/test_crud.py @@ -0,0 +1,273 @@ +import pytest +from unittest.mock import patch, MagicMock +from sqlalchemy.orm import Session +from app.crud.filmes import CRUDMovie +from app.crud.usuarios import CRUDUser +from app.schemas.filme import MovieCreate, MovieUpdate +from app.schemas.usuario import UserCreate, UserUpdate +from app.models.filme import Movie +from app.models.usuario import User + + +class TestCRUDMovie: + + def test_create_movie(self, db_session): + crud = CRUDMovie() + movie_data = MovieCreate( + title="Test Movie", + year=2024, + director="Test Director", + genre="Action", + synopsis="Test Synopsis", + duration=120, + rating=8.5, + ) + + movie = crud.create(db_session, movie_data) + + assert movie.title == movie_data.title + assert movie.year == movie_data.year + assert movie.id is not None + + def test_get_movie(self, db_session, mock_movie): + crud = CRUDMovie() + db_session.add(mock_movie) + db_session.commit() + + movie = crud.get(db_session, mock_movie.id) + + assert movie is not None + assert movie.id == mock_movie.id + assert movie.title == mock_movie.title + + def test_get_movie_not_found(self, db_session): + crud = CRUDMovie() + + movie = crud.get(db_session, 999) + + assert movie is None + + def test_get_all_movies(self, db_session): + crud = CRUDMovie() + + movies = [] + for i in range(3): + movie = Movie( + title=f"Movie {i+1}", + year=2020 + i, + director=f"Director {i+1}", + genre="Action", + synopsis=f"Synopsis {i+1}", + duration=120, + rating=8.0, + ) + movies.append(movie) + db_session.add(movie) + db_session.commit() + + result = crud.get_all(db_session) + + assert len(result) == 3 + assert all(isinstance(m, Movie) for m in result) + + def test_get_all_movies_pagination(self, db_session): + crud = CRUDMovie() + + for i in range(5): + movie = Movie( + title=f"Movie {i+1}", + year=2020 + i, + director=f"Director {i+1}", + genre="Action", + synopsis=f"Synopsis {i+1}", + duration=120, + rating=8.0, + ) + db_session.add(movie) + db_session.commit() + + result = crud.get_all(db_session, limit=3) + assert len(result) == 3 + + result = crud.get_all(db_session, skip=2, limit=2) + assert len(result) == 2 + assert result[0].title == "Movie 3" + + def test_update_movie(self, db_session, mock_movie): + crud = CRUDMovie() + db_session.add(mock_movie) + db_session.commit() + + update_data = MovieUpdate(title="Updated Title", rating=9.0) + + movie = crud.update(db_session, mock_movie.id, update_data) + + assert movie is not None + assert movie.title == "Updated Title" + assert movie.rating == 9.0 + assert movie.year == mock_movie.year + + def test_update_movie_not_found(self, db_session): + crud = CRUDMovie() + update_data = MovieUpdate(title="New Title") + + movie = crud.update(db_session, 999, update_data) + + assert movie is None + + def test_delete_movie(self, db_session, mock_movie): + crud = CRUDMovie() + db_session.add(mock_movie) + db_session.commit() + + success = crud.delete(db_session, mock_movie.id) + + assert success is True + + movie = crud.get(db_session, mock_movie.id) + assert movie is None + + def test_delete_movie_not_found(self, db_session): + crud = CRUDMovie() + + success = crud.delete(db_session, 999) + + assert success is False + + def test_get_by_title(self, db_session, mock_movie): + crud = CRUDMovie() + db_session.add(mock_movie) + db_session.commit() + + movie = crud.get_by_title(db_session, mock_movie.title) + + assert movie is not None + assert movie.title == mock_movie.title + + def test_get_by_genre(self, db_session): + crud = CRUDMovie() + + movies = [ + Movie(title="Action 1", genre="Action", year=2020, director="Director 1"), + Movie(title="Action 2", genre="Action", year=2021, director="Director 2"), + Movie(title="Drama 1", genre="Drama", year=2022, director="Director 3"), + ] + for movie in movies: + db_session.add(movie) + db_session.commit() + + result = crud.get_by_genre(db_session, "Action") + + assert len(result) == 2 + assert all(m.genre == "Action" for m in result) + + +class TestCRUDUser: + + def test_create_user(self, db_session): + + crud = CRUDUser() + user_data = UserCreate( + email="test@example.com", password="123456", name="Test User" + ) + + user = crud.create(db_session, user_data) + + assert user.email == user_data.email + assert user.name == user_data.name + assert user.password_hash is not None + assert user.password_hash != user_data.password + + def test_get_user(self, db_session, mock_user): + crud = CRUDUser() + db_session.add(mock_user) + db_session.commit() + + user = crud.get(db_session, mock_user.id) + + assert user is not None + assert user.id == mock_user.id + assert user.email == mock_user.email + + def test_get_user_by_email(self, db_session, mock_user): + crud = CRUDUser() + db_session.add(mock_user) + db_session.commit() + + user = crud.get_by_email(db_session, mock_user.email) + + assert user is not None + assert user.email == mock_user.email + + def test_authenticate_success(self, db_session, mock_user): + crud = CRUDUser() + db_session.add(mock_user) + db_session.commit() + + saved_user = crud.get_by_email(db_session, mock_user.email) + assert saved_user is not None + + user = crud.authenticate(db_session, mock_user.email, "123456") + + assert user is not None + assert user.email == mock_user.email + + def test_authenticate_wrong_password(self, db_session, mock_user): + crud = CRUDUser() + db_session.add(mock_user) + db_session.commit() + + user = crud.authenticate(db_session, mock_user.email, "wrong_password") + + assert user is None + + def test_authenticate_user_not_found(self, db_session): + crud = CRUDUser() + + user = crud.authenticate(db_session, "nonexistent@example.com", "123456") + + assert user is None + + def test_update_user(self, db_session, mock_user): + crud = CRUDUser() + db_session.add(mock_user) + db_session.commit() + + update_data = UserUpdate( + name="Updated Name", email="updated@example.com" + ) + + user = crud.update(db_session, mock_user.id, update_data) + + assert user is not None + assert user.name == "Updated Name" + assert user.email == "updated@example.com" + + def test_update_user_password(self, db_session, mock_user): + crud = CRUDUser() + db_session.add(mock_user) + db_session.commit() + + update_data = UserUpdate(password="new_password_123") + + user = crud.update(db_session, mock_user.id, update_data) + + assert user is not None + + auth_user = crud.authenticate( + db_session, mock_user.email, "new_password_123" + ) + assert auth_user is not None + + def test_delete_user(self, db_session, mock_user): + + crud = CRUDUser() + db_session.add(mock_user) + db_session.commit() + + success = crud.delete(db_session, mock_user.id) + + assert success is True + + user = crud.get(db_session, mock_user.id) + assert user is None diff --git a/tests/test_filmes.py b/tests/test_filmes.py new file mode 100644 index 000000000..6833db2b8 --- /dev/null +++ b/tests/test_filmes.py @@ -0,0 +1,181 @@ +import pytest +from unittest.mock import patch, MagicMock +from fastapi import status +from app.models.filme import Movie + + +class TestMoviesRoutes: + + def test_get_movies_empty(self, client): + response = client.get("/movies") + + assert response.status_code == status.HTTP_200_OK + data = response.json() + assert data == [] + + def test_get_movies_with_data(self, client, db_session, mock_movie): + db_session.add(mock_movie) + db_session.commit() + + response = client.get("/movies") + + assert response.status_code == status.HTTP_200_OK + data = response.json() + assert len(data) == 1 + assert data[0]["title"] == mock_movie.title + assert data[0]["year"] == mock_movie.year + + def test_get_movies_pagination(self, client, db_session): + movies = [] + for i in range(5): + movie = Movie( + id=i + 1, + title=f"Movie {i+1}", + year=2020 + i, + director=f"Director {i+1}", + genre="Action", + synopsis=f"Synopsis {i+1}", + duration=120, + rating=8.0, + ) + movies.append(movie) + db_session.add(movie) + db_session.commit() + + response = client.get("/movies?limit=3") + assert response.status_code == status.HTTP_200_OK + data = response.json() + assert len(data) == 3 + + response = client.get("/movies?skip=2&limit=2") + assert response.status_code == status.HTTP_200_OK + data = response.json() + assert len(data) == 2 + assert data[0]["title"] == "Movie 3" + + def test_get_movie_by_id_success(self, client, db_session, mock_movie): + db_session.add(mock_movie) + db_session.commit() + + response = client.get(f"/movies/{mock_movie.id}") + + assert response.status_code == status.HTTP_200_OK + data = response.json() + assert data["id"] == mock_movie.id + assert data["title"] == mock_movie.title + + def test_get_movie_by_id_not_found(self, client): + response = client.get("/movies/999") + + assert response.status_code == status.HTTP_404_NOT_FOUND + assert "Movie not found" in response.json()["detail"] + + def test_create_movie_success( + self, client, db_session, mock_user, headers_auth, movie_create_data + ): + db_session.add(mock_user) + db_session.commit() + + response = client.post("/movies", json=movie_create_data, headers=headers_auth) + + assert response.status_code == status.HTTP_200_OK + data = response.json() + assert data["title"] == movie_create_data["title"] + assert data["year"] == movie_create_data["year"] + assert "id" in data + + def test_create_movie_unauthorized(self, client, movie_create_data): + response = client.post("/movies", json=movie_create_data) + + assert response.status_code == status.HTTP_403_FORBIDDEN + + def test_create_movie_invalid_data( + self, client, db_session, mock_user, headers_auth + ): + db_session.add(mock_user) + db_session.commit() + + response = client.post( + "/movies", + json={"title": "", "year": "year_invalid"}, + headers=headers_auth, + ) + + assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY + + def test_update_movie_success( + self, client, db_session, mock_movie, mock_user, headers_auth + ): + + db_session.add(mock_movie) + db_session.add(mock_user) + db_session.commit() + + update_data = {"title": "Interstellar (Updated)", "rating": 9.0} + + response = client.put( + f"/movies/{mock_movie.id}", json=update_data, headers=headers_auth + ) + + assert response.status_code == status.HTTP_200_OK + data = response.json() + assert data["title"] == update_data["title"] + assert data["rating"] == update_data["rating"] + + def test_update_movie_not_found( + self, client, db_session, mock_user, headers_auth + ): + + db_session.add(mock_user) + db_session.commit() + + response = client.put( + "/movies/999", json={"title": "New Title"}, headers=headers_auth + ) + + assert response.status_code == status.HTTP_404_NOT_FOUND + assert "Movie not found" in response.json()["detail"] + + def test_update_movie_unauthorized(self, client, db_session, mock_movie): + db_session.add(mock_movie) + db_session.commit() + + response = client.put( + f"/movies/{mock_movie.id}", json={"title": "New Title"} + ) + + assert response.status_code == status.HTTP_403_FORBIDDEN + + def test_delete_movie_success( + self, client, db_session, mock_movie, mock_user, headers_auth + ): + db_session.add(mock_movie) + db_session.add(mock_user) + db_session.commit() + + response = client.delete(f"/movies/{mock_movie.id}", headers=headers_auth) + + assert response.status_code == status.HTTP_200_OK + assert "Movie removed successfully" in response.json()["message"] + + get_response = client.get(f"/movies/{mock_movie.id}") + assert get_response.status_code == status.HTTP_404_NOT_FOUND + + def test_delete_movie_not_found( + self, client, db_session, mock_user, headers_auth + ): + db_session.add(mock_user) + db_session.commit() + + response = client.delete("/movies/999", headers=headers_auth) + + assert response.status_code == status.HTTP_404_NOT_FOUND + assert "Movie not found" in response.json()["detail"] + + def test_delete_movie_unauthorized(self, client, db_session, mock_movie): + db_session.add(mock_movie) + db_session.commit() + + response = client.delete(f"/movies/{mock_movie.id}") + + assert response.status_code == status.HTTP_403_FORBIDDEN diff --git a/tests/test_usuarios.py b/tests/test_usuarios.py new file mode 100644 index 000000000..15c2afc67 --- /dev/null +++ b/tests/test_usuarios.py @@ -0,0 +1,225 @@ +import pytest +from unittest.mock import patch, MagicMock +from fastapi import status +from app.models.usuario import User + + +class TestUsuariosRoutes: + + def test_get_usuarios_empty(self, client, db_session, mock_usuario, headers_auth): + db_session.add(mock_usuario) + db_session.commit() + + response = client.get("/users", headers=headers_auth) + + assert response.status_code == status.HTTP_200_OK + data = response.json() + assert len(data) == 1 + + def test_get_usuarios_with_data( + self, client, db_session, mock_usuario, headers_auth + ): + db_session.add(mock_usuario) + + outro_usuario = User( + id=2, + email="outro@exemplo.com", + password_hash="hash_senha", + name="Outro Usuário", + ) + db_session.add(outro_usuario) + db_session.commit() + + response = client.get("/users", headers=headers_auth) + + assert response.status_code == status.HTTP_200_OK + data = response.json() + assert len(data) == 2 + + def test_get_usuarios_unauthorized(self, client): + response = client.get("/users") + + assert response.status_code == status.HTTP_403_FORBIDDEN + + def test_get_usuario_by_id_success( + self, client, db_session, mock_usuario, headers_auth + ): + db_session.add(mock_usuario) + db_session.commit() + + response = client.get(f"/users/{mock_usuario.id}", headers=headers_auth) + + assert response.status_code == status.HTTP_200_OK + data = response.json() + assert data["id"] == mock_usuario.id + assert data["email"] == mock_usuario.email + + def test_get_usuario_by_id_not_found( + self, client, db_session, mock_usuario, headers_auth + ): + db_session.add(mock_usuario) + db_session.commit() + + response = client.get("/users/999", headers=headers_auth) + + assert response.status_code == status.HTTP_404_NOT_FOUND + assert "User not found" in response.json()["detail"] + + def test_get_usuario_by_id_unauthorized(self, client): + response = client.get("/users/1") + + assert response.status_code == status.HTTP_403_FORBIDDEN + + def test_update_usuario_success( + self, client, db_session, mock_usuario, headers_auth + ): + db_session.add(mock_usuario) + db_session.commit() + + update_data = {"name": "Usuário Atualizado", "email": "atualizado@exemplo.com"} + + response = client.put( + f"/users/{mock_usuario.id}", json=update_data, headers=headers_auth + ) + + assert response.status_code == status.HTTP_200_OK + data = response.json() + assert data["name"] == update_data["name"] + assert data["email"] == update_data["email"] + + def test_update_usuario_own_profile_only( + self, client, db_session, mock_usuario, headers_auth + ): + db_session.add(mock_usuario) + + outro_usuario = User( + id=2, + email="outro@exemplo.com", + password_hash="hash_senha", + name="Outro Usuário", + ) + db_session.add(outro_usuario) + db_session.commit() + + response = client.put( + "/users/2", json={"name": "Hackeado"}, headers=headers_auth + ) + + assert response.status_code == status.HTTP_403_FORBIDDEN + assert "You can only update your own profile" in response.json()["detail"] + + def test_update_usuario_duplicate_email( + self, client, db_session, mock_usuario, headers_auth + ): + db_session.add(mock_usuario) + + outro_usuario = User( + id=2, + email="outro@exemplo.com", + password_hash="hash_senha", + name="Outro Usuário", + ) + db_session.add(outro_usuario) + db_session.commit() + + response = client.put( + f"/users/{mock_usuario.id}", + json={"email": outro_usuario.email}, + headers=headers_auth, + ) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert "Email already registered" in response.json()["detail"] + + def test_update_usuario_password( + self, client, db_session, mock_usuario, headers_auth + ): + + db_session.add(mock_usuario) + db_session.commit() + + update_data = {"password": "nova_senha_123"} + + response = client.put( + f"/users/{mock_usuario.id}", json=update_data, headers=headers_auth + ) + + assert response.status_code == status.HTTP_200_OK + login_response = client.post( + "/auth/login", + json={"email": mock_usuario.email, "password": "nova_senha_123"}, + ) + assert login_response.status_code == status.HTTP_200_OK + + def test_update_usuario_not_found( + self, client, db_session, mock_usuario, headers_auth + ): + db_session.add(mock_usuario) + db_session.commit() + + response = client.put( + "/users/999", json={"name": "Novo Nome"}, headers=headers_auth + ) + + assert response.status_code == status.HTTP_403_FORBIDDEN + assert "You can only update your own profile" in response.json()["detail"] + + def test_update_usuario_unauthorized(self, client, db_session, mock_usuario): + db_session.add(mock_usuario) + db_session.commit() + + response = client.put(f"/users/{mock_usuario.id}", json={"name": "Novo Nome"}) + + assert response.status_code == status.HTTP_403_FORBIDDEN + + def test_delete_usuario_success( + self, client, db_session, mock_usuario, headers_auth + ): + db_session.add(mock_usuario) + db_session.commit() + + response = client.delete(f"/users/{mock_usuario.id}", headers=headers_auth) + + assert response.status_code == status.HTTP_200_OK + assert "User removed successfully" in response.json()["message"] + + get_response = client.get(f"/users/{mock_usuario.id}", headers=headers_auth) + assert get_response.status_code == status.HTTP_401_UNAUTHORIZED + + def test_delete_usuario_own_profile_only( + self, client, db_session, mock_usuario, headers_auth + ): + db_session.add(mock_usuario) + + outro_usuario = User( + id=2, + email="outro@exemplo.com", + password_hash="hash_senha", + name="Outro Usuário", + ) + db_session.add(outro_usuario) + db_session.commit() + + response = client.delete("/users/2", headers=headers_auth) + + assert response.status_code == status.HTTP_403_FORBIDDEN + assert "You can only delete your own profile" in response.json()["detail"] + + def test_delete_usuario_not_found( + self, client, db_session, mock_usuario, headers_auth + ): + db_session.add(mock_usuario) + db_session.commit() + + response = client.delete("/users/999", headers=headers_auth) + + assert response.status_code == status.HTTP_403_FORBIDDEN + assert "You can only delete your own profile" in response.json()["detail"] + + def test_delete_usuario_unauthorized(self, client, db_session, mock_usuario): + db_session.add(mock_usuario) + db_session.commit() + + response = client.delete(f"/users/{mock_usuario.id}") + + assert response.status_code == status.HTTP_403_FORBIDDEN