moadim 1.7.4

Loop engine for AI agents — routines over REST, MCP, and a built-in web UI
//! Tests for the query-command (`status`/`cleanup`/`stop`) JSON rendering and health-probing
//! helpers split out of `cli/mod.rs`'s `cli_query` module.

use super::*;

#[test]
fn status_json_reports_running_pid_and_address() {
    let health = HealthInfo {
        uptime_secs: 8123,
        version: "1.2.3".to_string(),
    };
    let value: serde_json::Value =
        serde_json::from_str(&status_json(true, Some(42), Some(&health))).unwrap();
    assert_eq!(value["running"], serde_json::json!(true));
    assert_eq!(value["pid"], serde_json::json!(42));
    assert_eq!(value["address"], serde_json::json!(BIND_ADDR));
    assert_eq!(value["uptime_secs"], serde_json::json!(8123));
    assert_eq!(value["version"], serde_json::json!("1.2.3"));
}

#[test]
fn status_json_null_pid_when_unknown_or_down() {
    let value: serde_json::Value = serde_json::from_str(&status_json(false, None, None)).unwrap();
    assert_eq!(value["running"], serde_json::json!(false));
    assert!(value["pid"].is_null());
    assert_eq!(value["address"], serde_json::json!(BIND_ADDR));
    // Server-sourced fields are null when no /health was folded in.
    assert!(value["uptime_secs"].is_null());
    assert!(value["version"].is_null());
}

#[test]
fn parse_health_reads_uptime_and_version() {
    let body = r#"{"status":"ok","uptime_secs":42,"running":true,"version":"9.9.9"}"#;
    assert_eq!(
        parse_health(body),
        Some(HealthInfo {
            uptime_secs: 42,
            version: "9.9.9".to_string(),
        })
    );
}

#[test]
fn parse_health_rejects_malformed_or_incomplete_bodies() {
    // Not JSON at all.
    assert_eq!(parse_health("not json"), None);
    // Missing version.
    assert_eq!(parse_health(r#"{"uptime_secs":1}"#), None);
    // Missing uptime_secs.
    assert_eq!(parse_health(r#"{"version":"1.0.0"}"#), None);
    // Wrong types.
    assert_eq!(
        parse_health(r#"{"uptime_secs":"x","version":"1.0.0"}"#),
        None
    );
}

#[test]
fn fetch_health_parses_a_well_formed_health_response() {
    let server = FakeServer::start(
        200,
        r#"{"status":"ok","uptime_secs":7,"running":true,"version":"3.2.1"}"#.to_string(),
    );
    let _addr = EnvGuard::set(BIND_ADDR_ENV, &server.addr);
    assert_eq!(
        fetch_health(),
        Some(HealthInfo {
            uptime_secs: 7,
            version: "3.2.1".to_string(),
        })
    );
}

#[test]
fn fetch_health_is_none_on_non_200_status() {
    let server = FakeServer::start(503, String::new());
    let _addr = EnvGuard::set(BIND_ADDR_ENV, &server.addr);
    assert_eq!(fetch_health(), None);
}

#[test]
fn fetch_health_is_none_when_no_server() {
    let _addr = EnvGuard::set(BIND_ADDR_ENV, UNREACHABLE_ADDR);
    assert_eq!(fetch_health(), None);
}

#[test]
fn wait_until_returns_true_immediately_when_check_already_passes() {
    assert!(wait_until(|| true, Duration::from_secs(0)));
}

#[test]
fn wait_until_calls_check_at_least_once_even_with_zero_timeout() {
    let calls = std::cell::Cell::new(0);
    let succeeded = wait_until(
        || {
            calls.set(calls.get() + 1);
            false
        },
        Duration::from_secs(0),
    );
    assert!(!succeeded);
    assert_eq!(calls.get(), 1);
}

#[test]
fn wait_until_polls_until_check_flips_true() {
    let calls = std::cell::Cell::new(0);
    let succeeded = wait_until(
        || {
            calls.set(calls.get() + 1);
            calls.get() >= 3
        },
        Duration::from_secs(5),
    );
    assert!(succeeded);
    assert_eq!(calls.get(), 3);
}

#[test]
fn cleanup_json_reports_removed_and_running() {
    let value: serde_json::Value = serde_json::from_str(&cleanup_json(3, 12345, true)).unwrap();
    assert_eq!(value["running"], serde_json::json!(true));
    assert_eq!(value["removed"], serde_json::json!(3));
    assert_eq!(value["freed_bytes"], serde_json::json!(12345));
    assert_eq!(value["address"], serde_json::json!(BIND_ADDR));

    let down: serde_json::Value = serde_json::from_str(&cleanup_json(0, 0, false)).unwrap();
    assert_eq!(down["running"], serde_json::json!(false));
    assert_eq!(down["removed"], serde_json::json!(0));
    assert_eq!(down["freed_bytes"], serde_json::json!(0));
    assert_eq!(down["address"], serde_json::json!(BIND_ADDR));
}

#[test]
fn stop_json_reports_running_pid_and_address() {
    let up: serde_json::Value = serde_json::from_str(&stop_json(true, Some(42))).unwrap();
    assert_eq!(up["running"], serde_json::json!(true));
    assert_eq!(up["pid"], serde_json::json!(42));
    assert_eq!(up["address"], serde_json::json!(BIND_ADDR));

    let down: serde_json::Value = serde_json::from_str(&stop_json(false, None)).unwrap();
    assert_eq!(down["running"], serde_json::json!(false));
    assert!(down["pid"].is_null());
    assert_eq!(down["address"], serde_json::json!(BIND_ADDR));
}

/// Collect the top-level object keys of a JSON document into an order-independent set.
fn json_key_set(json: &str) -> std::collections::BTreeSet<String> {
    serde_json::from_str::<serde_json::Value>(json)
        .unwrap()
        .as_object()
        .unwrap()
        .keys()
        .cloned()
        .collect()
}

#[test]
fn status_and_stop_json_share_a_common_key_set() {
    // `status --json` and `stop --json` share a common `{running,pid,address}` base so consumers
    // can parse either uniformly; `status` additionally folds in server-sourced `uptime_secs`/
    // `version` (see `status_and_stop_json_share_the_same_shape`, which guards the shared fields'
    // *values*). Here we guard the key *sets*: every key `stop` emits must also appear in `status`,
    // for both the running and the down/null-pid branches, so a key can't be dropped from one side
    // without the drift being caught.
    assert!(
        json_key_set(&stop_json(true, Some(42))).is_subset(&json_key_set(&status_json(
            true,
            Some(42),
            None
        ))),
        "every key in stop --json must also appear in status --json (running branch)"
    );
    assert!(
        json_key_set(&stop_json(false, None))
            .is_subset(&json_key_set(&status_json(false, None, None))),
        "every key in stop --json must also appear in status --json (down branch)"
    );
}

#[test]
fn liveness_exit_code_maps_running_to_codes() {
    // A reachable server exits 0; a missing one exits the documented EXIT_NOT_RUNNING.
    assert_eq!(liveness_exit_code(true), 0);
    assert_eq!(liveness_exit_code(false), EXIT_NOT_RUNNING);
    assert_eq!(EXIT_NOT_RUNNING, 3);
}