use crate::client::ClickUpClient;
use crate::output::compact_items;
use serde_json::{json, Value};
const MAX_PAGES: usize = 100;
#[derive(Debug, Clone, Copy, Default)]
pub struct PageArgs {
pub page: Option<u64>,
pub limit: Option<usize>,
pub all: bool,
pub requested: bool,
}
impl PageArgs {
pub fn from_args(args: &Value) -> Self {
let page = args.get("page").and_then(|v| v.as_u64());
let limit = args
.get("limit")
.and_then(|v| v.as_u64())
.map(|n| n as usize);
let all = args.get("all").and_then(|v| v.as_bool()).unwrap_or(false);
let requested = page.is_some() || limit.is_some() || args.get("all").is_some();
Self {
page,
limit,
all,
requested,
}
}
}
#[derive(Debug, Clone, Default)]
pub struct CursorArgs {
pub cursor: Option<String>,
pub limit: Option<usize>,
pub all: bool,
pub requested: bool,
}
impl CursorArgs {
pub fn from_args(args: &Value) -> Self {
let cursor = args
.get("cursor")
.and_then(|v| v.as_str())
.map(String::from);
let limit = args
.get("limit")
.and_then(|v| v.as_u64())
.map(|n| n as usize);
let all = args.get("all").and_then(|v| v.as_bool()).unwrap_or(false);
let requested = cursor.is_some() || limit.is_some() || args.get("all").is_some();
Self {
cursor,
limit,
all,
requested,
}
}
}
#[derive(Debug, Clone, Default)]
pub struct StartIdArgs {
pub start: Option<i64>,
pub start_id: Option<String>,
pub limit: Option<usize>,
pub all: bool,
pub requested: bool,
}
impl StartIdArgs {
pub fn from_args(args: &Value) -> Self {
let start = args.get("start").and_then(|v| v.as_i64());
let start_id = args
.get("start_id")
.and_then(|v| v.as_str())
.map(String::from);
let limit = args
.get("limit")
.and_then(|v| v.as_u64())
.map(|n| n as usize);
let all = args.get("all").and_then(|v| v.as_bool()).unwrap_or(false);
let requested =
start.is_some() || start_id.is_some() || limit.is_some() || args.get("all").is_some();
Self {
start,
start_id,
limit,
all,
requested,
}
}
}
#[derive(Debug, Clone, Default)]
pub struct BodyPaginationArgs {
pub page_rows: Option<i64>,
pub page_timestamp: Option<i64>,
pub page_direction: Option<String>,
pub limit: Option<usize>,
pub all: bool,
pub requested: bool,
}
impl BodyPaginationArgs {
pub fn from_args(args: &Value) -> Self {
let page_rows = args.get("page_rows").and_then(|v| v.as_i64());
let page_timestamp = args.get("page_timestamp").and_then(|v| v.as_i64());
let page_direction = args
.get("page_direction")
.and_then(|v| v.as_str())
.map(String::from);
let limit = args
.get("limit")
.and_then(|v| v.as_u64())
.map(|n| n as usize);
let all = args.get("all").and_then(|v| v.as_bool()).unwrap_or(false);
let requested = page_rows.is_some()
|| page_timestamp.is_some()
|| page_direction.is_some()
|| limit.is_some()
|| args.get("all").is_some();
Self {
page_rows,
page_timestamp,
page_direction,
limit,
all,
requested,
}
}
}
pub async fn page_dispatch<F>(
args: &PageArgs,
client: &ClickUpClient,
items_key: &str,
compact_fields: &[&str],
build_path: F,
) -> Result<Value, String>
where
F: Fn(u64) -> String,
{
let start_page = args.page.unwrap_or(0);
let mut collected: Vec<Value> = Vec::new();
let mut current_page = start_page;
#[allow(unused_assignments)]
let mut last_page = false;
let mut pages_fetched = 0usize;
loop {
let path = build_path(current_page);
let resp = client.get(&path).await.map_err(|e| e.to_string())?;
let items = extract_array(&resp, &[items_key, "data"]).unwrap_or_default();
last_page = resp
.get("last_page")
.and_then(|v| v.as_bool())
.unwrap_or(items.is_empty());
collected.extend(items);
pages_fetched += 1;
if !args.all {
break;
}
if last_page || pages_fetched >= MAX_PAGES {
break;
}
if let Some(limit) = args.limit {
if collected.len() >= limit {
break;
}
}
current_page += 1;
}
if let Some(limit) = args.limit {
collected.truncate(limit);
}
let compact = compact_items(&collected, compact_fields);
if !args.requested {
return Ok(compact);
}
let compact_arr = compact.as_array().cloned().unwrap_or_default();
let returned = compact_arr.len();
let has_more = !last_page;
let last_observed_page = if args.all { current_page } else { start_page };
Ok(json!({
"items": compact_arr,
"pagination": {
"style": "page",
"page": last_observed_page,
"last_page": last_page,
"has_more": has_more,
"returned": returned,
"all": args.all,
}
}))
}
pub async fn cursor_dispatch<F>(
args: &CursorArgs,
client: &ClickUpClient,
items_keys: &[&str],
compact_fields: &[&str],
build_path: F,
) -> Result<Value, String>
where
F: Fn(Option<&str>) -> String,
{
let mut cursor = args.cursor.clone();
let mut collected: Vec<Value> = Vec::new();
#[allow(unused_assignments)]
let mut next_cursor: Option<String> = None;
let mut pages_fetched = 0usize;
loop {
let path = build_path(cursor.as_deref());
let resp = client.get(&path).await.map_err(|e| e.to_string())?;
let items = extract_array(&resp, items_keys).unwrap_or_default();
next_cursor = resp
.get("next_cursor")
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
.map(String::from);
collected.extend(items);
pages_fetched += 1;
if !args.all {
break;
}
if next_cursor.is_none() || pages_fetched >= MAX_PAGES {
break;
}
if let Some(limit) = args.limit {
if collected.len() >= limit {
break;
}
}
cursor = next_cursor.clone();
}
if let Some(limit) = args.limit {
collected.truncate(limit);
}
let compact = compact_items(&collected, compact_fields);
if !args.requested {
return Ok(compact);
}
let compact_arr = compact.as_array().cloned().unwrap_or_default();
let returned = compact_arr.len();
let has_more = next_cursor.is_some();
let mut pagination = serde_json::Map::new();
pagination.insert("style".into(), json!("cursor"));
pagination.insert("has_more".into(), json!(has_more));
pagination.insert("returned".into(), json!(returned));
pagination.insert("all".into(), json!(args.all));
if let Some(c) = next_cursor {
pagination.insert("next_cursor".into(), json!(c));
}
Ok(json!({
"items": compact_arr,
"pagination": Value::Object(pagination),
}))
}
const START_ID_PAGE_HINT: usize = 25;
pub async fn start_id_dispatch<F>(
args: &StartIdArgs,
client: &ClickUpClient,
items_key: &str,
compact_fields: &[&str],
build_path: F,
) -> Result<Value, String>
where
F: Fn(Option<i64>, Option<&str>) -> String,
{
let mut current_start = args.start;
let mut current_start_id = args.start_id.clone();
let mut collected: Vec<Value> = Vec::new();
#[allow(unused_assignments)]
let mut next_boundary: Option<(i64, String)> = None;
let mut reached_end = false;
let mut pages_fetched = 0usize;
loop {
let path = build_path(current_start, current_start_id.as_deref());
let resp = client.get(&path).await.map_err(|e| e.to_string())?;
let items = extract_array(&resp, &[items_key, "data"]).unwrap_or_default();
let count = items.len();
if let Some(last) = items.last() {
let date_ms = last
.get("date")
.and_then(|v| v.as_str())
.and_then(|s| s.parse::<i64>().ok())
.or_else(|| last.get("date").and_then(|v| v.as_i64()));
let id = last.get("id").and_then(|v| v.as_str()).map(String::from);
next_boundary = match (date_ms, id) {
(Some(d), Some(i)) => Some((d, i)),
_ => None,
};
} else {
next_boundary = None;
}
collected.extend(items);
pages_fetched += 1;
if count < START_ID_PAGE_HINT {
reached_end = true;
}
if !args.all {
break;
}
if reached_end || pages_fetched >= MAX_PAGES {
break;
}
if let Some(limit) = args.limit {
if collected.len() >= limit {
break;
}
}
match next_boundary.clone() {
Some((d, i)) => {
current_start = Some(d);
current_start_id = Some(i);
}
None => break,
}
}
if let Some(limit) = args.limit {
collected.truncate(limit);
}
let compact = compact_items(&collected, compact_fields);
if !args.requested {
return Ok(compact);
}
let compact_arr = compact.as_array().cloned().unwrap_or_default();
let returned = compact_arr.len();
let has_more = !reached_end && next_boundary.is_some();
let mut pagination = serde_json::Map::new();
pagination.insert("style".into(), json!("start_id"));
pagination.insert("has_more".into(), json!(has_more));
pagination.insert("returned".into(), json!(returned));
pagination.insert("all".into(), json!(args.all));
if let Some((d, i)) = next_boundary {
pagination.insert("next_start".into(), json!(d));
pagination.insert("next_start_id".into(), json!(i));
}
Ok(json!({
"items": compact_arr,
"pagination": Value::Object(pagination),
}))
}
pub async fn body_pagination_dispatch<BB, NT>(
args: &BodyPaginationArgs,
client: &ClickUpClient,
path: &str,
items_keys: &[&str],
compact_fields: &[&str],
base_body: BB,
next_timestamp: NT,
) -> Result<Value, String>
where
BB: Fn() -> Value,
NT: Fn(&Value) -> Option<i64>,
{
let mut current_timestamp = args.page_timestamp;
let mut collected: Vec<Value> = Vec::new();
let mut reached_end = false;
#[allow(unused_assignments)]
let mut next_boundary: Option<i64> = None;
let mut pages_fetched = 0usize;
loop {
let mut body = base_body();
let mut pagination = serde_json::Map::new();
if let Some(n) = args.page_rows {
pagination.insert("pageRows".into(), json!(n));
}
if let Some(t) = current_timestamp {
pagination.insert("pageTimestamp".into(), json!(t));
}
if let Some(d) = args.page_direction.as_deref() {
pagination.insert("pageDirection".into(), json!(d));
}
if !pagination.is_empty() {
body["pagination"] = Value::Object(pagination);
}
let resp = client.post(path, &body).await.map_err(|e| e.to_string())?;
let items = extract_array(&resp, items_keys).unwrap_or_default();
let count = items.len();
next_boundary = items.last().and_then(&next_timestamp);
collected.extend(items);
pages_fetched += 1;
if count == 0 {
reached_end = true;
}
if !args.all {
break;
}
if reached_end || pages_fetched >= MAX_PAGES {
break;
}
if let Some(limit) = args.limit {
if collected.len() >= limit {
break;
}
}
match next_boundary {
Some(t) => current_timestamp = Some(t),
None => {
reached_end = true;
break;
}
}
}
if let Some(limit) = args.limit {
collected.truncate(limit);
}
let compact = compact_items(&collected, compact_fields);
if !args.requested {
return Ok(compact);
}
let compact_arr = compact.as_array().cloned().unwrap_or_default();
let returned = compact_arr.len();
let has_more = !reached_end && next_boundary.is_some();
let mut pagination = serde_json::Map::new();
pagination.insert("style".into(), json!("body"));
pagination.insert("has_more".into(), json!(has_more));
pagination.insert("returned".into(), json!(returned));
pagination.insert("all".into(), json!(args.all));
if let Some(t) = next_boundary {
pagination.insert("next_page_timestamp".into(), json!(t));
}
if let Some(d) = args.page_direction.as_deref() {
pagination.insert("page_direction".into(), json!(d));
}
Ok(json!({
"items": compact_arr,
"pagination": Value::Object(pagination),
}))
}
fn extract_array(resp: &Value, keys: &[&str]) -> Option<Vec<Value>> {
for key in keys {
if let Some(arr) = resp.get(key).and_then(|v| v.as_array()) {
return Some(arr.clone());
}
}
if let Some(arr) = resp.as_array() {
return Some(arr.clone());
}
None
}
#[cfg(test)]
mod tests {
use super::*;
use wiremock::matchers::{method, path, query_param, query_param_is_missing};
use wiremock::{Mock, MockServer, ResponseTemplate};
fn test_client(server: &MockServer) -> ClickUpClient {
ClickUpClient::new("pk_test", 30)
.expect("client")
.with_base_url(&server.uri())
}
#[tokio::test]
async fn page_dispatch_no_pagination_args_returns_bare_array() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/v2/list/L1/task"))
.and(query_param("page", "0"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"tasks": [{"id": "t1", "name": "A"}, {"id": "t2", "name": "B"}],
"last_page": true,
})))
.mount(&server)
.await;
let client = test_client(&server);
let args = PageArgs::from_args(&json!({}));
let result = page_dispatch(&args, &client, "tasks", &["id", "name"], |p| {
format!("/v2/list/L1/task?page={}", p)
})
.await
.unwrap();
assert!(result.is_array(), "expected bare array, got {}", result);
assert_eq!(result.as_array().unwrap().len(), 2);
}
#[tokio::test]
async fn page_dispatch_envelope_when_requested() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/v2/list/L1/task"))
.and(query_param("page", "0"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"tasks": [{"id": "t1", "name": "A"}],
"last_page": false,
})))
.mount(&server)
.await;
let client = test_client(&server);
let args = PageArgs::from_args(&json!({"page": 0}));
let result = page_dispatch(&args, &client, "tasks", &["id", "name"], |p| {
format!("/v2/list/L1/task?page={}", p)
})
.await
.unwrap();
assert!(result.is_object(), "expected envelope, got {}", result);
let items = result.get("items").and_then(|v| v.as_array()).unwrap();
assert_eq!(items.len(), 1);
let p = result.get("pagination").unwrap();
assert_eq!(p.get("style").and_then(|v| v.as_str()), Some("page"));
assert_eq!(p.get("last_page").and_then(|v| v.as_bool()), Some(false));
assert_eq!(p.get("has_more").and_then(|v| v.as_bool()), Some(true));
assert_eq!(p.get("returned").and_then(|v| v.as_u64()), Some(1));
assert_eq!(p.get("all").and_then(|v| v.as_bool()), Some(false));
}
#[tokio::test]
async fn page_dispatch_all_true_walks_pages_until_last() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/v2/list/L1/task"))
.and(query_param("page", "0"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"tasks": [{"id": "t1"}, {"id": "t2"}],
"last_page": false,
})))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/v2/list/L1/task"))
.and(query_param("page", "1"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"tasks": [{"id": "t3"}],
"last_page": true,
})))
.mount(&server)
.await;
let client = test_client(&server);
let args = PageArgs::from_args(&json!({"all": true}));
let result = page_dispatch(&args, &client, "tasks", &["id"], |p| {
format!("/v2/list/L1/task?page={}", p)
})
.await
.unwrap();
let items = result.get("items").and_then(|v| v.as_array()).unwrap();
assert_eq!(items.len(), 3, "expected 3 items across 2 pages");
let p = result.get("pagination").unwrap();
assert_eq!(p.get("last_page").and_then(|v| v.as_bool()), Some(true));
assert_eq!(p.get("has_more").and_then(|v| v.as_bool()), Some(false));
}
#[tokio::test]
async fn cursor_dispatch_follows_next_cursor() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/v3/workspaces/2648001/docs"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"data": [{"id": "d1", "name": "First"}],
"next_cursor": "ABC",
})))
.up_to_n_times(1)
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/v3/workspaces/2648001/docs"))
.and(query_param("cursor", "ABC"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"data": [{"id": "d2", "name": "Second"}],
"next_cursor": null,
})))
.mount(&server)
.await;
let client = test_client(&server);
let args = CursorArgs::from_args(&json!({"all": true}));
let result = cursor_dispatch(&args, &client, &["data"], &["id", "name"], |c| match c {
Some(c) => format!("/v3/workspaces/2648001/docs?cursor={}", c),
None => "/v3/workspaces/2648001/docs".to_string(),
})
.await
.unwrap();
let items = result.get("items").and_then(|v| v.as_array()).unwrap();
assert_eq!(items.len(), 2, "expected 2 items across 2 pages");
let p = result.get("pagination").unwrap();
assert_eq!(p.get("has_more").and_then(|v| v.as_bool()), Some(false));
assert!(p.get("next_cursor").is_none());
}
#[tokio::test]
async fn start_id_dispatch_no_pagination_args_returns_bare_array() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/v2/task/T1/comment"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"comments": [
{"id": "c1", "date": "1700000000000", "comment_text": "a"},
{"id": "c2", "date": "1700000005000", "comment_text": "b"},
],
})))
.mount(&server)
.await;
let client = test_client(&server);
let args = StartIdArgs::from_args(&json!({}));
let result = start_id_dispatch(
&args,
&client,
"comments",
&["id", "comment_text"],
|start, start_id| match (start, start_id) {
(Some(s), Some(i)) => format!("/v2/task/T1/comment?start={}&start_id={}", s, i),
_ => "/v2/task/T1/comment".to_string(),
},
)
.await
.unwrap();
assert!(result.is_array(), "expected bare array, got {}", result);
assert_eq!(result.as_array().unwrap().len(), 2);
}
#[tokio::test]
async fn start_id_dispatch_all_true_walks_pages_via_last_item_boundary() {
let server = MockServer::start().await;
let mut first_page = Vec::new();
for i in 0..25 {
first_page.push(json!({
"id": format!("c{}", i),
"date": format!("{}", 1_700_000_000_000_u64 + (i as u64) * 1000),
"comment_text": format!("comment {}", i),
}));
}
let last_first = &first_page[24];
let boundary_date = last_first["date"].as_str().unwrap();
let boundary_id = last_first["id"].as_str().unwrap();
Mock::given(method("GET"))
.and(path("/v2/task/T1/comment"))
.and(query_param_is_missing("start"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"comments": first_page,
})))
.up_to_n_times(1)
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/v2/task/T1/comment"))
.and(query_param("start", boundary_date))
.and(query_param("start_id", boundary_id))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"comments": [
{"id": "c25", "date": "1700000025000"},
{"id": "c26", "date": "1700000026000"},
],
})))
.mount(&server)
.await;
let client = test_client(&server);
let args = StartIdArgs::from_args(&json!({"all": true}));
let result = start_id_dispatch(
&args,
&client,
"comments",
&["id"],
|start, start_id| match (start, start_id) {
(Some(s), Some(i)) => format!("/v2/task/T1/comment?start={}&start_id={}", s, i),
_ => "/v2/task/T1/comment".to_string(),
},
)
.await
.unwrap();
let items = result.get("items").and_then(|v| v.as_array()).unwrap();
assert_eq!(items.len(), 27, "expected 25 + 2 across 2 pages");
let p = result.get("pagination").unwrap();
assert_eq!(p.get("style").and_then(|v| v.as_str()), Some("start_id"));
assert_eq!(p.get("has_more").and_then(|v| v.as_bool()), Some(false));
assert_eq!(p.get("next_start_id").and_then(|v| v.as_str()), Some("c26"));
}
#[tokio::test]
async fn page_dispatch_limit_truncates_but_has_more_reflects_server() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/v2/list/L1/task"))
.and(query_param("page", "0"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"tasks": [{"id": "t1"}, {"id": "t2"}, {"id": "t3"}],
"last_page": false,
})))
.mount(&server)
.await;
let client = test_client(&server);
let args = PageArgs::from_args(&json!({"limit": 2}));
let result = page_dispatch(&args, &client, "tasks", &["id"], |p| {
format!("/v2/list/L1/task?page={}", p)
})
.await
.unwrap();
let p = result.get("pagination").unwrap();
assert_eq!(
p.get("has_more").and_then(|v| v.as_bool()),
Some(true),
"limit-truncated page with last_page=false should report has_more=true"
);
}
#[tokio::test]
async fn cursor_dispatch_limit_truncates_but_has_more_reflects_server() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/v3/workspaces/2648001/chat/channels"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"data": [{"id": "c1"}, {"id": "c2"}, {"id": "c3"}],
"next_cursor": "MORE",
})))
.mount(&server)
.await;
let client = test_client(&server);
let args = CursorArgs::from_args(&json!({"limit": 2}));
let result = cursor_dispatch(&args, &client, &["data"], &["id"], |c| match c {
Some(c) => format!("/v3/workspaces/2648001/chat/channels?cursor={}", c),
None => "/v3/workspaces/2648001/chat/channels".to_string(),
})
.await
.unwrap();
let p = result.get("pagination").unwrap();
assert_eq!(
p.get("has_more").and_then(|v| v.as_bool()),
Some(true),
"limit-truncated page with non-empty next_cursor should report has_more=true"
);
assert_eq!(
p.get("next_cursor").and_then(|v| v.as_str()),
Some("MORE"),
"next_cursor must still be exposed so caller can fetch more if they want"
);
}
#[tokio::test]
async fn start_id_dispatch_limit_truncates_but_has_more_reflects_server() {
let server = MockServer::start().await;
let mut page = Vec::new();
for i in 0..25 {
page.push(json!({
"id": format!("c{}", i),
"date": format!("{}", 1_700_000_000_000_u64 + (i as u64) * 1000),
}));
}
Mock::given(method("GET"))
.and(path("/v2/task/T1/comment"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"comments": page,
})))
.mount(&server)
.await;
let client = test_client(&server);
let args = StartIdArgs::from_args(&json!({"limit": 10}));
let result = start_id_dispatch(
&args,
&client,
"comments",
&["id"],
|start, start_id| match (start, start_id) {
(Some(s), Some(i)) => format!("/v2/task/T1/comment?start={}&start_id={}", s, i),
_ => "/v2/task/T1/comment".to_string(),
},
)
.await
.unwrap();
let items = result.get("items").and_then(|v| v.as_array()).unwrap();
assert_eq!(items.len(), 10, "limit cap honoured");
let p = result.get("pagination").unwrap();
assert_eq!(
p.get("has_more").and_then(|v| v.as_bool()),
Some(true),
"limit-truncated full page should report has_more=true (server has more)"
);
assert_eq!(p.get("next_start_id").and_then(|v| v.as_str()), Some("c24"));
}
fn audit_event(id: &str, ts: i64) -> Value {
json!({"id": id, "eventTime": ts, "eventType": "auth"})
}
#[tokio::test]
async fn body_dispatch_no_pagination_args_returns_bare_array() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/v3/workspaces/W1/auditlogs"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"data": [audit_event("e1", 1700000000), audit_event("e2", 1700000005)],
})))
.mount(&server)
.await;
let client = test_client(&server);
let args = BodyPaginationArgs::from_args(&json!({}));
let result = body_pagination_dispatch(
&args,
&client,
"/v3/workspaces/W1/auditlogs",
&["data"],
&["id", "eventTime"],
|| json!({"applicability": "AUTH"}),
|item| item.get("eventTime").and_then(|v| v.as_i64()),
)
.await
.unwrap();
assert!(result.is_array(), "expected bare array, got {}", result);
assert_eq!(result.as_array().unwrap().len(), 2);
}
#[tokio::test]
async fn body_dispatch_all_true_walks_pages_via_timestamp_boundary() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/v3/workspaces/W1/auditlogs"))
.and(wiremock::matchers::body_partial_json(
json!({"applicability": "AUTH"}),
))
.and(wiremock::matchers::body_partial_json(
json!({"pagination": {"pageTimestamp": 1700000020_i64}}),
))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"data": [
audit_event("e4", 1700000030),
audit_event("e5", 1700000035),
],
})))
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path("/v3/workspaces/W1/auditlogs"))
.and(wiremock::matchers::body_partial_json(
json!({"pagination": {"pageTimestamp": 1700000035_i64}}),
))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"data": [],
})))
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path("/v3/workspaces/W1/auditlogs"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"data": [
audit_event("e1", 1700000010),
audit_event("e2", 1700000015),
audit_event("e3", 1700000020),
],
})))
.mount(&server)
.await;
let client = test_client(&server);
let args = BodyPaginationArgs::from_args(&json!({"all": true}));
let result = body_pagination_dispatch(
&args,
&client,
"/v3/workspaces/W1/auditlogs",
&["data"],
&["id"],
|| json!({"applicability": "AUTH"}),
|item| item.get("eventTime").and_then(|v| v.as_i64()),
)
.await
.unwrap();
let items = result.get("items").and_then(|v| v.as_array()).unwrap();
assert_eq!(items.len(), 5, "expected 3 + 2 + 0 across 3 pages");
let p = result.get("pagination").unwrap();
assert_eq!(p.get("style").and_then(|v| v.as_str()), Some("body"));
assert_eq!(
p.get("has_more").and_then(|v| v.as_bool()),
Some(false),
"reached natural end (empty page) -> has_more false"
);
}
#[tokio::test]
async fn body_dispatch_limit_truncates_but_has_more_reflects_server() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/v3/workspaces/W1/auditlogs"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"data": [
audit_event("e1", 1700000010),
audit_event("e2", 1700000015),
audit_event("e3", 1700000020),
],
})))
.mount(&server)
.await;
let client = test_client(&server);
let args = BodyPaginationArgs::from_args(&json!({"limit": 2}));
let result = body_pagination_dispatch(
&args,
&client,
"/v3/workspaces/W1/auditlogs",
&["data"],
&["id"],
|| json!({"applicability": "AUTH"}),
|item| item.get("eventTime").and_then(|v| v.as_i64()),
)
.await
.unwrap();
let items = result.get("items").and_then(|v| v.as_array()).unwrap();
assert_eq!(items.len(), 2, "limit cap honoured");
let p = result.get("pagination").unwrap();
assert_eq!(
p.get("has_more").and_then(|v| v.as_bool()),
Some(true),
"limit-truncated non-empty page should report has_more=true"
);
assert_eq!(
p.get("next_page_timestamp").and_then(|v| v.as_i64()),
Some(1700000020)
);
}
#[test]
fn body_pagination_args_empty() {
let a = BodyPaginationArgs::from_args(&json!({}));
assert!(!a.requested);
assert!(a.page_rows.is_none());
assert!(a.page_timestamp.is_none());
assert!(a.page_direction.is_none());
assert!(a.limit.is_none());
assert!(!a.all);
}
#[test]
fn body_pagination_args_full() {
let a = BodyPaginationArgs::from_args(&json!({
"page_rows": 100,
"page_timestamp": 1700000000_i64,
"page_direction": "PREVIOUS",
"limit": 50,
"all": true,
}));
assert!(a.requested);
assert_eq!(a.page_rows, Some(100));
assert_eq!(a.page_timestamp, Some(1700000000));
assert_eq!(a.page_direction.as_deref(), Some("PREVIOUS"));
assert_eq!(a.limit, Some(50));
assert!(a.all);
}
#[test]
fn page_args_empty() {
let p = PageArgs::from_args(&json!({}));
assert!(!p.requested);
assert_eq!(p.page, None);
assert_eq!(p.limit, None);
assert!(!p.all);
}
#[test]
fn page_args_full() {
let p = PageArgs::from_args(&json!({"page": 2, "limit": 50, "all": true}));
assert!(p.requested);
assert_eq!(p.page, Some(2));
assert_eq!(p.limit, Some(50));
assert!(p.all);
}
#[test]
fn page_args_just_all_flag() {
let p = PageArgs::from_args(&json!({"all": false}));
assert!(p.requested);
}
#[test]
fn cursor_args_empty() {
let c = CursorArgs::from_args(&json!({}));
assert!(!c.requested);
assert!(c.cursor.is_none());
}
#[test]
fn cursor_args_full() {
let c = CursorArgs::from_args(&json!({"cursor": "abc", "limit": 10}));
assert!(c.requested);
assert_eq!(c.cursor.as_deref(), Some("abc"));
assert_eq!(c.limit, Some(10));
}
#[test]
fn start_id_args_empty() {
let s = StartIdArgs::from_args(&json!({}));
assert!(!s.requested);
assert!(s.start.is_none());
assert!(s.start_id.is_none());
assert_eq!(s.limit, None);
assert!(!s.all);
}
#[test]
fn start_id_args_full() {
let s = StartIdArgs::from_args(
&json!({"start": 1700000000000_i64, "start_id": "c1", "limit": 20, "all": true}),
);
assert!(s.requested);
assert_eq!(s.start, Some(1700000000000));
assert_eq!(s.start_id.as_deref(), Some("c1"));
assert_eq!(s.limit, Some(20));
assert!(s.all);
}
#[test]
fn start_id_args_partial_start_only_still_requested() {
let s = StartIdArgs::from_args(&json!({"start": 1700000000000_i64}));
assert!(s.requested);
assert_eq!(s.start, Some(1700000000000));
assert!(s.start_id.is_none());
}
#[test]
fn extract_array_prefers_first_key() {
let resp = json!({"data": [1, 2], "tasks": [3, 4]});
let arr = extract_array(&resp, &["data", "tasks"]).unwrap();
assert_eq!(arr.len(), 2);
assert_eq!(arr[0], json!(1));
}
#[test]
fn extract_array_falls_back_to_second_key() {
let resp = json!({"tasks": [3, 4]});
let arr = extract_array(&resp, &["data", "tasks"]).unwrap();
assert_eq!(arr.len(), 2);
assert_eq!(arr[0], json!(3));
}
#[test]
fn extract_array_falls_back_to_bare_array() {
let resp = json!([1, 2, 3]);
let arr = extract_array(&resp, &["data"]).unwrap();
assert_eq!(arr.len(), 3);
}
#[test]
fn extract_array_returns_none_when_no_match() {
let resp = json!({"foo": "bar"});
assert!(extract_array(&resp, &["data", "tasks"]).is_none());
}
}