ferridriver-bdd 0.4.0

BDD/Cucumber test framework for ferridriver. 144 built-in Gherkin steps backed by the Page API.
Documentation
//! API request step definitions -- direct HTTP requests from BDD scenarios.
//!
//! Uses `HttpClient` from the core library for making HTTP requests
//! outside the browser context. Response stored in world for assertion chaining.
//!
//! ```gherkin
//! When I send a GET request to "/api/users"
//! When I send a POST request to "/api/users" with body:
//!   """
//!   {"name": "Alice"}
//!   """
//! Then the API response status should be 200
//! Then the API response body should contain "Alice"
//! Then the API response header "content-type" should contain "json"
//! ```

use crate::step::StepError;
use crate::world::BrowserWorld;
use ferridriver::http_client::{HttpClient, HttpClientOptions, HttpResponse, RequestOptions};
use ferridriver_bdd_macros::{then, when};

/// Stored API response for assertion chaining.
struct LastHttpResponse(HttpResponse);

fn get_or_create_ctx(world: &mut BrowserWorld) -> &HttpClient {
  if world.get_state::<HttpClient>().is_none() {
    world.set_state(HttpClient::new(HttpClientOptions::default()));
  }
  world.get_state::<HttpClient>().unwrap()
}

fn last_api_response(world: &BrowserWorld) -> Result<&HttpResponse, StepError> {
  world
    .get_state::<LastHttpResponse>()
    .map(|r| &r.0)
    .ok_or_else(|| StepError::from("no API response stored -- use 'When I send a GET/POST/... request' first"))
}

// ── Request steps ──────────────────────────────────────────────────────

#[when("I send a GET request to {string}")]
async fn send_get(world: &mut BrowserWorld, url: String) {
  let ctx = get_or_create_ctx(world);
  let resp = ctx
    .get(&url, None)
    .await
    .map_err(|e| StepError::wrap(format!("GET {url}"), e))?;
  world.set_state(LastHttpResponse(resp));
}

#[when("I send a POST request to {string}")]
async fn send_post_no_body(world: &mut BrowserWorld, url: String) {
  let ctx = get_or_create_ctx(world);
  let resp = ctx
    .post(&url, None)
    .await
    .map_err(|e| StepError::wrap(format!("POST {url}"), e))?;
  world.set_state(LastHttpResponse(resp));
}

#[when("I send a POST request to {string} with body:")]
async fn send_post_with_body(world: &mut BrowserWorld, url: String, docstring: Option<&str>) {
  let body = docstring.unwrap_or("").to_string();
  let ctx = get_or_create_ctx(world);
  let opts = RequestOptions {
    json_data: serde_json::from_str(&body).ok(),
    data: if serde_json::from_str::<serde_json::Value>(&body).is_err() {
      Some(body.into_bytes())
    } else {
      None
    },
    ..Default::default()
  };
  let resp = ctx
    .post(&url, Some(opts))
    .await
    .map_err(|e| StepError::wrap(format!("POST {url}"), e))?;
  world.set_state(LastHttpResponse(resp));
}

#[when("I send a PUT request to {string} with body:")]
async fn send_put_with_body(world: &mut BrowserWorld, url: String, docstring: Option<&str>) {
  let body = docstring.unwrap_or("").to_string();
  let ctx = get_or_create_ctx(world);
  let opts = RequestOptions {
    json_data: serde_json::from_str(&body).ok(),
    data: if serde_json::from_str::<serde_json::Value>(&body).is_err() {
      Some(body.into_bytes())
    } else {
      None
    },
    ..Default::default()
  };
  let resp = ctx
    .put(&url, Some(opts))
    .await
    .map_err(|e| StepError::wrap(format!("PUT {url}"), e))?;
  world.set_state(LastHttpResponse(resp));
}

#[when("I send a DELETE request to {string}")]
async fn send_delete(world: &mut BrowserWorld, url: String) {
  let ctx = get_or_create_ctx(world);
  let resp = ctx
    .delete(&url, None)
    .await
    .map_err(|e| StepError::wrap(format!("DELETE {url}"), e))?;
  world.set_state(LastHttpResponse(resp));
}

#[when("I send a PATCH request to {string} with body:")]
async fn send_patch_with_body(world: &mut BrowserWorld, url: String, docstring: Option<&str>) {
  let body = docstring.unwrap_or("").to_string();
  let ctx = get_or_create_ctx(world);
  let opts = RequestOptions {
    json_data: serde_json::from_str(&body).ok(),
    data: if serde_json::from_str::<serde_json::Value>(&body).is_err() {
      Some(body.into_bytes())
    } else {
      None
    },
    ..Default::default()
  };
  let resp = ctx
    .patch(&url, Some(opts))
    .await
    .map_err(|e| StepError::wrap(format!("PATCH {url}"), e))?;
  world.set_state(LastHttpResponse(resp));
}

// ── Response assertion steps ───────────────────────────────────────────

#[then("the API response status should be {int}")]
async fn api_response_status(world: &mut BrowserWorld, expected: i64) {
  let resp = last_api_response(world)?;
  let actual = resp.status() as i64;
  if actual != expected {
    return Err(StepError {
      message: format!("expected API response status {expected}, got {actual}"),
      diff: Some((expected.to_string(), actual.to_string())),
      pending: false,
    });
  }
}

#[then("the API response should be successful")]
async fn api_response_ok(world: &mut BrowserWorld) {
  let resp = last_api_response(world)?;
  if !resp.ok() {
    return Err(StepError::from(format!(
      "expected successful API response (2xx), got {}",
      resp.status()
    )));
  }
}

#[then("the API response body should contain {string}")]
async fn api_response_body_contains(world: &mut BrowserWorld, expected: String) {
  let resp = last_api_response(world)?;
  let body = resp.text()?;
  if !body.contains(&expected) {
    return Err(StepError {
      message: format!("API response body does not contain \"{expected}\""),
      diff: Some((expected, body)),
      pending: false,
    });
  }
}

#[then("the API response body should equal {string}")]
async fn api_response_body_equals(world: &mut BrowserWorld, expected: String) {
  let resp = last_api_response(world)?;
  let body = resp.text()?;
  if body.trim() != expected.trim() {
    return Err(StepError {
      message: "API response body does not match expected".to_string(),
      diff: Some((expected, body)),
      pending: false,
    });
  }
}

#[then("the API response header {string} should contain {string}")]
async fn api_response_header_contains(world: &mut BrowserWorld, header: String, expected: String) {
  let resp = last_api_response(world)?;
  let header_val = resp.header(&header).unwrap_or("");
  if !header_val.contains(&expected) {
    return Err(StepError {
      message: format!("API response header \"{header}\" does not contain \"{expected}\" (got \"{header_val}\")"),
      diff: Some((expected, header_val.to_string())),
      pending: false,
    });
  }
}