From 2304f6a825f698218d5bb14b921e08d6dae4730d Mon Sep 17 00:00:00 2001 From: ThomasTDS Date: Sat, 24 Oct 2026 16:34:19 -0300 Subject: [PATCH] Cobre o health check, os filtros por data e o Basic auth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adiciona os casos TC-026 a TC-034: o GET /ping, os filtros checkin e checkout do GET /booking (com análise de valor-limite) e o Basic auth em PUT, PATCH e DELETE. O BookingApiClient passa a aceitar basic_auth como opção, e o novo HealthApiClient faz o ping. Os três bugs encontrados nos filtros estão documentados nas issues #54, #55 e #56. --- README.en.md | 25 ++++++------ README.md | 25 ++++++------ api/booking_api_client.py | 38 ++++++++++++++---- api/health_api_client.py | 8 ++++ conftest.py | 6 ++- docs/test-cases.md | 9 +++++ docs/test-strategy.en.md | 16 ++++---- docs/test-strategy.md | 16 ++++---- features/delete_booking.feature | 7 ++++ features/get_booking.feature | 35 ++++++++++++++++ features/health.feature | 10 +++++ features/update_booking.feature | 25 ++++++++++++ steps/common_steps.py | 16 ++++++++ steps/test_create_booking.py | 5 --- steps/test_delete_booking.py | 8 +++- steps/test_get_booking.py | 36 ++++++++++++++++- steps/test_health.py | 16 ++++++++ steps/test_update_booking.py | 26 +++++++----- tests/unit/test_booking_api_client.py | 57 +++++++++++++++++++++++++++ tests/unit/test_health_api_client.py | 17 ++++++++ 20 files changed, 333 insertions(+), 68 deletions(-) create mode 100644 api/health_api_client.py create mode 100644 features/health.feature create mode 100644 steps/test_health.py create mode 100644 tests/unit/test_health_api_client.py diff --git a/README.en.md b/README.en.md index d56fdf7..a21436a 100644 --- a/README.en.md +++ b/README.en.md @@ -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=`. +| 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=`. 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. @@ -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. diff --git a/README.md b/README.md index 8ec3c5d..8e82993 100644 --- a/README.md +++ b/README.md @@ -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=`. +| 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=`. 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. @@ -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. diff --git a/api/booking_api_client.py b/api/booking_api_client.py index 9fe49a3..7bc2f1f 100644 --- a/api/booking_api_client.py +++ b/api/booking_api_client.py @@ -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)) diff --git a/api/health_api_client.py b/api/health_api_client.py new file mode 100644 index 0000000..fdbfc10 --- /dev/null +++ b/api/health_api_client.py @@ -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") diff --git a/conftest.py b/conftest.py index 8d18008..07b1306 100644 --- a/conftest.py +++ b/conftest.py @@ -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") @@ -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 diff --git a/docs/test-cases.md b/docs/test-cases.md index 68d7ad7..00ddf36 100644 --- a/docs/test-cases.md +++ b/docs/test-cases.md @@ -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. diff --git a/docs/test-strategy.en.md b/docs/test-strategy.en.md index 9413960..21f180b 100644 --- a/docs/test-strategy.en.md +++ b/docs/test-strategy.en.md @@ -13,8 +13,9 @@ Verify the behavior of the public [restful-booker](https://restful-booker.heroku **In scope:** - Authentication: token creation with valid and invalid credentials. -- Booking CRUD: creation, retrieval (by id and by name filter), full update (PUT), partial update (PATCH) and deletion. -- Authorization: write calls with no token and with an invalid token. +- Health check (`GET /ping`). +- Booking CRUD: creation, retrieval (by id, by name filter and by checkin and checkout date filters), full update (PUT), partial update (PATCH) and deletion. +- Authorization: both authentication methods for write operations (token in a cookie and Basic auth), and calls with no credentials or with invalid ones. - Input validation: missing fields, wrong types, negative values and dates in an invalid format. - Response contract: shape and type of every field. @@ -24,8 +25,6 @@ Verify the behavior of the public [restful-booker](https://restful-booker.heroku - Web interface: restful-booker does not have one. - Penetration testing: the project records access control flaws that show up in the functional tests, but it does not attack the API. -**Known gaps, planned:** the health check (`GET /ping`), the date filters (`checkin` and `checkout`) and authentication through `Authorization: Basic` have no scenarios yet. - ## 3. Approach by level | Level | Where | What it checks | Why | @@ -40,7 +39,8 @@ Verify the behavior of the public [restful-booker](https://restful-booker.heroku **Techniques applied:** - **Equivalence partitioning and invalid values:** for each field, one valid value and the relevant classes of invalid values (missing, wrong type, negative, wrong format). -- **Negative authorization tests:** every write operation is tested with no token and with an invalid token. +- **Boundary value analysis:** in the date filters, the booking is searched with a date before its checkin and with the same date, which is exactly where the faulty filter shows up (TC-027 and TC-028). +- **Negative authorization tests:** every write operation is tested with no token and with an invalid token, and Basic auth with invalid credentials. - **Characterization tests:** when the API has a bug, the scenario records the current behavior and points to the issue that documents it. If the API gets fixed, the scenario fails, signaling that the issue can be closed. ## 4. Environments @@ -93,12 +93,12 @@ The tests run on Python 3.12 and 3.13, with dependencies locked in [uv.lock](../ - The scenario that found the defect cites the issue in a comment in the `.feature` file and records the current behavior (section 3, characterization tests). - When a test fails in CI, the report shows the URL, status, headers and body of the last response, which already serves as evidence for the issue. -So far, 7 defects are documented, all of them about input validation or status codes. For example: the API returns 500 when a required field is missing ([#8](https://github.com/ThomasTDS/qa-api-python/issues/8)) and accepts a negative price ([#21](https://github.com/ThomasTDS/qa-api-python/issues/21)). +So far, 10 defects are documented: most about input validation or status codes, and two about logic in the date filters. When the cause can be confirmed in the [API source code](https://github.com/mwinteringham/restful-booker), the issue points to the line responsible. For example: the API returns 500 when a required field is missing ([#8](https://github.com/ThomasTDS/qa-api-python/issues/8)) and accepts a negative price ([#21](https://github.com/ThomasTDS/qa-api-python/issues/21)). ## 9. Metrics and reports - **Result of each run:** HTML report published on [GitHub Pages](https://thomastds.github.io/qa-api-python/) on every push to `master`, and kept as an artifact on every PR. - **Code coverage:** of `api/` and `models/`, tracked on [Codecov](https://codecov.io/gh/ThomasTDS/qa-api-python). -- **Test cases:** 25 in the [matrix](test-cases.md), 8 functional and 17 negative, with 3 of critical priority. -- **Defects found:** 7 open issues. +- **Test cases:** 34 in the [matrix](test-cases.md), 15 functional and 19 negative, with 3 of critical priority. +- **Defects found:** 10 open issues. - **Run time:** the full suite runs in under 1 second against the local API and in 25 to 40 seconds against the public one. diff --git a/docs/test-strategy.md b/docs/test-strategy.md index 2b5f853..ce3b72c 100644 --- a/docs/test-strategy.md +++ b/docs/test-strategy.md @@ -13,8 +13,9 @@ Verificar o comportamento da API pública [restful-booker](https://restful-booke **Dentro do escopo:** - Autenticação: geração de token com credenciais válidas e inválidas. -- CRUD de bookings: criação, consulta (por id e por filtro de nome), atualização completa (PUT), atualização parcial (PATCH) e remoção. -- Autorização: chamadas de escrita sem token e com token inválido. +- Health check (`GET /ping`). +- CRUD de bookings: criação, consulta (por id, por filtro de nome e por filtro de datas de checkin e checkout), atualização completa (PUT), atualização parcial (PATCH) e remoção. +- Autorização: as duas formas de autenticação das operações de escrita (token no cookie e Basic auth), e chamadas sem credencial ou com credencial inválida. - Validação de entrada: campos ausentes, tipos errados, valores negativos e datas em formato inválido. - Contrato das respostas: formato e tipo de cada campo. @@ -24,8 +25,6 @@ Verificar o comportamento da API pública [restful-booker](https://restful-booke - Interface web: a restful-booker não tem uma. - Testes de intrusão: o projeto registra falhas de controle de acesso que aparecem nos testes funcionais, mas não ataca a API. -**Lacunas conhecidas, planejadas:** o health check (`GET /ping`), os filtros por data (`checkin` e `checkout`) e a autenticação por `Authorization: Basic` ainda não têm cenários. - ## 3. Abordagem por nível | Nível | Onde | O que verifica | Por quê | @@ -40,7 +39,8 @@ Verificar o comportamento da API pública [restful-booker](https://restful-booke **Técnicas aplicadas:** - **Partição de equivalência e valores inválidos:** para cada campo, um valor válido e as classes de valor inválido relevantes (ausente, tipo errado, negativo, formato errado). -- **Testes negativos de autorização:** cada operação de escrita é testada sem token e com token inválido. +- **Análise de valor-limite:** nos filtros por data, o booking é buscado com uma data anterior e com a mesma data do seu checkin, que é exatamente onde o filtro errado aparece (TC-027 e TC-028). +- **Testes negativos de autorização:** cada operação de escrita é testada sem token e com token inválido, e o Basic auth, com credenciais inválidas. - **Testes de caracterização:** quando a API tem um bug, o cenário registra o comportamento atual e aponta para a issue que o documenta. Se a API for corrigida, o cenário falha, avisando que a issue pode ser fechada. ## 4. Ambientes @@ -93,12 +93,12 @@ Os testes rodam em Python 3.12 e 3.13, com as dependências travadas no [uv.lock - O cenário que encontrou o defeito cita a issue num comentário no `.feature` e registra o comportamento atual (seção 3, testes de caracterização). - Quando um teste falha no CI, o relatório mostra a URL, o status, os headers e o corpo da última resposta, o que já serve de evidência para a issue. -Até agora, 7 defeitos estão documentados, todos de validação de entrada ou de código de status. Por exemplo: a API devolve 500 quando falta um campo obrigatório ([#8](https://github.com/ThomasTDS/qa-api-python/issues/8)) e aceita um preço negativo ([#21](https://github.com/ThomasTDS/qa-api-python/issues/21)). +Até agora, 10 defeitos estão documentados: a maioria de validação de entrada ou de código de status, e dois de lógica nos filtros por data. Quando a causa pode ser confirmada no [código-fonte da API](https://github.com/mwinteringham/restful-booker), a issue aponta a linha responsável. Por exemplo: a API devolve 500 quando falta um campo obrigatório ([#8](https://github.com/ThomasTDS/qa-api-python/issues/8)) e aceita um preço negativo ([#21](https://github.com/ThomasTDS/qa-api-python/issues/21)). ## 9. Métricas e relatórios - **Resultado de cada execução:** relatório HTML publicado no [GitHub Pages](https://thomastds.github.io/qa-api-python/) a cada push na `master`, e guardado como artifact em todo PR. - **Cobertura de código:** de `api/` e `models/`, acompanhada no [Codecov](https://codecov.io/gh/ThomasTDS/qa-api-python). -- **Casos de teste:** 25 na [matriz](test-cases.md), sendo 8 funcionais e 17 negativos, com 3 de prioridade crítica. -- **Defeitos encontrados:** 7 issues abertas. +- **Casos de teste:** 34 na [matriz](test-cases.md), sendo 15 funcionais e 19 negativos, com 3 de prioridade crítica. +- **Defeitos encontrados:** 10 issues abertas. - **Tempo de execução:** a suíte completa roda em menos de 1 segundo contra a API local e em 25 a 40 segundos contra a pública. diff --git a/features/delete_booking.feature b/features/delete_booking.feature index e0aa2e8..277f537 100644 --- a/features/delete_booking.feature +++ b/features/delete_booking.feature @@ -23,3 +23,10 @@ Funcionalidade: Remoção de booking Dado que o usuário possui um token de autenticação válido Quando o usuário tenta remover o booking pelo id "999999999" Então a resposta deve indicar que o método não é permitido + + @TC-033 + Cenário: Remover booking com Basic auth + Dado que existe um booking criado + E que o usuário possui credenciais de Basic auth válidas + Quando o usuário remove o booking + Então o booking deve ser removido com sucesso diff --git a/features/get_booking.feature b/features/get_booking.feature index 8cf0607..240a87a 100644 --- a/features/get_booking.feature +++ b/features/get_booking.feature @@ -19,3 +19,38 @@ Funcionalidade: Consulta de booking Então o id do booking criado deve estar entre os resultados Quando o usuário busca bookings filtrando por um firstname e lastname que não correspondem a nenhum booking Então o id do booking criado não deve estar entre os resultados + + @TC-027 + Cenário: Buscar bookings com checkin a partir de uma data anterior à do booking + Dado que existe um booking criado com checkin em "2026-01-10" e checkout em "2026-01-15" + Quando o usuário busca o booking criado com o filtro de checkin "2026-01-09" + Então o id do booking criado deve estar entre os resultados + + # A documentação diz que o filtro checkin traz bookings com checkin "maior + # ou igual" à data informada, mas a API usa "maior que": um booking com + # checkin exatamente na data do filtro fica de fora. Defeito de valor-limite, + # com a causa confirmada no código da API, documentado em + # https://github.com/ThomasTDS/qa-api-python/issues/54. + @TC-028 + Cenário: Buscar bookings com checkin a partir da mesma data do booking + Dado que existe um booking criado com checkin em "2026-01-10" e checkout em "2026-01-15" + Quando o usuário busca o booking criado com o filtro de checkin "2026-01-10" + Então o id do booking criado não deve estar entre os resultados + + # Pela documentação, o filtro checkout também é "maior ou igual", então um + # checkout posterior ao do booking deveria deixá-lo de fora. A API faz o + # contrário e traz bookings com checkout até a data informada. Documentado + # em https://github.com/ThomasTDS/qa-api-python/issues/55. + @TC-029 + Cenário: Buscar bookings com checkout a partir de uma data posterior à do booking + Dado que existe um booking criado com checkin em "2026-01-10" e checkout em "2026-01-15" + Quando o usuário busca o booking criado com o filtro de checkout "2026-01-16" + Então o id do booking criado deve estar entre os resultados + + # Mesmo padrão do TC-015: em vez de rejeitar a data inválida com 400, a API + # derruba com 500. Documentado em + # https://github.com/ThomasTDS/qa-api-python/issues/56. + @TC-030 + Cenário: Tentar buscar bookings com uma data inválida no filtro + Quando o usuário busca bookings com o filtro de checkin "data-invalida" + Então a resposta deve indicar um erro interno do servidor diff --git a/features/health.feature b/features/health.feature new file mode 100644 index 0000000..1252a27 --- /dev/null +++ b/features/health.feature @@ -0,0 +1,10 @@ +# language: pt +Funcionalidade: Verificação de saúde da API + + # A documentação da API define o /ping como health check, com 201 Created + # como resposta de sucesso. É um código incomum para uma consulta (o usual + # seria 200), mas é o comportamento documentado, não um defeito. + @TC-026 + Cenário: Verificar que a API está no ar + Quando o usuário consulta o health check da API + Então a API deve responder que está no ar diff --git a/features/update_booking.feature b/features/update_booking.feature index 62770e5..e118a64 100644 --- a/features/update_booking.feature +++ b/features/update_booking.feature @@ -108,3 +108,28 @@ Funcionalidade: Atualização de booking (PUT e PATCH) | verbo | | atualizar | | atualizar parcialmente | + + # A API aceita Basic auth (cabeçalho Authorization) como alternativa ao + # token no cookie, nas operações de escrita. Os dois mecanismos precisam + # funcionar, e o inválido precisa ser recusado. + @TC-031 + Cenário: Atualizar booking com Basic auth + Dado que existe um booking criado + E que o usuário possui credenciais de Basic auth válidas + Quando o usuário atualiza o booking com novos dados + Então o booking deve ser atualizado com sucesso + + @TC-032 + Cenário: Atualizar parcialmente o booking com Basic auth + Dado que existe um booking criado + E que o usuário possui credenciais de Basic auth válidas + Quando o usuário atualiza parcialmente o booking alterando o sobrenome para "Basic" + Então o booking deve ser atualizado com sucesso + E o sobrenome do booking deve ser "Basic" + + @TC-034 + Cenário: Tentar atualizar booking com Basic auth inválido + Dado que existe um booking criado + E que o usuário possui credenciais de Basic auth inválidas + Quando o usuário tenta atualizar o booking com novos dados + Então a resposta deve indicar acesso não autorizado diff --git a/steps/common_steps.py b/steps/common_steps.py index 40fca32..111322d 100644 --- a/steps/common_steps.py +++ b/steps/common_steps.py @@ -22,6 +22,17 @@ def com_token_de_autenticacao_invalido(context: Context) -> None: context.token = "token-invalido-qualquer" +# Basic auth é a alternativa da API ao token no cookie, nas operações de escrita. +@given("que o usuário possui credenciais de Basic auth válidas") +def com_basic_auth_valido(context: Context) -> None: + context.basic_auth = (context.auth_client.username, context.auth_client.password) + + +@given("que o usuário possui credenciais de Basic auth inválidas") +def com_basic_auth_invalido(context: Context) -> None: + context.basic_auth = (context.auth_client.username, "senha-errada") + + @given("que existe um booking criado") def existe_um_booking_criado(context: Context) -> None: context.booking_data = default_booking() @@ -38,3 +49,8 @@ def resposta_indica_acesso_nao_autorizado(context: Context) -> None: @then("a resposta deve indicar que o método não é permitido") def resposta_indica_metodo_nao_permitido(context: Context) -> None: assert context.response.status_code == 405 + + +@then("a resposta deve indicar um erro interno do servidor") +def resposta_indica_erro_interno_do_servidor(context: Context) -> None: + assert context.response.status_code == 500 diff --git a/steps/test_create_booking.py b/steps/test_create_booking.py index 1f3fedd..641faf2 100644 --- a/steps/test_create_booking.py +++ b/steps/test_create_booking.py @@ -43,11 +43,6 @@ def tenta_criar_booking_sem_firstname(context: Context) -> None: _create_raw(context, booking_payload(without="firstname")) -@then("a resposta deve indicar um erro interno do servidor") -def resposta_indica_erro_interno_do_servidor(context: Context) -> None: - assert context.response.status_code == 500 - - @when("o usuário cria um booking com totalprice em formato inválido") def cria_booking_com_totalprice_invalido(context: Context) -> None: _create_raw(context, booking_payload(totalprice="nao-e-numero")) diff --git a/steps/test_delete_booking.py b/steps/test_delete_booking.py index ce98726..5abb47f 100644 --- a/steps/test_delete_booking.py +++ b/steps/test_delete_booking.py @@ -9,12 +9,16 @@ @when("o usuário tenta remover o booking") def remove_o_booking(context: Context) -> None: assert context.booking_id is not None - context.last_response = context.booking_client.delete_booking(context.booking_id, context.token_or_empty) + context.last_response = context.booking_client.delete_booking( + context.booking_id, context.token_or_empty, basic_auth=context.basic_auth + ) @when(parsers.parse('o usuário tenta remover o booking pelo id "{booking_id}"')) def tenta_remover_o_booking_pelo_id_informado(context: Context, booking_id: str) -> None: - context.last_response = context.booking_client.delete_booking(int(booking_id), context.token_or_empty) + context.last_response = context.booking_client.delete_booking( + int(booking_id), context.token_or_empty, basic_auth=context.basic_auth + ) @then("o booking deve ser removido com sucesso") diff --git a/steps/test_get_booking.py b/steps/test_get_booking.py index 1ac9f65..0b0ed4a 100644 --- a/steps/test_get_booking.py +++ b/steps/test_get_booking.py @@ -1,7 +1,10 @@ -from pytest_bdd import parsers, scenarios, then, when +from datetime import date + +from pytest_bdd import given, parsers, scenarios, then, when from conftest import Context -from models.booking import Booking, BookingIdList +from models.booking import Booking, BookingDates, BookingIdList, CreateBookingResponse +from steps.booking_data import default_booking scenarios("../features/get_booking.feature") @@ -58,3 +61,32 @@ def id_do_booking_criado_nao_esta_entre_os_resultados(context: Context) -> None: assert context.response.status_code == 200 body = BookingIdList.validate_json(context.response.text) assert not any(b.bookingid == context.booking_id for b in body) + + +# FILTROS POR DATA +# As datas ficam explícitas no cenário porque o que se testa é a fronteira +# entre a data do booking e a data do filtro. +@given(parsers.parse('que existe um booking criado com checkin em "{checkin}" e checkout em "{checkout}"')) +def existe_um_booking_criado_com_datas(context: Context, checkin: str, checkout: str) -> None: + dates = BookingDates(checkin=date.fromisoformat(checkin), checkout=date.fromisoformat(checkout)) + context.booking_data = default_booking().model_copy(update={"bookingdates": dates}) + response = context.booking_client.create_booking(context.booking_data) + context.booking_id = CreateBookingResponse.model_validate_json(response.text).bookingid + + +# Combina o filtro de data com o nome do booking criado, para que o resultado +# não dependa dos outros bookings que existem na API. +@when(parsers.parse('o usuário busca o booking criado com o filtro de {campo} "{data}"')) +def busca_o_booking_criado_com_filtro_de_data(context: Context, campo: str, data: str) -> None: + assert campo in ("checkin", "checkout"), campo + assert context.booking_data is not None + context.last_response = context.booking_client.get_booking_ids( + firstname=context.booking_data.firstname, + lastname=context.booking_data.lastname, + **{campo: data}, + ) + + +@when(parsers.parse('o usuário busca bookings com o filtro de checkin "{data}"')) +def busca_bookings_com_filtro_de_checkin(context: Context, data: str) -> None: + context.last_response = context.booking_client.get_booking_ids(checkin=data) diff --git a/steps/test_health.py b/steps/test_health.py new file mode 100644 index 0000000..935ffcf --- /dev/null +++ b/steps/test_health.py @@ -0,0 +1,16 @@ +from pytest_bdd import scenarios, then, when + +from conftest import Context + +scenarios("../features/health.feature") + + +@when("o usuário consulta o health check da API") +def consulta_o_health_check(context: Context) -> None: + context.last_response = context.health_client.ping() + + +# A documentação da API define 201 Created como a resposta de sucesso do /ping. +@then("a API deve responder que está no ar") +def api_responde_que_esta_no_ar(context: Context) -> None: + assert context.response.status_code == 201 diff --git a/steps/test_update_booking.py b/steps/test_update_booking.py index 5cbf74d..84fc93c 100644 --- a/steps/test_update_booking.py +++ b/steps/test_update_booking.py @@ -12,7 +12,9 @@ def atualiza_o_booking_com_novos_dados(context: Context) -> None: assert context.booking_data is not None assert context.booking_id is not None updated = context.booking_data.model_copy(update={"firstname": "Atualizado", "totalprice": 200}) - context.last_response = context.booking_client.update_booking(context.booking_id, updated, context.token_or_empty) + context.last_response = context.booking_client.update_booking( + context.booking_id, updated, context.token_or_empty, basic_auth=context.basic_auth + ) context.booking_data = updated @@ -21,7 +23,9 @@ def tenta_atualizar_o_booking_com_novos_dados(context: Context) -> None: assert context.booking_data is not None assert context.booking_id is not None updated = context.booking_data.model_copy(update={"firstname": "NaoDeveriaFuncionar"}) - context.last_response = context.booking_client.update_booking(context.booking_id, updated, context.token_or_empty) + context.last_response = context.booking_client.update_booking( + context.booking_id, updated, context.token_or_empty, basic_auth=context.basic_auth + ) @then("o booking deve ser atualizado com sucesso") @@ -34,7 +38,7 @@ def booking_deve_ser_atualizado_com_sucesso(context: Context) -> None: def atualiza_parcialmente_o_sobrenome(context: Context, lastname: str) -> None: assert context.booking_id is not None context.last_response = context.booking_client.partial_update_booking( - context.booking_id, {"lastname": lastname}, context.token_or_empty + context.booking_id, {"lastname": lastname}, context.token_or_empty, basic_auth=context.basic_auth ) @@ -47,7 +51,9 @@ def sobrenome_do_booking_deve_ser(context: Context, lastname: str) -> None: @when("o usuário atualiza o booking com corpo vazio") def atualiza_o_booking_com_corpo_vazio(context: Context) -> None: assert context.booking_id is not None - context.last_response = context.booking_client.update_booking_raw(context.booking_id, {}, context.token_or_empty) + context.last_response = context.booking_client.update_booking_raw( + context.booking_id, {}, context.token_or_empty, basic_auth=context.basic_auth + ) @then("a resposta deve indicar requisição inválida") @@ -60,7 +66,7 @@ def atualiza_o_booking_com_totalprice_invalido(context: Context) -> None: assert context.booking_id is not None payload = booking_payload(totalprice="nao-e-numero") context.last_response = context.booking_client.update_booking_raw( - context.booking_id, payload, context.token_or_empty + context.booking_id, payload, context.token_or_empty, basic_auth=context.basic_auth ) @@ -74,7 +80,7 @@ def atualiza_o_booking_sem_bookingdates(context: Context) -> None: assert context.booking_id is not None payload = booking_payload(without="bookingdates") context.last_response = context.booking_client.update_booking_raw( - context.booking_id, payload, context.token_or_empty + context.booking_id, payload, context.token_or_empty, basic_auth=context.basic_auth ) @@ -82,7 +88,7 @@ def atualiza_o_booking_sem_bookingdates(context: Context) -> None: def atualiza_parcialmente_o_booking_com_lastname_invalido(context: Context) -> None: assert context.booking_id is not None context.last_response = context.booking_client.partial_update_booking( - context.booking_id, {"lastname": 12345}, context.token_or_empty + context.booking_id, {"lastname": 12345}, context.token_or_empty, basic_auth=context.basic_auth ) @@ -95,7 +101,7 @@ def lastname_do_booking_atualizado_deve_ser_o_valor_numerico(context: Context) - def atualiza_parcialmente_o_booking_com_corpo_vazio(context: Context) -> None: assert context.booking_id is not None context.last_response = context.booking_client.partial_update_booking( - context.booking_id, {}, context.token_or_empty + context.booking_id, {}, context.token_or_empty, basic_auth=context.basic_auth ) @@ -109,9 +115,9 @@ def dados_do_booking_nao_devem_ter_sido_alterados(context: Context) -> None: def tenta_verbo_um_booking_inexistente(context: Context, verbo: str) -> None: if verbo == "atualizar": context.last_response = context.booking_client.update_booking( - 999999999, default_booking(), context.token_or_empty + 999999999, default_booking(), context.token_or_empty, basic_auth=context.basic_auth ) else: context.last_response = context.booking_client.partial_update_booking( - 999999999, {"lastname": "Novo"}, context.token_or_empty + 999999999, {"lastname": "Novo"}, context.token_or_empty, basic_auth=context.basic_auth ) diff --git a/tests/unit/test_booking_api_client.py b/tests/unit/test_booking_api_client.py index 34a1821..ef81b5b 100644 --- a/tests/unit/test_booking_api_client.py +++ b/tests/unit/test_booking_api_client.py @@ -117,3 +117,60 @@ def test_delete_booking_sends_the_auth_token_as_cookie(requests_mock: Mocker) -> assert requests_mock.last_request is not None assert requests_mock.last_request.headers["Cookie"] == "token=tok123" + + +BASIC_AUTH = ("usuario", "senha") +# "usuario:senha" em base64, como o requests monta o cabeçalho de Basic auth. +EXPECTED_AUTHORIZATION = "Basic dXN1YXJpbzpzZW5oYQ==" + + +def test_update_booking_with_basic_auth_sends_the_authorization_header_and_no_cookie(requests_mock: Mocker) -> None: + requests_mock.put(f"{BASE_URL}/booking/1", json={}) + client = BookingApiClient(requests.Session(), BASE_URL) + + client.update_booking(1, _booking(), token="", basic_auth=BASIC_AUTH) + + assert requests_mock.last_request is not None + assert requests_mock.last_request.headers["Authorization"] == EXPECTED_AUTHORIZATION + assert "Cookie" not in requests_mock.last_request.headers + + +def test_update_booking_raw_with_basic_auth_sends_the_authorization_header(requests_mock: Mocker) -> None: + requests_mock.put(f"{BASE_URL}/booking/1", json={}) + client = BookingApiClient(requests.Session(), BASE_URL) + + client.update_booking_raw(1, {}, token="", basic_auth=BASIC_AUTH) + + assert requests_mock.last_request is not None + assert requests_mock.last_request.headers["Authorization"] == EXPECTED_AUTHORIZATION + + +def test_partial_update_booking_with_basic_auth_sends_the_authorization_header(requests_mock: Mocker) -> None: + requests_mock.patch(f"{BASE_URL}/booking/1", json={}) + client = BookingApiClient(requests.Session(), BASE_URL) + + client.partial_update_booking(1, {"firstname": "Jane"}, token="", basic_auth=BASIC_AUTH) + + assert requests_mock.last_request is not None + assert requests_mock.last_request.headers["Authorization"] == EXPECTED_AUTHORIZATION + + +def test_delete_booking_with_basic_auth_sends_the_authorization_header(requests_mock: Mocker) -> None: + requests_mock.delete(f"{BASE_URL}/booking/1") + client = BookingApiClient(requests.Session(), BASE_URL) + + client.delete_booking(1, token="", basic_auth=BASIC_AUTH) + + assert requests_mock.last_request is not None + assert requests_mock.last_request.headers["Authorization"] == EXPECTED_AUTHORIZATION + + +def test_basic_auth_together_with_a_token_sends_both(requests_mock: Mocker) -> None: + requests_mock.delete(f"{BASE_URL}/booking/1") + client = BookingApiClient(requests.Session(), BASE_URL) + + client.delete_booking(1, token="tok123", basic_auth=BASIC_AUTH) + + assert requests_mock.last_request is not None + assert requests_mock.last_request.headers["Authorization"] == EXPECTED_AUTHORIZATION + assert requests_mock.last_request.headers["Cookie"] == "token=tok123" diff --git a/tests/unit/test_health_api_client.py b/tests/unit/test_health_api_client.py new file mode 100644 index 0000000..a7a480f --- /dev/null +++ b/tests/unit/test_health_api_client.py @@ -0,0 +1,17 @@ +import requests +from requests_mock import Mocker + +from api.health_api_client import HealthApiClient + +BASE_URL = "https://example.test" + + +def test_ping_hits_the_health_check_endpoint(requests_mock: Mocker) -> None: + requests_mock.get(f"{BASE_URL}/ping", status_code=201, text="Created") + client = HealthApiClient(requests.Session(), BASE_URL) + + response = client.ping() + + assert response.status_code == 201 + assert requests_mock.last_request is not None + assert requests_mock.last_request.method == "GET"