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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,7 @@ Autenticação: `POST /auth` com `{ "username": "admin", "password": "password12

- BDD / Gherkin: cenários claros e legíveis em `.feature`, executados via `pytest-bdd`.
- 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.
- Cobertura de autenticação, CRUD completo, PUT vs. PATCH, e casos de acesso não autorizado (403).
Expand Down
9 changes: 3 additions & 6 deletions api/auth_api_client.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,12 @@
import requests

from api.base_api_client import BaseApiClient
from models.booking import AuthResponse


class AuthApiClient:
def __init__(self, session: requests.Session, base_url: str) -> None:
self.session = session
self.base_url = base_url

class AuthApiClient(BaseApiClient):
def create_token(self, username: str, password: str) -> requests.Response:
return self.session.post(f"{self.base_url}/auth", json={"username": username, "password": password})
return self._request("POST", "/auth", json={"username": username, "password": password})

def get_valid_token(self) -> str:
response = self.create_token("admin", "password123")
Expand Down
26 changes: 26 additions & 0 deletions api/base_api_client.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
from typing import Any

import requests

# (conexão, resposta), em segundos. A conexão falha rápido se o servidor nem
# responde; a resposta tem margem para o Heroku acordar a aplicação, que pode
# demorar na primeira chamada depois de um tempo parada.
DEFAULT_TIMEOUT = (5, 30)


class BaseApiClient:
def __init__(
self,
session: requests.Session,
base_url: str,
timeout: tuple[float, float] = DEFAULT_TIMEOUT,
) -> None:
self.session = session
self.base_url = base_url
self.timeout = timeout

# Ponto único por onde passam todas as chamadas HTTP dos clients, para
# que nenhuma requisição fique sem timeout (o requests não tem um padrão
# e, sem ele, pode esperar uma resposta para sempre).
def _request(self, method: str, path: str, **kwargs: Any) -> requests.Response:
return self.session.request(method, f"{self.base_url}{path}", timeout=self.timeout, **kwargs)
34 changes: 12 additions & 22 deletions api/booking_api_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,11 @@

import requests

from api.base_api_client import BaseApiClient
from models.booking import Booking


class BookingApiClient:
def __init__(self, session: requests.Session, base_url: str) -> None:
self.session = session
self.base_url = base_url

class BookingApiClient(BaseApiClient):
def get_booking_ids(
self,
firstname: str | None = None,
Expand All @@ -24,41 +21,34 @@ def get_booking_ids(
"checkout": checkout,
}
params = {key: value for key, value in params.items() if value is not None}
return self.session.get(f"{self.base_url}/booking", params=params)
return self._request("GET", "/booking", params=params)

def get_booking(self, booking_id: int) -> requests.Response:
return self.session.get(f"{self.base_url}/booking/{booking_id}")
return self._request("GET", f"/booking/{booking_id}")

def create_booking(self, booking: Booking) -> requests.Response:
return self.session.post(f"{self.base_url}/booking", json=booking.model_dump(exclude_none=True))
return self._request("POST", "/booking", json=booking.model_dump(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.
def create_booking_raw(self, payload: dict[str, Any]) -> requests.Response:
return self.session.post(f"{self.base_url}/booking", json=payload)
return self._request("POST", "/booking", json=payload)

def update_booking(self, booking_id: int, booking: Booking, token: str) -> requests.Response:
return self.session.put(
f"{self.base_url}/booking/{booking_id}",
return self._request(
"PUT",
f"/booking/{booking_id}",
json=booking.model_dump(exclude_none=True),
cookies={"token": token},
)

# Variante crua de update_booking, para os testes de payload inválido no PUT
# (campos ausentes ou com tipo errado, que não passariam pelo tipo Booking).
def update_booking_raw(self, booking_id: int, payload: dict[str, Any], token: str) -> requests.Response:
return self.session.put(
f"{self.base_url}/booking/{booking_id}",
json=payload,
cookies={"token": token},
)
return self._request("PUT", f"/booking/{booking_id}", json=payload, cookies={"token": token})

def partial_update_booking(self, booking_id: int, partial_booking: dict[str, Any], token: str) -> requests.Response:
return self.session.patch(
f"{self.base_url}/booking/{booking_id}",
json=partial_booking,
cookies={"token": token},
)
return self._request("PATCH", f"/booking/{booking_id}", json=partial_booking, cookies={"token": token})

def delete_booking(self, booking_id: int, token: str) -> requests.Response:
return self.session.delete(f"{self.base_url}/booking/{booking_id}", cookies={"token": token})
return self._request("DELETE", f"/booking/{booking_id}", cookies={"token": token})
41 changes: 41 additions & 0 deletions tests/unit/test_base_api_client.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import requests
from requests_mock import Mocker

from api.base_api_client import DEFAULT_TIMEOUT, BaseApiClient

BASE_URL = "https://example.test"


# Os tipos do requests-mock declaram o timeout como int, mas em tempo de
# execução ele guarda o valor exatamente como foi passado (aqui, uma tupla).
def _sent_timeout(requests_mock: Mocker) -> object:
assert requests_mock.last_request is not None
return requests_mock.last_request.timeout


def test_request_uses_the_default_timeout_when_none_is_given(requests_mock: Mocker) -> None:
requests_mock.get(f"{BASE_URL}/ping")
client = BaseApiClient(requests.Session(), BASE_URL)

client._request("GET", "/ping")

assert _sent_timeout(requests_mock) == DEFAULT_TIMEOUT


def test_request_uses_the_timeout_given_to_the_client(requests_mock: Mocker) -> None:
requests_mock.get(f"{BASE_URL}/ping")
client = BaseApiClient(requests.Session(), BASE_URL, timeout=(1, 2))

client._request("GET", "/ping")

assert _sent_timeout(requests_mock) == (1, 2)


def test_request_joins_the_base_url_and_the_path(requests_mock: Mocker) -> None:
requests_mock.post(f"{BASE_URL}/booking/1")
client = BaseApiClient(requests.Session(), BASE_URL)

client._request("POST", "/booking/1")

assert requests_mock.last_request is not None
assert requests_mock.last_request.url == f"{BASE_URL}/booking/1"
Loading