hen 0.15.0

Run protocol-aware API request collections from the command line or through MCP.
Documentation
use super::*;
use std::{
    collections::HashMap,
    path::PathBuf,
    time::{Duration, SystemTime},
};

use http::{Method, StatusCode};
use serde_json::{json, Value};

use crate::{
    automation::{CollectionSummary, RunOutcome, VerificationResult},
    parser::{SyntaxRequestSummary, SyntaxSummary},
    request::{
        ArtifactMetadata, AssertionOutcome, AssertionStatus, ExecutionArtifact,
        ExecutionRecord, HttpArtifactMetadata, RequestExecution, RequestProtocol,
    },
};

#[test]
fn verification_result_json_matches_golden_fixture() {
    let result = VerificationResult {
        path: Some(PathBuf::from("/workspace/collection.hen")),
        summary: SyntaxSummary {
            name: "Fixture Collection".to_string(),
            description: "Collection used for structured output fixtures.".to_string(),
            requests: vec![
                SyntaxRequestSummary {
                    index: 0,
                    description: "Get one".to_string(),
                    method: "GET".to_string(),
                    url: "https://example.com/one".to_string(),
                    protocol: "http".to_string(),
                    protocol_context: None,
                },
                SyntaxRequestSummary {
                    index: 1,
                    description: "Get two".to_string(),
                    method: "POST".to_string(),
                    url: "https://example.com/two".to_string(),
                    protocol: "http".to_string(),
                    protocol_context: None,
                },
            ],
        },
        required_inputs: vec![
            crate::automation::PromptRequirement {
                name: "api_token".to_string(),
                default: None,
            },
            crate::automation::PromptRequirement {
                name: "region".to_string(),
                default: Some("us-east-1".to_string()),
            },
        ],
    };

    assert_json_fixture(
        verification_result_json(&result),
        include_str!("../../tests/fixtures/golden/verify_hen_syntax.json"),
    );
}

#[test]
fn run_outcome_json_matches_golden_fixture() {
    let result = RunOutcome {
        collection: CollectionSummary {
            path: PathBuf::from("/workspace/collection.hen"),
            name: "Fixture Collection".to_string(),
            description: "Collection used for structured output fixtures.".to_string(),
            requests: vec![crate::automation::RequestSummary {
                index: 0,
                description: "Get one".to_string(),
                method: "GET".to_string(),
                url: "https://example.com/one".to_string(),
                protocol: "http".to_string(),
                protocol_context: None,
                dependencies: vec![],
            }],
            required_inputs: vec![crate::automation::PromptRequirement {
                name: "api_token".to_string(),
                default: None,
            }],
        },
        plan: vec![0],
        selected_requests: vec![0],
        primary_target: Some(0),
        records: vec![ExecutionRecord {
            index: 0,
            description: "Get one".to_string(),
            method: Method::GET,
            url: "https://example.com/one".to_string(),
            execution: RequestExecution {
                output: "hello world".to_string(),
                export_env: HashMap::new(),
                artifact: ExecutionArtifact {
                    protocol: RequestProtocol::Http,
                    status: StatusCode::OK,
                    headers: Default::default(),
                    body: "hello world".to_string(),
                    json: None,
                    metadata: ArtifactMetadata::Http(HttpArtifactMetadata {
                        status: StatusCode::OK,
                        headers: Default::default(),
                    }),
                    timing_phases: vec![],
                    transcripts: vec![],
                    retained_artifacts: vec![],
                },
                assertions: vec![
                    AssertionOutcome {
                        assertion: "^ & body.ok == true".to_string(),
                        status: AssertionStatus::Passed,
                        message: None,
                        mismatch: None,
                    },
                    AssertionOutcome {
                        assertion: "[ true == false ] ^ & body.service == 'hen'".to_string(),
                        status: AssertionStatus::Skipped,
                        message: Some("guard evaluated to false".to_string()),
                        mismatch: None,
                    },
                ],
            },
            started_at: SystemTime::UNIX_EPOCH + Duration::from_millis(1_704_067_200_000),
            duration: Duration::from_millis(25),
        }],
        failures: vec![],
        execution_failed: false,
        interrupted: None,
    };

    assert_json_fixture(
        run_outcome_json(
            &result,
            BodyReportOptions {
                include_body: true,
                max_body_chars: Some(5),
            },
        ),
        include_str!("../../tests/fixtures/golden/run_hen.json"),
    );
}

#[test]
fn run_outcome_ndjson_emits_summary_and_record_lines() {
    let result = sample_run_outcome();

    let lines = run_outcome_ndjson(
        &result,
        BodyReportOptions {
            include_body: true,
            max_body_chars: None,
        },
    )
    .lines()
    .map(|line| serde_json::from_str::<Value>(line).expect("ndjson line should parse"))
    .collect::<Vec<_>>();

    assert_eq!(lines.len(), 2);
    assert_eq!(lines[0]["type"], "run");
    assert_eq!(lines[0]["interrupted"], false);
    assert_eq!(lines[0]["interruptSignal"], Value::Null);
    assert_eq!(lines[1]["type"], "record");
    assert_eq!(lines[1]["protocol"], "http");
    assert_eq!(lines[1]["protocolContext"], Value::Null);
    assert_eq!(lines[1]["status"], 200);
    assert_eq!(lines[1]["assertions"][0]["status"], "passed");
    assert_eq!(lines[1]["assertions"][1]["status"], "skipped");
}

#[test]
fn run_record_json_includes_graphql_protocol_context() {
    let record = ExecutionRecord {
        index: 0,
        description: "Get user via GraphQL".to_string(),
        method: Method::POST,
        url: "https://example.com/graphql".to_string(),
        execution: RequestExecution {
            output: r#"{"data":{"user":{"id":"123"}},"errors":null}"#.to_string(),
            export_env: HashMap::new(),
            artifact: ExecutionArtifact {
                protocol: RequestProtocol::Graphql,
                status: StatusCode::OK,
                headers: Default::default(),
                body: r#"{"data":{"user":{"id":"123"}},"errors":null}"#.to_string(),
                json: Some(serde_json::json!({
                    "data": { "user": { "id": "123" } },
                    "errors": null
                })),
                metadata: ArtifactMetadata::Http(HttpArtifactMetadata {
                    status: StatusCode::OK,
                    headers: Default::default(),
                }),
                timing_phases: vec![],
                transcripts: vec![crate::request::ArtifactTranscript {
                    direction: crate::request::ArtifactTranscriptDirection::Outgoing,
                    label: "graphql.request".to_string(),
                    body: String::new(),
                    attributes: HashMap::from([
                        ("operationName".to_string(), "GetUser".to_string()),
                        ("variables".to_string(), r#"{"id":"123"}"#.to_string()),
                    ]),
                }],
                retained_artifacts: vec![],
            },
            assertions: vec![],
        },
        started_at: SystemTime::UNIX_EPOCH,
        duration: Duration::from_millis(10),
    };

    let json = run_record_json(&record, BodyReportOptions::default());

    assert_eq!(json["protocol"], "graphql");
    assert_eq!(json["protocolContext"]["operationName"], "GetUser");
    assert_eq!(json["protocolContext"]["variables"]["id"], "123");
    assert_eq!(json["timing"]["totalMs"], 10);
    assert_eq!(json["timing"]["phases"], json!([]));
    assert_eq!(json["transcripts"][0]["direction"], "outgoing");
    assert_eq!(json["transcripts"][0]["label"], "graphql.request");
    assert_eq!(json["transcripts"][0]["attributes"]["operationName"], "GetUser");
    assert_eq!(json["retainedArtifacts"], json!([]));
}

#[test]
fn run_record_json_applies_body_options_to_artifacts() {
    let record = ExecutionRecord {
        index: 1,
        description: "Artifact export".to_string(),
        method: Method::POST,
        url: "https://example.com/artifacts".to_string(),
        execution: RequestExecution {
            output: "response body".to_string(),
            export_env: HashMap::new(),
            artifact: ExecutionArtifact {
                protocol: RequestProtocol::Http,
                status: StatusCode::OK,
                headers: Default::default(),
                body: "response body".to_string(),
                json: None,
                metadata: ArtifactMetadata::Http(HttpArtifactMetadata {
                    status: StatusCode::OK,
                    headers: Default::default(),
                }),
                timing_phases: vec![crate::request::ArtifactTimingPhase::new(
                    "bodyRead",
                    Duration::from_millis(2),
                )],
                transcripts: vec![crate::request::ArtifactTranscript {
                    direction: crate::request::ArtifactTranscriptDirection::Outgoing,
                    label: "http.request".to_string(),
                    body: "abcdef".to_string(),
                    attributes: HashMap::new(),
                }],
                retained_artifacts: vec![crate::request::RetainedArtifact {
                    name: "trace.txt".to_string(),
                    content_type: "text/plain".to_string(),
                    body: "uvwxyz".to_string(),
                }],
            },
            assertions: vec![],
        },
        started_at: SystemTime::UNIX_EPOCH,
        duration: Duration::from_millis(5),
    };

    let json = run_record_json(
        &record,
        BodyReportOptions {
            include_body: false,
            max_body_chars: Some(3),
        },
    );

    assert_eq!(json["body"], Value::Null);
    assert_eq!(json["timing"]["totalMs"], 5);
    assert_eq!(json["timing"]["phases"][0]["name"], "bodyRead");
    assert_eq!(json["timing"]["phases"][0]["durationMs"], 2);
    assert_eq!(json["transcripts"][0]["body"], Value::Null);
    assert_eq!(json["transcripts"][0]["bodyChars"], 6);
    assert_eq!(json["transcripts"][0]["bodyCharLimit"], 3);
    assert_eq!(json["retainedArtifacts"][0]["body"], Value::Null);
    assert_eq!(json["retainedArtifacts"][0]["bodyChars"], 6);
    assert_eq!(json["retainedArtifacts"][0]["bodyCharLimit"], 3);
}

#[test]
fn run_outcome_junit_renders_testcase() {
    let junit = run_outcome_junit(&sample_run_outcome());

    assert!(junit.contains("<testsuite"));
    assert!(junit.contains(
        "<testcase classname=\"Fixture Collection\" name=\"#0 GET https://example.com/one :: ^ &amp; body.ok == true\""
    ));
    assert!(junit.contains("tests=\"2\""));
    assert!(junit.contains("skipped=\"1\""));
    assert!(junit.contains("<skipped message=\"guard evaluated to false\"/>"));
}

#[test]
fn run_outcome_junit_includes_interruption_case() {
    let mut result = sample_run_outcome();
    result.execution_failed = true;
    result.interrupted = Some(crate::request::InterruptSignal::Sigterm);

    let junit = run_outcome_junit(&result);

    assert!(junit.contains("errors=\"1\""));
    assert!(junit.contains("run interrupted by SIGTERM"));
    assert!(junit.contains("Execution interrupted by SIGTERM"));
}

fn sample_run_outcome() -> RunOutcome {
    RunOutcome {
        collection: CollectionSummary {
            path: PathBuf::from("/workspace/collection.hen"),
            name: "Fixture Collection".to_string(),
            description: "Collection used for structured output fixtures.".to_string(),
            requests: vec![crate::automation::RequestSummary {
                index: 0,
                description: "Get one".to_string(),
                method: "GET".to_string(),
                url: "https://example.com/one".to_string(),
                protocol: "http".to_string(),
                protocol_context: None,
                dependencies: vec![],
            }],
            required_inputs: vec![crate::automation::PromptRequirement {
                name: "api_token".to_string(),
                default: None,
            }],
        },
        plan: vec![0],
        selected_requests: vec![0],
        primary_target: Some(0),
        records: vec![ExecutionRecord {
            index: 0,
            description: "Get one".to_string(),
            method: Method::GET,
            url: "https://example.com/one".to_string(),
            execution: RequestExecution {
                output: "hello world".to_string(),
                export_env: HashMap::new(),
                artifact: ExecutionArtifact {
                    protocol: RequestProtocol::Http,
                    status: StatusCode::OK,
                    headers: Default::default(),
                    body: "hello world".to_string(),
                    json: None,
                    metadata: ArtifactMetadata::Http(HttpArtifactMetadata {
                        status: StatusCode::OK,
                        headers: Default::default(),
                    }),
                    timing_phases: vec![],
                    transcripts: vec![],
                    retained_artifacts: vec![],
                },
                assertions: vec![
                    AssertionOutcome {
                        assertion: "^ & body.ok == true".to_string(),
                        status: AssertionStatus::Passed,
                        message: None,
                        mismatch: None,
                    },
                    AssertionOutcome {
                        assertion: "[ true == false ] ^ & body.service == 'hen'".to_string(),
                        status: AssertionStatus::Skipped,
                        message: Some("guard evaluated to false".to_string()),
                        mismatch: None,
                    },
                ],
            },
            started_at: SystemTime::UNIX_EPOCH + Duration::from_millis(1_704_067_200_000),
            duration: Duration::from_millis(25),
        }],
        failures: vec![],
        execution_failed: false,
        interrupted: None,
    }
}

fn assert_json_fixture(value: Value, expected: &str) {
    let actual = serde_json::to_string_pretty(&value).expect("json should serialize");
    assert_eq!(actual.trim(), expected.trim());
}