notedthat-server 0.1.4

NotedThat server binary — HTTP API, WebDAV, and remote MCP in one process
Documentation
#![expect(
    dead_code,
    reason = "replace helpers are shared by forthcoming E2E cases"
)]

#[path = "patch_backends.rs"]
mod patch_backends;

use reqwest::{Response, StatusCode};
use std::time::Duration;
use tokio::task::JoinHandle;

pub const API_TOKEN: &str = "e2e-test-token";

pub struct PatchServer {
    pub client: reqwest::Client,
    pub base_url: String,
    pub mcp_url: String,
    pub kb: String,
    server_handle: JoinHandle<()>,
    _guards: patch_backends::BackendGuards,
}

impl PatchServer {
    pub async fn start(max_patchable_size: u64) -> Self {
        let runtime = patch_backends::start_runtime(max_patchable_size).await;
        let config = runtime.config;
        let base_url = format!("http://{}", config.listen_addr);
        let mcp_url = format!("http://{}/mcp", config.mcp_http_bind);
        let server_handle = tokio::spawn(async move {
            notedthat_server::run::run(config)
                .await
                .expect("server run failed");
        });

        wait_for_http(&format!("{base_url}/healthz"), Duration::from_secs(10)).await;

        Self {
            client: reqwest::Client::new(),
            base_url,
            mcp_url,
            kb: runtime.kb,
            server_handle,
            _guards: runtime.guards,
        }
    }

    pub fn object_url(&self, path: &str) -> String {
        format!("{}/v1/knowledgebases/{}/{}", self.base_url, self.kb, path)
    }

    pub async fn put_text(&self, path: &str, body: &str) -> String {
        let response = self
            .client
            .put(self.object_url(path))
            .header("Authorization", format!("Bearer {API_TOKEN}"))
            .header("Content-Type", "text/markdown")
            .body(body.to_owned())
            .send()
            .await
            .expect("PUT object failed");
        assert_eq!(response.status(), StatusCode::CREATED);
        etag(&response)
    }

    pub async fn get_text(&self, path: &str) -> String {
        self.get(path)
            .await
            .text()
            .await
            .expect("GET body should read")
    }

    pub async fn get(&self, path: &str) -> Response {
        let response = self
            .client
            .get(self.object_url(path))
            .header("Authorization", format!("Bearer {API_TOKEN}"))
            .send()
            .await
            .expect("GET object failed");
        assert_eq!(response.status(), StatusCode::OK);
        response
    }

    pub async fn patch_content_range(
        &self,
        path: &str,
        content_range: &str,
        if_match: Option<&str>,
        body: &str,
    ) -> Response {
        let mut request = self
            .client
            .patch(self.object_url(path))
            .header("Authorization", format!("Bearer {API_TOKEN}"))
            .header("Content-Range", content_range)
            .body(body.to_owned());
        if let Some(etag) = if_match {
            request = request.header("If-Match", etag);
        }
        request.send().await.expect("PATCH object failed")
    }

    pub async fn patch_append(&self, path: &str, if_match: Option<&str>, body: &str) -> Response {
        let mut request = self
            .client
            .patch(self.object_url(path))
            .header("Authorization", format!("Bearer {API_TOKEN}"))
            .header("NT-Patch-Mode", "append")
            .body(body.to_owned());
        if let Some(etag) = if_match {
            request = request.header("If-Match", etag);
        }
        request.send().await.expect("PATCH append failed")
    }

    pub async fn replace_json(
        &self,
        path: &str,
        if_match: &str,
        old_string: &str,
        new_string: &str,
        replace_all: bool,
    ) -> Response {
        let url = format!(
            "{}/v1/knowledgebases/{}/replace/{}",
            self.base_url, self.kb, path
        );
        let body = serde_json::json!({
            "old_string": old_string,
            "new_string": new_string,
            "replace_all": replace_all,
        });
        self.client
            .post(url)
            .header("Authorization", format!("Bearer {API_TOKEN}"))
            .header("Content-Type", "application/json")
            .header("If-Match", if_match)
            .body(body.to_string())
            .send()
            .await
            .expect("replace request should return")
    }

    pub async fn head_text_status(&self, path: &str) -> StatusCode {
        self.client
            .head(self.object_url(path))
            .header("Authorization", format!("Bearer {API_TOKEN}"))
            .send()
            .await
            .expect("head request should return")
            .status()
    }
}

impl Drop for PatchServer {
    fn drop(&mut self) {
        self.server_handle.abort();
    }
}

pub fn etag(response: &Response) -> String {
    response
        .headers()
        .get(reqwest::header::ETAG)
        .and_then(|value| value.to_str().ok())
        .unwrap_or_else(|| panic!("response should contain ETag: {:?}", response.headers()))
        .to_owned()
}

pub async fn assert_error_code(response: Response, status: StatusCode, code: &str) {
    assert_eq!(response.status(), status);
    let json = response
        .json::<serde_json::Value>()
        .await
        .expect("error response should be JSON");
    assert_eq!(json["error"], code);
}

pub async fn assert_replace_success(resp: Response, expected_match_count: u64) -> String {
    assert_eq!(resp.status(), StatusCode::OK, "replace should return 200");
    let etag_header = resp
        .headers()
        .get("etag")
        .expect("etag header should be present")
        .to_str()
        .expect("etag should be valid str")
        .to_owned();
    let body: serde_json::Value = resp.json().await.expect("json body");
    assert_eq!(
        body["match_count"].as_u64(),
        Some(expected_match_count),
        "match_count mismatch"
    );
    assert!(
        body["total_bytes"].is_number(),
        "total_bytes should be a number"
    );
    assert_eq!(
        body["etag"].as_str().map(str::to_owned),
        Some(etag_header.clone()),
        "etag body == header"
    );
    etag_header
}

pub async fn mcp_request(
    client: &reqwest::Client,
    mcp_url: &str,
    id: u64,
    method: &str,
    params: serde_json::Value,
) -> serde_json::Value {
    let response = client
        .post(mcp_url)
        .header("Authorization", format!("Bearer {API_TOKEN}"))
        .header("Accept", "application/json, text/event-stream")
        .header("Content-Type", "application/json")
        .json(&serde_json::json!({
            "jsonrpc": "2.0",
            "id": id,
            "method": method,
            "params": params,
        }))
        .send()
        .await
        .expect("MCP HTTP request failed");
    assert!(
        response.status().is_success(),
        "MCP HTTP {method} should succeed, got {}",
        response.status()
    );
    response
        .json::<serde_json::Value>()
        .await
        .expect("MCP HTTP response must be JSON")
}

pub async fn mcp_call_tool(
    client: &reqwest::Client,
    mcp_url: &str,
    id: u64,
    tool_name: &str,
    arguments: serde_json::Value,
) -> serde_json::Value {
    mcp_request(
        client,
        mcp_url,
        id,
        "tools/call",
        serde_json::json!({"name": tool_name, "arguments": arguments}),
    )
    .await
}

async fn wait_for_http(url: &str, timeout: Duration) {
    let client = reqwest::Client::new();
    let deadline = tokio::time::Instant::now() + timeout;
    loop {
        assert!(
            tokio::time::Instant::now() <= deadline,
            "HTTP server did not become ready at {url}"
        );
        if client
            .get(url)
            .send()
            .await
            .is_ok_and(|response| response.status().is_success())
        {
            return;
        }
        tokio::time::sleep(Duration::from_millis(200)).await;
    }
}