1use crate::client::ClickUpClient;
53use crate::output::compact_items;
54use serde_json::{json, Value};
55
56const MAX_PAGES: usize = 100;
59
60#[derive(Debug, Clone, Copy, Default)]
62pub struct PageArgs {
63 pub page: Option<u64>,
64 pub limit: Option<usize>,
65 pub all: bool,
66 pub requested: bool,
69}
70
71impl PageArgs {
72 pub fn from_args(args: &Value) -> Self {
73 let page = args.get("page").and_then(|v| v.as_u64());
74 let limit = args
75 .get("limit")
76 .and_then(|v| v.as_u64())
77 .map(|n| n as usize);
78 let all = args.get("all").and_then(|v| v.as_bool()).unwrap_or(false);
79 let requested = page.is_some() || limit.is_some() || args.get("all").is_some();
80 Self {
81 page,
82 limit,
83 all,
84 requested,
85 }
86 }
87}
88
89#[derive(Debug, Clone, Default)]
91pub struct CursorArgs {
92 pub cursor: Option<String>,
93 pub limit: Option<usize>,
94 pub all: bool,
95 pub requested: bool,
96}
97
98impl CursorArgs {
99 pub fn from_args(args: &Value) -> Self {
100 let cursor = args
101 .get("cursor")
102 .and_then(|v| v.as_str())
103 .map(String::from);
104 let limit = args
105 .get("limit")
106 .and_then(|v| v.as_u64())
107 .map(|n| n as usize);
108 let all = args.get("all").and_then(|v| v.as_bool()).unwrap_or(false);
109 let requested = cursor.is_some() || limit.is_some() || args.get("all").is_some();
110 Self {
111 cursor,
112 limit,
113 all,
114 requested,
115 }
116 }
117}
118
119#[derive(Debug, Clone, Default)]
128pub struct StartIdArgs {
129 pub start: Option<i64>,
130 pub start_id: Option<String>,
131 pub limit: Option<usize>,
132 pub all: bool,
133 pub requested: bool,
134}
135
136impl StartIdArgs {
137 pub fn from_args(args: &Value) -> Self {
138 let start = args.get("start").and_then(|v| v.as_i64());
139 let start_id = args
140 .get("start_id")
141 .and_then(|v| v.as_str())
142 .map(String::from);
143 let limit = args
144 .get("limit")
145 .and_then(|v| v.as_u64())
146 .map(|n| n as usize);
147 let all = args.get("all").and_then(|v| v.as_bool()).unwrap_or(false);
148 let requested =
149 start.is_some() || start_id.is_some() || limit.is_some() || args.get("all").is_some();
150 Self {
151 start,
152 start_id,
153 limit,
154 all,
155 requested,
156 }
157 }
158}
159
160#[derive(Debug, Clone, Default)]
167pub struct BodyPaginationArgs {
168 pub page_rows: Option<i64>,
169 pub page_timestamp: Option<i64>,
170 pub page_direction: Option<String>,
171 pub limit: Option<usize>,
172 pub all: bool,
173 pub requested: bool,
174}
175
176impl BodyPaginationArgs {
177 pub fn from_args(args: &Value) -> Self {
178 let page_rows = args.get("page_rows").and_then(|v| v.as_i64());
179 let page_timestamp = args.get("page_timestamp").and_then(|v| v.as_i64());
180 let page_direction = args
181 .get("page_direction")
182 .and_then(|v| v.as_str())
183 .map(String::from);
184 let limit = args
185 .get("limit")
186 .and_then(|v| v.as_u64())
187 .map(|n| n as usize);
188 let all = args.get("all").and_then(|v| v.as_bool()).unwrap_or(false);
189 let requested = page_rows.is_some()
190 || page_timestamp.is_some()
191 || page_direction.is_some()
192 || limit.is_some()
193 || args.get("all").is_some();
194 Self {
195 page_rows,
196 page_timestamp,
197 page_direction,
198 limit,
199 all,
200 requested,
201 }
202 }
203}
204
205pub async fn page_dispatch<F>(
211 args: &PageArgs,
212 client: &ClickUpClient,
213 items_key: &str,
214 compact_fields: &[&str],
215 build_path: F,
216) -> Result<Value, String>
217where
218 F: Fn(u64) -> String,
219{
220 let start_page = args.page.unwrap_or(0);
221 let mut collected: Vec<Value> = Vec::new();
222 let mut current_page = start_page;
223 #[allow(unused_assignments)]
226 let mut last_page = false;
227 let mut pages_fetched = 0usize;
228
229 loop {
230 let path = build_path(current_page);
231 let resp = client.get(&path).await.map_err(|e| e.to_string())?;
232
233 let items = extract_array(&resp, &[items_key, "data"]).unwrap_or_default();
234
235 last_page = resp
236 .get("last_page")
237 .and_then(|v| v.as_bool())
238 .unwrap_or(items.is_empty());
239
240 collected.extend(items);
241 pages_fetched += 1;
242
243 if !args.all {
246 break;
247 }
248 if last_page || pages_fetched >= MAX_PAGES {
249 break;
250 }
251 if let Some(limit) = args.limit {
252 if collected.len() >= limit {
253 break;
254 }
255 }
256 current_page += 1;
257 }
258
259 if let Some(limit) = args.limit {
261 collected.truncate(limit);
262 }
263
264 let compact = compact_items(&collected, compact_fields);
265
266 if !args.requested {
267 return Ok(compact);
270 }
271
272 let compact_arr = compact.as_array().cloned().unwrap_or_default();
273 let returned = compact_arr.len();
274 let has_more = !last_page;
280 let last_observed_page = if args.all { current_page } else { start_page };
281 Ok(json!({
282 "items": compact_arr,
283 "pagination": {
284 "style": "page",
285 "page": last_observed_page,
286 "last_page": last_page,
287 "has_more": has_more,
288 "returned": returned,
289 "all": args.all,
290 }
291 }))
292}
293
294pub async fn cursor_dispatch<F>(
301 args: &CursorArgs,
302 client: &ClickUpClient,
303 items_keys: &[&str],
304 compact_fields: &[&str],
305 build_path: F,
306) -> Result<Value, String>
307where
308 F: Fn(Option<&str>) -> String,
309{
310 let mut cursor = args.cursor.clone();
311 let mut collected: Vec<Value> = Vec::new();
312 #[allow(unused_assignments)]
315 let mut next_cursor: Option<String> = None;
316 let mut pages_fetched = 0usize;
317
318 loop {
319 let path = build_path(cursor.as_deref());
320 let resp = client.get(&path).await.map_err(|e| e.to_string())?;
321
322 let items = extract_array(&resp, items_keys).unwrap_or_default();
323
324 next_cursor = resp
325 .get("next_cursor")
326 .and_then(|v| v.as_str())
327 .filter(|s| !s.is_empty())
328 .map(String::from);
329
330 collected.extend(items);
331 pages_fetched += 1;
332
333 if !args.all {
334 break;
335 }
336 if next_cursor.is_none() || pages_fetched >= MAX_PAGES {
337 break;
338 }
339 if let Some(limit) = args.limit {
340 if collected.len() >= limit {
341 break;
342 }
343 }
344 cursor = next_cursor.clone();
345 }
346
347 if let Some(limit) = args.limit {
348 collected.truncate(limit);
349 }
350
351 let compact = compact_items(&collected, compact_fields);
352
353 if !args.requested {
354 return Ok(compact);
355 }
356
357 let compact_arr = compact.as_array().cloned().unwrap_or_default();
358 let returned = compact_arr.len();
359 let has_more = next_cursor.is_some();
363
364 let mut pagination = serde_json::Map::new();
365 pagination.insert("style".into(), json!("cursor"));
366 pagination.insert("has_more".into(), json!(has_more));
367 pagination.insert("returned".into(), json!(returned));
368 pagination.insert("all".into(), json!(args.all));
369 if let Some(c) = next_cursor {
370 pagination.insert("next_cursor".into(), json!(c));
371 }
372 Ok(json!({
373 "items": compact_arr,
374 "pagination": Value::Object(pagination),
375 }))
376}
377
378const START_ID_PAGE_HINT: usize = 25;
385
386pub async fn start_id_dispatch<F>(
397 args: &StartIdArgs,
398 client: &ClickUpClient,
399 items_key: &str,
400 compact_fields: &[&str],
401 build_path: F,
402) -> Result<Value, String>
403where
404 F: Fn(Option<i64>, Option<&str>) -> String,
405{
406 let mut current_start = args.start;
407 let mut current_start_id = args.start_id.clone();
408 let mut collected: Vec<Value> = Vec::new();
409 #[allow(unused_assignments)]
414 let mut next_boundary: Option<(i64, String)> = None;
415 let mut reached_end = false;
419 let mut pages_fetched = 0usize;
420
421 loop {
422 let path = build_path(current_start, current_start_id.as_deref());
423 let resp = client.get(&path).await.map_err(|e| e.to_string())?;
424 let items = extract_array(&resp, &[items_key, "data"]).unwrap_or_default();
425 let count = items.len();
426
427 if let Some(last) = items.last() {
430 let date_ms = last
431 .get("date")
432 .and_then(|v| v.as_str())
433 .and_then(|s| s.parse::<i64>().ok())
434 .or_else(|| last.get("date").and_then(|v| v.as_i64()));
435 let id = last.get("id").and_then(|v| v.as_str()).map(String::from);
436 next_boundary = match (date_ms, id) {
437 (Some(d), Some(i)) => Some((d, i)),
438 _ => None,
439 };
440 } else {
441 next_boundary = None;
442 }
443
444 collected.extend(items);
445 pages_fetched += 1;
446
447 if count < START_ID_PAGE_HINT {
451 reached_end = true;
452 }
453
454 if !args.all {
455 break;
456 }
457 if reached_end || pages_fetched >= MAX_PAGES {
458 break;
459 }
460 if let Some(limit) = args.limit {
461 if collected.len() >= limit {
462 break;
463 }
464 }
465
466 match next_boundary.clone() {
470 Some((d, i)) => {
471 current_start = Some(d);
472 current_start_id = Some(i);
473 }
474 None => break,
475 }
476 }
477
478 if let Some(limit) = args.limit {
479 collected.truncate(limit);
480 }
481
482 let compact = compact_items(&collected, compact_fields);
483
484 if !args.requested {
485 return Ok(compact);
486 }
487
488 let compact_arr = compact.as_array().cloned().unwrap_or_default();
489 let returned = compact_arr.len();
490 let has_more = !reached_end && next_boundary.is_some();
495
496 let mut pagination = serde_json::Map::new();
497 pagination.insert("style".into(), json!("start_id"));
498 pagination.insert("has_more".into(), json!(has_more));
499 pagination.insert("returned".into(), json!(returned));
500 pagination.insert("all".into(), json!(args.all));
501 if let Some((d, i)) = next_boundary {
502 pagination.insert("next_start".into(), json!(d));
503 pagination.insert("next_start_id".into(), json!(i));
504 }
505 Ok(json!({
506 "items": compact_arr,
507 "pagination": Value::Object(pagination),
508 }))
509}
510
511pub async fn body_pagination_dispatch<BB, NT>(
530 args: &BodyPaginationArgs,
531 client: &ClickUpClient,
532 path: &str,
533 items_keys: &[&str],
534 compact_fields: &[&str],
535 base_body: BB,
536 next_timestamp: NT,
537) -> Result<Value, String>
538where
539 BB: Fn() -> Value,
540 NT: Fn(&Value) -> Option<i64>,
541{
542 let mut current_timestamp = args.page_timestamp;
543 let mut collected: Vec<Value> = Vec::new();
544 let mut reached_end = false;
547 #[allow(unused_assignments)]
550 let mut next_boundary: Option<i64> = None;
551 let mut pages_fetched = 0usize;
552
553 loop {
554 let mut body = base_body();
559 let mut pagination = serde_json::Map::new();
560 if let Some(n) = args.page_rows {
561 pagination.insert("pageRows".into(), json!(n));
562 }
563 if let Some(t) = current_timestamp {
564 pagination.insert("pageTimestamp".into(), json!(t));
565 }
566 if let Some(d) = args.page_direction.as_deref() {
567 pagination.insert("pageDirection".into(), json!(d));
568 }
569 if !pagination.is_empty() {
570 body["pagination"] = Value::Object(pagination);
571 }
572
573 let resp = client.post(path, &body).await.map_err(|e| e.to_string())?;
574 let items = extract_array(&resp, items_keys).unwrap_or_default();
575 let count = items.len();
576
577 next_boundary = items.last().and_then(&next_timestamp);
580
581 collected.extend(items);
582 pages_fetched += 1;
583
584 if count == 0 {
585 reached_end = true;
586 }
587
588 if !args.all {
589 break;
590 }
591 if reached_end || pages_fetched >= MAX_PAGES {
592 break;
593 }
594 if let Some(limit) = args.limit {
595 if collected.len() >= limit {
596 break;
597 }
598 }
599
600 match next_boundary {
604 Some(t) => current_timestamp = Some(t),
605 None => {
606 reached_end = true;
607 break;
608 }
609 }
610 }
611
612 if let Some(limit) = args.limit {
613 collected.truncate(limit);
614 }
615
616 let compact = compact_items(&collected, compact_fields);
617
618 if !args.requested {
619 return Ok(compact);
620 }
621
622 let compact_arr = compact.as_array().cloned().unwrap_or_default();
623 let returned = compact_arr.len();
624 let has_more = !reached_end && next_boundary.is_some();
625
626 let mut pagination = serde_json::Map::new();
627 pagination.insert("style".into(), json!("body"));
628 pagination.insert("has_more".into(), json!(has_more));
629 pagination.insert("returned".into(), json!(returned));
630 pagination.insert("all".into(), json!(args.all));
631 if let Some(t) = next_boundary {
632 pagination.insert("next_page_timestamp".into(), json!(t));
633 }
634 if let Some(d) = args.page_direction.as_deref() {
635 pagination.insert("page_direction".into(), json!(d));
636 }
637 Ok(json!({
638 "items": compact_arr,
639 "pagination": Value::Object(pagination),
640 }))
641}
642
643fn extract_array(resp: &Value, keys: &[&str]) -> Option<Vec<Value>> {
647 for key in keys {
648 if let Some(arr) = resp.get(key).and_then(|v| v.as_array()) {
649 return Some(arr.clone());
650 }
651 }
652 if let Some(arr) = resp.as_array() {
654 return Some(arr.clone());
655 }
656 None
657}
658
659#[cfg(test)]
660mod tests {
661 use super::*;
662 use wiremock::matchers::{method, path, query_param, query_param_is_missing};
663 use wiremock::{Mock, MockServer, ResponseTemplate};
664
665 fn test_client(server: &MockServer) -> ClickUpClient {
666 ClickUpClient::new("pk_test", 30)
667 .expect("client")
668 .with_base_url(&server.uri())
669 }
670
671 #[tokio::test]
672 async fn page_dispatch_no_pagination_args_returns_bare_array() {
673 let server = MockServer::start().await;
674 Mock::given(method("GET"))
675 .and(path("/v2/list/L1/task"))
676 .and(query_param("page", "0"))
677 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
678 "tasks": [{"id": "t1", "name": "A"}, {"id": "t2", "name": "B"}],
679 "last_page": true,
680 })))
681 .mount(&server)
682 .await;
683 let client = test_client(&server);
684 let args = PageArgs::from_args(&json!({}));
685 let result = page_dispatch(&args, &client, "tasks", &["id", "name"], |p| {
686 format!("/v2/list/L1/task?page={}", p)
687 })
688 .await
689 .unwrap();
690 assert!(result.is_array(), "expected bare array, got {}", result);
692 assert_eq!(result.as_array().unwrap().len(), 2);
693 }
694
695 #[tokio::test]
696 async fn page_dispatch_envelope_when_requested() {
697 let server = MockServer::start().await;
698 Mock::given(method("GET"))
699 .and(path("/v2/list/L1/task"))
700 .and(query_param("page", "0"))
701 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
702 "tasks": [{"id": "t1", "name": "A"}],
703 "last_page": false,
704 })))
705 .mount(&server)
706 .await;
707 let client = test_client(&server);
708 let args = PageArgs::from_args(&json!({"page": 0}));
709 let result = page_dispatch(&args, &client, "tasks", &["id", "name"], |p| {
710 format!("/v2/list/L1/task?page={}", p)
711 })
712 .await
713 .unwrap();
714 assert!(result.is_object(), "expected envelope, got {}", result);
716 let items = result.get("items").and_then(|v| v.as_array()).unwrap();
717 assert_eq!(items.len(), 1);
718 let p = result.get("pagination").unwrap();
719 assert_eq!(p.get("style").and_then(|v| v.as_str()), Some("page"));
720 assert_eq!(p.get("last_page").and_then(|v| v.as_bool()), Some(false));
721 assert_eq!(p.get("has_more").and_then(|v| v.as_bool()), Some(true));
722 assert_eq!(p.get("returned").and_then(|v| v.as_u64()), Some(1));
723 assert_eq!(p.get("all").and_then(|v| v.as_bool()), Some(false));
724 }
725
726 #[tokio::test]
727 async fn page_dispatch_all_true_walks_pages_until_last() {
728 let server = MockServer::start().await;
729 Mock::given(method("GET"))
730 .and(path("/v2/list/L1/task"))
731 .and(query_param("page", "0"))
732 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
733 "tasks": [{"id": "t1"}, {"id": "t2"}],
734 "last_page": false,
735 })))
736 .mount(&server)
737 .await;
738 Mock::given(method("GET"))
739 .and(path("/v2/list/L1/task"))
740 .and(query_param("page", "1"))
741 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
742 "tasks": [{"id": "t3"}],
743 "last_page": true,
744 })))
745 .mount(&server)
746 .await;
747 let client = test_client(&server);
748 let args = PageArgs::from_args(&json!({"all": true}));
749 let result = page_dispatch(&args, &client, "tasks", &["id"], |p| {
750 format!("/v2/list/L1/task?page={}", p)
751 })
752 .await
753 .unwrap();
754 let items = result.get("items").and_then(|v| v.as_array()).unwrap();
755 assert_eq!(items.len(), 3, "expected 3 items across 2 pages");
756 let p = result.get("pagination").unwrap();
757 assert_eq!(p.get("last_page").and_then(|v| v.as_bool()), Some(true));
758 assert_eq!(p.get("has_more").and_then(|v| v.as_bool()), Some(false));
759 }
760
761 #[tokio::test]
762 async fn cursor_dispatch_follows_next_cursor() {
763 let server = MockServer::start().await;
764 Mock::given(method("GET"))
766 .and(path("/v3/workspaces/2648001/docs"))
767 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
768 "data": [{"id": "d1", "name": "First"}],
769 "next_cursor": "ABC",
770 })))
771 .up_to_n_times(1)
772 .mount(&server)
773 .await;
774 Mock::given(method("GET"))
776 .and(path("/v3/workspaces/2648001/docs"))
777 .and(query_param("cursor", "ABC"))
778 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
779 "data": [{"id": "d2", "name": "Second"}],
780 "next_cursor": null,
781 })))
782 .mount(&server)
783 .await;
784 let client = test_client(&server);
785 let args = CursorArgs::from_args(&json!({"all": true}));
786 let result = cursor_dispatch(&args, &client, &["data"], &["id", "name"], |c| match c {
787 Some(c) => format!("/v3/workspaces/2648001/docs?cursor={}", c),
788 None => "/v3/workspaces/2648001/docs".to_string(),
789 })
790 .await
791 .unwrap();
792 let items = result.get("items").and_then(|v| v.as_array()).unwrap();
793 assert_eq!(items.len(), 2, "expected 2 items across 2 pages");
794 let p = result.get("pagination").unwrap();
795 assert_eq!(p.get("has_more").and_then(|v| v.as_bool()), Some(false));
796 assert!(p.get("next_cursor").is_none());
798 }
799
800 #[tokio::test]
801 async fn start_id_dispatch_no_pagination_args_returns_bare_array() {
802 let server = MockServer::start().await;
805 Mock::given(method("GET"))
806 .and(path("/v2/task/T1/comment"))
807 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
808 "comments": [
809 {"id": "c1", "date": "1700000000000", "comment_text": "a"},
810 {"id": "c2", "date": "1700000005000", "comment_text": "b"},
811 ],
812 })))
813 .mount(&server)
814 .await;
815 let client = test_client(&server);
816 let args = StartIdArgs::from_args(&json!({}));
817 let result = start_id_dispatch(
818 &args,
819 &client,
820 "comments",
821 &["id", "comment_text"],
822 |start, start_id| match (start, start_id) {
823 (Some(s), Some(i)) => format!("/v2/task/T1/comment?start={}&start_id={}", s, i),
824 _ => "/v2/task/T1/comment".to_string(),
825 },
826 )
827 .await
828 .unwrap();
829 assert!(result.is_array(), "expected bare array, got {}", result);
830 assert_eq!(result.as_array().unwrap().len(), 2);
831 }
832
833 #[tokio::test]
834 async fn start_id_dispatch_all_true_walks_pages_via_last_item_boundary() {
835 let server = MockServer::start().await;
839
840 let mut first_page = Vec::new();
841 for i in 0..25 {
842 first_page.push(json!({
843 "id": format!("c{}", i),
844 "date": format!("{}", 1_700_000_000_000_u64 + (i as u64) * 1000),
845 "comment_text": format!("comment {}", i),
846 }));
847 }
848 let last_first = &first_page[24];
851 let boundary_date = last_first["date"].as_str().unwrap();
852 let boundary_id = last_first["id"].as_str().unwrap();
853
854 Mock::given(method("GET"))
855 .and(path("/v2/task/T1/comment"))
856 .and(query_param_is_missing("start"))
858 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
859 "comments": first_page,
860 })))
861 .up_to_n_times(1)
862 .mount(&server)
863 .await;
864 Mock::given(method("GET"))
865 .and(path("/v2/task/T1/comment"))
866 .and(query_param("start", boundary_date))
867 .and(query_param("start_id", boundary_id))
868 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
869 "comments": [
870 {"id": "c25", "date": "1700000025000"},
871 {"id": "c26", "date": "1700000026000"},
872 ],
873 })))
874 .mount(&server)
875 .await;
876
877 let client = test_client(&server);
878 let args = StartIdArgs::from_args(&json!({"all": true}));
879 let result = start_id_dispatch(
880 &args,
881 &client,
882 "comments",
883 &["id"],
884 |start, start_id| match (start, start_id) {
885 (Some(s), Some(i)) => format!("/v2/task/T1/comment?start={}&start_id={}", s, i),
886 _ => "/v2/task/T1/comment".to_string(),
887 },
888 )
889 .await
890 .unwrap();
891 let items = result.get("items").and_then(|v| v.as_array()).unwrap();
892 assert_eq!(items.len(), 27, "expected 25 + 2 across 2 pages");
893 let p = result.get("pagination").unwrap();
894 assert_eq!(p.get("style").and_then(|v| v.as_str()), Some("start_id"));
895 assert_eq!(p.get("has_more").and_then(|v| v.as_bool()), Some(false));
897 assert_eq!(p.get("next_start_id").and_then(|v| v.as_str()), Some("c26"));
900 }
901
902 #[tokio::test]
912 async fn page_dispatch_limit_truncates_but_has_more_reflects_server() {
913 let server = MockServer::start().await;
914 Mock::given(method("GET"))
915 .and(path("/v2/list/L1/task"))
916 .and(query_param("page", "0"))
917 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
918 "tasks": [{"id": "t1"}, {"id": "t2"}, {"id": "t3"}],
919 "last_page": false,
920 })))
921 .mount(&server)
922 .await;
923 let client = test_client(&server);
924 let args = PageArgs::from_args(&json!({"limit": 2}));
926 let result = page_dispatch(&args, &client, "tasks", &["id"], |p| {
927 format!("/v2/list/L1/task?page={}", p)
928 })
929 .await
930 .unwrap();
931 let p = result.get("pagination").unwrap();
932 assert_eq!(
933 p.get("has_more").and_then(|v| v.as_bool()),
934 Some(true),
935 "limit-truncated page with last_page=false should report has_more=true"
936 );
937 }
938
939 #[tokio::test]
940 async fn cursor_dispatch_limit_truncates_but_has_more_reflects_server() {
941 let server = MockServer::start().await;
942 Mock::given(method("GET"))
943 .and(path("/v3/workspaces/2648001/chat/channels"))
944 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
945 "data": [{"id": "c1"}, {"id": "c2"}, {"id": "c3"}],
946 "next_cursor": "MORE",
947 })))
948 .mount(&server)
949 .await;
950 let client = test_client(&server);
951 let args = CursorArgs::from_args(&json!({"limit": 2}));
952 let result = cursor_dispatch(&args, &client, &["data"], &["id"], |c| match c {
953 Some(c) => format!("/v3/workspaces/2648001/chat/channels?cursor={}", c),
954 None => "/v3/workspaces/2648001/chat/channels".to_string(),
955 })
956 .await
957 .unwrap();
958 let p = result.get("pagination").unwrap();
959 assert_eq!(
960 p.get("has_more").and_then(|v| v.as_bool()),
961 Some(true),
962 "limit-truncated page with non-empty next_cursor should report has_more=true"
963 );
964 assert_eq!(
965 p.get("next_cursor").and_then(|v| v.as_str()),
966 Some("MORE"),
967 "next_cursor must still be exposed so caller can fetch more if they want"
968 );
969 }
970
971 #[tokio::test]
972 async fn start_id_dispatch_limit_truncates_but_has_more_reflects_server() {
973 let server = MockServer::start().await;
978 let mut page = Vec::new();
979 for i in 0..25 {
980 page.push(json!({
981 "id": format!("c{}", i),
982 "date": format!("{}", 1_700_000_000_000_u64 + (i as u64) * 1000),
983 }));
984 }
985 Mock::given(method("GET"))
986 .and(path("/v2/task/T1/comment"))
987 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
988 "comments": page,
989 })))
990 .mount(&server)
991 .await;
992 let client = test_client(&server);
993 let args = StartIdArgs::from_args(&json!({"limit": 10}));
994 let result = start_id_dispatch(
995 &args,
996 &client,
997 "comments",
998 &["id"],
999 |start, start_id| match (start, start_id) {
1000 (Some(s), Some(i)) => format!("/v2/task/T1/comment?start={}&start_id={}", s, i),
1001 _ => "/v2/task/T1/comment".to_string(),
1002 },
1003 )
1004 .await
1005 .unwrap();
1006 let items = result.get("items").and_then(|v| v.as_array()).unwrap();
1007 assert_eq!(items.len(), 10, "limit cap honoured");
1008 let p = result.get("pagination").unwrap();
1009 assert_eq!(
1010 p.get("has_more").and_then(|v| v.as_bool()),
1011 Some(true),
1012 "limit-truncated full page should report has_more=true (server has more)"
1013 );
1014 assert_eq!(p.get("next_start_id").and_then(|v| v.as_str()), Some("c24"));
1018 }
1019
1020 fn audit_event(id: &str, ts: i64) -> Value {
1023 json!({"id": id, "eventTime": ts, "eventType": "auth"})
1024 }
1025
1026 #[tokio::test]
1027 async fn body_dispatch_no_pagination_args_returns_bare_array() {
1028 let server = MockServer::start().await;
1029 Mock::given(method("POST"))
1030 .and(path("/v3/workspaces/W1/auditlogs"))
1031 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
1032 "data": [audit_event("e1", 1700000000), audit_event("e2", 1700000005)],
1033 })))
1034 .mount(&server)
1035 .await;
1036 let client = test_client(&server);
1037 let args = BodyPaginationArgs::from_args(&json!({}));
1038 let result = body_pagination_dispatch(
1039 &args,
1040 &client,
1041 "/v3/workspaces/W1/auditlogs",
1042 &["data"],
1043 &["id", "eventTime"],
1044 || json!({"applicability": "AUTH"}),
1045 |item| item.get("eventTime").and_then(|v| v.as_i64()),
1046 )
1047 .await
1048 .unwrap();
1049 assert!(result.is_array(), "expected bare array, got {}", result);
1050 assert_eq!(result.as_array().unwrap().len(), 2);
1051 }
1052
1053 #[tokio::test]
1054 async fn body_dispatch_all_true_walks_pages_via_timestamp_boundary() {
1055 let server = MockServer::start().await;
1059
1060 Mock::given(method("POST"))
1061 .and(path("/v3/workspaces/W1/auditlogs"))
1062 .and(wiremock::matchers::body_partial_json(
1063 json!({"applicability": "AUTH"}),
1064 ))
1065 .and(wiremock::matchers::body_partial_json(
1066 json!({"pagination": {"pageTimestamp": 1700000020_i64}}),
1067 ))
1068 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
1069 "data": [
1070 audit_event("e4", 1700000030),
1071 audit_event("e5", 1700000035),
1072 ],
1073 })))
1074 .mount(&server)
1075 .await;
1076 Mock::given(method("POST"))
1077 .and(path("/v3/workspaces/W1/auditlogs"))
1078 .and(wiremock::matchers::body_partial_json(
1079 json!({"pagination": {"pageTimestamp": 1700000035_i64}}),
1080 ))
1081 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
1082 "data": [],
1083 })))
1084 .mount(&server)
1085 .await;
1086 Mock::given(method("POST"))
1089 .and(path("/v3/workspaces/W1/auditlogs"))
1090 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
1091 "data": [
1092 audit_event("e1", 1700000010),
1093 audit_event("e2", 1700000015),
1094 audit_event("e3", 1700000020),
1095 ],
1096 })))
1097 .mount(&server)
1098 .await;
1099
1100 let client = test_client(&server);
1101 let args = BodyPaginationArgs::from_args(&json!({"all": true}));
1102 let result = body_pagination_dispatch(
1103 &args,
1104 &client,
1105 "/v3/workspaces/W1/auditlogs",
1106 &["data"],
1107 &["id"],
1108 || json!({"applicability": "AUTH"}),
1109 |item| item.get("eventTime").and_then(|v| v.as_i64()),
1110 )
1111 .await
1112 .unwrap();
1113 let items = result.get("items").and_then(|v| v.as_array()).unwrap();
1114 assert_eq!(items.len(), 5, "expected 3 + 2 + 0 across 3 pages");
1115 let p = result.get("pagination").unwrap();
1116 assert_eq!(p.get("style").and_then(|v| v.as_str()), Some("body"));
1117 assert_eq!(
1118 p.get("has_more").and_then(|v| v.as_bool()),
1119 Some(false),
1120 "reached natural end (empty page) -> has_more false"
1121 );
1122 }
1123
1124 #[tokio::test]
1125 async fn body_dispatch_limit_truncates_but_has_more_reflects_server() {
1126 let server = MockServer::start().await;
1129 Mock::given(method("POST"))
1130 .and(path("/v3/workspaces/W1/auditlogs"))
1131 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
1132 "data": [
1133 audit_event("e1", 1700000010),
1134 audit_event("e2", 1700000015),
1135 audit_event("e3", 1700000020),
1136 ],
1137 })))
1138 .mount(&server)
1139 .await;
1140 let client = test_client(&server);
1141 let args = BodyPaginationArgs::from_args(&json!({"limit": 2}));
1142 let result = body_pagination_dispatch(
1143 &args,
1144 &client,
1145 "/v3/workspaces/W1/auditlogs",
1146 &["data"],
1147 &["id"],
1148 || json!({"applicability": "AUTH"}),
1149 |item| item.get("eventTime").and_then(|v| v.as_i64()),
1150 )
1151 .await
1152 .unwrap();
1153 let items = result.get("items").and_then(|v| v.as_array()).unwrap();
1154 assert_eq!(items.len(), 2, "limit cap honoured");
1155 let p = result.get("pagination").unwrap();
1156 assert_eq!(
1157 p.get("has_more").and_then(|v| v.as_bool()),
1158 Some(true),
1159 "limit-truncated non-empty page should report has_more=true"
1160 );
1161 assert_eq!(
1165 p.get("next_page_timestamp").and_then(|v| v.as_i64()),
1166 Some(1700000020)
1167 );
1168 }
1169
1170 #[test]
1171 fn body_pagination_args_empty() {
1172 let a = BodyPaginationArgs::from_args(&json!({}));
1173 assert!(!a.requested);
1174 assert!(a.page_rows.is_none());
1175 assert!(a.page_timestamp.is_none());
1176 assert!(a.page_direction.is_none());
1177 assert!(a.limit.is_none());
1178 assert!(!a.all);
1179 }
1180
1181 #[test]
1182 fn body_pagination_args_full() {
1183 let a = BodyPaginationArgs::from_args(&json!({
1184 "page_rows": 100,
1185 "page_timestamp": 1700000000_i64,
1186 "page_direction": "PREVIOUS",
1187 "limit": 50,
1188 "all": true,
1189 }));
1190 assert!(a.requested);
1191 assert_eq!(a.page_rows, Some(100));
1192 assert_eq!(a.page_timestamp, Some(1700000000));
1193 assert_eq!(a.page_direction.as_deref(), Some("PREVIOUS"));
1194 assert_eq!(a.limit, Some(50));
1195 assert!(a.all);
1196 }
1197
1198 #[test]
1199 fn page_args_empty() {
1200 let p = PageArgs::from_args(&json!({}));
1201 assert!(!p.requested);
1202 assert_eq!(p.page, None);
1203 assert_eq!(p.limit, None);
1204 assert!(!p.all);
1205 }
1206
1207 #[test]
1208 fn page_args_full() {
1209 let p = PageArgs::from_args(&json!({"page": 2, "limit": 50, "all": true}));
1210 assert!(p.requested);
1211 assert_eq!(p.page, Some(2));
1212 assert_eq!(p.limit, Some(50));
1213 assert!(p.all);
1214 }
1215
1216 #[test]
1217 fn page_args_just_all_flag() {
1218 let p = PageArgs::from_args(&json!({"all": false}));
1219 assert!(p.requested);
1221 }
1222
1223 #[test]
1224 fn cursor_args_empty() {
1225 let c = CursorArgs::from_args(&json!({}));
1226 assert!(!c.requested);
1227 assert!(c.cursor.is_none());
1228 }
1229
1230 #[test]
1231 fn cursor_args_full() {
1232 let c = CursorArgs::from_args(&json!({"cursor": "abc", "limit": 10}));
1233 assert!(c.requested);
1234 assert_eq!(c.cursor.as_deref(), Some("abc"));
1235 assert_eq!(c.limit, Some(10));
1236 }
1237
1238 #[test]
1239 fn start_id_args_empty() {
1240 let s = StartIdArgs::from_args(&json!({}));
1241 assert!(!s.requested);
1242 assert!(s.start.is_none());
1243 assert!(s.start_id.is_none());
1244 assert_eq!(s.limit, None);
1245 assert!(!s.all);
1246 }
1247
1248 #[test]
1249 fn start_id_args_full() {
1250 let s = StartIdArgs::from_args(
1251 &json!({"start": 1700000000000_i64, "start_id": "c1", "limit": 20, "all": true}),
1252 );
1253 assert!(s.requested);
1254 assert_eq!(s.start, Some(1700000000000));
1255 assert_eq!(s.start_id.as_deref(), Some("c1"));
1256 assert_eq!(s.limit, Some(20));
1257 assert!(s.all);
1258 }
1259
1260 #[test]
1261 fn start_id_args_partial_start_only_still_requested() {
1262 let s = StartIdArgs::from_args(&json!({"start": 1700000000000_i64}));
1267 assert!(s.requested);
1268 assert_eq!(s.start, Some(1700000000000));
1269 assert!(s.start_id.is_none());
1270 }
1271
1272 #[test]
1273 fn extract_array_prefers_first_key() {
1274 let resp = json!({"data": [1, 2], "tasks": [3, 4]});
1275 let arr = extract_array(&resp, &["data", "tasks"]).unwrap();
1276 assert_eq!(arr.len(), 2);
1277 assert_eq!(arr[0], json!(1));
1278 }
1279
1280 #[test]
1281 fn extract_array_falls_back_to_second_key() {
1282 let resp = json!({"tasks": [3, 4]});
1283 let arr = extract_array(&resp, &["data", "tasks"]).unwrap();
1284 assert_eq!(arr.len(), 2);
1285 assert_eq!(arr[0], json!(3));
1286 }
1287
1288 #[test]
1289 fn extract_array_falls_back_to_bare_array() {
1290 let resp = json!([1, 2, 3]);
1291 let arr = extract_array(&resp, &["data"]).unwrap();
1292 assert_eq!(arr.len(), 3);
1293 }
1294
1295 #[test]
1296 fn extract_array_returns_none_when_no_match() {
1297 let resp = json!({"foo": "bar"});
1298 assert!(extract_array(&resp, &["data", "tasks"]).is_none());
1299 }
1300}