diff --git a/api/booking_api_client.py b/api/booking_api_client.py index 6ece8d5..588403c 100644 --- a/api/booking_api_client.py +++ b/api/booking_api_client.py @@ -44,6 +44,15 @@ def update_booking(self, booking_id: int, booking: Booking, token: str) -> reque 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}, + ) + 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}", diff --git a/docs/test-cases.md b/docs/test-cases.md index 58a8faf..56b8c0f 100644 --- a/docs/test-cases.md +++ b/docs/test-cases.md @@ -28,9 +28,20 @@ Legenda: | TC-014 | Remoção | Tentar remover um booking que não existe | Negativo | Baixa | Automatizado | [delete_booking.feature:16](../features/delete_booking.feature#L16) | | TC-015 | Criação | Tentar criar booking sem um campo obrigatório (bug) | Negativo | Média | Automatizado | [create_booking.feature:13](../features/create_booking.feature#L13) | | TC-016 | Criação | Criar booking com totalprice em formato inválido | Negativo | Média | Automatizado | [create_booking.feature:24](../features/create_booking.feature#L24) | +| TC-017 | Criação | Criar booking com depositpaid em formato inválido | Negativo | Média | Automatizado | [create_booking.feature:39](../features/create_booking.feature#L39) | +| TC-018 | Criação | Criar booking com checkin em formato inválido | Negativo | Média | Automatizado | [create_booking.feature:49](../features/create_booking.feature#L49) | +| TC-019 | Criação | Criar booking com totalprice negativo | Negativo | Baixa | Automatizado | [create_booking.feature:58](../features/create_booking.feature#L58) | +| TC-020 | Atualização | Tentar atualizar booking com corpo vazio (PUT) | Negativo | Alta | Automatizado | [update_booking.feature:47](../features/update_booking.feature#L47) | +| TC-021 | Atualização | Atualizar booking com totalprice em formato inválido | Negativo | Média | Automatizado | [update_booking.feature:59](../features/update_booking.feature#L59) | +| TC-022 | Atualização | Tentar atualizar booking sem o campo bookingdates | Negativo | Alta | Automatizado | [update_booking.feature:67](../features/update_booking.feature#L67) | +| TC-023 | Atualização | Atualizar parcialmente com lastname em formato inválido | Negativo | Média | Automatizado | [update_booking.feature:77](../features/update_booking.feature#L77) | +| TC-024 | Atualização | Atualizar parcialmente com corpo vazio não altera nada | Funcional | Baixa | Automatizado | [update_booking.feature:87](../features/update_booking.feature#L87) | +| TC-025 | Atualização | Tentar atualizar (PUT/PATCH) um booking inexistente | Negativo | Média | Automatizado | [update_booking.feature:100](../features/update_booking.feature#L100) | `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. +TC-017 a TC-025 estendem a investigação de payload inválido (iniciada em TC-015/TC-016) para `depositpaid`, `checkin`, `totalprice` negativo e para os verbos PUT/PATCH: PUT valida corretamente campos obrigatórios ausentes (400), mas ainda corrompe silenciosamente um `totalprice` de tipo inválido — o mesmo defeito do TC-016, agora confirmado também nesse verbo; PATCH não valida tipo nenhum; e PUT/PATCH em um id inexistente retornam 405 Method Not Allowed, o mesmo comportamento já visto em DELETE (TC-014). Bugs reais novos (TC-017, TC-018, TC-019, TC-021+TC-016, TC-023, TC-025+TC-014) estão documentados como issues no GitHub. + ## Smoke suite Os fluxos ponta-a-ponta mais críticos (um caminho feliz por operação de CRUD, cobrindo autenticação + criação + consulta + atualização + remoção) estão marcados com a tag `@smoke` (marker `smoke` do pytest): **TC-001, TC-003, TC-004, TC-007, TC-010**. diff --git a/features/create_booking.feature b/features/create_booking.feature index b89fee7..dd50cd6 100644 --- a/features/create_booking.feature +++ b/features/create_booking.feature @@ -31,3 +31,32 @@ Feature: Criação de booking Scenario: Criar booking com totalprice em formato inválido When ele cria um booking com totalprice em formato inválido Then a resposta da API não deve corresponder ao schema esperado + + # A API não valida o tipo de depositpaid: qualquer string não vazia é + # coagida para true, então não existe forma de a API rejeitar um valor + # inválido nesse campo. Documentado em + # https://github.com/ThomasTDS/qa-api-python/issues/19. + @TC-017 + Scenario: Criar booking com depositpaid em formato inválido + When ele cria um booking com depositpaid em formato inválido + Then o booking deve ser criado com sucesso + And o depositpaid do booking criado deve ser true + + # Uma data de check-in com formato inválido não é validada nem rejeitada: + # o valor é processado e gravado corrompido (formato "NaN"), em vez de a + # API retornar 400. Documentado em + # https://github.com/ThomasTDS/qa-api-python/issues/20. + @TC-018 + Scenario: Criar booking com checkin em formato inválido + When ele cria um booking com checkin em formato inválido + Then o booking deve ser criado com sucesso + And o checkin do booking criado deve estar corrompido + + # Não há validação de regra de negócio para totalprice: valores negativos + # são aceitos normalmente, sem nenhuma restrição. Documentado em + # https://github.com/ThomasTDS/qa-api-python/issues/21. + @TC-019 + Scenario: Criar booking com totalprice negativo + When ele cria um booking com totalprice negativo + Then o booking deve ser criado com sucesso + And o totalprice do booking criado deve ser negativo diff --git a/features/update_booking.feature b/features/update_booking.feature index 44f980c..c9d9738 100644 --- a/features/update_booking.feature +++ b/features/update_booking.feature @@ -40,3 +40,70 @@ Feature: Atualização de booking (PUT e PATCH) And que ele não possui nenhum token de autenticação When ele tenta atualizar parcialmente o booking alterando o sobrenome para "NaoDeveriaFuncionar" Then a resposta deve indicar acesso não autorizado + + # PUT valida corretamente a ausência de campos obrigatórios (ao contrário + # do POST de criação, que derruba com 500 no mesmo cenário — ver TC-015): + # aqui a API responde com 400, como esperado. + @TC-020 + Scenario: Tentar atualizar booking com corpo vazio + Given que existe um booking criado + And que ele possui um token de autenticação válido + When ele atualiza o booking com corpo vazio + Then a resposta deve indicar requisição inválida + + # Mesmo defeito do TC-016 (totalprice de tipo inválido não é validado), + # agora observado também no PUT: a API aceita e grava totalprice como + # null em silêncio. Documentado em + # https://github.com/ThomasTDS/qa-api-python/issues/22, que cobre os + # dois verbos. + @TC-021 + Scenario: Atualizar booking com totalprice em formato inválido + Given que existe um booking criado + And que ele possui um token de autenticação válido + When ele atualiza o booking com totalprice em formato inválido + Then o booking deve ser atualizado com sucesso + And o totalprice do booking atualizado deve ser nulo + + @TC-022 + Scenario: Tentar atualizar booking sem o campo bookingdates + Given que existe um booking criado + And que ele possui um token de autenticação válido + When ele atualiza o booking sem o campo bookingdates + Then a resposta deve indicar requisição inválida + + # PATCH não valida o tipo de nenhum campo enviado: um lastname numérico é + # aceito e gravado como número, quebrando o contrato do schema. Documentado + # em https://github.com/ThomasTDS/qa-api-python/issues/23. + @TC-023 + Scenario: Atualizar parcialmente o booking com lastname em formato inválido + Given que existe um booking criado + And que ele possui um token de autenticação válido + When ele atualiza parcialmente o booking com lastname em formato inválido + Then o booking deve ser atualizado com sucesso + And o lastname do booking atualizado deve ser o valor numérico enviado + + # Comportamento seguro, não um bug: PATCH com corpo vazio não falha nem + # altera nada, documentado aqui como um no-op válido. + @TC-024 + Scenario: Atualizar parcialmente o booking com corpo vazio não altera nada + Given que existe um booking criado + And que ele possui um token de autenticação válido + When ele atualiza parcialmente o booking com corpo vazio + Then o booking deve ser atualizado com sucesso + And os dados do booking não devem ter sido alterados + + # Mesmo comportamento do TC-014 (DELETE em id inexistente retorna 405), + # agora confirmado também em PUT e PATCH: nenhum dos três verbos retorna + # 404 para um id que nunca existiu. Documentado em + # https://github.com/ThomasTDS/qa-api-python/issues/24, que cobre os + # três verbos. + @TC-025 + Scenario Outline: Tentar atualizar um booking inexistente + Given que ele possui um token de autenticação válido + When ele tenta "" um booking inexistente + Then a resposta deve indicar que o método não é permitido + + Examples: + | verbo | + | atualizar | + | atualizar parcialmente | diff --git a/pyproject.toml b/pyproject.toml index 8fa31a3..2a7faf9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -51,6 +51,15 @@ markers = [ "TC-014: id do test case, ver docs/test-cases.md", "TC-015: id do test case, ver docs/test-cases.md", "TC-016: id do test case, ver docs/test-cases.md", + "TC-017: id do test case, ver docs/test-cases.md", + "TC-018: id do test case, ver docs/test-cases.md", + "TC-019: id do test case, ver docs/test-cases.md", + "TC-020: id do test case, ver docs/test-cases.md", + "TC-021: id do test case, ver docs/test-cases.md", + "TC-022: id do test case, ver docs/test-cases.md", + "TC-023: id do test case, ver docs/test-cases.md", + "TC-024: id do test case, ver docs/test-cases.md", + "TC-025: id do test case, ver docs/test-cases.md", ] [tool.ruff] diff --git a/steps/test_booking_steps.py b/steps/test_booking_steps.py index 58db32e..aa99674 100644 --- a/steps/test_booking_steps.py +++ b/steps/test_booking_steps.py @@ -124,6 +124,66 @@ def resposta_nao_corresponde_ao_schema(context: Context) -> None: CreateBookingResponse.model_validate(context.last_response.json()) +@when("ele cria um booking com depositpaid em formato inválido") +def cria_booking_com_depositpaid_invalido(context: Context) -> None: + payload = { + "firstname": "Fulano", + "lastname": "Ciclano", + "totalprice": 150, + "depositpaid": "sim", + "bookingdates": {"checkin": "2026-01-01", "checkout": "2026-01-05"}, + } + context.last_response = context.booking_client.create_booking_raw(payload) + context.booking_id = context.last_response.json().get("bookingid") + + +@then("o depositpaid do booking criado deve ser true") +def depositpaid_do_booking_criado_deve_ser_true(context: Context) -> None: + assert context.last_response is not None + body = context.last_response.json() + assert body["booking"]["depositpaid"] is True + + +@when("ele cria um booking com checkin em formato inválido") +def cria_booking_com_checkin_invalido(context: Context) -> None: + payload = { + "firstname": "Fulano", + "lastname": "Ciclano", + "totalprice": 150, + "depositpaid": True, + "bookingdates": {"checkin": "data-invalida", "checkout": "2026-01-05"}, + } + context.last_response = context.booking_client.create_booking_raw(payload) + context.booking_id = context.last_response.json().get("bookingid") + + +@then("o checkin do booking criado deve estar corrompido") +def checkin_do_booking_criado_deve_estar_corrompido(context: Context) -> None: + assert context.last_response is not None + checkin = context.last_response.json()["booking"]["bookingdates"]["checkin"] + assert checkin != "data-invalida" + assert "NaN" in checkin + + +@when("ele cria um booking com totalprice negativo") +def cria_booking_com_totalprice_negativo(context: Context) -> None: + payload = { + "firstname": "Fulano", + "lastname": "Ciclano", + "totalprice": -150, + "depositpaid": True, + "bookingdates": {"checkin": "2026-01-01", "checkout": "2026-01-05"}, + } + context.last_response = context.booking_client.create_booking_raw(payload) + context.booking_id = context.last_response.json().get("bookingid") + + +@then("o totalprice do booking criado deve ser negativo") +def totalprice_do_booking_criado_deve_ser_negativo(context: Context) -> None: + assert context.last_response is not None + assert context.last_response.json()["booking"]["totalprice"] < 0 + + # CONSULTA @when("ele busca o booking pelo id") def busca_o_booking_pelo_id(context: Context) -> None: @@ -236,6 +296,80 @@ def sobrenome_do_booking_deve_ser(context: Context, lastname: str) -> None: assert body.lastname == lastname +@when("ele 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 "") + + +@then("a resposta deve indicar requisição inválida") +def resposta_indica_requisicao_invalida(context: Context) -> None: + assert context.last_response is not None + assert context.last_response.status_code == 400 + + +@when("ele atualiza o booking com totalprice em formato inválido") +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"} + context.last_response = context.booking_client.update_booking_raw(context.booking_id, payload, context.token or "") + + +@then("o totalprice do booking atualizado deve ser nulo") +def totalprice_do_booking_atualizado_deve_ser_nulo(context: Context) -> None: + assert context.last_response is not None + assert context.last_response.json()["totalprice"] is None + + +@when("ele atualiza o booking sem o campo bookingdates") +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) + del payload["bookingdates"] + context.last_response = context.booking_client.update_booking_raw(context.booking_id, payload, context.token or "") + + +@when("ele atualiza parcialmente o booking com lastname em formato inválido") +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 "" + ) + + +@then("o lastname do booking atualizado deve ser o valor numérico enviado") +def lastname_do_booking_atualizado_deve_ser_o_valor_numerico(context: Context) -> None: + assert context.last_response is not None + assert context.last_response.json()["lastname"] == 12345 + + +@when("ele atualiza parcialmente o booking com corpo vazio") +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 "") + + +@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()) + assert body == context.booking_data + + +@when(parsers.parse('ele tenta "{verbo}" um booking inexistente')) +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 "" + ) + else: + context.last_response = context.booking_client.partial_update_booking( + 999999999, {"lastname": "Novo"}, context.token or "" + ) + + # REMOÇÃO @when("ele remove o booking") def remove_o_booking(context: Context) -> None: diff --git a/tests/unit/test_booking_api_client.py b/tests/unit/test_booking_api_client.py index 7e7075e..7eaf041 100644 --- a/tests/unit/test_booking_api_client.py +++ b/tests/unit/test_booking_api_client.py @@ -72,6 +72,18 @@ def test_update_booking_sends_the_full_payload_with_the_auth_token_as_cookie(req assert requests_mock.last_request.headers["Cookie"] == "token=tok123" +def test_update_booking_raw_sends_the_payload_as_is_with_the_auth_token_as_cookie(requests_mock: Mocker) -> None: + requests_mock.put(f"{BASE_URL}/booking/1", json={}) + client = BookingApiClient(requests.Session(), BASE_URL) + payload = {"firstname": "Jane", "totalprice": "nao-e-numero"} + + client.update_booking_raw(1, payload, token="tok123") + + assert requests_mock.last_request is not None + assert requests_mock.last_request.json() == payload + assert requests_mock.last_request.headers["Cookie"] == "token=tok123" + + def test_partial_update_booking_sends_only_the_given_fields(requests_mock: Mocker) -> None: requests_mock.patch(f"{BASE_URL}/booking/1", json={}) client = BookingApiClient(requests.Session(), BASE_URL)