Files
outline/tests/pagination.rs
henning dec8face54 Add Outline API client library foundation
Implements a first vertical slice of the Outline RPC API (auth, documents,
collections, users) covering the structural patterns used across the whole
API: single object, paginated list, tree, create/update, delete, search.
Chosen as the basis for future CLI and GUI clients built on top of this crate.

- Handwritten client (not codegen) against the vendored OpenAPI spec, since
  Outline's API is uniformly POST /api/<resource>.<action> with JSON bodies
  and inline/anonymous schemas that generators handle poorly
- Async (reqwest + tokio) Client, cheaply cloneable, no &mut self methods
- thiserror-based Error with ErrorKind classification and boxed API error
  context; forward-compatible models (unknown fields ignored, unknown
  string-enum values preserved via a catch-all variant) since the API is
  unversioned and self-hosted instances vary in age
- Pagination via Page<T>/Paginator with next_page/collect_all/into_stream
- wiremock-based test suite plus a spec-coverage test guarding against typos
  in RPC method names
2026-07-30 13:00:09 +02:00

114 lines
3.4 KiB
Rust

mod common;
use serde_json::json;
use wiremock::matchers::{body_json, method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
fn doc(id: &str) -> serde_json::Value {
json!({ "id": id, "title": id })
}
#[tokio::test]
async fn paginator_advances_offset_and_stops_on_short_page() {
let server = MockServer::start().await;
let client = common::client_for(&server).await;
Mock::given(method("POST"))
.and(path("/api/documents.list"))
.and(body_json(json!({ "limit": 2, "offset": 0 })))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"ok": true,
"data": [doc("doc-1"), doc("doc-2")],
"pagination": { "limit": 2, "offset": 0 }
})))
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path("/api/documents.list"))
.and(body_json(json!({ "limit": 2, "offset": 2 })))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"ok": true,
"data": [doc("doc-3")],
"pagination": { "limit": 2, "offset": 2 }
})))
.mount(&server)
.await;
let items = client
.documents()
.list()
.limit(2)
.paginate()
.collect_all()
.await
.unwrap();
let ids: Vec<_> = items
.into_iter()
.map(|d| d.id.as_str().to_string())
.collect();
assert_eq!(ids, vec!["doc-1", "doc-2", "doc-3"]);
}
#[tokio::test]
async fn paginator_stops_immediately_on_empty_first_page() {
let server = MockServer::start().await;
let client = common::client_for(&server).await;
Mock::given(method("POST"))
.and(path("/api/documents.list"))
.and(body_json(json!({ "limit": 25, "offset": 0 })))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"ok": true,
"data": [],
"pagination": { "limit": 25, "offset": 0 }
})))
.mount(&server)
.await;
let mut paginator = client.documents().list().paginate();
let page = paginator
.next_page()
.await
.unwrap()
.expect("first page always returned");
assert!(page.items.is_empty());
assert!(paginator.next_page().await.unwrap().is_none());
}
#[cfg(feature = "stream")]
#[tokio::test]
async fn into_stream_yields_items_across_pages() {
use futures_util::StreamExt;
let server = MockServer::start().await;
let client = common::client_for(&server).await;
Mock::given(method("POST"))
.and(path("/api/documents.list"))
.and(body_json(json!({ "limit": 2, "offset": 0 })))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"ok": true,
"data": [doc("doc-1"), doc("doc-2")],
"pagination": { "limit": 2, "offset": 0 }
})))
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path("/api/documents.list"))
.and(body_json(json!({ "limit": 2, "offset": 2 })))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"ok": true,
"data": [doc("doc-3")],
"pagination": { "limit": 2, "offset": 2 }
})))
.mount(&server)
.await;
let stream = client.documents().list().limit(2).paginate().into_stream();
let items: Vec<_> = stream.collect().await;
assert_eq!(items.len(), 3);
assert!(items.iter().all(|item| item.is_ok()));
}