diff --git a/README.md b/README.md index 7ad43c2..796fe1f 100644 --- a/README.md +++ b/README.md @@ -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). diff --git a/api/auth_api_client.py b/api/auth_api_client.py index 8fb2bed..5029771 100644 --- a/api/auth_api_client.py +++ b/api/auth_api_client.py @@ -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 diff --git a/api/booking_api_client.py b/api/booking_api_client.py index 75ff944..9fe49a3 100644 --- a/api/booking_api_client.py +++ b/api/booking_api_client.py @@ -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. @@ -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}, ) diff --git a/models/booking.py b/models/booking.py index 32b196f..2907513 100644 --- a/models/booking.py +++ b/models/booking.py @@ -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 @@ -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]) diff --git a/steps/test_booking_steps.py b/steps/test_booking_steps.py index aa99674..7c6e40e 100644 --- a/steps/test_booking_steps.py +++ b/steps/test_booking_steps.py @@ -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") @@ -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", ) @@ -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 @@ -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 @@ -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 @@ -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") @@ -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 @@ -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) @@ -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) @@ -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 @@ -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 "") @@ -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 "") @@ -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 diff --git a/tests/unit/test_booking_api_client.py b/tests/unit/test_booking_api_client.py index 7eaf041..6c8fb6e 100644 --- a/tests/unit/test_booking_api_client.py +++ b/tests/unit/test_booking_api_client.py @@ -1,3 +1,5 @@ +from datetime import date + import requests from requests_mock import Mocker @@ -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", ) @@ -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 @@ -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" diff --git a/tests/unit/test_booking_models.py b/tests/unit/test_booking_models.py index f6b6618..c3adba4 100644 --- a/tests/unit/test_booking_models.py +++ b/tests/unit/test_booking_models.py @@ -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]: @@ -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 @@ -38,7 +46,7 @@ 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: @@ -46,18 +54,54 @@ def test_booking_rejects_incomplete_bookingdates() -> None: 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) @@ -65,4 +109,4 @@ def test_create_booking_response_nests_the_booking_model() -> None: def test_auth_response_requires_a_token() -> None: with pytest.raises(ValidationError): - AuthResponse.model_validate({}) + AuthResponse.model_validate_json("{}")