-
Notifications
You must be signed in to change notification settings - Fork 74
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Adicionando verificação de integridade no endpoint de put
Isso altera significativamente todas as aulas após a 5, pois esse endpoint muda bastante closes #232
- Loading branch information
1 parent
0043b1e
commit d1f42f0
Showing
20 changed files
with
528 additions
and
65 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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 | ||
|
||
|
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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, | ||
|
@@ -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`. | ||
|
@@ -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. | ||
|
@@ -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}', | ||
|
@@ -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( | ||
|
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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 | ||
|
@@ -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( | ||
|
@@ -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 | ||
``` | ||
|
||
|
@@ -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 | ||
``` | ||
|
||
|
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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') | ||
|
||
|
Oops, something went wrong.