Files
outline/tests/errors.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

107 lines
3.2 KiB
Rust

mod common;
use outline::ErrorKind;
use serde_json::json;
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
#[tokio::test]
async fn maps_401_to_unauthenticated() {
let server = MockServer::start().await;
let client = common::client_for(&server).await;
Mock::given(method("POST"))
.and(path("/api/auth.info"))
.respond_with(ResponseTemplate::new(401).set_body_json(json!({
"ok": false,
"status": 401,
"error": "authentication_required",
"message": "Authentication required"
})))
.mount(&server)
.await;
let err = client.auth().info().await.unwrap_err();
assert_eq!(err.kind(), ErrorKind::Unauthenticated);
assert_eq!(err.status(), Some(http::StatusCode::UNAUTHORIZED));
assert!(!err.is_retryable());
}
#[tokio::test]
async fn maps_404_to_not_found() {
let server = MockServer::start().await;
let client = common::client_for(&server).await;
Mock::given(method("POST"))
.and(path("/api/collections.info"))
.respond_with(ResponseTemplate::new(404).set_body_json(json!({
"ok": false,
"status": 404,
"error": "not_found",
"message": "Collection not found"
})))
.mount(&server)
.await;
let err = client.collections().info("missing-id").await.unwrap_err();
assert!(err.is_not_found());
assert_eq!(err.kind(), ErrorKind::NotFound);
}
#[tokio::test]
async fn maps_429_to_rate_limited_and_parses_retry_after() {
let server = MockServer::start().await;
let client = common::client_for(&server).await;
Mock::given(method("POST"))
.and(path("/api/documents.list"))
.respond_with(
ResponseTemplate::new(429)
.insert_header("Retry-After", "12")
.set_body_json(json!({
"ok": false,
"status": 429,
"error": "rate_limit_exceeded",
"message": "Rate limit exceeded"
})),
)
.mount(&server)
.await;
let err = client.documents().list().send().await.unwrap_err();
assert_eq!(err.kind(), ErrorKind::RateLimited);
assert!(err.is_retryable());
assert_eq!(err.retry_after(), Some(std::time::Duration::from_secs(12)));
}
#[tokio::test]
async fn maps_5xx_to_server_error() {
let server = MockServer::start().await;
let client = common::client_for(&server).await;
Mock::given(method("POST"))
.and(path("/api/auth.info"))
.respond_with(ResponseTemplate::new(503).set_body_string("Service Unavailable"))
.mount(&server)
.await;
let err = client.auth().info().await.unwrap_err();
assert_eq!(err.kind(), ErrorKind::Server);
assert!(err.is_retryable());
}
#[tokio::test]
async fn malformed_success_body_yields_decode_error() {
let server = MockServer::start().await;
let client = common::client_for(&server).await;
Mock::given(method("POST"))
.and(path("/api/auth.info"))
.respond_with(ResponseTemplate::new(200).set_body_string("not json"))
.mount(&server)
.await;
let err = client.auth().info().await.unwrap_err();
assert_eq!(err.kind(), ErrorKind::Decode);
}