use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::Path;
use super::RunnerError;
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct TestSpec {
#[serde(default)]
pub name: Option<String>,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub env: EnvConfig,
#[serde(default)]
pub requests: HashMap<String, TestRequest>,
#[serde(default)]
pub tests: HashMap<String, TestCase>,
#[serde(default)]
pub diffs: HashMap<String, DiffCase>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct DiffCase {
#[serde(default)]
pub description: Option<String>,
pub endpoints: Vec<EndpointDef>,
#[serde(default = "default_get_method")]
pub method: String,
#[serde(default)]
pub requests: Vec<RequestRef>,
#[serde(default)]
pub setup: Vec<SetupStep>,
#[serde(default)]
pub assertions: Vec<Assertion>,
#[serde(default = "default_status")]
pub expect_status: u16,
#[serde(default)]
pub timeout_ms: Option<u64>,
}
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
pub struct EnvConfig {
#[serde(default)]
pub base_url: Option<String>,
#[serde(default)]
pub timeout_ms: Option<u64>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct TestRequest {
#[serde(default)]
pub body: Option<serde_json::Value>,
#[serde(default)]
pub headers: HashMap<String, String>,
#[serde(default)]
pub extends: Option<String>,
#[serde(default)]
pub query_params: HashMap<String, String>,
#[serde(default)]
pub extract: HashMap<String, String>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct TestCase {
#[serde(default)]
pub description: Option<String>,
pub endpoint: String,
#[serde(default = "default_method")]
pub method: String,
pub requests: Vec<String>,
#[serde(default)]
pub skip_first: bool,
#[serde(default)]
pub fail_fast: bool,
#[serde(default)]
pub assertions: Vec<Assertion>,
#[serde(default = "default_status")]
pub expect_status: u16,
#[serde(default)]
pub timeout_ms: Option<u64>,
}
fn default_method() -> String {
"POST".to_string()
}
fn default_get_method() -> String {
"GET".to_string()
}
fn default_status() -> u16 {
200
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct Assertion {
#[serde(default)]
pub query: Option<String>,
#[serde(default)]
pub expect: Option<serde_json::Value>,
#[serde(default)]
pub expect_gt: Option<AssertionValue>,
#[serde(default)]
pub expect_lt: Option<AssertionValue>,
#[serde(default)]
pub expect_gte: Option<AssertionValue>,
#[serde(default)]
pub expect_lte: Option<AssertionValue>,
#[serde(default)]
pub expect_type: Option<String>,
#[serde(default)]
pub expect_match: Option<String>,
#[serde(default)]
pub message: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub scope: Option<Vec<String>>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(untagged)]
pub enum AssertionValue {
Number(f64),
String(String),
Integer(i64),
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(untagged)]
pub enum EndpointDef {
Simple(String),
Named { name: String, path: String },
}
impl EndpointDef {
pub fn path(&self) -> &str {
match self {
EndpointDef::Simple(s) => s,
EndpointDef::Named { path, .. } => path,
}
}
pub fn name(&self) -> &str {
match self {
EndpointDef::Simple(s) => s,
EndpointDef::Named { name, .. } => name,
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(untagged)]
pub enum RequestRef {
Simple(String),
Scoped { name: String, scope: Vec<String> },
}
impl RequestRef {
pub fn request_name(&self) -> &str {
match self {
RequestRef::Simple(s) => s,
RequestRef::Scoped { name, .. } => name,
}
}
pub fn applies_to(&self, endpoint_name: &str) -> bool {
match self {
RequestRef::Simple(_) => true,
RequestRef::Scoped { scope, .. } => scope.iter().any(|s| s == endpoint_name),
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct SetupStep {
pub request: String,
pub endpoint: String,
#[serde(default = "default_method")]
pub method: String,
}
impl TestSpec {
pub fn from_file<P: AsRef<Path>>(path: P) -> Result<Self, RunnerError> {
let content = std::fs::read_to_string(path)?;
Self::from_yaml(&content)
}
pub fn from_yaml(yaml: &str) -> Result<Self, RunnerError> {
let spec: TestSpec = serde_yaml::from_str(yaml)?;
spec.validate()?;
Ok(spec)
}
fn validate(&self) -> Result<(), RunnerError> {
for (test_name, test) in &self.tests {
for req_name in &test.requests {
if !self.requests.contains_key(req_name) {
return Err(RunnerError::ParseError(format!(
"Test '{}' references unknown request '{}'",
test_name, req_name
)));
}
}
}
for (diff_name, diff) in &self.diffs {
if diff.endpoints.len() < 2 {
return Err(RunnerError::ParseError(format!(
"Diff '{}' must have at least 2 endpoints",
diff_name
)));
}
let endpoint_names: Vec<&str> = diff.endpoints.iter().map(|e| e.name()).collect();
for req_ref in &diff.requests {
if !self.requests.contains_key(req_ref.request_name()) {
return Err(RunnerError::ParseError(format!(
"Diff '{}' references unknown request '{}'",
diff_name, req_ref.request_name()
)));
}
if let RequestRef::Scoped { scope, .. } = req_ref {
if scope.is_empty() {
return Err(RunnerError::ParseError(format!(
"Diff '{}' request '{}' has empty scope",
diff_name, req_ref.request_name()
)));
}
for s in scope {
if !endpoint_names.contains(&s.as_str()) {
return Err(RunnerError::ParseError(format!(
"Diff '{}' request '{}' scope references unknown endpoint '{}'",
diff_name, req_ref.request_name(), s
)));
}
}
}
}
for assertion in &diff.assertions {
if let Some(ref scope) = assertion.scope {
if scope.is_empty() {
return Err(RunnerError::ParseError(format!(
"Diff '{}' has assertion with empty scope",
diff_name
)));
}
for s in scope {
if !endpoint_names.contains(&s.as_str()) {
return Err(RunnerError::ParseError(format!(
"Diff '{}' assertion scope references unknown endpoint '{}'",
diff_name, s
)));
}
}
}
}
for step in &diff.setup {
if !self.requests.contains_key(&step.request) {
return Err(RunnerError::ParseError(format!(
"Diff '{}' setup references unknown request '{}'",
diff_name, step.request
)));
}
}
}
for (req_name, req) in &self.requests {
if let Some(ref extends) = req.extends {
if !self.requests.contains_key(extends) {
return Err(RunnerError::ParseError(format!(
"Request '{}' extends unknown request '{}'",
req_name, extends
)));
}
}
}
Ok(())
}
pub fn resolve_request(&self, name: &str) -> Result<TestRequest, RunnerError> {
let req = self
.requests
.get(name)
.ok_or_else(|| RunnerError::RequestNotFound(name.to_string()))?;
if let Some(ref extends) = req.extends {
let parent = self.resolve_request(extends)?;
Ok(merge_requests(&parent, req))
} else {
Ok(req.clone())
}
}
}
fn merge_requests(parent: &TestRequest, child: &TestRequest) -> TestRequest {
let mut merged = parent.clone();
for (k, v) in &child.headers {
merged.headers.insert(k.clone(), v.clone());
}
for (k, v) in &child.query_params {
merged.query_params.insert(k.clone(), v.clone());
}
if child.body.is_some() {
merged.body = child.body.clone();
}
for (k, v) in &child.extract {
merged.extract.insert(k.clone(), v.clone());
}
merged.extends = None;
merged
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_basic_spec() {
let yaml = r#"
name: "Test Suite"
description: "A test suite"
env:
base_url: "http://localhost:8080"
timeout_ms: 5000
requests:
simple_request:
body:
name: "John"
email: "john@example.com"
tests:
basic_test:
endpoint: /api/v1/test
requests: [simple_request]
assertions:
- query: "$[status]"
expect: "ok"
"#;
let spec = TestSpec::from_yaml(yaml).unwrap();
assert_eq!(spec.name, Some("Test Suite".to_string()));
assert!(spec.requests.contains_key("simple_request"));
assert!(spec.tests.contains_key("basic_test"));
}
#[test]
fn test_invalid_request_reference() {
let yaml = r#"
requests:
existing_request:
body: {}
tests:
bad_test:
endpoint: /api/v1/test
requests: [nonexistent_request]
assertions: []
"#;
let result = TestSpec::from_yaml(yaml);
assert!(result.is_err());
}
#[test]
fn test_request_inheritance() {
let yaml = r#"
requests:
base_request:
headers:
Content-Type: application/json
body:
name: "Base"
child_request:
extends: base_request
body:
name: "Child"
extra: "field"
tests:
test:
endpoint: /test
requests: [child_request]
assertions: []
"#;
let spec = TestSpec::from_yaml(yaml).unwrap();
let resolved = spec.resolve_request("child_request").unwrap();
assert_eq!(
resolved.headers.get("Content-Type"),
Some(&"application/json".to_string())
);
let body = resolved.body.unwrap();
assert_eq!(body["name"], "Child");
assert_eq!(body["extra"], "field");
}
#[test]
fn test_parse_diff_simple_endpoints() {
let yaml = r#"
requests:
get_users:
body: {}
diffs:
version_compare:
endpoints:
- /api/v1/users
- /api/v2/users
requests: [get_users]
assertions: []
"#;
let spec = TestSpec::from_yaml(yaml).unwrap();
let diff = &spec.diffs["version_compare"];
assert_eq!(diff.endpoints[0].path(), "/api/v1/users");
assert_eq!(diff.endpoints[0].name(), "/api/v1/users");
assert_eq!(diff.endpoints[1].path(), "/api/v2/users");
}
#[test]
fn test_parse_diff_named_endpoints() {
let yaml = r#"
requests:
get_users:
body: {}
diffs:
version_compare:
endpoints:
- name: v1
path: /api/v1/users
- name: v2
path: /api/v2/users
requests: [get_users]
assertions: []
"#;
let spec = TestSpec::from_yaml(yaml).unwrap();
let diff = &spec.diffs["version_compare"];
assert_eq!(diff.endpoints[0].name(), "v1");
assert_eq!(diff.endpoints[0].path(), "/api/v1/users");
assert_eq!(diff.endpoints[1].name(), "v2");
assert_eq!(diff.endpoints[1].path(), "/api/v2/users");
}
#[test]
fn test_parse_diff_mixed_endpoints() {
let yaml = r#"
requests:
get_users:
body: {}
diffs:
version_compare:
endpoints:
- /api/v1/users
- name: v2
path: /api/v2/users
requests: [get_users]
assertions: []
"#;
let spec = TestSpec::from_yaml(yaml).unwrap();
let diff = &spec.diffs["version_compare"];
assert_eq!(diff.endpoints[0].name(), "/api/v1/users");
assert_eq!(diff.endpoints[1].name(), "v2");
}
#[test]
fn test_parse_diff_scoped_requests() {
let yaml = r#"
requests:
v1_call:
body:
format: v1
v2_call:
body:
format: v2
common_call:
body: {}
diffs:
version_compare:
endpoints:
- name: v1
path: /api/v1/users
- name: v2
path: /api/v2/users
requests:
- common_call
- name: v1_call
scope: [v1]
- name: v2_call
scope: [v2]
assertions: []
"#;
let spec = TestSpec::from_yaml(yaml).unwrap();
let diff = &spec.diffs["version_compare"];
assert_eq!(diff.requests.len(), 3);
assert!(diff.requests[0].applies_to("v1"));
assert!(diff.requests[0].applies_to("v2"));
assert!(diff.requests[1].applies_to("v1"));
assert!(!diff.requests[1].applies_to("v2"));
assert!(!diff.requests[2].applies_to("v1"));
assert!(diff.requests[2].applies_to("v2"));
}
#[test]
fn test_parse_diff_setup() {
let yaml = r#"
requests:
login:
body:
username: admin
password: secret
extract:
token: "$[token]"
get_users:
headers:
Authorization: "Bearer {{token}}"
diffs:
authed_compare:
setup:
- request: login
endpoint: /auth/login
endpoints:
- /api/v1/users
- /api/v2/users
requests: [get_users]
assertions: []
"#;
let spec = TestSpec::from_yaml(yaml).unwrap();
let diff = &spec.diffs["authed_compare"];
assert_eq!(diff.setup.len(), 1);
assert_eq!(diff.setup[0].request, "login");
assert_eq!(diff.setup[0].endpoint, "/auth/login");
assert_eq!(diff.setup[0].method, "POST");
}
#[test]
fn test_parse_diff_scoped_assertions() {
let yaml = r#"
requests:
get_users:
body: {}
diffs:
version_compare:
endpoints:
- name: v1
path: /api/v1/users
- name: v2
path: /api/v2/users
- name: v3
path: /api/v3/users
requests: [get_users]
assertions:
- query: "$[additions]"
expect: 0
scope: [v1, v2]
- query: "$[removals]"
expect: 0
"#;
let spec = TestSpec::from_yaml(yaml).unwrap();
let diff = &spec.diffs["version_compare"];
assert_eq!(diff.assertions[0].scope, Some(vec!["v1".to_string(), "v2".to_string()]));
assert_eq!(diff.assertions[1].scope, None);
}
#[test]
fn test_validate_scoped_request_unknown_endpoint() {
let yaml = r#"
requests:
call:
body: {}
diffs:
bad:
endpoints:
- name: v1
path: /v1
- name: v2
path: /v2
requests:
- name: call
scope: [v3]
assertions: []
"#;
let result = TestSpec::from_yaml(yaml);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("unknown endpoint 'v3'"));
}
#[test]
fn test_validate_empty_scope() {
let yaml = r#"
requests:
call:
body: {}
diffs:
bad:
endpoints:
- name: v1
path: /v1
- name: v2
path: /v2
requests:
- name: call
scope: []
assertions: []
"#;
let result = TestSpec::from_yaml(yaml);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("empty scope"));
}
#[test]
fn test_validate_setup_unknown_request() {
let yaml = r#"
requests:
get_users:
body: {}
diffs:
bad:
setup:
- request: nonexistent
endpoint: /auth
endpoints:
- /v1
- /v2
requests: [get_users]
assertions: []
"#;
let result = TestSpec::from_yaml(yaml);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("setup references unknown request"));
}
#[test]
fn test_validate_assertion_scope_unknown_endpoint() {
let yaml = r#"
requests:
call:
body: {}
diffs:
bad:
endpoints:
- name: v1
path: /v1
- name: v2
path: /v2
requests: [call]
assertions:
- query: "$[additions]"
expect: 0
scope: [v1, v99]
"#;
let result = TestSpec::from_yaml(yaml);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("unknown endpoint 'v99'"));
}
}