// 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, /// 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>>, /// 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) -> 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 { 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::().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()); }