Skip to content

Commit

Permalink
Adicionando verificação de integridade no endpoint de put
Browse files Browse the repository at this point in the history
Isso altera significativamente todas as aulas após a 5, pois esse endpoint muda bastante

closes #232
  • Loading branch information
dunossauro committed Oct 3, 2024
1 parent 0043b1e commit d1f42f0
Show file tree
Hide file tree
Showing 20 changed files with 528 additions and 65 deletions.
125 changes: 125 additions & 0 deletions aulas/05.md
Original file line number Diff line number Diff line change
Expand Up @@ -535,6 +535,131 @@ def test_update_user(client, user):
}
```

### O caso do conflito

Embora pareça que está tudo certo, o teste está sendo executado com sucesso. Porém, existe um caso que não foi pensado nesse update. Alguns dados no nosso modelo (`username` e `email`) estão marcados como `unique` na base de dados. O que pode ocasionar um erro em potencial, caso alguém altere esses valores para um valor já existente.

Por exemplo, imagine que duas pessoas se cadastraram na nossa aplicação. Uma com `#!py {'username': 'faustino'}` e outra com `#!py {'username': 'dunossauro'}`. Até esse momento, não teríamos nenhum problema.

Mas o que aconteceria se fausto fizesse um update e quisesse se chamar dunossauro?

Vamos iniciar a escrita de um cenário de testes que contemple isso para ficar mais claro:

```python title="tests/test_app.py" hl_lines="1"
def test_update_integrity_error(client, user):
# Inserindo fausto
client.post(
'/users',
json={
'username': 'fausto',
'email': '[email protected]',
'password': 'secret',
},
)

# Alterando o user das fixture para fausto
response_update = client.put(
f'/users/{user.id}',
json={
'username': 'fausto',
'email': '[email protected]',
'password': 'mynewpassword',
},
)
```

Mesmo sem escrever nenhum `#!py assert` nesse teste, se executarmos o código, ele falhará:


```shell title="$ Execução no terminal!" hl_lines="12 13"
task test

# ...

tests/test_app.py::test_root_deve_retornar_ok_e_ola_mundo PASSED
tests/test_app.py::test_create_user PASSED
tests/test_app.py::test_read_users PASSED
tests/test_app.py::test_read_users_with_users PASSED
tests/test_app.py::test_update_user FAILED

================================ short test summary info =================================
FAILED tests/test_app.py::test_update_integrity_error - sqlalchemy.exc.IntegrityError:
(sqlite3.IntegrityError) UNIQUE constraint failed: users.username
[SQL: UPDATE users SET username=?, password=?, email=? WHERE users.id = ?]
[parameters: ('fausto', 'mynewpassword', '[email protected]', 1)]
(Background on this error at: https://sqlalche.me/e/20/gkpj)
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! stopping after 1 failures !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
```

O erro foi iniciado pelo sqlalchemy. Como podemos constatar na mensagem de erro: `#!python sqlalchemy.exc.IntegrityError: (sqlite3.IntegrityError) UNIQUE constraint failed: users.username.`

Traduzindo de forma literal, ele disse que temos um problema de integridade: `falha na restrição UNIQUE: users.username`. Isso acontece, pois temos a restrição UNIQUE no campo `username` da tabela `users`. Quando adicionamos o mesmo nome a um registro que já existia, causamos um erro de integridade.

Uma forma de evitar o erro é contando com a possibilidade de que ele aconteça. Para isso, poderíamos criar um fluxo esperando essa exceção no endpoint. Algo como:


```python title="fast_zero/app.py" hl_lines="1 25-29"
from sqlalchemy.exc import IntegrityError

# ...

@app.put('/users/{user_id}', response_model=UserPublic)
def update_user(
user_id: int, user: UserSchema, session: Session = Depends(get_session)
):

db_user = session.scalar(select(User).where(User.id == user_id))
if not db_user:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail='User not found'
)

try:
db_user.username = user.username
db_user.password = user.password
db_user.email = user.email
session.commit()
session.refresh(db_user)

return db_user

except IntegrityError: #(1)!
raise HTTPException(
status_code=HTTPStatus.CONFLICT, #(2)!
detail='Username or Email already exists'
)
```

1. Esse erro será levantado no caso a instrução de `session.commit()` não conseguir efetuar a persistência.
2. Conflito é o status code para `#!python 409`, quando existe um conflito na solicitação em relação ao estado pretendido pela requisição.

Agora temos uma validação para os conflitos acontecerem por conta dos campos marcados como `unique`. Toda vez que isso acontecer, a API retornará o código `#!python 409` com o json `#!python {'detail': 'Username or Email already exists'}`.

Sabendo disso, podemos retornar ao teste e adicionar as instruções de `#!python assert` para garantir essas condições:

```python title="tests/test_app.py"
def test_update_integrity_error(client, user):
# ...

assert response_update.status_code == HTTPStatus.CONFLICT
assert response_update.json() == {
'detail': 'Username or Email already exists'
}
```

Executando os testes, tudo deve funcionar corretamente:

```shell title="$ Execução no terminal!"
task test

# ...

tests/test_app.py::test_root_deve_retornar_ok_e_ola_mundo PASSED
tests/test_app.py::test_create_user PASSED
tests/test_app.py::test_read_users PASSED
tests/test_app.py::test_read_users_with_users PASSED
tests/test_app.py::test_update_user PASSED
```

## Modificando o Endpoint DELETE /users

Expand Down
55 changes: 38 additions & 17 deletions aulas/06.md
Original file line number Diff line number Diff line change
Expand Up @@ -264,7 +264,7 @@ tests/test_app.py::test_create_user PASSED

É igualmente importante modificar a função `update_user` para também criar um hash da senha antes de atualizar `User` no banco de dados. Caso contrário, a senha em texto puro seria armazenada no banco de dados no momento da atualização.

```python title="fast_zero/app.py" hl_lines="14"
```python title="fast_zero/app.py" hl_lines="15"
@app.put('/users/{user_id}', response_model=UserPublic)
def update_user(
user_id: int,
Expand All @@ -274,15 +274,18 @@ def update_user(
db_user = session.scalar(select(User).where(User.id == user_id))
if not db_user:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail='User not found'
)
status_code=HTTPStatus.NOT_FOUND, detail='User not found'
)

db_user.username = user.username
db_user.password = get_password_hash(user.password)
db_user.email = user.email
session.commit()
session.refresh(db_user)
return db_user
try:
db_user.username = user.username
db_user.password = get_password_hash(user.password)
db_user.email = user.email
session.commit()
session.refresh(db_user)

return db_user
# ...
```

Assim, a atualização de um `User`, via método `PUT`, também criará o hash da senha no momento da atualização. Pois, nesse caso em específico, existe a possibilidade de alterar qualquer coluna da tabela, inclusive o campo `password`.
Expand Down Expand Up @@ -578,14 +581,15 @@ def update_user(
raise HTTPException(
status_code=HTTPStatus.FORBIDDEN, detail='Not enough permissions'
)
try:
current_user.username = user.username
current_user.password = get_password_hash(user.password)
current_user.email = user.email
session.commit()
session.refresh(current_user)

current_user.username = user.username
current_user.password = get_password_hash(user.password)
current_user.email = user.email
session.commit()
session.refresh(current_user)

return current_user
return current_user
# ...
```

Com isso, podemos remover a query feita no endpoint para encontrar o User, pois ela já está sendo feita no `get_current_user`, simplificando ainda mais nosso endpoint.
Expand Down Expand Up @@ -628,7 +632,7 @@ def token(client, user):

Agora, podemos atualizar os testes para o endpoint PUT e DELETE para incluir a autenticação.

```python title="tests/test_app.py" hl_lines="3 4 15 20 21 22 23" linenums="44"
```python title="tests/test_app.py" hl_lines="3 4 15 18 23 36 38 39"
def test_update_user(client, user, token):
response = client.put(
f'/users/{user.id}',
Expand All @@ -646,6 +650,23 @@ def test_update_user(client, user, token):
'id': user.id,
}

def test_update_integrity_error(client, user, token):
# ... bloco de código omitido
# Alterando o user das fixture para fausto
response_update = client.put(
f'/users/{user.id}',
headers={'Authorization': f'Bearer {token}'},
json={
'username': 'fausto',
'email': '[email protected]',
'password': 'mynewpassword',
},
)

assert response_update.status_code == HTTPStatus.CONFLICT
assert response_update.json() == {
'detail': 'Username or Email already exists'
}

def test_delete_user(client, user, token):
response = client.delete(
Expand Down
30 changes: 30 additions & 0 deletions aulas/07.md
Original file line number Diff line number Diff line change
Expand Up @@ -344,6 +344,7 @@ tests/test_app.py::test_create_user PASSED
tests/test_app.py::test_read_users PASSED
tests/test_app.py::test_read_users_with_users PASSED
tests/test_app.py::test_update_user PASSED
tests/test_users.py::test_update_integrity_error PASSED
tests/test_app.py::test_delete_user PASSED
tests/test_app.py::test_get_token PASSED
tests/test_db.py::test_create_user PASSED
Expand Down Expand Up @@ -443,6 +444,33 @@ def test_update_user(client, user, token):
'id': 1,
}

def test_update_integrity_error(client, user, token):
# Inserindo fausto
client.post(
'/users',
json={
'username': 'fausto',
'email': '[email protected]',
'password': 'secret',
},
)

# Alterando o user das fixture para fausto
response_update = client.put(
f'/users/{user.id}',
headers={'Authorization': f'Bearer {token}'},
json={
'username': 'fausto',
'email': '[email protected]',
'password': 'mynewpassword',
},
)

assert response_update.status_code == HTTPStatus.CONFLICT
assert response_update.json() == {
'detail': 'Username or Email already exists'
}


def test_delete_user(client, user, token):
response = client.delete(
Expand Down Expand Up @@ -472,6 +500,7 @@ tests/test_users.py::test_create_user PASSED
tests/test_users.py::test_read_users PASSED
tests/test_users.py::test_read_users_with_users PASSED
tests/test_users.py::test_update_user PASSED
tests/test_users.py::test_update_integrity_error PASSED
tests/test_users.py::test_delete_user PASSED
```

Expand Down Expand Up @@ -725,6 +754,7 @@ tests/test_users.py::test_create_user PASSED
tests/test_users.py::test_read_users PASSED
tests/test_users.py::test_read_users_with_users PASSED
tests/test_users.py::test_update_user PASSED
tests/test_users.py::test_update_integrity_error PASSED
tests/test_users.py::test_delete_user PASSED
```

Expand Down
2 changes: 2 additions & 0 deletions aulas/08.md
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,7 @@ tests/test_users.py::test_create_user PASSED
tests/test_users.py::test_read_users PASSED
tests/test_users.py::test_read_users_with_users PASSED
tests/test_users.py::test_update_user PASSED
tests/test_users.py::test_update_integrity_error PASSED
tests/test_users.py::test_update_user_with_wrong_user PASSED
tests/test_users.py::test_delete_user PASSED
```
Expand Down Expand Up @@ -459,6 +460,7 @@ tests/test_users.py::test_create_user PASSED
tests/test_users.py::test_read_users PASSED
tests/test_users.py::test_read_users_with_users PASSED
tests/test_users.py::test_update_user PASSED
tests/test_users.py::test_update_integrity_error PASSED
tests/test_users.py::test_update_user_with_wrong_user PASSED
tests/test_users.py::test_delete_user PASSED
tests/test_users.py::test_delete_user_wrong_user PASSED
Expand Down
20 changes: 14 additions & 6 deletions codigo_das_aulas/05/fast_zero/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from fastapi import Depends, FastAPI, HTTPException
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session

from fast_zero.database import get_session
Expand Down Expand Up @@ -65,13 +66,20 @@ def update_user(
status_code=HTTPStatus.NOT_FOUND, detail='User not found'
)

db_user.username = user.username
db_user.password = user.password
db_user.email = user.email
session.commit()
session.refresh(db_user)
try:
db_user.username = user.username
db_user.password = user.password
db_user.email = user.email
session.commit()
session.refresh(db_user)

return db_user
return db_user

except IntegrityError:
raise HTTPException(
status_code=HTTPStatus.CONFLICT,
detail='Username or Email already exists'
)


@app.delete('/users/{user_id}', response_model=Message)
Expand Down
27 changes: 27 additions & 0 deletions codigo_das_aulas/05/tests/test_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,33 @@ def test_update_user(client, user):
}


def test_update_integrity_error(client, user):
# Inserindo fausto
client.post(
'/users',
json={
'username': 'fausto',
'email': '[email protected]',
'password': 'secret',
},
)

# Alterando o user das fixture para fausto
response_update = client.put(
f'/users/{user.id}',
json={
'username': 'fausto',
'email': '[email protected]',
'password': 'mynewpassword',
},
)

assert response_update.status_code == HTTPStatus.CONFLICT
assert response_update.json() == {
'detail': 'Username or Email already exists'
}


def test_delete_user(client, user):
response = client.delete('/users/1')

Expand Down
Loading

0 comments on commit d1f42f0

Please sign in to comment.