Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,7 @@ Autenticação: `POST /auth` com `{ "username": "admin", "password": "password12
- API Client Objects: `AuthApiClient` e `BookingApiClient` encapsulam as chamadas HTTP, do mesmo jeito que Page Objects encapsulam elementos de UI.
- Timeout em todas as chamadas HTTP: os dois clients herdam de `BaseApiClient`, que aplica 5 segundos de limite para conectar e 30 para receber a resposta. Sem isso, o `requests` pode esperar indefinidamente, e uma API travada deixaria o CI parado até o limite de execução do GitHub Actions.
- Pirâmide de testes: além dos cenários de BDD (`steps/`), que são testes de integração reais contra a `restful-booker`, `tests/unit/` cobre `api/` e `models/` de forma isolada, com `requests-mock` simulando as respostas HTTP — sem rede, sem depender da API pública estar no ar.
- Validação de schema com [Pydantic](https://docs.pydantic.dev/): as respostas da API são validadas em tempo de execução contra os modelos em `models/booking.py` (fonte única de verdade), não só tipadas por anotação — se a API mudar o formato de uma resposta, o teste falha com uma mensagem clara em vez de passar silenciosamente ou quebrar mais adiante.
- Validação de schema com [Pydantic](https://docs.pydantic.dev/): as respostas da API são validadas em tempo de execução contra os modelos em `models/booking.py` (fonte única de verdade), não só tipadas por anotação — se a API mudar o formato de uma resposta, o teste falha com uma mensagem clara em vez de passar silenciosamente ou quebrar mais adiante. Os modelos usam o modo estrito do Pydantic, que não converte tipos por conta própria: um `totalprice` enviado como texto (`"150"`), um `depositpaid` como `"true"` ou uma data fora do formato `AAAA-MM-DD` reprovam a validação. Campos extras na resposta são ignorados, porque a API é de terceiros e um campo a mais não é defeito.
- Cobertura de autenticação, CRUD completo, PUT vs. PATCH, e casos de acesso não autorizado (403).
- Relatório HTML automatizado a cada execução (`reports/report.html`, via `pytest-html`), com a versão da `master` publicada em [thomastds.github.io/qa-api-python](https://thomastds.github.io/qa-api-python/) via GitHub Pages.
- Cobertura de código (`pytest-cov`) de `api/` e `models/` em cada execução, acompanhada no [Codecov](https://codecov.io/gh/ThomasTDS/qa-api-python).
Expand Down
2 changes: 1 addition & 1 deletion api/auth_api_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,5 @@ def create_token(self, username: str, password: str) -> requests.Response:

def get_valid_token(self) -> str:
response = self.create_token("admin", "password123")
body = AuthResponse.model_validate(response.json())
body = AuthResponse.model_validate_json(response.text)
return body.token
4 changes: 2 additions & 2 deletions api/booking_api_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ def get_booking(self, booking_id: int) -> requests.Response:
return self._request("GET", f"/booking/{booking_id}")

def create_booking(self, booking: Booking) -> requests.Response:
return self._request("POST", "/booking", json=booking.model_dump(exclude_none=True))
return self._request("POST", "/booking", json=booking.model_dump(mode="json", exclude_none=True))

# Recebe um dict cru (sem passar pela validação do Pydantic) para testes
# negativos que precisam mandar um payload propositalmente inválido.
Expand All @@ -38,7 +38,7 @@ def update_booking(self, booking_id: int, booking: Booking, token: str) -> reque
return self._request(
"PUT",
f"/booking/{booking_id}",
json=booking.model_dump(exclude_none=True),
json=booking.model_dump(mode="json", exclude_none=True),
cookies={"token": token},
)

Expand Down
32 changes: 24 additions & 8 deletions models/booking.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,23 @@
from pydantic import BaseModel
from datetime import date

from pydantic import BaseModel, ConfigDict, TypeAdapter

class BookingDates(BaseModel):
checkin: str
checkout: str

# Modo estrito: o Pydantic deixa de converter tipos por conta própria. Sem
# ele, um totalprice "150" (texto) viraria 150.0 e um depositpaid "true"
# viraria True, e uma mudança de tipo na resposta da API passaria sem ser
# notada. Campos extras na resposta continuam sendo ignorados, já que a API é
# de terceiros e um campo a mais não é defeito.
class StrictModel(BaseModel):
model_config = ConfigDict(strict=True)

class Booking(BaseModel):

class BookingDates(StrictModel):
checkin: date
checkout: date


class Booking(StrictModel):
firstname: str
lastname: str
totalprice: float
Expand All @@ -15,14 +26,19 @@ class Booking(BaseModel):
additionalneeds: str | None = None


class BookingId(BaseModel):
class BookingId(StrictModel):
bookingid: int


class CreateBookingResponse(BaseModel):
class CreateBookingResponse(StrictModel):
bookingid: int
booking: Booking


class AuthResponse(BaseModel):
class AuthResponse(StrictModel):
token: str


# GET /booking devolve uma lista solta, sem um objeto em volta, então a
# validação é feita por um TypeAdapter em vez de um modelo.
BookingIdList = TypeAdapter(list[BookingId])
28 changes: 15 additions & 13 deletions steps/test_booking_steps.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
from datetime import date

import pytest
from pydantic import ValidationError
from pytest_bdd import given, parsers, scenarios, then, when

from conftest import Context
from models.booking import AuthResponse, Booking, BookingDates, BookingId, CreateBookingResponse
from models.booking import AuthResponse, Booking, BookingDates, BookingIdList, CreateBookingResponse

scenarios("../features")

Expand All @@ -14,7 +16,7 @@ def _default_booking() -> Booking:
lastname="Ciclano",
totalprice=150,
depositpaid=True,
bookingdates=BookingDates(checkin="2026-01-01", checkout="2026-01-05"),
bookingdates=BookingDates(checkin=date(2026, 1, 1), checkout=date(2026, 1, 5)),
additionalneeds="Breakfast",
)

Expand Down Expand Up @@ -44,7 +46,7 @@ def solicita_um_token(context: Context, username: str, password: str) -> None:
def deve_receber_um_token_valido(context: Context) -> None:
assert context.last_response is not None
assert context.last_response.status_code == 200
body = AuthResponse.model_validate(context.last_response.json())
body = AuthResponse.model_validate_json(context.last_response.text)
assert body.token


Expand All @@ -60,7 +62,7 @@ def resposta_indica_credenciais_invalidas(context: Context) -> None:
def existe_um_booking_criado(context: Context) -> None:
context.booking_data = _default_booking()
response = context.booking_client.create_booking(context.booking_data)
body = CreateBookingResponse.model_validate(response.json())
body = CreateBookingResponse.model_validate_json(response.text)
context.booking_id = body.bookingid


Expand All @@ -79,7 +81,7 @@ def booking_deve_ser_criado_com_sucesso(context: Context) -> None:
@then("o id do booking criado deve ser retornado")
def id_do_booking_criado_deve_ser_retornado(context: Context) -> None:
assert context.last_response is not None
body = CreateBookingResponse.model_validate(context.last_response.json())
body = CreateBookingResponse.model_validate_json(context.last_response.text)
assert body.bookingid > 0
context.booking_id = body.bookingid

Expand Down Expand Up @@ -121,7 +123,7 @@ def resposta_nao_corresponde_ao_schema(context: Context) -> None:
assert context.last_response is not None
assert context.last_response.status_code == 200
with pytest.raises(ValidationError):
CreateBookingResponse.model_validate(context.last_response.json())
CreateBookingResponse.model_validate_json(context.last_response.text)


@when("ele cria um booking com depositpaid em formato inválido")
Expand Down Expand Up @@ -200,7 +202,7 @@ def busca_o_booking_pelo_id_informado(context: Context, booking_id: str) -> None
def dados_correspondem_ao_booking_criado(context: Context) -> None:
assert context.last_response is not None
assert context.last_response.status_code == 200
body = Booking.model_validate(context.last_response.json())
body = Booking.model_validate_json(context.last_response.text)
assert body == context.booking_data


Expand All @@ -223,7 +225,7 @@ def busca_bookings_filtrando_pelo_booking_criado(context: Context) -> None:
def id_do_booking_criado_esta_entre_os_resultados(context: Context) -> None:
assert context.last_response is not None
assert context.last_response.status_code == 200
body = [BookingId.model_validate(item) for item in context.last_response.json()]
body = BookingIdList.validate_json(context.last_response.text)
assert any(b.bookingid == context.booking_id for b in body)


Expand All @@ -239,7 +241,7 @@ def busca_bookings_filtrando_por_dados_inexistentes(context: Context) -> None:
def id_do_booking_criado_nao_esta_entre_os_resultados(context: Context) -> None:
assert context.last_response is not None
assert context.last_response.status_code == 200
body = [BookingId.model_validate(item) for item in context.last_response.json()]
body = BookingIdList.validate_json(context.last_response.text)
assert not any(b.bookingid == context.booking_id for b in body)


Expand Down Expand Up @@ -292,7 +294,7 @@ def tenta_atualizar_parcialmente_o_sobrenome(context: Context, lastname: str) ->
@then(parsers.parse('o sobrenome do booking deve ser "{lastname}"'))
def sobrenome_do_booking_deve_ser(context: Context, lastname: str) -> None:
assert context.last_response is not None
body = Booking.model_validate(context.last_response.json())
body = Booking.model_validate_json(context.last_response.text)
assert body.lastname == lastname


Expand All @@ -312,7 +314,7 @@ def resposta_indica_requisicao_invalida(context: Context) -> None:
def atualiza_o_booking_com_totalprice_invalido(context: Context) -> None:
assert context.booking_data is not None
assert context.booking_id is not None
payload = {**context.booking_data.model_dump(exclude_none=True), "totalprice": "nao-e-numero"}
payload = {**context.booking_data.model_dump(mode="json", exclude_none=True), "totalprice": "nao-e-numero"}
context.last_response = context.booking_client.update_booking_raw(context.booking_id, payload, context.token or "")


Expand All @@ -326,7 +328,7 @@ def totalprice_do_booking_atualizado_deve_ser_nulo(context: Context) -> None:
def atualiza_o_booking_sem_bookingdates(context: Context) -> None:
assert context.booking_data is not None
assert context.booking_id is not None
payload = context.booking_data.model_dump(exclude_none=True)
payload = context.booking_data.model_dump(mode="json", exclude_none=True)
del payload["bookingdates"]
context.last_response = context.booking_client.update_booking_raw(context.booking_id, payload, context.token or "")

Expand Down Expand Up @@ -354,7 +356,7 @@ def atualiza_parcialmente_o_booking_com_corpo_vazio(context: Context) -> None:
@then("os dados do booking não devem ter sido alterados")
def dados_do_booking_nao_devem_ter_sido_alterados(context: Context) -> None:
assert context.last_response is not None
body = Booking.model_validate(context.last_response.json())
body = Booking.model_validate_json(context.last_response.text)
assert body == context.booking_data


Expand Down
8 changes: 5 additions & 3 deletions tests/unit/test_booking_api_client.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
from datetime import date

import requests
from requests_mock import Mocker

Expand All @@ -13,7 +15,7 @@ def _booking() -> Booking:
lastname="Brown",
totalprice=111,
depositpaid=True,
bookingdates=BookingDates(checkin="2024-01-01", checkout="2024-01-02"),
bookingdates=BookingDates(checkin=date(2024, 1, 1), checkout=date(2024, 1, 2)),
additionalneeds="Breakfast",
)

Expand Down Expand Up @@ -56,7 +58,7 @@ def test_create_booking_sends_the_booking_payload_and_no_auth_cookie(requests_mo
client.create_booking(booking)

assert requests_mock.last_request is not None
assert requests_mock.last_request.json() == booking.model_dump(exclude_none=True)
assert requests_mock.last_request.json() == booking.model_dump(mode="json", exclude_none=True)
assert "Cookie" not in requests_mock.last_request.headers


Expand All @@ -68,7 +70,7 @@ def test_update_booking_sends_the_full_payload_with_the_auth_token_as_cookie(req
client.update_booking(1, booking, token="tok123")

assert requests_mock.last_request is not None
assert requests_mock.last_request.json() == booking.model_dump(exclude_none=True)
assert requests_mock.last_request.json() == booking.model_dump(mode="json", exclude_none=True)
assert requests_mock.last_request.headers["Cookie"] == "token=tok123"


Expand Down
62 changes: 53 additions & 9 deletions tests/unit/test_booking_models.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import json
from datetime import date
from typing import Any

import pytest
from pydantic import ValidationError

from models.booking import AuthResponse, Booking, BookingDates, BookingId, CreateBookingResponse
from models.booking import AuthResponse, Booking, BookingDates, BookingId, BookingIdList, CreateBookingResponse


def _valid_booking_payload() -> dict[str, Any]:
Expand All @@ -17,18 +19,24 @@ def _valid_booking_payload() -> dict[str, Any]:
}


# Os modelos validam o corpo das respostas em JSON (model_validate_json), do
# mesmo jeito que os steps fazem com o texto que vem da API.
def _validate_booking(payload: dict[str, Any]) -> Booking:
return Booking.model_validate_json(json.dumps(payload))


def test_booking_accepts_a_full_valid_payload() -> None:
booking = Booking.model_validate(_valid_booking_payload())
booking = _validate_booking(_valid_booking_payload())

assert booking.firstname == "Jim"
assert booking.bookingdates == BookingDates(checkin="2024-01-01", checkout="2024-01-02")
assert booking.bookingdates == BookingDates(checkin=date(2024, 1, 1), checkout=date(2024, 1, 2))


def test_booking_additionalneeds_defaults_to_none_when_omitted() -> None:
payload = _valid_booking_payload()
del payload["additionalneeds"]

booking = Booking.model_validate(payload)
booking = _validate_booking(payload)

assert booking.additionalneeds is None

Expand All @@ -38,31 +46,67 @@ def test_booking_rejects_a_missing_required_field() -> None:
del payload["totalprice"]

with pytest.raises(ValidationError):
Booking.model_validate(payload)
_validate_booking(payload)


def test_booking_rejects_incomplete_bookingdates() -> None:
payload = _valid_booking_payload()
del payload["bookingdates"]["checkout"]

with pytest.raises(ValidationError):
Booking.model_validate(payload)
_validate_booking(payload)


def test_booking_rejects_a_numeric_string_as_totalprice() -> None:
payload = {**_valid_booking_payload(), "totalprice": "111"}

with pytest.raises(ValidationError):
_validate_booking(payload)


def test_booking_rejects_a_string_as_depositpaid() -> None:
payload = {**_valid_booking_payload(), "depositpaid": "true"}

with pytest.raises(ValidationError):
_validate_booking(payload)


def test_booking_rejects_a_corrupted_checkin_date() -> None:
payload = _valid_booking_payload()
payload["bookingdates"]["checkin"] = "0NaN-aN-aN"

with pytest.raises(ValidationError):
_validate_booking(payload)


def test_booking_ignores_fields_it_does_not_know() -> None:
payload = {**_valid_booking_payload(), "campo_novo": "qualquer"}

booking = _validate_booking(payload)

assert booking.firstname == "Jim"


def test_booking_id_rejects_a_non_numeric_id() -> None:
with pytest.raises(ValidationError):
BookingId.model_validate({"bookingid": "not-a-number"})
BookingId.model_validate_json('{"bookingid": "not-a-number"}')


def test_booking_id_list_validates_every_item() -> None:
ids = BookingIdList.validate_json('[{"bookingid": 1}, {"bookingid": 2}]')

assert [item.bookingid for item in ids] == [1, 2]


def test_create_booking_response_nests_the_booking_model() -> None:
payload = {"bookingid": 1, "booking": _valid_booking_payload()}

response = CreateBookingResponse.model_validate(payload)
response = CreateBookingResponse.model_validate_json(json.dumps(payload))

assert response.bookingid == 1
assert isinstance(response.booking, Booking)


def test_auth_response_requires_a_token() -> None:
with pytest.raises(ValidationError):
AuthResponse.model_validate({})
AuthResponse.model_validate_json("{}")
Loading