438 lines
18 KiB
Rust
438 lines
18 KiB
Rust
// SPDX-License-Identifier: MIT
|
|
// Copyright (c) 2026 James Maes
|
|
//! Tests for blocking Notion API client and Notion response adapters.
|
|
//!
|
|
//! # Purpose
|
|
//!
|
|
//! This integration-test module exercises two concerns of the `notion`
|
|
//! module in the `notion_sql` crate:
|
|
//!
|
|
//! 1. The mapping of Notion HTTP error responses into the crate's own
|
|
//! [`NotionApiError`] type, and the human-readable text that type
|
|
//! renders. These tests guard the *operator-facing* error experience:
|
|
//! error code labels, remediation guidance (token/sharing hints), the
|
|
//! request-id passthrough, and the deliberate suppression of raw,
|
|
//! unparseable response bodies.
|
|
//! 2. The recovery of a [`NotionApiError`] from inside an [`anyhow::Error`]
|
|
//! chain via [`find_notion_api_error`], which callers rely on to decide
|
|
//! whether a failure originated from Notion.
|
|
//!
|
|
//! # Testing approach
|
|
//!
|
|
//! The bulk of the file is scaffolding for a hand-rolled, single-threaded
|
|
//! HTTP mock server ([`MockServer`]) built directly on [`TcpListener`]
|
|
//! rather than a heavier mock-HTTP crate. The server is queued with a fixed
|
|
//! sequence of [`MockResponse`]s, records every inbound [`MockRequest`], and
|
|
//! lets a test inspect exactly what the [`NotionClient`] sent on the wire
|
|
//! (method, path, headers, body). This is the machinery that would back
|
|
//! end-to-end client tests (retry behaviour, header injection, etc.).
|
|
//!
|
|
//! Note: at the time of writing the mock-server scaffolding and several
|
|
//! helpers carry `#[allow(dead_code)]` because the currently *enabled* tests
|
|
//! (the `#[test]` functions near the bottom) only call the pure
|
|
//! error-formatting paths and do not yet drive a live [`NotionClient`]
|
|
//! against the mock server. The scaffolding is kept in place so those
|
|
//! client-level tests can be added without rebuilding it.
|
|
//!
|
|
//! # Key items defined here
|
|
//!
|
|
//! - [`MockRequest`] / [`MockResponse`]: wire-level request capture and
|
|
//! canned response description.
|
|
//! - [`MockServer`]: the lifecycle (start, serve N responses, finish) of the
|
|
//! loopback test server.
|
|
//! - [`read_mock_request`] / [`write_mock_response`]: the minimal HTTP/1.1
|
|
//! parse and serialize routines used by the server thread.
|
|
//! - [`test_client`]: constructs a [`NotionClient`] wired to a mock base URL
|
|
//! with retry sleeps stubbed out so tests never actually block.
|
|
|
|
use std::collections::{HashMap, VecDeque};
|
|
use std::io::{BufRead, BufReader, Read, Write};
|
|
use std::net::{TcpListener, TcpStream};
|
|
use std::sync::{Arc, Mutex};
|
|
use std::thread::{self, JoinHandle};
|
|
use std::time::Duration;
|
|
|
|
use notion_sql::notion::{find_notion_api_error, NotionApiError, NotionClient};
|
|
use reqwest::Method;
|
|
use serde_json::Value;
|
|
|
|
/// Notion API version string sent in the `Notion-Version` header.
|
|
///
|
|
/// Pinned to the date-stamped revision the crate targets; kept here so the
|
|
/// (currently dormant) client-level tests can assert the client forwards the
|
|
/// correct version. Marked `dead_code` while no enabled test references it.
|
|
#[allow(dead_code)]
|
|
const NOTION_VERSION: &str = "2022-06-28";
|
|
|
|
/// A request captured by the [`MockServer`] exactly as it arrived on the wire.
|
|
///
|
|
/// Used by tests to assert what the [`NotionClient`] actually sent: the HTTP
|
|
/// method, the request-target path, the headers, and the raw body. Header
|
|
/// names are stored lower-cased (see [`read_mock_request`]) so lookups are
|
|
/// case-insensitive without per-call normalization.
|
|
#[allow(dead_code)]
|
|
#[derive(Debug, Clone)]
|
|
struct MockRequest {
|
|
/// HTTP method token from the request line (e.g. `GET`, `POST`).
|
|
method: String,
|
|
/// Request-target path from the request line (e.g. `/v1/search`).
|
|
path: String,
|
|
/// Request headers, keyed by lower-cased name to value.
|
|
headers: HashMap<String, String>,
|
|
/// Decoded UTF-8 request body (empty when no `Content-Length` was sent).
|
|
body: String,
|
|
}
|
|
|
|
/// A canned HTTP response the [`MockServer`] will serve for one request.
|
|
///
|
|
/// Responses are supplied to [`MockServer::start`] as an ordered queue; the
|
|
/// server pops one per incoming connection, so the test controls precisely
|
|
/// what each successive request observes (useful for, e.g., a 429 followed by
|
|
/// a 200 to exercise retry logic).
|
|
#[allow(dead_code)]
|
|
struct MockResponse {
|
|
/// HTTP status code to return.
|
|
status: u16,
|
|
/// Extra response headers beyond the always-sent content/connection ones.
|
|
headers: Vec<(&'static str, &'static str)>,
|
|
/// Raw response body (already serialized; see [`MockResponse::json`]).
|
|
body: String,
|
|
}
|
|
|
|
impl MockResponse {
|
|
/// Builds a JSON response from a status code and a [`serde_json::Value`].
|
|
///
|
|
/// `status` is the HTTP status code; `body` is serialized to its string
|
|
/// form and stored verbatim. Starts with no extra headers; chain
|
|
/// [`MockResponse::with_header`] to add any. Returns the constructed
|
|
/// response.
|
|
#[allow(dead_code)]
|
|
fn json(status: u16, body: Value) -> Self {
|
|
Self {
|
|
status,
|
|
headers: Vec::new(),
|
|
body: body.to_string(),
|
|
}
|
|
}
|
|
|
|
/// Appends one extra response header and returns `self` for chaining.
|
|
///
|
|
/// `name`/`value` are `'static` because tests use string literals; this
|
|
/// keeps the builder allocation-free. Use this to inject headers the
|
|
/// client cares about, such as `Retry-After` on a 429. Returns the
|
|
/// updated response.
|
|
#[allow(dead_code)]
|
|
fn with_header(mut self, name: &'static str, value: &'static str) -> Self {
|
|
self.headers.push((name, value));
|
|
self
|
|
}
|
|
}
|
|
|
|
/// A minimal single-threaded loopback HTTP server for client tests.
|
|
///
|
|
/// On [`MockServer::start`] it binds an ephemeral loopback port and spawns a
|
|
/// background thread that serves exactly as many connections as there are
|
|
/// queued [`MockResponse`]s, recording each [`MockRequest`]. The owning test
|
|
/// points a [`NotionClient`] at [`base_url`](Self::base_url), drives it, then
|
|
/// calls [`MockServer::finish`] to join the thread and recover the captured
|
|
/// requests.
|
|
///
|
|
/// # Invariants
|
|
///
|
|
/// - The server handles a fixed, finite number of requests (one per queued
|
|
/// response) and then the accept loop ends, allowing the thread to exit.
|
|
/// A test that issues more requests than responses will hang on the
|
|
/// unanswered connection, so the response queue must match the expected
|
|
/// request count.
|
|
#[allow(dead_code)]
|
|
struct MockServer {
|
|
/// Base URL (scheme + host + port) the client should target.
|
|
base_url: String,
|
|
/// Shared, mutex-guarded log of every request the server received.
|
|
requests: Arc<Mutex<Vec<MockRequest>>>,
|
|
/// Handle to the background accept/serve thread, joined in `finish`.
|
|
handle: JoinHandle<()>,
|
|
}
|
|
|
|
impl MockServer {
|
|
/// Starts the mock server with a fixed queue of responses to serve.
|
|
///
|
|
/// `responses` are served in order, one per accepted connection. Binds
|
|
/// `127.0.0.1:0` so the OS assigns a free port (avoiding collisions when
|
|
/// tests run in parallel) and derives [`base_url`](Self::base_url) from
|
|
/// the resolved local address. Returns the running [`MockServer`].
|
|
///
|
|
/// # Panics
|
|
///
|
|
/// Panics if the listener cannot bind or its local address cannot be
|
|
/// resolved. (Per-connection failures panic inside the spawned thread and
|
|
/// surface when [`MockServer::finish`] joins it.)
|
|
#[allow(dead_code)]
|
|
fn start(responses: Vec<MockResponse>) -> Self {
|
|
let listener = TcpListener::bind("127.0.0.1:0").expect("bind mock server");
|
|
let base_url = format!("http://{}", listener.local_addr().expect("local address"));
|
|
let requests = Arc::new(Mutex::new(Vec::new()));
|
|
// The accept loop owns its own Arc clone so the original handle can be
|
|
// recovered by `finish` once the thread has dropped this one.
|
|
let thread_requests = Arc::clone(&requests);
|
|
|
|
let handle = thread::spawn(move || {
|
|
// Drain the response queue front-to-back; the loop terminates
|
|
// once every queued response has been served, which is what lets
|
|
// the thread (and thus `finish`'s join) complete.
|
|
let mut responses = VecDeque::from(responses);
|
|
while let Some(response) = responses.pop_front() {
|
|
let (mut stream, _) = listener.accept().expect("accept mock request");
|
|
let request = read_mock_request(&mut stream);
|
|
thread_requests
|
|
.lock()
|
|
.expect("lock mock requests")
|
|
.push(request);
|
|
write_mock_response(&mut stream, response);
|
|
}
|
|
});
|
|
|
|
Self {
|
|
base_url,
|
|
requests,
|
|
handle,
|
|
}
|
|
}
|
|
|
|
/// Joins the server thread and returns every request it captured.
|
|
///
|
|
/// Consumes the server. Joining first guarantees the thread has finished
|
|
/// writing to the shared request log, after which the `Arc`/`Mutex` are
|
|
/// unwrapped to hand back the owned `Vec` without cloning. Returns the
|
|
/// captured requests in arrival order.
|
|
///
|
|
/// # Panics
|
|
///
|
|
/// Panics if the server thread panicked, if the `Arc` is still referenced
|
|
/// elsewhere (it should be sole owner once the thread exits), or if the
|
|
/// mutex is poisoned.
|
|
#[allow(dead_code)]
|
|
fn finish(self) -> Vec<MockRequest> {
|
|
self.handle.join().expect("mock server thread");
|
|
// After the join, the spawned thread's Arc clone is dropped, so this
|
|
// handle is the unique owner and `try_unwrap` can succeed.
|
|
Arc::try_unwrap(self.requests)
|
|
.expect("mock requests still referenced")
|
|
.into_inner()
|
|
.expect("mock requests mutex")
|
|
}
|
|
}
|
|
|
|
/// Parses a single HTTP/1.1 request off `stream` into a [`MockRequest`].
|
|
///
|
|
/// Reads the request line (method + path, ignoring the version token), then
|
|
/// the header block up to the blank separator line, then exactly
|
|
/// `Content-Length` bytes of body. Header names are lower-cased so the
|
|
/// `content-length` lookup and later test assertions are case-insensitive.
|
|
/// Returns the captured request.
|
|
///
|
|
/// This is a deliberately tiny, assumption-heavy parser sufficient for the
|
|
/// well-formed requests `reqwest` emits; it is not a general HTTP parser
|
|
/// (e.g. it does not support chunked transfer encoding).
|
|
///
|
|
/// # Panics
|
|
///
|
|
/// Panics on any malformed input or I/O error: a request line missing the
|
|
/// method or path, a header line without a `:` separator, a body shorter than
|
|
/// the declared `Content-Length`, or a non-UTF-8 body.
|
|
#[allow(dead_code)]
|
|
fn read_mock_request(stream: &mut TcpStream) -> MockRequest {
|
|
let mut reader = BufReader::new(stream);
|
|
let mut request_line = String::new();
|
|
reader
|
|
.read_line(&mut request_line)
|
|
.expect("read request line");
|
|
let mut request_parts = request_line.split_whitespace();
|
|
let method = request_parts.next().expect("request method").to_string();
|
|
let path = request_parts.next().expect("request path").to_string();
|
|
// The HTTP version token (third field) is intentionally ignored.
|
|
let mut headers = HashMap::new();
|
|
|
|
// Read header lines until the empty CRLF line that ends the header block.
|
|
loop {
|
|
let mut line = String::new();
|
|
reader.read_line(&mut line).expect("read header line");
|
|
let line = line.trim_end();
|
|
if line.is_empty() {
|
|
break;
|
|
}
|
|
let (name, value) = line.split_once(':').expect("header separator");
|
|
headers.insert(name.to_ascii_lowercase(), value.trim().to_string());
|
|
}
|
|
|
|
// Absent or unparseable Content-Length is treated as a zero-length body
|
|
// rather than an error, since GET requests legitimately omit it.
|
|
let content_length = headers
|
|
.get("content-length")
|
|
.and_then(|value| value.parse::<usize>().ok())
|
|
.unwrap_or(0);
|
|
// read_exact ensures we consume precisely the declared body length so the
|
|
// stream is left at the correct position (and short bodies fail loudly).
|
|
let mut body = vec![0; content_length];
|
|
reader.read_exact(&mut body).expect("read request body");
|
|
|
|
MockRequest {
|
|
method,
|
|
path,
|
|
headers,
|
|
body: String::from_utf8(body).expect("utf8 request body"),
|
|
}
|
|
}
|
|
|
|
/// Serializes and writes a [`MockResponse`] to `stream` as HTTP/1.1.
|
|
///
|
|
/// `response` supplies the status, any extra headers, and the body. The
|
|
/// status line, `Content-Type: application/json`, an accurate
|
|
/// `Content-Length`, and `Connection: close` are always emitted; the
|
|
/// response's extra headers are appended before the blank separator line and
|
|
/// body. `Connection: close` keeps the exchange to one request per
|
|
/// connection, matching the one-response-per-accept model of [`MockServer`].
|
|
///
|
|
/// # Panics
|
|
///
|
|
/// Panics if writing the headers or body to the stream fails.
|
|
#[allow(dead_code)]
|
|
fn write_mock_response(stream: &mut TcpStream, response: MockResponse) {
|
|
// Map only the status codes the tests use; everything else gets a
|
|
// placeholder reason phrase, which clients ignore anyway.
|
|
let reason = match response.status {
|
|
200 => "OK",
|
|
401 => "Unauthorized",
|
|
429 => "Too Many Requests",
|
|
_ => "Mock Status",
|
|
};
|
|
// Content-Length is the byte length of the body so the client knows
|
|
// exactly how much to read; a wrong value would hang or truncate it.
|
|
let mut headers = format!(
|
|
"HTTP/1.1 {} {}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n",
|
|
response.status,
|
|
reason,
|
|
response.body.len()
|
|
);
|
|
for (name, value) in response.headers {
|
|
headers.push_str(&format!("{name}: {value}\r\n"));
|
|
}
|
|
// Blank line terminates the header block, per HTTP/1.1.
|
|
headers.push_str("\r\n");
|
|
stream.write_all(headers.as_bytes()).expect("write headers");
|
|
stream
|
|
.write_all(response.body.as_bytes())
|
|
.expect("write body");
|
|
}
|
|
|
|
/// Builds a [`NotionClient`] wired to a mock server for testing.
|
|
///
|
|
/// `base_url` is the address of a running [`MockServer`]. Uses a dummy bearer
|
|
/// token and a short request timeout, and—crucially—installs a no-op retry
|
|
/// sleeper so retry paths run instantly instead of actually sleeping, keeping
|
|
/// the test suite fast and deterministic. Returns the constructed client.
|
|
///
|
|
/// # Panics
|
|
///
|
|
/// Panics if [`NotionClient::new_for_tests`] fails to build the client.
|
|
#[allow(dead_code)]
|
|
fn test_client(base_url: String) -> NotionClient {
|
|
// No-op sleeper: retry backoff is exercised without real wall-clock waits.
|
|
let retry_sleeper = Arc::new(|_| {});
|
|
NotionClient::new_for_tests(
|
|
"secret-token".to_string(),
|
|
base_url,
|
|
Duration::from_secs(2),
|
|
retry_sleeper,
|
|
)
|
|
.expect("test client")
|
|
}
|
|
|
|
/// A 401 response must render with the `unauthorized` code, a clear
|
|
/// "token rejected" message, actionable token guidance, and the passed-through
|
|
/// request id.
|
|
///
|
|
/// Guards the operator experience for the most common misconfiguration (a bad
|
|
/// `NOTION_TOKEN`): the user should see *why* and *what to fix*, plus the
|
|
/// request id for support correlation.
|
|
#[test]
|
|
fn formats_unauthorized_error_with_token_guidance() {
|
|
let message = NotionApiError::from_response(
|
|
&Method::POST,
|
|
"/v1/search",
|
|
reqwest::StatusCode::UNAUTHORIZED,
|
|
r#"{"object":"error","status":401,"code":"unauthorized","message":"API token is invalid.","request_id":"req-123"}"#,
|
|
);
|
|
let message = message.render_pretty();
|
|
|
|
assert!(message.contains("notion-sql error"));
|
|
assert!(message.contains("Code : unauthorized"));
|
|
assert!(message.contains("Token rejected by Notion."));
|
|
assert!(message.contains("Request ID: req-123"));
|
|
}
|
|
|
|
/// A 403 `restricted_resource` response must explain the integration lacks
|
|
/// access and instruct the user to share the resource with the integration
|
|
/// behind `NOTION_TOKEN`.
|
|
///
|
|
/// This is the second most common failure (token valid, but the page/database
|
|
/// was never shared with the integration); the remediation differs entirely
|
|
/// from the 401 case, so it gets its own guidance text.
|
|
#[test]
|
|
fn formats_restricted_resource_error_with_sharing_guidance() {
|
|
let message = NotionApiError::from_response(
|
|
&Method::GET,
|
|
"/v1/databases/database-id",
|
|
reqwest::StatusCode::FORBIDDEN,
|
|
r#"{"object":"error","status":403,"code":"restricted_resource","message":"Insufficient permissions.","request_id":"req-456"}"#,
|
|
);
|
|
let message = message.render_pretty();
|
|
|
|
assert!(message.contains("The integration does not have access to this object."));
|
|
assert!(message.contains("Share it with the integration connected to NOTION_TOKEN"));
|
|
}
|
|
|
|
/// When the error body is not valid Notion JSON, the rendered message must
|
|
/// fall back to `unknown_error` with a "no message" note and must NOT echo the
|
|
/// raw body.
|
|
///
|
|
/// The "not-json" assertion is the load-bearing one: dumping an arbitrary,
|
|
/// possibly large or sensitive upstream body to the user is undesirable, so
|
|
/// the formatter deliberately suppresses it. The 502 status here stands in for
|
|
/// any non-Notion-shaped failure (gateway/proxy error pages, etc.).
|
|
#[test]
|
|
fn formats_unparseable_error_body_without_raw_json_dump() {
|
|
let message = NotionApiError::from_response(
|
|
&Method::POST,
|
|
"/v1/search",
|
|
reqwest::StatusCode::BAD_GATEWAY,
|
|
"not-json",
|
|
);
|
|
let message = message.render_pretty();
|
|
|
|
assert!(message.contains("Code : unknown_error"));
|
|
assert!(message.contains("Notion did not return an error message"));
|
|
assert!(!message.contains("not-json"));
|
|
}
|
|
|
|
/// A [`NotionApiError`] converted into an [`anyhow::Error`] must still be
|
|
/// discoverable via [`find_notion_api_error`].
|
|
///
|
|
/// Callers wrap errors in `anyhow` as they propagate; this verifies the
|
|
/// downcast-through-the-chain helper works so higher layers can detect a
|
|
/// Notion-origin failure (and, e.g., render the pretty guidance above) even
|
|
/// after the error has been boxed.
|
|
#[test]
|
|
fn finds_notion_api_error_in_anyhow_chain() {
|
|
// `.into()` boxes the typed error into anyhow, mimicking real propagation.
|
|
let error: anyhow::Error = NotionApiError::from_response(
|
|
&Method::POST,
|
|
"/v1/search",
|
|
reqwest::StatusCode::UNAUTHORIZED,
|
|
r#"{"code":"unauthorized","message":"API token is invalid."}"#,
|
|
)
|
|
.into();
|
|
|
|
assert!(find_notion_api_error(&error).is_some());
|
|
}
|