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
25 changes: 13 additions & 12 deletions README.en.md
Original file line number Diff line number Diff line change
Expand Up @@ -157,17 +157,18 @@ BASE_URL=http://localhost:3001 API_USERNAME=qa API_PASSWORD=qa123 pytest

### API under test

| Method | Route | Purpose |
| ------ | -------------- | --------------------------------------------------- |
| POST | `/auth` | Creates an authentication token |
| GET | `/booking` | Lists booking IDs (accepts filters) |
| GET | `/booking/:id` | Gets a specific booking |
| POST | `/booking` | Creates a booking |
| PUT | `/booking/:id` | Updates a booking (all fields required) |
| PATCH | `/booking/:id` | Partially updates a booking |
| DELETE | `/booking/:id` | Deletes a booking |

Authentication: `POST /auth` with `{ "username": "admin", "password": "password123" }` (public credentials from the API documentation, configurable through `API_USERNAME` and `API_PASSWORD`) returns a token, sent on `PUT`, `PATCH` and `DELETE` calls as the cookie `token=<value>`.
| Method | Route | Purpose |
| ------ | -------------- | --------------------------------------------------------------------- |
| GET | `/ping` | Health check (returns 201 when the API is up) |
| POST | `/auth` | Creates an authentication token |
| GET | `/booking` | Lists booking IDs (filters by name and by checkin and checkout dates) |
| GET | `/booking/:id` | Gets a specific booking |
| POST | `/booking` | Creates a booking |
| PUT | `/booking/:id` | Updates a booking (all fields required) |
| PATCH | `/booking/:id` | Partially updates a booking |
| DELETE | `/booking/:id` | Deletes a booking |

Authentication: `POST /auth` with `{ "username": "admin", "password": "password123" }` (public credentials from the API documentation, configurable through `API_USERNAME` and `API_PASSWORD`) returns a token, sent on `PUT`, `PATCH` and `DELETE` calls as the cookie `token=<value>`. These calls also accept the same credentials through Basic auth (the `Authorization` header), and both methods are tested.

**Security note:** `POST /booking` (creation) **does not require authentication**: anyone can create bookings without a token. This is an access control flaw in the demo app itself, and it is covered explicitly in [features/create_booking.feature](features/create_booking.feature) as documented behavior, not as a test bug.

Expand All @@ -180,7 +181,7 @@ Authentication: `POST /auth` with `{ "username": "admin", "password": "password1
- Timeout on every HTTP call: both clients inherit from `BaseApiClient`, which applies a 5-second limit to connect and 30 seconds to receive the response. Without it, `requests` can wait forever, and a hung API would leave CI stuck until the GitHub Actions run time limit.
- Test pyramid: besides the BDD scenarios (`steps/`), which are real integration tests against `restful-booker`, `tests/unit/` covers `api/` and `models/` in isolation, with `requests-mock` simulating the HTTP responses: no network, no dependency on the public API being up.
- Schema validation with [Pydantic](https://docs.pydantic.dev/): API responses are validated at runtime against the models in `models/booking.py` (single source of truth), not just typed through annotations. If the API changes the shape of a response, the test fails with a clear message instead of passing silently or breaking further along. The models use Pydantic's strict mode, which does not convert types on its own: a `totalprice` sent as text (`"150"`), a `depositpaid` as `"true"` or a date outside the `YYYY-MM-DD` format fails validation. Extra fields in the response are ignored, because the API belongs to a third party and an extra field is not a defect.
- Coverage of authentication, full CRUD, PUT vs. PATCH, and unauthorized access cases (403).
- Coverage of the health check, authentication (token and Basic auth), full CRUD, filters by name and by date, PUT vs. PATCH, and unauthorized access cases (403).
- Automated HTML report on every run (`reports/report.html`, through `pytest-html`), with the `master` version published at [thomastds.github.io/qa-api-python](https://thomastds.github.io/qa-api-python/) through GitHub Pages.
- Code coverage (`pytest-cov`) of `api/` and `models/` on every run, tracked on [Codecov](https://codecov.io/gh/ThomasTDS/qa-api-python).
- Automatic cleanup: the `context` fixture deletes the booking created in the scenario (with its own cleanup token) at the end of each test, so data does not pile up on the public API. If the deletion fails because of a network or authentication error, pytest shows a warning with the id of the booking left behind, without changing the test result.
Expand Down
25 changes: 13 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -155,17 +155,18 @@ BASE_URL=http://localhost:3001 API_USERNAME=qa API_PASSWORD=qa123 pytest

### API testada

| Método | Rota | Função |
| ------ | -------------- | --------------------------------------------------- |
| POST | `/auth` | Gera token de autenticação |
| GET | `/booking` | Lista IDs de bookings (aceita filtros) |
| GET | `/booking/:id` | Busca um booking específico |
| POST | `/booking` | Cria um booking |
| PUT | `/booking/:id` | Atualiza um booking (todos os campos obrigatórios) |
| PATCH | `/booking/:id` | Atualiza parcialmente um booking |
| DELETE | `/booking/:id` | Remove um booking |

Autenticação: `POST /auth` com `{ "username": "admin", "password": "password123" }` (credenciais públicas da documentação da API, configuráveis por `API_USERNAME` e `API_PASSWORD`) retorna um token, enviado nas chamadas de `PUT`, `PATCH` e `DELETE` via cookie `token=<valor>`.
| Método | Rota | Função |
| ------ | -------------- | -------------------------------------------------------------------------- |
| GET | `/ping` | Health check (responde 201 quando a API está no ar) |
| POST | `/auth` | Gera token de autenticação |
| GET | `/booking` | Lista IDs de bookings (filtros por nome e por datas de checkin e checkout) |
| GET | `/booking/:id` | Busca um booking específico |
| POST | `/booking` | Cria um booking |
| PUT | `/booking/:id` | Atualiza um booking (todos os campos obrigatórios) |
| PATCH | `/booking/:id` | Atualiza parcialmente um booking |
| DELETE | `/booking/:id` | Remove um booking |

Autenticação: `POST /auth` com `{ "username": "admin", "password": "password123" }` (credenciais públicas da documentação da API, configuráveis por `API_USERNAME` e `API_PASSWORD`) retorna um token, enviado nas chamadas de `PUT`, `PATCH` e `DELETE` via cookie `token=<valor>`. Essas chamadas também aceitam as mesmas credenciais por Basic auth (cabeçalho `Authorization`), e as duas formas são testadas.

**Observação de segurança:** `POST /booking` (criação) **não exige autenticação** — qualquer pessoa consegue criar bookings sem token. Isso é uma falha de controle de acesso do próprio app de demonstração, e está coberta explicitamente em [features/create_booking.feature](features/create_booking.feature) como comportamento documentado, não como bug do teste.

Expand All @@ -178,7 +179,7 @@ Autenticação: `POST /auth` com `{ "username": "admin", "password": "password12
- 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. 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).
- Cobertura de health check, autenticação (token e Basic auth), CRUD completo, filtros por nome e por data, 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).
- Limpeza automática: a fixture `context` remove o booking criado no cenário (via token próprio de limpeza) ao final de cada teste, evitando acúmulo de dados na API pública. Se a remoção falhar por erro de rede ou de autenticação, o pytest mostra um aviso com o id do booking que ficou para trás, sem mudar o resultado do teste.
Expand Down
38 changes: 30 additions & 8 deletions api/booking_api_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,21 +34,43 @@ def create_booking(self, booking: Booking) -> requests.Response:
def create_booking_raw(self, payload: dict[str, Any]) -> requests.Response:
return self._request("POST", "/booking", json=payload)

def update_booking(self, booking_id: int, booking: Booking, token: str) -> requests.Response:
# A API aceita duas formas de autenticação nas operações de escrita: o token
# no cookie (padrão) ou usuário e senha em Basic auth. Com Basic auth e sem
# token, o cookie não é enviado, para que a requisição use só o cabeçalho.
@staticmethod
def _credentials(token: str, basic_auth: tuple[str, str] | None) -> dict[str, Any]:
if basic_auth is None:
return {"cookies": {"token": token}}
credentials: dict[str, Any] = {"auth": basic_auth}
if token:
credentials["cookies"] = {"token": token}
return credentials

def update_booking(
self, booking_id: int, booking: Booking, token: str, basic_auth: tuple[str, str] | None = None
) -> requests.Response:
return self._request(
"PUT",
f"/booking/{booking_id}",
json=booking.model_dump(mode="json", exclude_none=True),
cookies={"token": token},
**self._credentials(token, basic_auth),
)

# 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._request("PUT", f"/booking/{booking_id}", json=payload, cookies={"token": token})
def update_booking_raw(
self, booking_id: int, payload: dict[str, Any], token: str, basic_auth: tuple[str, str] | None = None
) -> requests.Response:
return self._request("PUT", f"/booking/{booking_id}", json=payload, **self._credentials(token, basic_auth))

def partial_update_booking(self, booking_id: int, partial_booking: dict[str, Any], token: str) -> requests.Response:
return self._request("PATCH", f"/booking/{booking_id}", json=partial_booking, cookies={"token": token})
def partial_update_booking(
self, booking_id: int, partial_booking: dict[str, Any], token: str, basic_auth: tuple[str, str] | None = None
) -> requests.Response:
return self._request(
"PATCH", f"/booking/{booking_id}", json=partial_booking, **self._credentials(token, basic_auth)
)

def delete_booking(self, booking_id: int, token: str) -> requests.Response:
return self._request("DELETE", f"/booking/{booking_id}", cookies={"token": token})
def delete_booking(
self, booking_id: int, token: str, basic_auth: tuple[str, str] | None = None
) -> requests.Response:
return self._request("DELETE", f"/booking/{booking_id}", **self._credentials(token, basic_auth))
8 changes: 8 additions & 0 deletions api/health_api_client.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import requests

from api.base_api_client import BaseApiClient


class HealthApiClient(BaseApiClient):
def ping(self) -> requests.Response:
return self._request("GET", "/ping")
6 changes: 5 additions & 1 deletion conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,10 @@

from api.auth_api_client import AuthApiClient
from api.booking_api_client import BookingApiClient
from api.health_api_client import HealthApiClient
from models.booking import Booking

# Steps compartilhados entre features (tokens, booking criado, 403 e 405).
# Steps compartilhados entre features (token, Basic auth, booking criado, 403, 405 e 500).
pytest_plugins = ["steps.common_steps"]

BASE_URL = os.environ.get("BASE_URL", "https://restful-booker.herokuapp.com")
Expand Down Expand Up @@ -51,7 +52,10 @@ def __init__(self, session: requests.Session) -> None:
self.session = session
self.auth_client = AuthApiClient(session, BASE_URL, API_USERNAME, API_PASSWORD)
self.booking_client = BookingApiClient(session, BASE_URL)
self.health_client = HealthApiClient(session, BASE_URL)
self.token: str | None = None
# Credenciais de Basic auth, alternativa ao token no cookie.
self.basic_auth: tuple[str, str] | None = None
self.booking_id: int | None = None
self.booking_data: Booking | None = None
self.last_response: requests.Response | None = None
Expand Down
9 changes: 9 additions & 0 deletions docs/test-cases.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,15 @@ Legenda:
| TC-023 | Atualização | Atualizar parcialmente com lastname em formato inválido | Negativo | Média | Automatizado | [update_booking.feature:79](../features/update_booking.feature#L79) |
| TC-024 | Atualização | Atualizar parcialmente com corpo vazio não altera nada | Funcional | Baixa | Automatizado | [update_booking.feature:89](../features/update_booking.feature#L89) |
| TC-025 | Atualização | Tentar atualizar (PUT/PATCH) um booking inexistente | Negativo | Média | Automatizado | [update_booking.feature:102](../features/update_booking.feature#L102) |
| TC-026 | Saúde | Verificar que a API está no ar (health check) | Funcional | Média | Automatizado | [health.feature:8](../features/health.feature#L8) |
| TC-027 | Consulta | Buscar bookings com checkin a partir de data anterior | Funcional | Média | Automatizado | [get_booking.feature:24](../features/get_booking.feature#L24) |
| TC-028 | Consulta | Buscar bookings com checkin na mesma data (bug) | Funcional | Média | Automatizado | [get_booking.feature:35](../features/get_booking.feature#L35) |
| TC-029 | Consulta | Buscar bookings com checkout posterior (bug) | Funcional | Média | Automatizado | [get_booking.feature:45](../features/get_booking.feature#L45) |
| TC-030 | Consulta | Tentar buscar bookings com data inválida no filtro | Negativo | Média | Automatizado | [get_booking.feature:54](../features/get_booking.feature#L54) |
| TC-031 | Atualização | Atualizar booking com Basic auth (PUT) | Funcional | Alta | Automatizado | [update_booking.feature:116](../features/update_booking.feature#L116) |
| TC-032 | Atualização | Atualizar parcialmente com Basic auth (PATCH) | Funcional | Média | Automatizado | [update_booking.feature:123](../features/update_booking.feature#L123) |
| TC-033 | Remoção | Remover booking com Basic auth | Funcional | Alta | Automatizado | [delete_booking.feature:28](../features/delete_booking.feature#L28) |
| TC-034 | Atualização | Tentar atualizar booking com Basic auth inválido | Negativo | Alta | Automatizado | [update_booking.feature:131](../features/update_booking.feature#L131) |

`BookingApiClient.get_booking_ids` também aceita filtro por `checkin`/`checkout`, mas o TC-006 cobre só `firstname`/`lastname`: o filtro por data na API pública de demonstração é conhecido por ser instável, e testá-lo arriscaria um teste flaky em vez de validar um comportamento real.

Expand Down
Loading
Loading