mod test_helpers;
use aperture_cli::cache::models::{CachedCommand, CachedParameter, CachedSpec, PaginationInfo};
use aperture_cli::cli::OutputFormat;
use aperture_cli::engine::executor::{execute_request, RetryContext};
use clap::{Arg, Command};
use std::collections::HashMap;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, Respond, ResponseTemplate};
macro_rules! cached_parameter {
($name:expr, $location:expr, $required:expr) => {
CachedParameter {
name: $name.to_string(),
location: $location.to_string(),
required: $required,
description: None,
schema: Some(r#"{"type": "string"}"#.to_string()),
schema_type: Some("string".to_string()),
format: None,
default_value: None,
enum_values: vec![],
example: None,
}
};
}
macro_rules! cached_command {
($name:expr, $op_id:expr, $method:expr, $path:expr, $params:expr) => {
CachedCommand {
name: $name.to_string(),
description: None,
summary: None,
operation_id: $op_id.to_string(),
method: $method.to_string(),
path: $path.to_string(),
parameters: $params,
request_body: None,
responses: vec![],
security_requirements: vec![],
tags: vec![$name.to_string()],
deprecated: false,
external_docs_url: None,
examples: vec![],
display_group: None,
display_name: None,
aliases: vec![],
hidden: false,
pagination: PaginationInfo::default(),
}
};
}
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![{
let mut cmd = cached_command!(
"users",
"getUserById",
"GET",
"/users/{id}",
vec![cached_parameter!("id", "path", true)]
);
cmd.description = Some("Get user by ID".to_string());
cmd
}],
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(),
}
}
struct FailThenSucceed {
fail_count: usize,
fail_status: u16,
call_count: Arc<AtomicUsize>,
}
impl FailThenSucceed {
fn new(fail_count: usize, fail_status: u16) -> (Self, Arc<AtomicUsize>) {
let call_count = Arc::new(AtomicUsize::new(0));
(
Self {
fail_count,
fail_status,
call_count: call_count.clone(),
},
call_count,
)
}
}
impl Respond for FailThenSucceed {
fn respond(&self, _request: &wiremock::Request) -> ResponseTemplate {
let count = self.call_count.fetch_add(1, Ordering::SeqCst);
if count < self.fail_count {
ResponseTemplate::new(self.fail_status).set_body_json(serde_json::json!({
"error": "Service temporarily unavailable"
}))
} else {
ResponseTemplate::new(200).set_body_json(serde_json::json!({
"id": "123",
"name": "Test User"
}))
}
}
}
#[tokio::test]
async fn test_retry_succeeds_after_transient_503_errors() {
let mock_server = MockServer::start().await;
let (responder, call_count) = FailThenSucceed::new(2, 503);
Mock::given(method("GET"))
.and(path("/users/123"))
.respond_with(responder)
.expect(3) .mount(&mock_server)
.await;
let spec = create_test_spec();
let command = Command::new("api").subcommand(
Command::new("users")
.subcommand(Command::new("get-user-by-id").arg(Arg::new("id").required(true))),
);
let matches = command.get_matches_from(vec!["api", "users", "get-user-by-id", "123"]);
let retry_context = RetryContext {
max_attempts: 3,
initial_delay_ms: 10, max_delay_ms: 100,
force_retry: false,
method: Some("GET".to_string()),
has_idempotency_key: false,
};
let result = execute_request(
&spec,
&matches,
Some(&mock_server.uri()),
false,
None,
None,
&OutputFormat::Json,
None,
None,
true, Some(&retry_context),
)
.await;
assert!(result.is_ok(), "Request should succeed after retries");
assert_eq!(
call_count.load(Ordering::SeqCst),
3,
"Should have made exactly 3 requests"
);
}
#[tokio::test]
async fn test_retry_exhausted_returns_error_status() {
let mock_server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/users/123"))
.respond_with(ResponseTemplate::new(503).set_body_json(serde_json::json!({
"error": "Service temporarily unavailable"
})))
.expect(3) .mount(&mock_server)
.await;
let spec = create_test_spec();
let command = Command::new("api").subcommand(
Command::new("users")
.subcommand(Command::new("get-user-by-id").arg(Arg::new("id").required(true))),
);
let matches = command.get_matches_from(vec!["api", "users", "get-user-by-id", "123"]);
let retry_context = RetryContext {
max_attempts: 3,
initial_delay_ms: 10,
max_delay_ms: 100,
force_retry: false,
method: Some("GET".to_string()),
has_idempotency_key: false,
};
let result = execute_request(
&spec,
&matches,
Some(&mock_server.uri()),
false,
None,
None,
&OutputFormat::Json,
None,
None,
true,
Some(&retry_context),
)
.await;
assert!(
result.is_err(),
"Request should fail after all retries exhausted"
);
let err = result.unwrap_err();
assert!(
err.to_string().contains("503") || err.to_string().contains("Service"),
"Error should indicate 503 status"
);
}
#[tokio::test]
async fn test_retry_respects_retry_after_header() {
let mock_server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/users/123"))
.respond_with(
ResponseTemplate::new(429)
.insert_header("Retry-After", "1")
.set_body_json(serde_json::json!({
"error": "Too many requests"
})),
)
.up_to_n_times(1)
.mount(&mock_server)
.await;
Mock::given(method("GET"))
.and(path("/users/123"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"id": "123",
"name": "Test User"
})))
.mount(&mock_server)
.await;
let spec = create_test_spec();
let command = Command::new("api").subcommand(
Command::new("users")
.subcommand(Command::new("get-user-by-id").arg(Arg::new("id").required(true))),
);
let matches = command.get_matches_from(vec!["api", "users", "get-user-by-id", "123"]);
let retry_context = RetryContext {
max_attempts: 3,
initial_delay_ms: 10,
max_delay_ms: 5000, force_retry: false,
method: Some("GET".to_string()),
has_idempotency_key: false,
};
let result = execute_request(
&spec,
&matches,
Some(&mock_server.uri()),
false,
None,
None,
&OutputFormat::Json,
None,
None,
true,
Some(&retry_context),
)
.await;
assert!(
result.is_ok(),
"Request should succeed after retry following 429"
);
}
#[tokio::test]
async fn test_no_retry_on_4xx_client_errors() {
let mock_server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/users/123"))
.respond_with(ResponseTemplate::new(404).set_body_json(serde_json::json!({
"error": "User not found"
})))
.expect(1) .mount(&mock_server)
.await;
let spec = create_test_spec();
let command = Command::new("api").subcommand(
Command::new("users")
.subcommand(Command::new("get-user-by-id").arg(Arg::new("id").required(true))),
);
let matches = command.get_matches_from(vec!["api", "users", "get-user-by-id", "123"]);
let retry_context = RetryContext {
max_attempts: 3,
initial_delay_ms: 10,
max_delay_ms: 100,
force_retry: false,
method: Some("GET".to_string()),
has_idempotency_key: false,
};
let result = execute_request(
&spec,
&matches,
Some(&mock_server.uri()),
false,
None,
None,
&OutputFormat::Json,
None,
None,
true,
Some(&retry_context),
)
.await;
assert!(result.is_err(), "Request should fail on 404");
}
#[tokio::test]
async fn test_retry_disabled_when_max_attempts_zero() {
let mock_server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/users/123"))
.respond_with(ResponseTemplate::new(503).set_body_json(serde_json::json!({
"error": "Service temporarily unavailable"
})))
.expect(1) .mount(&mock_server)
.await;
let spec = create_test_spec();
let command = Command::new("api").subcommand(
Command::new("users")
.subcommand(Command::new("get-user-by-id").arg(Arg::new("id").required(true))),
);
let matches = command.get_matches_from(vec!["api", "users", "get-user-by-id", "123"]);
let retry_context = RetryContext {
max_attempts: 0, initial_delay_ms: 10,
max_delay_ms: 100,
force_retry: false,
method: Some("GET".to_string()),
has_idempotency_key: false,
};
let result = execute_request(
&spec,
&matches,
Some(&mock_server.uri()),
false,
None,
None,
&OutputFormat::Json,
None,
None,
true,
Some(&retry_context),
)
.await;
assert!(
result.is_err(),
"Request should fail on 503 without retries"
);
}