aiseo 0.7.4

Agent-first CLI for SEO, GEO (generative engine optimisation), and AEO (answer engine optimisation) audits.
//! Verify JSON envelope structure on stdout/stderr.
//!
//! assert_cmd runs the binary in a pipe (not a TTY), so JSON auto-detection
//! kicks in — no need for `--json`.

use assert_cmd::Command;
use std::io::Write;

fn bin() -> Command {
    Command::cargo_bin(env!("CARGO_PKG_NAME")).unwrap()
}

fn fixture() -> tempfile::NamedTempFile {
    let mut f = tempfile::Builder::new()
        .suffix(".html")
        .tempfile()
        .expect("tempfile");
    writeln!(
        f,
        r#"<!DOCTYPE html><html><head><title>Test page about cholesterol</title><meta name="description" content="A May 2026 guide to optimal LDL cholesterol targets and the evidence behind aggressive lipid lowering with statins and PCSK9 inhibitors."><meta property="og:title" content="Test"><meta property="og:image" content="https://example.com/og.png"></head><body><h1>LDL Cholesterol</h1><p><strong>TL;DR</strong>: Aim for LDL under 70 mg/dL.</p><h2>Why</h2><p>30% relative risk reduction with PCSK9 inhibitors.</p><h2>How</h2><p>Statins first-line.</p></body></html>"#
    )
    .unwrap();
    f
}

// ── Success envelope ───────────────────────────────────────────────────────

#[test]
fn success_envelope_shape() {
    let f = fixture();
    let out = bin().args(["audit", f.path().to_str().unwrap()]).output().unwrap();

    assert!(out.status.success());

    let json: serde_json::Value =
        serde_json::from_slice(&out.stdout).expect("stdout should be valid JSON");

    assert_eq!(json["version"], "1");
    assert_eq!(json["status"], "success");
    assert!(json["data"].is_object(), "envelope must have a 'data' field");
    assert!(json["data"]["score"].is_number(), "audit must report a score");
    assert!(json["data"]["suggestions"].is_array(), "audit must report suggestions");
}

#[test]
fn success_envelope_with_json_flag() {
    let f = fixture();
    let out = bin()
        .args(["audit", f.path().to_str().unwrap(), "--json"])
        .output()
        .unwrap();

    let json: serde_json::Value =
        serde_json::from_slice(&out.stdout).expect("--json stdout should be valid JSON");

    assert_eq!(json["status"], "success");
    assert_eq!(json["data"]["file_type"], "html");
}

// ── Error envelope ─────────────────────────────────────────────────────────

#[test]
fn error_envelope_shape() {
    let out = bin().args(["contract", "3"]).output().unwrap();

    assert!(!out.status.success());

    let json: serde_json::Value =
        serde_json::from_slice(&out.stderr).expect("stderr should be valid JSON");

    assert_eq!(json["version"], "1");
    assert_eq!(json["status"], "error");
    assert!(
        json["error"].is_object(),
        "error envelope must have 'error' field"
    );
    assert!(json["error"]["code"].is_string(), "error must have 'code'");
    assert!(
        json["error"]["message"].is_string(),
        "error must have 'message'"
    );
    assert!(
        json["error"]["suggestion"].is_string(),
        "error must have 'suggestion'"
    );
}

#[test]
fn error_code_matches_variant() {
    let out = bin().args(["contract", "2"]).output().unwrap();
    let json: serde_json::Value = serde_json::from_slice(&out.stderr).unwrap();
    assert_eq!(json["error"]["code"], "config_error");

    let out = bin().args(["contract", "4"]).output().unwrap();
    let json: serde_json::Value = serde_json::from_slice(&out.stderr).unwrap();
    assert_eq!(json["error"]["code"], "rate_limited");
}

// ── Help/version wrapping ──────────────────────────────────────────────────

#[test]
fn help_wrapped_in_envelope_when_piped() {
    let out = bin().arg("--help").output().unwrap();
    assert!(out.status.success());

    let json: serde_json::Value =
        serde_json::from_slice(&out.stdout).expect("piped --help should be JSON");

    assert_eq!(json["status"], "success");
    assert!(json["data"]["usage"].is_string());
}

#[test]
fn version_wrapped_in_envelope_when_piped() {
    let out = bin().arg("--version").output().unwrap();
    assert!(out.status.success());

    let json: serde_json::Value =
        serde_json::from_slice(&out.stdout).expect("piped --version should be JSON");

    assert_eq!(json["status"], "success");
}

// ── Quiet flag ─────────────────────────────────────────────────────────────

#[test]
fn quiet_still_emits_json() {
    let f = fixture();
    let out = bin()
        .args(["audit", f.path().to_str().unwrap(), "--json", "--quiet"])
        .output()
        .unwrap();

    let json: serde_json::Value =
        serde_json::from_slice(&out.stdout).expect("--quiet should not suppress JSON");

    assert_eq!(json["status"], "success");
}

// ── Manifest ↔ audit output parity ─────────────────────────────────────────

/// Every key the manifest claims `audit` returns must actually appear in
/// the real audit output. Catches the v0.7.0 drift where `performance`
/// and `link_graph` were added without updating agent-info / README.
#[test]
fn agent_info_audit_keys_match_real_output() {
    let info_out = bin().arg("agent-info").output().unwrap();
    let info: serde_json::Value = serde_json::from_slice(&info_out.stdout).unwrap();
    let manifest_keys: Vec<&str> = info["commands"]["audit"]["output_shape"]
        .as_object()
        .unwrap()
        .keys()
        .map(|s| s.as_str())
        .collect();

    let f = fixture();
    let audit_out = bin()
        .args(["audit", f.path().to_str().unwrap()])
        .output()
        .unwrap();
    let audit: serde_json::Value = serde_json::from_slice(&audit_out.stdout).unwrap();
    let data = audit["data"].as_object().expect("envelope.data");

    for key in &manifest_keys {
        assert!(
            data.contains_key(*key),
            "agent-info advertises `{key}` but real audit output is missing it"
        );
    }
    for key in data.keys() {
        assert!(
            manifest_keys.contains(&key.as_str()),
            "audit output has `{key}` but agent-info doesn't document it"
        );
    }
}

// ── Parse error envelope ───────────────────────────────────────────────────

#[test]
fn parse_error_wrapped_in_envelope() {
    let out = bin()
        .arg("audit") // missing required <file>
        .output()
        .unwrap();

    assert_eq!(out.status.code(), Some(3));

    let json: serde_json::Value =
        serde_json::from_slice(&out.stderr).expect("parse error should be JSON on stderr");

    assert_eq!(json["status"], "error");
    assert_eq!(json["error"]["code"], "invalid_input");
    assert!(
        json["error"]["suggestion"]
            .as_str()
            .unwrap()
            .contains("--help")
    );
}