mod test_helpers;
use aperture_cli::batch::{BatchConfig, BatchFile, BatchMetadata, BatchOperation, BatchProcessor};
use aperture_cli::cache::models::{CachedCommand, CachedParameter, CachedSpec, PaginationInfo};
use aperture_cli::cli::OutputFormat;
use aperture_cli::constants;
use std::collections::HashMap;
use std::io::Write;
use tempfile::NamedTempFile;
fn create_test_spec() -> CachedSpec {
CachedSpec {
cache_format_version: aperture_cli::cache::models::CACHE_FORMAT_VERSION,
name: "test-api".to_string(),
version: "1.0.0".to_string(),
commands: vec![
CachedCommand {
name: "users".to_string(),
description: Some("Get user by ID".to_string()),
summary: None,
operation_id: "getUserById".to_string(),
method: constants::HTTP_METHOD_GET.to_string(),
path: "/users/{id}".to_string(),
parameters: vec![CachedParameter {
name: "id".to_string(),
location: "path".to_string(),
required: true,
description: Some("User ID".to_string()),
schema: Some(r#"{"type": "string"}"#.to_string()),
schema_type: Some("string".to_string()),
format: None,
default_value: None,
enum_values: vec![],
example: None,
}],
request_body: None,
responses: vec![],
security_requirements: vec![],
tags: vec!["users".to_string()],
deprecated: false,
external_docs_url: None,
examples: vec![],
display_group: None,
display_name: None,
aliases: vec![],
hidden: false,
pagination: PaginationInfo::default(),
},
CachedCommand {
name: "users".to_string(),
description: Some("Create a new user".to_string()),
summary: None,
operation_id: "createUser".to_string(),
method: constants::HTTP_METHOD_POST.to_string(),
path: "/users".to_string(),
parameters: vec![],
request_body: Some(aperture_cli::cache::models::CachedRequestBody {
description: Some("User data".to_string()),
required: true,
content_type: constants::CONTENT_TYPE_JSON.to_string(),
schema: r#"{"type": "object"}"#.to_string(),
example: Some(
r#"{"name": "John Doe", "email": "john@example.com"}"#.to_string(),
),
}),
responses: vec![],
security_requirements: vec![],
tags: vec!["users".to_string()],
deprecated: false,
external_docs_url: None,
examples: vec![],
display_group: None,
display_name: None,
aliases: vec![],
hidden: false,
pagination: PaginationInfo::default(),
},
],
base_url: Some("https://api.example.com".to_string()),
servers: vec!["https://api.example.com".to_string()],
security_schemes: HashMap::new(),
skipped_endpoints: vec![],
server_variables: HashMap::new(),
}
}
#[tokio::test]
async fn test_batch_file_parsing_json() {
let batch_content = r#"{
"operations": [
{
"id": "get-user-1",
"args": ["users", "get-user-by-id", "--id", "123"]
},
{
"id": "get-user-2",
"args": ["users", "get-user-by-id", "--id", "456"]
}
]
}"#;
let mut temp_file = NamedTempFile::new().unwrap();
temp_file.write_all(batch_content.as_bytes()).unwrap();
temp_file.flush().unwrap();
let batch_file = BatchProcessor::parse_batch_file(temp_file.path())
.await
.unwrap();
assert_eq!(batch_file.operations.len(), 2);
assert_eq!(batch_file.operations[0].id, Some("get-user-1".to_string()));
assert_eq!(
batch_file.operations[0].args,
vec!["users", "get-user-by-id", "--id", "123"]
);
assert_eq!(batch_file.operations[1].id, Some("get-user-2".to_string()));
assert_eq!(
batch_file.operations[1].args,
vec!["users", "get-user-by-id", "--id", "456"]
);
}
#[tokio::test]
async fn test_batch_file_parsing_yaml() {
let batch_content = r#"
operations:
- id: create-user-1
args: [users, create-user, --body, '{"name": "Alice", "email": "alice@example.com"}']
- id: create-user-2
args: [users, create-user, --body, '{"name": "Bob", "email": "bob@example.com"}']
"#;
let mut temp_file = NamedTempFile::new().unwrap();
temp_file.write_all(batch_content.as_bytes()).unwrap();
temp_file.flush().unwrap();
let batch_file = BatchProcessor::parse_batch_file(temp_file.path())
.await
.unwrap();
assert_eq!(batch_file.operations.len(), 2);
assert_eq!(
batch_file.operations[0].id,
Some("create-user-1".to_string())
);
assert_eq!(batch_file.operations[0].args[0], "users");
assert_eq!(batch_file.operations[0].args[1], "create-user");
assert_eq!(
batch_file.operations[1].id,
Some("create-user-2".to_string())
);
}
#[tokio::test]
async fn test_batch_file_parsing_invalid_format() {
let batch_content = "invalid json content {";
let mut temp_file = NamedTempFile::new().unwrap();
temp_file.write_all(batch_content.as_bytes()).unwrap();
temp_file.flush().unwrap();
let result = BatchProcessor::parse_batch_file(temp_file.path()).await;
assert!(result.is_err());
}
#[tokio::test]
async fn test_batch_file_parsing_empty_operations() {
let batch_content = r#"{
"operations": []
}"#;
let mut temp_file = NamedTempFile::new().unwrap();
temp_file.write_all(batch_content.as_bytes()).unwrap();
temp_file.flush().unwrap();
let batch_file = BatchProcessor::parse_batch_file(temp_file.path())
.await
.unwrap();
assert_eq!(batch_file.operations.len(), 0);
}
#[tokio::test]
async fn test_batch_config_default() {
let config = BatchConfig::default();
assert_eq!(config.max_concurrency, 5);
assert_eq!(config.rate_limit, None);
assert!(config.continue_on_error);
assert!(config.show_progress);
}
#[tokio::test]
async fn test_batch_config_custom() {
let config = BatchConfig {
max_concurrency: 10,
rate_limit: Some(100),
continue_on_error: true,
show_progress: true,
suppress_output: false,
};
assert_eq!(config.max_concurrency, 10);
assert_eq!(config.rate_limit, Some(100));
assert!(config.continue_on_error);
assert!(config.show_progress);
}
#[tokio::test]
async fn test_batch_processor_creation() {
let config = BatchConfig {
max_concurrency: 3,
rate_limit: Some(50),
continue_on_error: false,
show_progress: false,
suppress_output: false,
};
let _processor = BatchProcessor::new(config);
}
#[tokio::test]
async fn test_batch_operation_serialization() {
let operation = BatchOperation {
id: Some("test-op".to_string()),
args: vec![
"users".to_string(),
"get-user-by-id".to_string(),
"--id".to_string(),
"123".to_string(),
],
description: None,
headers: std::collections::HashMap::new(),
use_cache: None,
..Default::default()
};
let serialized = serde_json::to_string(&operation).unwrap();
let deserialized: BatchOperation = serde_json::from_str(&serialized).unwrap();
assert_eq!(deserialized.id, Some("test-op".to_string()));
assert_eq!(
deserialized.args,
vec!["users", "get-user-by-id", "--id", "123"]
);
}
#[tokio::test]
async fn test_batch_file_serialization() {
let batch_file = BatchFile {
metadata: None,
operations: vec![
BatchOperation {
id: Some("op1".to_string()),
args: vec![
"users".to_string(),
"get-user-by-id".to_string(),
"--id".to_string(),
"123".to_string(),
],
description: None,
headers: std::collections::HashMap::new(),
use_cache: None,
..Default::default()
},
BatchOperation {
id: Some("op2".to_string()),
args: vec![
"users".to_string(),
"get-user-by-id".to_string(),
"--id".to_string(),
"456".to_string(),
],
description: None,
headers: std::collections::HashMap::new(),
use_cache: None,
..Default::default()
},
],
};
let serialized = serde_json::to_string_pretty(&batch_file).unwrap();
let deserialized: BatchFile = serde_json::from_str(&serialized).unwrap();
assert_eq!(deserialized.operations.len(), 2);
assert_eq!(deserialized.operations[0].id, Some("op1".to_string()));
assert_eq!(deserialized.operations[1].id, Some("op2".to_string()));
}
#[tokio::test]
async fn test_batch_dry_run_execution() {
let spec = create_test_spec();
let config = BatchConfig::default();
let processor = BatchProcessor::new(config);
let batch_file = BatchFile {
metadata: None,
operations: vec![BatchOperation {
id: Some("test-op".to_string()),
args: vec![
"users".to_string(),
"get-user-by-id".to_string(),
"--id".to_string(),
"123".to_string(),
],
description: None,
headers: std::collections::HashMap::new(),
use_cache: None,
..Default::default()
}],
};
let result = processor
.execute_batch(
&spec,
batch_file,
None,
None,
true, &OutputFormat::Json,
None,
)
.await;
assert!(result.is_ok());
let batch_result = result.unwrap();
assert_eq!(batch_result.results.len(), 1);
assert_eq!(batch_result.success_count, 1);
assert_eq!(batch_result.failure_count, 0);
}
#[tokio::test]
async fn test_batch_complex_operations() {
let batch_content = r#"{
"operations": [
{
"id": "create-user",
"args": ["users", "create-user", "--body", "{\"name\": \"John\", \"email\": \"john@example.com\"}"]
},
{
"id": "get-user",
"args": ["users", "get-user-by-id", "--id", "123"]
},
{
"id": "update-user",
"args": ["users", "update-user", "123", "--body", "{\"name\": \"John Updated\"}"]
}
]
}"#;
let mut temp_file = NamedTempFile::new().unwrap();
temp_file.write_all(batch_content.as_bytes()).unwrap();
temp_file.flush().unwrap();
let batch_file = BatchProcessor::parse_batch_file(temp_file.path())
.await
.unwrap();
assert_eq!(batch_file.operations.len(), 3);
assert_eq!(batch_file.operations[0].id, Some("create-user".to_string()));
assert!(batch_file.operations[0]
.args
.contains(&"--body".to_string()));
assert_eq!(batch_file.operations[1].id, Some("get-user".to_string()));
assert!(batch_file.operations[1]
.args
.contains(&"get-user-by-id".to_string()));
assert_eq!(batch_file.operations[2].id, Some("update-user".to_string()));
assert!(batch_file.operations[2]
.args
.contains(&"update-user".to_string()));
}
#[tokio::test]
async fn test_batch_real_execution_with_mock_server() {
let mock_server = wiremock::MockServer::start().await;
mock_server
.register(
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/users/123"))
.respond_with(
wiremock::ResponseTemplate::new(200)
.set_body_json(serde_json::json!({
"id": 123,
"name": "John Doe",
"email": "john@example.com"
}))
.insert_header("content-type", constants::CONTENT_TYPE_JSON),
),
)
.await;
mock_server
.register(
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/users/456"))
.respond_with(
wiremock::ResponseTemplate::new(200)
.set_body_json(serde_json::json!({
"id": 456,
"name": "Jane Smith",
"email": "jane@example.com"
}))
.insert_header("content-type", constants::CONTENT_TYPE_JSON),
),
)
.await;
let mut spec = create_test_spec();
spec.base_url = Some(mock_server.uri());
spec.servers = vec![mock_server.uri()];
let config = BatchConfig::default();
let processor = BatchProcessor::new(config);
let batch_file = BatchFile {
metadata: Some(BatchMetadata {
name: Some("Test Batch".to_string()),
version: Some("1.0".to_string()),
description: Some("Test batch execution".to_string()),
defaults: None,
}),
operations: vec![
BatchOperation {
id: Some("get-user-123".to_string()),
args: vec![
"users".to_string(),
"get-user-by-id".to_string(),
"--id".to_string(),
"123".to_string(),
],
description: Some("Get user 123".to_string()),
headers: std::collections::HashMap::new(),
use_cache: Some(false),
..Default::default()
},
BatchOperation {
id: Some("get-user-456".to_string()),
args: vec![
"users".to_string(),
"get-user-by-id".to_string(),
"--id".to_string(),
"456".to_string(),
],
description: Some("Get user 456".to_string()),
headers: std::collections::HashMap::new(),
use_cache: Some(false),
..Default::default()
},
],
};
let result = processor
.execute_batch(
&spec,
batch_file,
None,
Some(&mock_server.uri()),
false, &OutputFormat::Json,
None,
)
.await;
assert!(result.is_ok(), "Batch execution should succeed");
let batch_result = result.unwrap();
assert_eq!(batch_result.results.len(), 2);
assert_eq!(batch_result.success_count, 2);
assert_eq!(batch_result.failure_count, 0);
assert!(batch_result.total_duration.as_millis() > 0);
mock_server.verify().await;
}
#[tokio::test]
async fn test_batch_execution_with_error_handling() {
let mock_server = wiremock::MockServer::start().await;
mock_server
.register(
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/users/123"))
.respond_with(
wiremock::ResponseTemplate::new(200)
.set_body_json(serde_json::json!({
"id": 123,
"name": "John Doe",
"email": "john@example.com"
}))
.insert_header("content-type", constants::CONTENT_TYPE_JSON),
),
)
.await;
mock_server
.register(
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/users/999"))
.respond_with(wiremock::ResponseTemplate::new(404)),
)
.await;
let mut spec = create_test_spec();
spec.base_url = Some(mock_server.uri());
spec.servers = vec![mock_server.uri()];
let config = BatchConfig {
max_concurrency: 2,
rate_limit: None,
continue_on_error: true,
show_progress: false,
suppress_output: false,
};
let processor = BatchProcessor::new(config);
let batch_file = BatchFile {
metadata: None,
operations: vec![
BatchOperation {
id: Some("get-user-123".to_string()),
args: vec![
"users".to_string(),
"get-user-by-id".to_string(),
"--id".to_string(),
"123".to_string(),
],
description: Some("Get user 123".to_string()),
headers: std::collections::HashMap::new(),
use_cache: Some(false),
..Default::default()
},
BatchOperation {
id: Some("get-user-999".to_string()),
args: vec![
"users".to_string(),
"get-user-by-id".to_string(),
"--id".to_string(),
"999".to_string(),
],
description: Some("Get non-existent user".to_string()),
headers: std::collections::HashMap::new(),
use_cache: Some(false),
..Default::default()
},
],
};
let result = processor
.execute_batch(
&spec,
batch_file,
None,
Some(&mock_server.uri()),
false, &OutputFormat::Json,
None,
)
.await;
assert!(
result.is_ok(),
"Batch execution should complete even with errors"
);
let batch_result = result.unwrap();
assert_eq!(batch_result.results.len(), 2);
assert_eq!(batch_result.success_count, 1); assert_eq!(batch_result.failure_count, 1);
mock_server.verify().await;
}
#[tokio::test]
async fn test_batch_operation_body_file_field_is_parsed() {
let batch_content = r#"{
"operations": [
{
"id": "create-event",
"args": ["events", "add"],
"body_file": "/tmp/payload.json"
}
]
}"#;
let mut temp_file = NamedTempFile::new().unwrap();
temp_file.write_all(batch_content.as_bytes()).unwrap();
temp_file.flush().unwrap();
let batch_file = BatchProcessor::parse_batch_file(temp_file.path())
.await
.unwrap();
assert_eq!(batch_file.operations.len(), 1);
assert_eq!(
batch_file.operations[0].body_file.as_deref(),
Some("/tmp/payload.json"),
"body_file field should be deserialised from the batch file"
);
}
#[tokio::test]
async fn test_batch_execution_with_body_file() {
let body_json = r#"{"name":"Alice","email":"alice@example.com"}"#;
let mut tmp = NamedTempFile::new().unwrap();
tmp.write_all(body_json.as_bytes()).unwrap();
tmp.flush().unwrap();
let mock_server = wiremock::MockServer::start().await;
mock_server
.register(
wiremock::Mock::given(wiremock::matchers::method("POST"))
.and(wiremock::matchers::path("/users"))
.and(wiremock::matchers::body_json(serde_json::json!({
"name": "Alice",
"email": "alice@example.com"
})))
.respond_with(
wiremock::ResponseTemplate::new(201)
.set_body_json(serde_json::json!({"id": 1}))
.insert_header("content-type", constants::CONTENT_TYPE_JSON),
)
.expect(1),
)
.await;
let mut spec = create_test_spec();
spec.base_url = Some(mock_server.uri());
spec.servers = vec![mock_server.uri()];
let config = BatchConfig::default();
let processor = BatchProcessor::new(config);
let batch_file = BatchFile {
metadata: None,
operations: vec![BatchOperation {
id: Some("create-alice".to_string()),
args: vec!["users".to_string(), "create-user".to_string()],
body_file: Some(tmp.path().to_str().unwrap().to_string()),
..Default::default()
}],
};
let result = processor
.execute_batch(
&spec,
batch_file,
None,
Some(&mock_server.uri()),
false,
&OutputFormat::Json,
None,
)
.await
.expect("batch execution with body_file should succeed");
assert_eq!(result.success_count, 1);
assert_eq!(result.failure_count, 0);
mock_server.verify().await;
}
#[tokio::test]
async fn test_body_file_field_conflicts_with_body_file_in_args() {
let body_json = r#"{"name":"Alice"}"#;
let mut tmp = NamedTempFile::new().unwrap();
tmp.write_all(body_json.as_bytes()).unwrap();
tmp.flush().unwrap();
let spec = create_test_spec();
let config = BatchConfig::default();
let processor = BatchProcessor::new(config);
let batch_file = BatchFile {
metadata: None,
operations: vec![BatchOperation {
id: Some("create-alice".to_string()),
args: vec![
"users".to_string(),
"create-user".to_string(),
"--body-file".to_string(),
"/other.json".to_string(),
],
body_file: Some(tmp.path().to_str().unwrap().to_string()),
..Default::default()
}],
};
let result = processor
.execute_batch(
&spec,
batch_file,
None,
None,
false,
&OutputFormat::Json,
None,
)
.await
.expect("batch should not hard-fail; conflict is reported per-operation");
assert_eq!(result.failure_count, 1, "conflict should produce a failure");
let err = result.results[0].error.as_deref().unwrap_or("");
assert!(
err.contains("conflicts"),
"error should mention the conflict; got: {err}"
);
}
#[tokio::test]
async fn test_body_file_path_interpolated_in_dependent_batch() {
let role = "admin";
let tmp_dir = tempfile::tempdir().unwrap();
let resolved_path = tmp_dir.path().join(format!("body-{role}.json"));
std::fs::write(&resolved_path, r#"{"name":"Eve","role":"admin"}"#).unwrap();
let body_file_template = format!("{}/body-{{{{user_role}}}}.json", tmp_dir.path().display());
let mock_server = wiremock::MockServer::start().await;
mock_server
.register(
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/users/1"))
.respond_with(
wiremock::ResponseTemplate::new(200)
.set_body_json(serde_json::json!({"id": 1, "role": role}))
.insert_header("content-type", constants::CONTENT_TYPE_JSON),
)
.expect(1),
)
.await;
mock_server
.register(
wiremock::Mock::given(wiremock::matchers::method("POST"))
.and(wiremock::matchers::path("/users"))
.and(wiremock::matchers::body_json(serde_json::json!({
"name": "Eve",
"role": "admin"
})))
.respond_with(
wiremock::ResponseTemplate::new(201)
.set_body_json(serde_json::json!({"id": 2}))
.insert_header("content-type", constants::CONTENT_TYPE_JSON),
)
.expect(1),
)
.await;
let mut spec = create_test_spec();
spec.base_url = Some(mock_server.uri());
spec.servers = vec![mock_server.uri()];
let config = BatchConfig {
show_progress: false,
suppress_output: false,
..BatchConfig::default()
};
let processor = BatchProcessor::new(config);
let batch_file = BatchFile {
metadata: None,
operations: vec![
BatchOperation {
id: Some("get-user".to_string()),
args: vec![
"users".to_string(),
"get-user-by-id".to_string(),
"--id".to_string(),
"1".to_string(),
],
capture: Some(std::collections::HashMap::from([(
"user_role".to_string(),
".role".to_string(),
)])),
..Default::default()
},
BatchOperation {
id: Some("create-user".to_string()),
args: vec!["users".to_string(), "create-user".to_string()],
body_file: Some(body_file_template),
depends_on: Some(vec!["get-user".to_string()]),
..Default::default()
},
],
};
let result = processor
.execute_batch(
&spec,
batch_file,
None,
Some(&mock_server.uri()),
false,
&OutputFormat::Json,
None,
)
.await
.expect("dependent batch with body_file interpolation should succeed");
assert_eq!(result.success_count, 2, "both operations should succeed");
assert_eq!(result.failure_count, 0);
mock_server.verify().await;
}