use serde_json::Value;
use std::process::Output;
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
fn run_json(server_uri: &str, args: &[&str]) -> Output {
std::process::Command::new(env!("CARGO_BIN_EXE_anvil"))
.arg("--json")
.args(args)
.env("ANVIL_SERVER_URL", server_uri)
.env("ANVIL_TOKEN", "test-token")
.output()
.expect("run anvil")
}
fn stdout_json(out: &Output) -> Value {
let s = String::from_utf8_lossy(&out.stdout);
serde_json::from_str(&s).unwrap_or_else(|e| {
panic!(
"stdout was not a single JSON value ({e}).\n--- stdout ---\n{s}\n--- stderr ---\n{}",
String::from_utf8_lossy(&out.stderr)
)
})
}
#[tokio::test]
async fn list_echoes_server_envelope_verbatim() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/myorg/myrepo/ci/runs"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"ci_runs": [{"short_id": "ci_1", "status": "passed", "commit_sha": "deadbeefcafe"}]
})))
.mount(&server)
.await;
let out = run_json(&server.uri(), &["ci", "list", "myorg/myrepo"]);
assert!(out.status.success(), "expected exit 0");
let v = stdout_json(&out);
assert!(v["ci_runs"].is_array(), "expected ci_runs array, got {v}");
assert_eq!(v["ci_runs"][0]["short_id"], "ci_1");
assert!(v.get("items").is_none(), "read must not wrap in items: {v}");
}
#[tokio::test]
async fn list_empty_is_valid_json_not_none_line() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/myorg/myrepo/ci/runs"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({"ci_runs": []})))
.mount(&server)
.await;
let out = run_json(&server.uri(), &["ci", "list", "myorg/myrepo"]);
assert!(out.status.success());
let v = stdout_json(&out);
assert!(v["ci_runs"].is_array());
assert_eq!(v["ci_runs"].as_array().unwrap().len(), 0);
}
#[tokio::test]
async fn list_server_error_yields_error_envelope_and_nonzero_exit() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/myorg/myrepo/ci/runs"))
.respond_with(
ResponseTemplate::new(500)
.insert_header("content-type", "application/json")
.set_body_string(r#"{"error":"boom"}"#),
)
.mount(&server)
.await;
let out = run_json(&server.uri(), &["ci", "list", "myorg/myrepo"]);
assert!(!out.status.success(), "5xx must produce nonzero exit");
let v = stdout_json(&out);
assert_eq!(v["ok"], false, "expected {{ok:false}} envelope, got {v}");
assert!(v.get("error").is_some(), "expected error field, got {v}");
}
#[tokio::test]
async fn view_echoes_wrapped_body_verbatim() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/myorg/myrepo/ci/runs/ci_7"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"ci_run": {"short_id": "ci_7", "status": "running", "branch": "main"}
})))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&["ci", "view", "ci_7", "--repo", "myorg/myrepo"],
);
assert!(out.status.success());
let v = stdout_json(&out);
assert_eq!(v["ci_run"]["short_id"], "ci_7");
}
#[tokio::test]
async fn view_404_yields_error_envelope_and_nonzero_exit() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/myorg/myrepo/ci/runs/missing"))
.respond_with(
ResponseTemplate::new(404)
.insert_header("content-type", "application/json")
.set_body_string(r#"{"error":"not_found"}"#),
)
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&["ci", "view", "missing", "--repo", "myorg/myrepo"],
);
assert!(!out.status.success());
let v = stdout_json(&out);
assert_eq!(v["ok"], false);
}
#[tokio::test]
async fn trigger_unwraps_ci_run_envelope_no_double_nest() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/v1/myorg/myrepo/ci/runs"))
.respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({
"ci_run": {"short_id": "ci_new", "id": "uuid-1", "status": "queued"}
})))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&["ci", "run", "myorg/myrepo", "--branch", "feature-x"],
);
assert!(out.status.success());
let v = stdout_json(&out);
assert_eq!(v["ok"], true);
assert_eq!(
v["run"]["short_id"], "ci_new",
"expected unwrapped run, got {v}"
);
assert!(v["run"]["ci_run"].is_null(), "double-nested ci_run: {v}");
}
#[tokio::test]
async fn trigger_flat_body_is_wrapped_once() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/v1/myorg/myrepo/ci/runs"))
.respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({
"short_id": "ci_flat", "status": "queued"
})))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&["ci", "run", "myorg/myrepo", "--branch", "main"],
);
assert!(out.status.success());
let v = stdout_json(&out);
assert_eq!(v["ok"], true);
assert_eq!(v["run"]["short_id"], "ci_flat");
}
#[tokio::test]
async fn cancel_flat_body_is_correct() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/v1/myorg/myrepo/ci/runs/ci_9/cancel"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"short_id": "ci_9", "status": "cancelled"
})))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&["ci", "cancel", "ci_9", "--repo", "myorg/myrepo"],
);
assert!(out.status.success());
let v = stdout_json(&out);
assert_eq!(v["ok"], true);
assert_eq!(v["run"]["short_id"], "ci_9");
}
#[tokio::test]
async fn cancel_unwraps_ci_run_envelope() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/v1/myorg/myrepo/ci/runs/ci_9/cancel"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"ci_run": {"short_id": "ci_9", "status": "cancelled"}
})))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&["ci", "cancel", "ci_9", "--repo", "myorg/myrepo"],
);
assert!(out.status.success());
let v = stdout_json(&out);
assert_eq!(v["ok"], true);
assert_eq!(
v["run"]["status"], "cancelled",
"expected unwrapped run, got {v}"
);
assert_eq!(
v["run"]["short_id"], "ci_9",
"expected unwrapped run, got {v}"
);
assert!(v["run"]["ci_run"].is_null(), "double-nested ci_run: {v}");
}
#[tokio::test]
async fn secrets_uses_items_envelope() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/myorg/myrepo/ci/secrets"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"secrets": [{"name": "TOKEN", "environment": "prod"}]
})))
.mount(&server)
.await;
let out = run_json(&server.uri(), &["ci", "secrets", "myorg/myrepo"]);
assert!(out.status.success());
let v = stdout_json(&out);
assert!(
v["items"].is_array(),
"client-built list must use items: {v}"
);
assert_eq!(v["items"][0]["name"], "TOKEN");
}
#[tokio::test]
async fn secrets_empty_is_items_empty_array() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/myorg/myrepo/ci/secrets"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({"secrets": []})))
.mount(&server)
.await;
let out = run_json(&server.uri(), &["ci", "secrets", "myorg/myrepo"]);
assert!(out.status.success());
let v = stdout_json(&out);
assert_eq!(v["items"].as_array().unwrap().len(), 0);
}
#[tokio::test]
async fn secrets_missing_key_yields_empty_items_not_null() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/myorg/myrepo/ci/secrets"))
.respond_with(
ResponseTemplate::new(200).set_body_json(serde_json::json!({"unexpected": 1})),
)
.mount(&server)
.await;
let out = run_json(&server.uri(), &["ci", "secrets", "myorg/myrepo"]);
assert!(out.status.success());
let v = stdout_json(&out);
assert!(v["items"].is_array(), "expected items array, got {v}");
assert_eq!(v["items"].as_array().unwrap().len(), 0);
}
#[tokio::test]
async fn set_secret_unwraps_secret_envelope_no_double_nest() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/v1/myorg/myrepo/ci/secrets"))
.respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({
"secret": {"name": "TOKEN", "environment": "prod"}
})))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&[
"ci",
"set-secret",
"--name",
"TOKEN",
"--value",
"s3cret",
"--repo",
"myorg/myrepo",
],
);
assert!(out.status.success());
let v = stdout_json(&out);
assert_eq!(v["ok"], true);
assert_eq!(
v["secret"]["name"], "TOKEN",
"expected unwrapped secret: {v}"
);
assert!(v["secret"]["secret"].is_null(), "double-nested secret: {v}");
}
#[tokio::test]
async fn set_secret_flat_body_is_wrapped_once() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/v1/myorg/myrepo/ci/secrets"))
.respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({
"name": "TOKEN", "environment": "*"
})))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&[
"ci",
"set-secret",
"--name",
"TOKEN",
"--value",
"v",
"--repo",
"myorg/myrepo",
],
);
assert!(out.status.success());
let v = stdout_json(&out);
assert_eq!(v["secret"]["name"], "TOKEN");
}
#[tokio::test]
async fn delete_secret_synthesizes_confirmation() {
let server = MockServer::start().await;
Mock::given(method("DELETE"))
.and(path("/api/v1/myorg/myrepo/ci/secrets/TOKEN"))
.respond_with(ResponseTemplate::new(204))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&[
"ci",
"delete-secret",
"--name",
"TOKEN",
"--repo",
"myorg/myrepo",
],
);
assert!(out.status.success());
let v = stdout_json(&out);
assert_eq!(v["ok"], true);
:
assert_eq!(v["secret"]["name"], "TOKEN");
assert_eq!(v["secret"]["deleted"], true);
}
#[tokio::test]
async fn delete_secret_error_yields_error_envelope() {
let server = MockServer::start().await;
Mock::given(method("DELETE"))
.and(path("/api/v1/myorg/myrepo/ci/secrets/MISSING"))
.respond_with(
ResponseTemplate::new(404)
.insert_header("content-type", "application/json")
.set_body_string(r#"{"error":"no such secret"}"#),
)
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&[
"ci",
"delete-secret",
"--name",
"MISSING",
"--repo",
"myorg/myrepo",
],
);
assert!(!out.status.success());
let v = stdout_json(&out);
assert_eq!(v["ok"], false);
}
#[tokio::test]
async fn job_view_captures_logs_into_json_field() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/myorg/myrepo/ci/jobs/job_1"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"job": {"short_id": "job_1", "name": "build", "status": "passed"}
})))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/api/v1/myorg/myrepo/ci/jobs/job_1/logs"))
.respond_with(
ResponseTemplate::new(200)
.insert_header("content-type", "text/event-stream")
.set_body_string(
"event: log_line\ndata: {\"content\":\"compiling anvil\",\"stream\":\"stdout\"}\n\n\
event: log_line\ndata: {\"content\":\"done\",\"stream\":\"stderr\"}\n\n\
event: done\ndata: {\"status\":\"passed\",\"exit_code\":0}\n\n",
),
)
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&[
"ci",
"job-view",
"job_1",
"--no-follow",
"--repo",
"myorg/myrepo",
],
);
assert!(out.status.success());
let v = stdout_json(&out);
assert_eq!(v["job"]["short_id"], "job_1", "job metadata field: {v}");
let logs = v["logs"].as_array().expect("logs must be an array field");
assert_eq!(logs.len(), 2, "captured log lines: {v}");
assert_eq!(logs[0]["content"], "compiling anvil");
assert_eq!(logs[0]["stream"], "stdout");
assert_eq!(logs[1]["stream"], "stderr");
assert!(v["job"]["job"].is_null(), "double-nested job: {v}");
}
#[tokio::test]
async fn job_view_log_fetch_failure_is_error_envelope() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/myorg/myrepo/ci/jobs/job_2"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"job": {"short_id": "job_2", "status": "running"}
})))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/api/v1/myorg/myrepo/ci/jobs/job_2/logs"))
.respond_with(
ResponseTemplate::new(500)
.insert_header("content-type", "application/json")
.set_body_string(r#"{"error":"log backend down"}"#),
)
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&[
"ci",
"job-view",
"job_2",
"--no-follow",
"--repo",
"myorg/myrepo",
],
);
assert!(!out.status.success(), "log fetch failure must exit nonzero");
let v = stdout_json(&out);
assert_eq!(v["ok"], false, "expected error envelope, got {v}");
assert!(
v["job"].is_null(),
"partial metadata leaked onto stdout: {v}"
);
}
#[tokio::test]
async fn job_view_empty_logs_is_empty_array() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/myorg/myrepo/ci/jobs/job_3"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"job": {"short_id": "job_3", "status": "passed"}
})))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/api/v1/myorg/myrepo/ci/jobs/job_3/logs"))
.respond_with(
ResponseTemplate::new(200)
.insert_header("content-type", "text/event-stream")
.set_body_string("event: done\ndata: {\"status\":\"passed\",\"exit_code\":0}\n\n"),
)
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&[
"ci",
"job-view",
"job_3",
"--no-follow",
"--repo",
"myorg/myrepo",
],
);
assert!(out.status.success());
let v = stdout_json(&out);
assert!(v["logs"].is_array());
assert_eq!(v["logs"].as_array().unwrap().len(), 0);
}