haematite-cli 0.21.8

The haematite command: serve a store, its door and Studio, answer MCP, query with jq, and host the store's assistant seat
Documentation
//! Start defines the assistant's record collection under the person's pass, when the store has none.
use super::assistant_test_support::*;
use crate::serve::{
    frame,
    protocol::{PROTOCOL, RECEIPT_CAPABILITY},
};
use serde_json::{Value, json};
use std::sync::{Arc, Mutex};

/// How the stand-in store answers the one `collection.put` Start may send.
#[derive(Clone, Copy)]
pub(super) enum Put {
    Accepted,
    Refused,
}

pub(super) struct Store {
    calls: Arc<Mutex<Vec<Value>>>,
    worker: std::thread::JoinHandle<Result>,
}

impl Store {
    /// The store's collections on main, page by page, and how it answers a define.
    pub(super) fn start(f: &Fixture, pages: Vec<Vec<&'static str>>, put: Put) -> Result<Self> {
        let listener = std::os::unix::net::UnixListener::bind(f.files.path().join("store.sock"))?;
        let calls = Arc::new(Mutex::new(Vec::new()));
        let captured = Arc::clone(&calls);
        let worker = std::thread::spawn(move || -> Result {
            // A door that never connects ends the stand-in with no calls after ten seconds, rather than holding the test.
            listener.set_nonblocking(true)?;
            let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
            let mut socket = loop {
                match listener.accept() {
                    Ok((socket, _)) => break socket,
                    Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
                        if std::time::Instant::now() > deadline {
                            return Ok(());
                        }
                        std::thread::sleep(std::time::Duration::from_millis(20));
                    }
                    Err(error) => return Err(error.into()),
                }
            };
            socket.set_nonblocking(false)?;
            let cap = std::num::NonZeroU32::new(1_048_576).ok_or_else(|| anyhow::anyhow!("cap"))?;
            while let Some(bytes) = frame::read(&mut socket, cap)? {
                let request: Value = serde_json::from_slice(&bytes)?;
                let method = request["operation"]["method"]
                    .as_str()
                    .unwrap_or_default()
                    .to_owned();
                let p = request["operation"]["params"].clone();
                captured
                    .lock()
                    .map_err(|_| anyhow::anyhow!("calls"))?
                    .push(request.clone());
                let version = json!({"generation":"1","sequence":"5"});
                let answer = match method.as_str() {
                    "hello" => Ok(
                        json!({"protocol":PROTOCOL,"capabilities":[RECEIPT_CAPABILITY],"max_page":64,"max_frame":cap.get()}),
                    ),
                    "session.pass" => {
                        assert_eq!(p["pass"], "person-pass");
                        Ok(json!({"holder":PERSON,"expires":crate::serve::pass::now()+3600}))
                    }
                    "collections.list" => {
                        let index = p["after"]
                            .as_str()
                            .map_or(0, |after| after.parse::<usize>().unwrap_or(usize::MAX));
                        let page = pages
                            .get(index)
                            .ok_or_else(|| anyhow::anyhow!("page {index}"))?;
                        let next = (index + 1 < pages.len()).then(|| (index + 1).to_string());
                        let entries: Vec<_> =
                            page.iter().map(|name| json!({"name":name})).collect();
                        Ok(
                            json!({"branch":"main","version":version,"entries":entries,"next_after":next}),
                        )
                    }
                    "store.stats" => Ok(json!({"selection":{"branch":"main","version":version}})),
                    "mutation.apply" => match put {
                        Put::Accepted => Ok(
                            json!({"branch":"main","version":{"generation":"1","sequence":"6"},"request_id":p["request_id"]}),
                        ),
                        Put::Refused => Err(
                            json!({"code":"permission_refused","message":"{\"refused\":{\"resource\":\"haematite.database:wards\",\"action\":\"collection.put\",\"request\":\"https://lys.example/#/requests\"}}"}),
                        ),
                    },
                    other => anyhow::bail!("unexpected method {other}"),
                };
                let reply = match answer {
                    Ok(result) => json!({"id":request["id"],"result":result}),
                    Err(error) => json!({"id":request["id"],"error":error}),
                };
                frame::write(&mut socket, &serde_json::to_vec(&reply)?, cap)?;
            }
            Ok(())
        });
        Ok(Self { calls, worker })
    }

    /// Every `mutation.apply` the door sent, once the door has closed its connection.
    pub(super) fn mutations(self) -> Result<Vec<Value>> {
        self.worker
            .join()
            .map_err(|_| anyhow::anyhow!("store panic"))??;
        let calls = self.calls.lock().map_err(|_| anyhow::anyhow!("calls"))?;
        Ok(calls
            .iter()
            .filter(|call| call["operation"]["method"] == "mutation.apply")
            .map(|call| call["operation"]["params"].clone())
            .collect())
    }
}

#[test]
fn start_on_a_store_without_the_record_defines_it_under_the_person_pass() -> Result {
    let f = Fixture::new(json!({}))?;
    let store = Store::start(&f, vec![vec!["patients", "wards"]], Put::Accepted)?;
    let text = written(f.answer("POST", "/start", &json!({})))?;
    assert!(
        text.starts_with("HTTP/1.1 501 ") && text.contains("assistant_start_in_lys"),
        "{text}"
    );
    let mutations = store.mutations()?;
    assert_eq!(mutations.len(), 1, "{mutations:?}");
    assert_eq!(mutations[0]["branch"], "main");
    assert_eq!(
        mutations[0]["expected"],
        json!({"generation":"1","sequence":"5"})
    );
    assert_eq!(
        mutations[0]["change"],
        json!({"kind":"collection.put","value":{"collection":"_assistant","schema":true}})
    );
    Ok(())
}

#[test]
fn start_on_a_store_that_holds_the_record_defines_nothing_even_on_a_later_page() -> Result {
    let f = Fixture::new(json!({}))?;
    let store = Store::start(
        &f,
        vec![vec!["patients"], vec!["_assistant", "wards"]],
        Put::Refused,
    )?;
    let text = written(f.answer("POST", "/start", &json!({})))?;
    assert!(
        text.starts_with("HTTP/1.1 501 ") && text.contains("assistant_start_in_lys"),
        "{text}"
    );
    assert!(store.mutations()?.is_empty());
    Ok(())
}

#[test]
fn a_person_who_may_not_define_the_record_is_told_why_with_the_way_to_lys() -> Result {
    let f = Fixture::new(json!({}))?;
    let store = Store::start(&f, vec![vec!["patients"]], Put::Refused)?;
    let text = written(f.answer("POST", "/start", &json!({})))?;
    assert!(
        text.starts_with("HTTP/1.1 403 ")
            && text.contains("assistant_record_unplaced")
            && text.contains("you may not create it")
            && text.contains("https://lys.example"),
        "{text}"
    );
    assert_eq!(store.mutations()?.len(), 1);
    Ok(())
}