mod test_helpers;
use aperture_cli::cache::models::{CachedSpec, PaginationInfo, PaginationStrategy};
use aperture_cli::invocation::{ExecutionContext, OperationCall};
use aperture_cli::pagination::execute_paginated;
use std::collections::HashMap;
use wiremock::matchers::{method, path, query_param};
use wiremock::{Mock, MockServer, ResponseTemplate};
fn make_spec_with_pagination(base_url: &str, pagination: PaginationInfo) -> CachedSpec {
let cmd = aperture_cli::cache::models::CachedCommand {
pagination,
..test_helpers::test_command("items", "listItems", "GET", "/items")
};
CachedSpec {
cache_format_version: aperture_cli::cache::models::CACHE_FORMAT_VERSION,
name: "test-api".to_string(),
version: "1.0.0".to_string(),
commands: vec![cmd],
base_url: Some(base_url.to_string()),
servers: vec![base_url.to_string()],
security_schemes: HashMap::new(),
skipped_endpoints: vec![],
server_variables: HashMap::new(),
}
}
const fn base_ctx() -> ExecutionContext {
ExecutionContext {
dry_run: false,
idempotency_key: None,
cache_config: None,
retry_context: None,
base_url: None,
global_config: None,
server_var_args: vec![],
auto_paginate: true,
}
}
fn base_call(query_params: HashMap<String, String>) -> OperationCall {
OperationCall {
operation_id: "listItems".to_string(),
path_params: HashMap::new(),
query_params,
header_params: HashMap::new(),
body: None,
custom_headers: vec![],
}
}
fn parse_ndjson(buf: &[u8]) -> Vec<serde_json::Value> {
let text = std::str::from_utf8(buf).expect("output should be valid UTF-8");
text.lines()
.filter(|l| !l.is_empty())
.map(|l| serde_json::from_str(l).expect("each line should be valid JSON"))
.collect()
}
#[tokio::test]
async fn test_cursor_pagination_collects_all_pages() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/items"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"data": [{"id": 1}, {"id": 2}],
"next_cursor": "cursor_page2"
})))
.up_to_n_times(1)
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/items"))
.and(query_param("cursor", "cursor_page2"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"data": [{"id": 3}, {"id": 4}],
"next_cursor": "cursor_page3"
})))
.up_to_n_times(1)
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/items"))
.and(query_param("cursor", "cursor_page3"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"data": [{"id": 5}],
"next_cursor": null
})))
.up_to_n_times(1)
.mount(&server)
.await;
let spec = make_spec_with_pagination(
&server.uri(),
PaginationInfo {
strategy: PaginationStrategy::Cursor,
cursor_field: Some("next_cursor".to_string()),
cursor_param: Some("cursor".to_string()),
page_param: None,
limit_param: None,
},
);
let mut buf: Vec<u8> = Vec::new();
let count = execute_paginated(&spec, base_call(HashMap::new()), base_ctx(), &mut buf)
.await
.expect("execute_paginated should succeed");
assert_eq!(count, 5, "should have collected 5 items across 3 pages");
let items = parse_ndjson(&buf);
assert_eq!(items.len(), 5);
assert_eq!(items[0]["id"], 1);
assert_eq!(items[4]["id"], 5);
}
#[tokio::test]
async fn test_cursor_pagination_stops_on_empty_cursor() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/items"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"data": [{"id": 1}],
"next_cursor": "" })))
.mount(&server)
.await;
let spec = make_spec_with_pagination(
&server.uri(),
PaginationInfo {
strategy: PaginationStrategy::Cursor,
cursor_field: Some("next_cursor".to_string()),
cursor_param: Some("cursor".to_string()),
page_param: None,
limit_param: None,
},
);
let mut buf: Vec<u8> = Vec::new();
let count = execute_paginated(&spec, base_call(HashMap::new()), base_ctx(), &mut buf)
.await
.expect("should succeed");
assert_eq!(count, 1);
}
#[tokio::test]
async fn test_offset_pagination_page_style_collects_all_pages() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/items"))
.and(query_param("limit", "2"))
.respond_with(
ResponseTemplate::new(200).set_body_json(serde_json::json!([{"id": 1}, {"id": 2}])),
)
.up_to_n_times(1)
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/items"))
.and(query_param("page", "2"))
.and(query_param("limit", "2"))
.respond_with(
ResponseTemplate::new(200).set_body_json(serde_json::json!([{"id": 3}])), )
.up_to_n_times(1)
.mount(&server)
.await;
let spec = make_spec_with_pagination(
&server.uri(),
PaginationInfo {
strategy: PaginationStrategy::Offset,
cursor_field: None,
cursor_param: None,
page_param: Some("page".to_string()),
limit_param: Some("limit".to_string()),
},
);
let mut params = HashMap::new();
params.insert("limit".to_string(), "2".to_string());
let mut buf: Vec<u8> = Vec::new();
let count = execute_paginated(&spec, base_call(params), base_ctx(), &mut buf)
.await
.expect("should succeed");
assert_eq!(count, 3, "should collect items from both pages");
let items = parse_ndjson(&buf);
assert_eq!(items[0]["id"], 1);
assert_eq!(items[2]["id"], 3);
}
#[tokio::test]
async fn test_offset_pagination_stops_on_empty_page() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/items"))
.respond_with(
ResponseTemplate::new(200).set_body_json(serde_json::json!([])), )
.mount(&server)
.await;
let spec = make_spec_with_pagination(
&server.uri(),
PaginationInfo {
strategy: PaginationStrategy::Offset,
cursor_field: None,
cursor_param: None,
page_param: Some("page".to_string()),
limit_param: Some("limit".to_string()),
},
);
let mut buf: Vec<u8> = Vec::new();
let count = execute_paginated(&spec, base_call(HashMap::new()), base_ctx(), &mut buf)
.await
.expect("should succeed");
assert_eq!(count, 0);
assert!(buf.is_empty());
}
#[tokio::test]
async fn test_link_header_pagination_collects_all_pages() {
let server = MockServer::start().await;
let base = server.uri();
let page2_url = format!("{base}/items?page=2");
let link_header = format!(r#"<{page2_url}>; rel="next", <{base}/items?page=5>; rel="last""#);
Mock::given(method("GET"))
.and(path("/items"))
.respond_with(
ResponseTemplate::new(200)
.insert_header("link", link_header.as_str())
.set_body_json(serde_json::json!([{"id": 1}, {"id": 2}])),
)
.up_to_n_times(1)
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/items"))
.and(query_param("page", "2"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([{"id": 3}])))
.up_to_n_times(1)
.mount(&server)
.await;
let spec = make_spec_with_pagination(
&server.uri(),
PaginationInfo {
strategy: PaginationStrategy::LinkHeader,
cursor_field: None,
cursor_param: None,
page_param: None,
limit_param: None,
},
);
let mut buf: Vec<u8> = Vec::new();
let count = execute_paginated(&spec, base_call(HashMap::new()), base_ctx(), &mut buf)
.await
.expect("should succeed");
assert_eq!(count, 3, "should collect 3 items across 2 pages");
let items = parse_ndjson(&buf);
assert_eq!(items[0]["id"], 1);
assert_eq!(items[2]["id"], 3);
}
#[tokio::test]
async fn test_no_strategy_runs_once() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/items"))
.respond_with(
ResponseTemplate::new(200).set_body_json(serde_json::json!([{"id": 1}, {"id": 2}])),
)
.mount(&server)
.await;
let spec = make_spec_with_pagination(
&server.uri(),
PaginationInfo {
strategy: PaginationStrategy::None,
cursor_field: None,
cursor_param: None,
page_param: None,
limit_param: None,
},
);
let mut buf: Vec<u8> = Vec::new();
let count = execute_paginated(&spec, base_call(HashMap::new()), base_ctx(), &mut buf)
.await
.expect("should succeed");
assert_eq!(count, 2, "should have output 2 items from the single page");
assert_eq!(server.received_requests().await.unwrap().len(), 1);
}