commit 6ffc5c9ef619b1ca78c5a9f7e1acada89bd3b16f Author: Antero Júnior Date: Wed Mar 5 21:35:50 2025 -0400 Commit Inicial diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1800114 --- /dev/null +++ b/.gitignore @@ -0,0 +1,174 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# UV +# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +#uv.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/latest/usage/project/#working-with-version-control +.pdm.toml +.pdm-python +.pdm-build/ + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ + +# Ruff stuff: +.ruff_cache/ + +# PyPI configuration file +.pypirc \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..5d38e3e --- /dev/null +++ b/README.md @@ -0,0 +1,166 @@ +Claro! Aqui está um exemplo de **README** para o seu projeto, cobrindo a criação do **virtual environment (venv)**, instalação de dependências, execução do **Flasgger** para documentação e execução de **testes**: + +--- + +# Projeto de Testes com SQLAlchemy, Factory Boy e Flask + +Este é um projeto simples de teste utilizando **SQLAlchemy**, **Factory Boy** para a criação de dados e **Flasgger** para a documentação de APIs. O projeto inclui exemplos de como configurar o ambiente de desenvolvimento, realizar testes e gerar documentação interativa para suas APIs. + +## Índice + +- [Pré-requisitos](#pré-requisitos) +- [Instalação](#instalação) +- [Uso](#uso) + - [Rodar Flasgger (Documentação da API)](#rodar-flasgger-documentação-da-api) + - [Rodar Testes Unitários](#rodar-testes-unitários) +- [Estrutura de Diretórios](#estrutura-de-diretórios) + +## Pré-requisitos + +Antes de começar, você precisa ter o **Python** instalado na sua máquina. Você pode verificar se o Python está instalado executando o seguinte comando: + +```sh +python --version +``` + +Se você não tiver o Python instalado, pode obter a versão mais recente [aqui](https://www.python.org/downloads/). + +Além disso, é necessário ter o **pip** (gerenciador de pacotes do Python) instalado. Você pode verificar isso com: + +```sh +pip --version +``` + +## Instalação + +1. **Crie um ambiente virtual (virtualenv):** + + Para isolar as dependências do seu projeto, é recomendável criar um **ambiente virtual**. Navegue até o diretório do projeto e execute: + + ```sh + python -m venv venv + ``` + +2. **Ative o ambiente virtual:** + + - **No Windows:** + + ```sh + venv\Scripts\activate + ``` + + - **No macOS/Linux:** + + ```sh + source venv/bin/activate + ``` + +3. **Instale as dependências:** + + Após ativar o ambiente virtual, instale as dependências necessárias listadas no `requirements.txt`: + + ```sh + pip install -r requirements.txt + ``` + +4. **Requisitos (requirements.txt)**: + + Certifique-se de que o arquivo `requirements.txt` contenha as bibliotecas necessárias: + + ```txt + Flask + Flask-SQLAlchemy + Flask-Flasgger + factory_boy + pytest + ``` + +## Uso + +### Rodar Flasgger (Documentação da API) + +Este projeto utiliza o **Flasgger** para gerar uma interface de documentação interativa para as APIs Flask. Para visualizar a documentação da sua API: + +1. **Inicie o servidor Flask:** + + Certifique-se de que o ambiente virtual está ativado e execute o seguinte comando: + + ```sh + flask run + ``` + +2. **Abra a documentação no navegador:** + + Depois que o servidor estiver em execução, você pode acessar a documentação da API através do seguinte endereço: + + ``` + http://127.0.0.1:5000/apidocs/ + ``` + + A interface do **Flasgger** permitirá que você veja as rotas, envie requisições e veja as respostas de forma interativa. + +### Rodar Testes Unitários + +Este projeto usa o **pytest** para rodar testes unitários. Os testes estão localizados na pasta `tests/`. + +Para rodar os testes, execute: + +```sh +pytest -v +``` + +Isso irá rodar todos os testes dentro da pasta `tests/`. Você verá a saída com os resultados dos testes diretamente no terminal. + +#### Exemplo de execução de teste unitário: + +```sh +tests/test_invoices.py::test_get_invoices +``` + +### Rodar Testes Rápidos (Sem Flask) + +Se você quiser rodar testes rápidos sem o Flask (usando apenas SQLAlchemy e Factory Boy), pode executar o seguinte código diretamente, sem iniciar o servidor Flask. Apenas instancie o banco em memória e rode a fábrica. + +```python +# Executar a criação de usuários sem o Flask (em memória) +from app.database import db +from tests.factories import UserFactory + +# Configurar banco de dados em memória +engine = create_engine('sqlite:///:memory:') +Base.metadata.create_all(engine) + +# Criar um usuário usando a factory +user = UserFactory.create(name="João", email="joao@example.com") +print(user.name, user.email) +``` + +Este código irá criar um banco de dados SQLite temporário em memória e usar o **Factory Boy** para gerar dados de teste rapidamente. + +## Estrutura de Diretórios + +A estrutura do projeto segue a seguinte organização: + +``` +/project-root + ├── /app + │ ├── /__init__.py + │ ├── /models.py + │ ├── /routes.py + │ └── /database.py + ├── /tests + │ ├── /__init__.py + │ ├── /test_invoices.py + │ └── /factories.py + ├── requirements.txt + ├── run.py + ├── /venv + └── README.md +``` + +- **/app**: Contém a lógica da aplicação Flask (modelos, rotas, configuração do banco de dados). +- **/tests**: Contém os testes unitários, como `test_invoices.py`. +- **requirements.txt**: Arquivo que contém todas as dependências do projeto. +- **run.py**: Script para iniciar o servidor Flask. +- **venv/**: Diretório do ambiente virtual. + diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..5299b5b --- /dev/null +++ b/app/__init__.py @@ -0,0 +1,58 @@ +from flask import Flask +from flasgger import Swagger +from app.database import db +from app.models.user import User +from app.routes.user_routes import user_bp +from app.routes.invoice_routes import invoice_bp +from werkzeug.security import generate_password_hash + +def create_default_user(): + # Verifica se o banco de dados está vazio + user = User.query.first() + + if not user: + # Cria um usuário padrão com senha "senha123" + default_user = User( + name="admin", + email="admin@example.com", + password=generate_password_hash("senha123", method="pbkdf2:sha256") + ) + + # Adiciona o usuário no banco de dados + db.session.add(default_user) + db.session.commit() + + print("Usuário padrão criado com sucesso!") + + +def create_app(): + app = Flask(__name__) + app.config.from_object("config") + + db.init_app(app) + + + # Inicializa o Flasgger com a definição de segurança para autenticação JWT + swagger = Swagger(app, + template={ + "securityDefinitions": { + "BearerAuth": { + "type": "apiKey", + "in": "header", + "name": "Authorization", + "description": "Digite 'Bearer '" + } + }, + "security": [ + { + "BearerAuth": [] + } + ] + }) + + + # Registrar Blueprints + app.register_blueprint(user_bp, url_prefix="/users") + app.register_blueprint(invoice_bp, url_prefix="/invoices") + + return app diff --git a/app/auth.py b/app/auth.py new file mode 100644 index 0000000..987c635 --- /dev/null +++ b/app/auth.py @@ -0,0 +1,63 @@ +import jwt +import datetime +from functools import wraps +from flask import request, jsonify +from app.models.user import User +from config import TOKEN_EXPIRATION_TIME as token_time +from config import SECRET_KEY + +def generate_token(user): + """ + Gera um token JWT para o usuário + """ + expiration_time = datetime.datetime.utcnow() + datetime.timedelta(hours=token_time) + token = jwt.encode( + {"id": user.id, "email": user.email, "exp": expiration_time}, + SECRET_KEY, + algorithm="HS256" + ) + return token + +def decode_token(token): + """ + Decodifica um token JWT e retorna os dados do usuário + """ + try: + decoded = jwt.decode(token, SECRET_KEY, algorithms=["HS256"]) + return decoded + except jwt.ExpiredSignatureError: + return None + except jwt.InvalidTokenError: + return None + +def token_required(f): + """ + Decorator para proteger as rotas, exigindo um token válido. + """ + @wraps(f) + def decorated_function(*args, **kwargs): + token = None + + # Verifica se o token está no cabeçalho da requisição + if "Authorization" in request.headers: + token = request.headers["Authorization"].split(" ")[1] # Acessa apenas o token após "Bearer" + + if not token: + return jsonify({"message": "Token é necessário!"}), 401 + + # Decodifica o token + data = decode_token(token) + + if not data: + return jsonify({"message": "Token inválido ou expirado!"}), 401 + + # Recupera o usuário do banco de dados + user = User.query.get(data["id"]) + if not user: + return jsonify({"message": "Usuário não encontrado!"}), 404 + + # Passa o usuário para a função protegida + return f(user, *args, **kwargs) + + return decorated_function + diff --git a/app/database.py b/app/database.py new file mode 100644 index 0000000..2e1eeb6 --- /dev/null +++ b/app/database.py @@ -0,0 +1,3 @@ +from flask_sqlalchemy import SQLAlchemy + +db = SQLAlchemy() \ No newline at end of file diff --git a/app/docs/__init__.py b/app/docs/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/docs/invoice_docs.py b/app/docs/invoice_docs.py new file mode 100644 index 0000000..4c4771d --- /dev/null +++ b/app/docs/invoice_docs.py @@ -0,0 +1,14 @@ +invoice_get_doc = { + "tags": ["Invoices"], + "summary": "Lista todas as faturas", + "responses": { + "200": { + "description": "Lista de faturas", + "examples": { + "application/json": [ + {"id": 1, "amount": 100.50, "user_id": 1} + ] + } + } + } +} diff --git a/app/docs/user_docs.py b/app/docs/user_docs.py new file mode 100644 index 0000000..72b3213 --- /dev/null +++ b/app/docs/user_docs.py @@ -0,0 +1,116 @@ +user_get_doc = { + "tags": ["Users"], + "summary": "Lista todos os usuários", + "responses": { + "200": { + "description": "Lista de usuários", + "examples": { + "application/json": [ + {"id": 1, "name": "João", "email": "joao@email.com"} + ] + } + } + } +} + +user_post_doc = { + "tags": ["Usuários"], + "description": "Cria um novo usuário no sistema com senha", + "parameters": [ + { + "name": "user", + "in": "body", + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Nome do usuário", + "example": "João Silva" + }, + "email": { + "type": "string", + "description": "Email do usuário", + "example": "joao.silva@example.com" + }, + "password": { + "type": "string", + "description": "Senha do usuário", + "example": "senha123" + } + }, + "required": ["name", "email", "password"] + } + ], + "responses": { + "201": { + "description": "Usuário criado com sucesso", + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string", + "example": "Usuário registrado com sucesso!" + } + } + } + }, + "400": { + "description": "Erro na validação dos dados", + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string", + "example": "Usuário já existe!" + } + } + } + } + } +} + +login_post_doc = { + "tags": ["Usuários"], + "description": "Rota para login de usuário. Retorna um token JWT.", + "parameters": [ + { + "name": "user", + "in": "body", + "type": "object", + "properties": { + "email": { + "type": "string", + "description": "Email do usuário", + "example": "usuario@example.com" + }, + "password": { + "type": "string", + "description": "Senha do usuário", + "example": "senha123" + } + }, + "required": ["email", "password"] + } + ], + "responses": { + "200": { + "description": "Login bem-sucedido", + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string", + "example": "Login bem-sucedido" + }, + "token": { + "type": "string", + "example": "seu_token_jwt_aqui" + } + } + } + }, + "401": { + "description": "Credenciais inválidas" + } + } +} diff --git a/app/models/__init__.py b/app/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/models/invoice.py b/app/models/invoice.py new file mode 100644 index 0000000..34ed24a --- /dev/null +++ b/app/models/invoice.py @@ -0,0 +1,6 @@ +from app.database import db + +class Invoice(db.Model): + id = db.Column(db.Integer, primary_key=True) + amount = db.Column(db.Float, nullable=False) + user_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False) #Chave estrangeira diff --git a/app/models/user.py b/app/models/user.py new file mode 100644 index 0000000..df1433f --- /dev/null +++ b/app/models/user.py @@ -0,0 +1,11 @@ +from app.database import db + +class User(db.Model): + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(100), nullable=False) + email = db.Column(db.String(100), unique=True, nullable=False) + password = db.Column(db.String(200), nullable=False) + invoices = db.relationship("Invoice", backref="user", lazy=True) #Chave estrangeira + + def __repr__(self): + return f'' \ No newline at end of file diff --git a/app/routes/__init__.py b/app/routes/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/routes/invoice_routes.py b/app/routes/invoice_routes.py new file mode 100644 index 0000000..b5f10b6 --- /dev/null +++ b/app/routes/invoice_routes.py @@ -0,0 +1,15 @@ +from flask import Blueprint, jsonify +from app.auth import token_required +from app.models.invoice import Invoice +from app.database import db +from app.docs.invoice_docs import invoice_get_doc +from flasgger import swag_from + +invoice_bp = Blueprint("invoices", __name__) + +@invoice_bp.route("/", methods=["GET"]) +@token_required # Protege a rota +@swag_from(invoice_get_doc) +def get_invoices(): + invoices = Invoice.query.all() # select * from Invoice + return jsonify([{"id": i.id, "amount": i.amount, "user_id": i.user_id} for i in invoices]) diff --git a/app/routes/user_routes.py b/app/routes/user_routes.py new file mode 100644 index 0000000..8f6843b --- /dev/null +++ b/app/routes/user_routes.py @@ -0,0 +1,61 @@ +from flask import Blueprint, jsonify, request +from app.auth import generate_token, token_required +from app.models.user import User +from app.database import db +from werkzeug.security import generate_password_hash, check_password_hash +from app.docs.user_docs import user_get_doc, user_post_doc, login_post_doc +from flasgger import swag_from + +user_bp = Blueprint("users", __name__) + +@user_bp.route("/", methods=["GET"]) +@swag_from(user_get_doc) +@token_required # Protege a rota +def get_users(user): + users = User.query.all() # select * from User + return jsonify([{"id": u.id, "name": u.name, "email": u.email} for u in users]) + +@user_bp.route("/", methods=["POST"]) +@swag_from(user_post_doc) +@token_required # Protege a rota +def create_user(): + data = request.get_json() + + name = data.get("name") + email = data.get("email") + password = data.get("password") + + # Verificar se o usuário já existe + if User.query.filter_by(email=email).first(): + return jsonify({"message": "Usuário já existe!"}), 400 + + # Gerar senha criptografada + hashed_password = generate_password_hash(password, method="pbkdf2:sha256") + + # Criar novo usuário + new_user = User(name=name, email=email, password=hashed_password) + + # Adicionar e salvar no banco de dados + db.session.add(new_user) + db.session.commit() + + return jsonify({"message": "Usuário registrado com sucesso!"}), 201 + +# Rota para login de usuário +@user_bp.route("/login", methods=["POST"]) +@swag_from(login_post_doc) +def login_user(): + data = request.get_json() + + email = data.get("email") + password = data.get("password") + + user = User.query.filter_by(email=email).first() + + if not user or not check_password_hash(user.password, password): + return jsonify({"message": "Credenciais inválidas!"}), 401 + + token = generate_token(user) + + return jsonify({"message": "Login bem-sucedido", "token": token}), 200 + diff --git a/config.py b/config.py new file mode 100644 index 0000000..9acc84e --- /dev/null +++ b/config.py @@ -0,0 +1,6 @@ +import os + +SQLALCHEMY_DATABASE_URI = "sqlite:///database.db" +SQLALCHEMY_TRACK_MODIFICATIONS = False +TOKEN_EXPIRATION_TIME = 1 +SECRET_KEY = "sua_chave_secreta" # Altere isso para algo mais seguro! diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..9e4be8f --- /dev/null +++ b/requirements.txt @@ -0,0 +1,28 @@ +attrs==25.1.0 +blinker==1.9.0 +click==8.1.8 +factory_boy==3.3.3 +Faker==36.2.2 +flasgger==0.9.7.1 +Flask==3.1.0 +Flask-SQLAlchemy==3.1.1 +Flask-Testing==0.8.1 +greenlet==3.1.1 +iniconfig==2.0.0 +itsdangerous==2.2.0 +Jinja2==3.1.6 +jsonschema==4.23.0 +jsonschema-specifications==2024.10.1 +MarkupSafe==3.0.2 +mistune==3.1.2 +packaging==24.2 +pluggy==1.5.0 +pytest==8.3.5 +PyYAML==6.0.2 +referencing==0.36.2 +rpds-py==0.23.1 +six==1.17.0 +SQLAlchemy==2.0.38 +typing_extensions==4.12.2 +tzdata==2025.1 +Werkzeug==3.1.3 diff --git a/run.py b/run.py new file mode 100644 index 0000000..4b54c37 --- /dev/null +++ b/run.py @@ -0,0 +1,13 @@ +from app import create_app, create_default_user +from app.database import db +from flasgger import Swagger + +app = create_app() + +if __name__ == "__main__": + with app.app_context(): + db.create_all() + create_default_user() + + print("Banco de dados criado com sucesso!") + app.run(debug=True) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..6f7af32 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,30 @@ +import pytest +from app import create_app +from app.database import db + +@pytest.fixture +def app(): + """Cria uma instância do Flask para testes""" + app = create_app() + app.config.update({ + "TESTING": True, + "SQLALCHEMY_DATABASE_URI": "sqlite:///:memory:", # Banco em memória para testes rápidos + "SQLALCHEMY_TRACK_MODIFICATIONS": False + }) + + with app.app_context(): + db.create_all() # Criar tabelas no banco de testes + yield app # Executa os testes + db.session.remove() + db.drop_all() # Limpa o banco após os testes + +@pytest.fixture +def client(app): + """Cliente de testes do Flask""" + return app.test_client() + +@pytest.fixture +def db_session(app): + """Sessão do banco de dados para os testes""" + with app.app_context(): + yield db.session diff --git a/tests/factories.py b/tests/factories.py new file mode 100644 index 0000000..bd4d731 --- /dev/null +++ b/tests/factories.py @@ -0,0 +1,28 @@ +import factory +from faker import Faker +from app.models.user import User +from app.models.invoice import Invoice +from app.database import db + +# Configurar Faker para usar dados do Brasil +faker = Faker("pt_BR") + +class UserFactory(factory.alchemy.SQLAlchemyModelFactory): + class Meta: + model = User + sqlalchemy_session = db.session + sqlalchemy_session_persistence = "commit" + + id = factory.Sequence(lambda n: n + 1) + name = factory.LazyAttribute(lambda _: faker.name()) # Nome em pt_BR + email = factory.LazyAttribute(lambda _: faker.email()) # Email realista em pt_BR + +class InvoiceFactory(factory.alchemy.SQLAlchemyModelFactory): + class Meta: + model = Invoice + sqlalchemy_session = db.session + sqlalchemy_session_persistence = "commit" + + id = factory.Sequence(lambda n: n + 1) + amount = factory.Faker("pydecimal", left_digits=4, right_digits=2, positive=True, locale="pt_BR") + user = factory.SubFactory(UserFactory) # Relacionamento com um usuário diff --git a/tests/test_invoices.py b/tests/test_invoices.py new file mode 100644 index 0000000..efacbce --- /dev/null +++ b/tests/test_invoices.py @@ -0,0 +1,11 @@ +from tests.factories import InvoiceFactory + +def test_get_invoices(client, db_session): + """Testa a listagem de faturas""" + InvoiceFactory.create_batch(2) # Cria 2 faturas fictícias + db_session.commit() + + response = client.get("/invoices/") + assert response.status_code == 200 + data = response.get_json() + assert len(data) == 2 diff --git a/tests/test_users.py b/tests/test_users.py new file mode 100644 index 0000000..13c1025 --- /dev/null +++ b/tests/test_users.py @@ -0,0 +1,18 @@ +from tests.factories import UserFactory + +def test_create_user(client): + """Testa a criação de um usuário via API""" + response = client.post("/users/", json={"name": "Teste", "email": "teste@email.com"}) + assert response.status_code == 201 + data = response.get_json() + assert data["message"] == "Usuário criado com sucesso!" + +def test_get_users(client, db_session): + """Testa a listagem de usuários""" + UserFactory.create_batch(3) # Cria 3 usuários fictícios + db_session.commit() + + response = client.get("/users/") + assert response.status_code == 200 + data = response.get_json() + assert len(data) == 3