Files
outline/tests/documents.rs
T
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

156 lines
4.6 KiB
Rust

mod common;
use serde_json::json;
use wiremock::matchers::{body_json, method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
fn sample_document(id: &str, title: &str) -> serde_json::Value {
json!({
"id": id,
"collectionId": "col-1",
"title": title,
"text": "Hello world",
"urlId": "abc123",
})
}
#[tokio::test]
async fn documents_info_by_id() {
let server = MockServer::start().await;
let client = common::client_for(&server).await;
Mock::given(method("POST"))
.and(path("/api/documents.info"))
.and(body_json(json!({ "id": "doc-1" })))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"ok": true,
"data": sample_document("doc-1", "Welcome")
})))
.mount(&server)
.await;
let document = client.documents().info("doc-1").await.unwrap();
assert_eq!(document.title, "Welcome");
assert_eq!(document.collection_id.unwrap().as_str(), "col-1");
}
#[tokio::test]
async fn documents_list_returns_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, "collectionId": "col-1" })))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"ok": true,
"data": [sample_document("doc-1", "One"), sample_document("doc-2", "Two")],
"pagination": { "limit": 2, "offset": 0 }
})))
.mount(&server)
.await;
let page = client
.documents()
.list()
.collection_id("col-1")
.limit(2)
.send()
.await
.unwrap();
assert_eq!(page.items.len(), 2);
assert_eq!(page.items[0].title, "One");
assert_eq!(page.pagination.limit, Some(2));
}
#[tokio::test]
async fn documents_create_sends_title_and_collection() {
let server = MockServer::start().await;
let client = common::client_for(&server).await;
Mock::given(method("POST"))
.and(path("/api/documents.create"))
.and(body_json(
json!({ "title": "New Doc", "collectionId": "col-1", "publish": true }),
))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"ok": true,
"data": sample_document("doc-3", "New Doc")
})))
.mount(&server)
.await;
let document = client
.documents()
.create("New Doc")
.collection_id("col-1")
.publish(true)
.send()
.await
.unwrap();
assert_eq!(document.id.as_str(), "doc-3");
}
#[tokio::test]
async fn documents_update_sends_only_set_fields() {
let server = MockServer::start().await;
let client = common::client_for(&server).await;
Mock::given(method("POST"))
.and(path("/api/documents.update"))
.and(body_json(json!({ "id": "doc-1", "title": "Renamed" })))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"ok": true,
"data": sample_document("doc-1", "Renamed")
})))
.mount(&server)
.await;
let document = client
.documents()
.update("doc-1")
.title("Renamed")
.send()
.await
.unwrap();
assert_eq!(document.title, "Renamed");
}
#[tokio::test]
async fn documents_delete_ignores_response_shape() {
let server = MockServer::start().await;
let client = common::client_for(&server).await;
Mock::given(method("POST"))
.and(path("/api/documents.delete"))
.and(body_json(json!({ "id": "doc-1", "permanent": false })))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({ "success": true })))
.mount(&server)
.await;
client.documents().delete("doc-1", false).await.unwrap();
}
#[tokio::test]
async fn documents_search_returns_ranked_results() {
let server = MockServer::start().await;
let client = common::client_for(&server).await;
Mock::given(method("POST"))
.and(path("/api/documents.search"))
.and(body_json(json!({ "query": "hiring" })))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"ok": true,
"data": [
{ "context": "our hiring practices", "ranking": 1.5, "document": sample_document("doc-4", "Hiring Guide") }
],
"pagination": { "limit": 25, "offset": 0 }
})))
.mount(&server)
.await;
let page = client.documents().search("hiring").send().await.unwrap();
assert_eq!(page.items.len(), 1);
assert_eq!(page.items[0].document.title, "Hiring Guide");
}