diff --git a/README.md b/README.md index 13fdda6..7ad43c2 100644 --- a/README.md +++ b/README.md @@ -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). diff --git a/api/auth_api_client.py b/api/auth_api_client.py index b8c0556..8fb2bed 100644 --- a/api/auth_api_client.py +++ b/api/auth_api_client.py @@ -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") diff --git a/api/base_api_client.py b/api/base_api_client.py new file mode 100644 index 0000000..eeeff47 --- /dev/null +++ b/api/base_api_client.py @@ -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) diff --git a/api/booking_api_client.py b/api/booking_api_client.py index 588403c..75ff944 100644 --- a/api/booking_api_client.py +++ b/api/booking_api_client.py @@ -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, @@ -24,22 +21,23 @@ 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}, ) @@ -47,18 +45,10 @@ def update_booking(self, booking_id: int, booking: Booking, token: str) -> reque # 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}) diff --git a/tests/unit/test_base_api_client.py b/tests/unit/test_base_api_client.py new file mode 100644 index 0000000..dc709b3 --- /dev/null +++ b/tests/unit/test_base_api_client.py @@ -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"