iris-chat 0.1.5

Iris Chat command line client and shared encrypted chat core
Documentation
use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::OnceLock;
use std::time::{Duration, Instant};
use std::{io::BufRead, io::BufReader, process::Stdio};

use iris_chat_core::FfiApp;
use serde_json::Value;
use tempfile::TempDir;

fn iris_binary() -> &'static PathBuf {
    static BIN: OnceLock<PathBuf> = OnceLock::new();
    BIN.get_or_init(|| {
        if let Some(path) = option_env!("CARGO_BIN_EXE_iris") {
            let path = PathBuf::from(path);
            if path.exists() {
                return path;
            }
        }

        let mut fallback = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
        fallback.push("target");
        fallback.push("debug");
        fallback.push("iris");
        #[cfg(windows)]
        fallback.set_extension("exe");
        if fallback.exists() {
            return fallback;
        }

        let status = Command::new("cargo")
            .args(["build", "--bin", "iris"])
            .current_dir(env!("CARGO_MANIFEST_DIR"))
            .status()
            .expect("build iris binary");
        assert!(status.success(), "cargo build --bin iris failed");
        fallback
    })
}

fn run_iris(data_dir: &Path, args: &[&str]) -> Value {
    let output = Command::new(iris_binary())
        .arg("--json")
        .arg("--data-dir")
        .arg(data_dir)
        .args(args)
        .output()
        .expect("run iris");
    let stdout = String::from_utf8_lossy(&output.stdout);
    let stderr = String::from_utf8_lossy(&output.stderr);
    assert!(
        output.status.success(),
        "iris failed status={}\nstdout={}\nstderr={}",
        output.status,
        stdout,
        stderr
    );
    serde_json::from_str(stdout.trim())
        .unwrap_or_else(|error| panic!("invalid json: {error}\nstdout={stdout}\nstderr={stderr}"))
}

fn run_iris_error(data_dir: &Path, args: &[&str]) -> Value {
    let output = Command::new(iris_binary())
        .arg("--json")
        .arg("--data-dir")
        .arg(data_dir)
        .args(args)
        .output()
        .expect("run iris");
    let stdout = String::from_utf8_lossy(&output.stdout);
    let stderr = String::from_utf8_lossy(&output.stderr);
    assert!(
        !output.status.success(),
        "iris unexpectedly succeeded\nstdout={}\nstderr={}",
        stdout,
        stderr
    );
    serde_json::from_str(stdout.trim()).unwrap_or_else(|error| {
        panic!("invalid error json: {error}\nstdout={stdout}\nstderr={stderr}")
    })
}

fn start_iris(data_dir: &Path, args: &[&str]) -> std::process::Child {
    Command::new(iris_binary())
        .arg("--json")
        .arg("--data-dir")
        .arg(data_dir)
        .args(args)
        .stdout(Stdio::piped())
        .stderr(Stdio::piped())
        .spawn()
        .expect("start iris")
}

fn read_json_line(reader: &mut BufReader<std::process::ChildStdout>) -> Value {
    let started = Instant::now();
    let mut line = String::new();
    while started.elapsed() < Duration::from_secs(5) {
        line.clear();
        if reader.read_line(&mut line).expect("read iris stdout") > 0 {
            return serde_json::from_str(line.trim())
                .unwrap_or_else(|error| panic!("invalid json line: {error}\nline={line}"));
        }
    }
    panic!("timed out waiting for iris stdout");
}

#[test]
fn account_create_persists_and_restores_for_next_process() {
    let dir = TempDir::new().unwrap();

    let created = run_iris(dir.path(), &["account", "create", "--name", "Alice"]);
    assert_eq!(created["status"], "ok");
    assert_eq!(created["data"]["name"], "Alice");
    assert!(created["data"]["user_id"].as_str().unwrap().len() >= 64);

    let whoami = run_iris(dir.path(), &["whoami"]);
    assert_eq!(whoami["data"]["user_id"], created["data"]["user_id"]);
    assert_eq!(whoami["data"]["device_state"], "authorized");

    let synced = run_iris(dir.path(), &["sync", "--wait-ms", "100"]);
    assert_eq!(
        synced["data"]["account"]["user_id"],
        created["data"]["user_id"]
    );

    let bundle = run_iris(dir.path(), &["account", "bundle"]);
    assert_eq!(bundle["data"]["has_owner_secret"], true);
    assert_eq!(bundle["data"]["has_device_secret"], true);
}

#[test]
fn direct_chat_send_read_search_and_tail_work_offline() {
    let alice = TempDir::new().unwrap();
    let bob = TempDir::new().unwrap();

    let alice_account = run_iris(alice.path(), &["account", "create", "--name", "Alice"]);
    let bob_account = run_iris(bob.path(), &["account", "create", "--name", "Bob"]);
    let bob_npub = bob_account["data"]["npub"].as_str().unwrap();

    run_iris(alice.path(), &["relay", "set"]);
    let sent = run_iris(alice.path(), &["send", bob_npub, "queued offline"]);
    assert_eq!(sent["data"]["body"], "queued offline");
    assert_eq!(sent["data"]["delivery"], "queued");
    let chat_id = sent["data"]["chat_id"].as_str().unwrap();
    let message_id = sent["data"]["id"].as_str().unwrap();

    let reacted = run_iris(alice.path(), &["react", chat_id, message_id, "+1"]);
    assert_eq!(reacted["data"]["reactions"][0]["emoji"], "+1");
    assert_eq!(reacted["data"]["reactions"][0]["reacted_by_me"], true);

    let expiring = run_iris(
        alice.path(),
        &["send", chat_id, "short lived", "--ttl", "60"],
    );
    assert_eq!(expiring["data"]["body"], "short lived");
    assert!(expiring["data"]["expires_at_secs"].as_u64().unwrap() > 0);

    let typing = run_iris(alice.path(), &["typing", chat_id]);
    assert_eq!(typing["data"]["typing"], true);

    let read = run_iris(alice.path(), &["read", chat_id]);
    assert_eq!(read["data"]["messages"].as_array().unwrap().len(), 2);
    assert_eq!(read["data"]["messages"][0]["body"], "queued offline");

    let found = run_iris(alice.path(), &["search", "offline"]);
    assert_eq!(found["data"]["messages"][0]["body"], "queued offline");

    let tail = run_iris(alice.path(), &["tail", "--limit", "1"]);
    assert_eq!(tail["data"]["messages"][0]["body"], "short lived");

    let list = run_iris(alice.path(), &["chat", "list"]);
    assert_eq!(list["data"]["chats"].as_array().unwrap().len(), 1);
    assert_eq!(list["data"]["chats"][0]["last_message"], "short lived");

    assert_ne!(
        alice_account["data"]["user_id"],
        bob_account["data"]["user_id"]
    );
}

#[test]
fn invite_create_group_create_relays_and_logout_are_scriptable() {
    let dir = TempDir::new().unwrap();
    let bob = TempDir::new().unwrap();
    run_iris(dir.path(), &["account", "create", "--name", "Alice"]);
    let bob_account = run_iris(bob.path(), &["account", "create", "--name", "Bob"]);
    let bob_user_id = bob_account["data"]["user_id"].as_str().unwrap();
    run_iris(dir.path(), &["relay", "set"]);

    let invite = run_iris(dir.path(), &["invite", "create"]);
    assert!(invite["data"]["url"].as_str().unwrap().contains("iris"));

    let group = run_iris(dir.path(), &["group", "create", "Notes", bob_user_id]);
    let chat_id = group["data"]["current_chat"]["chat_id"].as_str().unwrap();
    assert!(chat_id.starts_with("group:"));

    let group_id = group["data"]["current_chat"]["group_id"].as_str().unwrap();
    let sent = run_iris(dir.path(), &["group", "send", group_id, "group note"]);
    assert_eq!(sent["data"]["body"], "group note");
    let message_id = sent["data"]["id"].as_str().unwrap();

    let reacted = run_iris(dir.path(), &["group", "react", group_id, message_id, "+1"]);
    assert_eq!(reacted["data"]["reactions"][0]["emoji"], "+1");

    let read = run_iris(dir.path(), &["group", "read", group_id]);
    assert_eq!(read["data"]["messages"][0]["body"], "group note");

    let renamed = run_iris(dir.path(), &["group", "rename", group_id, "Renamed"]);
    assert_eq!(renamed["data"]["name"], "Renamed");

    let admin = run_iris(dir.path(), &["group", "add-admin", group_id, bob_user_id]);
    assert!(admin["data"]["members"]
        .as_array()
        .unwrap()
        .iter()
        .any(|member| member["user_id"] == bob_user_id && member["admin"] == true));

    let member = run_iris(
        dir.path(),
        &["group", "remove-admin", group_id, bob_user_id],
    );
    assert!(member["data"]["members"]
        .as_array()
        .unwrap()
        .iter()
        .any(|member| member["user_id"] == bob_user_id && member["admin"] == false));

    let removed = run_iris(dir.path(), &["group", "remove", group_id, bob_user_id]);
    assert!(!removed["data"]["members"]
        .as_array()
        .unwrap()
        .iter()
        .any(|member| member["user_id"] == bob_user_id));

    let relays = run_iris(
        dir.path(),
        &[
            "relay",
            "set",
            "wss://relay-one.example",
            "wss://relay-two.example",
        ],
    );
    assert_eq!(
        relays["data"]["message_servers"].as_array().unwrap().len(),
        2
    );

    let deleted = run_iris(dir.path(), &["group", "delete", group_id]);
    assert_eq!(deleted["data"]["deleted"], true);

    let logout = run_iris(dir.path(), &["logout"]);
    assert_eq!(logout["data"]["logged_out"], true);
}

#[test]
fn tail_follow_streams_new_sqlite_messages_for_agents() {
    let dir = TempDir::new().unwrap();
    let bob = TempDir::new().unwrap();
    run_iris(dir.path(), &["account", "create", "--name", "Alice"]);
    run_iris(dir.path(), &["relay", "set"]);
    let bob_account = run_iris(bob.path(), &["account", "create", "--name", "Bob"]);
    let bob_user_id = bob_account["data"]["user_id"].as_str().unwrap();
    let chat = run_iris(dir.path(), &["chat", "create", bob_user_id]);
    let chat_id = chat["data"]["chat"]["chat_id"].as_str().unwrap();

    let mut child = start_iris(dir.path(), &["tail", "--follow", "--interval-ms", "100"]);
    let stdout = child.stdout.take().expect("stdout");
    let mut reader = BufReader::new(stdout);
    let ready = read_json_line(&mut reader);
    assert_eq!(ready["command"], "tail");
    assert_eq!(ready["data"]["ready"], true);
    assert_eq!(ready["data"]["network"], false);

    run_iris(dir.path(), &["send", chat_id, "from another process"]);
    let message = read_json_line(&mut reader);
    assert_eq!(message["command"], "message");
    assert_eq!(message["data"]["body"], "from another process");

    let _ = child.kill();
    let _ = child.wait();
}

#[test]
fn listen_owns_the_core_lock_for_the_data_dir() {
    let dir = TempDir::new().unwrap();
    run_iris(dir.path(), &["account", "create", "--name", "Alice"]);

    let mut child = start_iris(dir.path(), &["listen", "--interval-ms", "100"]);
    let stdout = child.stdout.take().expect("stdout");
    let mut reader = BufReader::new(stdout);
    let ready = read_json_line(&mut reader);
    assert_eq!(ready["command"], "listen");
    assert_eq!(ready["data"]["ready"], true);
    assert_eq!(ready["data"]["network"], true);

    let error = run_iris_error(dir.path(), &["whoami"]);
    assert_eq!(error["status"], "error");
    assert!(
        error["error"]
            .as_str()
            .unwrap()
            .contains("already using this data folder"),
        "unexpected error: {error}"
    );

    let _ = child.kill();
    let _ = child.wait();
}

#[test]
fn second_core_process_fails_while_data_dir_is_locked() {
    let dir = TempDir::new().unwrap();
    let app = FfiApp::new(
        dir.path().to_string_lossy().to_string(),
        String::new(),
        "test".to_string(),
    );

    let error = run_iris_error(dir.path(), &["whoami"]);
    assert_eq!(error["status"], "error");
    assert!(
        error["error"]
            .as_str()
            .unwrap()
            .contains("already using this data folder"),
        "unexpected error: {error}"
    );

    app.shutdown();
    let after = run_iris_error(dir.path(), &["whoami"]);
    assert_eq!(after["error"], "Create or restore a profile first.");
}

#[test]
fn link_create_outputs_device_invite() {
    let dir = TempDir::new().unwrap();
    run_iris(dir.path(), &["relay", "set"]);

    let link = run_iris(dir.path(), &["link", "create"]);
    assert!(link["data"]["url"].as_str().unwrap().contains("iris"));
    assert!(link["data"]["device_input"]
        .as_str()
        .unwrap()
        .starts_with("npub"));
}