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 {
let cfg = std::env::temp_dir().join(format!("anvil-adv-release-cfg-{}.json", unique_suffix()));
std::process::Command::new(env!("CARGO_BIN_EXE_anvil"))
.arg("--json")
.args(args)
.env("ANVIL_SERVER_URL", server_uri)
.env("ANVIL_TOKEN", "test-token")
.env("ANVIL_CONFIG", cfg)
.output()
.expect("run anvil")
}
fn unique_suffix() -> u128 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
}
fn stdout_json(out: &Output) -> Value {
let s = String::from_utf8_lossy(&out.stdout);
serde_json::from_str(s.trim())
.unwrap_or_else(|e| panic!("stdout was not a single JSON value: {e}\n--- stdout ---\n{s}"))
}
fn exit_code(out: &Output) -> i32 {
out.status.code().unwrap_or(-1)
}
const REPO: &str = "acme/widgets";
const BASE: &str = "/api/v1/acme/widgets/releases";
#[tokio::test]
async fn create_wrapped_body_is_not_double_nested() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path(BASE))
.respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({
"release": {"tag_name": "v1.0.0", "title": "First", "draft": true}
})))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&[
"release", "create", "--repo", REPO, "--tag", "v1.0.0", "--title", "First",
],
);
let v = stdout_json(&out);
assert_eq!(v["ok"], Value::Bool(true));
assert!(v["release"]["release"].is_null(), "double-nested: {v}");
assert_eq!(v["release"]["tag_name"], "v1.0.0");
assert_eq!(v["release"]["draft"], Value::Bool(true));
}
#[tokio::test]
async fn create_flat_body_wraps_under_release() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path(BASE))
.respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({
"tag_name": "v1.0.0", "title": "First"
})))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&["release", "create", "--repo", REPO, "--tag", "v1.0.0"],
);
let v = stdout_json(&out);
assert_eq!(v["ok"], Value::Bool(true));
assert_eq!(v["release"]["tag_name"], "v1.0.0");
assert!(v["release"]["release"].is_null(), "double-nested: {v}");
}
#[tokio::test]
async fn create_server_error_is_error_envelope_nonzero() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path(BASE))
.respond_with(
ResponseTemplate::new(422)
.insert_header("content-type", "application/json")
.set_body_json(serde_json::json!({"error": "tag already exists"})),
)
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&["release", "create", "--repo", REPO, "--tag", "v1.0.0"],
);
let v = stdout_json(&out);
assert_eq!(
v["ok"],
Value::Bool(false),
"expected failure envelope: {v}"
);
assert!(v.get("error").is_some(), "missing error field: {v}");
assert_ne!(exit_code(&out), 0, "error path must exit nonzero");
}
#[tokio::test]
async fn view_wrapped_body_echoes_bare_release() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path(format!("{BASE}/v1.0.0")))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"release": {"tag_name": "v1.0.0", "title": "T"}
})))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&["release", "view", "v1.0.0", "--repo", REPO],
);
let v = stdout_json(&out);
assert_eq!(v["tag_name"], "v1.0.0");
assert!(
v["release"].is_null(),
"should have unwrapped the envelope: {v}"
);
}
#[tokio::test]
async fn view_200_with_string_author_echoes_body_verbatim() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path(format!("{BASE}/v1.0.0")))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"tag_name": "v1.0.0",
"title": "T",
"author": "cole"
})))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&["release", "view", "v1.0.0", "--repo", REPO],
);
let v = stdout_json(&out);
assert_eq!(exit_code(&out), 0, "200 read must succeed: {v}");
assert_eq!(v["author"], "cole", "{v}");
assert_eq!(v["tag_name"], "v1.0.0", "{v}");
}
#[tokio::test]
async fn view_404_is_error_envelope_nonzero() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path(format!("{BASE}/v9.9.9")))
.respond_with(
ResponseTemplate::new(404)
.insert_header("content-type", "application/json")
.set_body_json(serde_json::json!({"error": "not found"})),
)
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&["release", "view", "v9.9.9", "--repo", REPO],
);
let v = stdout_json(&out);
assert_eq!(v["ok"], Value::Bool(false), "{v}");
assert_ne!(exit_code(&out), 0);
}
#[tokio::test]
async fn list_wrapped_array_echoes_bare_array() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path(BASE))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"releases": [{"tag_name": "v1.0.0"}, {"tag_name": "v0.9.0"}]
})))
.mount(&server)
.await;
let out = run_json(&server.uri(), &["release", "list", REPO]);
let v = stdout_json(&out);
assert!(v.is_array(), "list should echo a bare array: {v}");
assert_eq!(v.as_array().unwrap().len(), 2);
assert_eq!(v[0]["tag_name"], "v1.0.0");
}
#[tokio::test]
async fn list_empty_is_empty_array_no_human_none_line() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path(BASE))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"releases": []
})))
.mount(&server)
.await;
let out = run_json(&server.uri(), &["release", "list", REPO]);
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(
!stdout.contains("(none)"),
"human '(none)' leaked: {stdout}"
);
let v = stdout_json(&out);
assert_eq!(v, serde_json::json!([]), "empty list should be []: {v}");
}
#[tokio::test]
async fn list_null_collection_emits_empty_array() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path(BASE))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"releases": null
})))
.mount(&server)
.await;
let out = run_json(&server.uri(), &["release", "list", REPO]);
let v = stdout_json(&out);
assert!(
v.is_array() && v.as_array().unwrap().is_empty(),
"expected []: {v}"
);
assert_eq!(exit_code(&out), 0);
}
#[tokio::test]
async fn update_wrapped_body_is_not_double_nested() {
let server = MockServer::start().await;
Mock::given(method("PUT"))
.and(path(format!("{BASE}/v1.0.0")))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"release": {"tag_name": "v1.0.0", "title": "New"}
})))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&[
"release", "update", "v1.0.0", "--title", "New", "--repo", REPO,
],
);
let v = stdout_json(&out);
assert_eq!(v["ok"], Value::Bool(true));
assert!(v["release"]["release"].is_null(), "double-nested: {v}");
assert_eq!(v["release"]["title"], "New");
}
#[tokio::test]
async fn update_no_fields_is_ok_envelope_no_server_call() {
let server = MockServer::start().await;
let out = run_json(
&server.uri(),
&["release", "update", "v1.0.0", "--repo", REPO],
);
let v = stdout_json(&out);
assert_eq!(v["ok"], Value::Bool(true), "{v}");
assert_eq!(exit_code(&out), 0);
}
#[tokio::test]
async fn publish_emits_ok_release_payload() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path(format!("{BASE}/v1.0.0/publish")))
.respond_with(ResponseTemplate::new(200))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&["release", "publish", "v1.0.0", "--repo", REPO],
);
let v = stdout_json(&out);
assert_eq!(v["ok"], Value::Bool(true));
assert_eq!(v["release"]["tag_name"], "v1.0.0");
assert_eq!(v["release"]["draft"], Value::Bool(false));
assert!(v["release"]["release"].is_null(), "double-nested: {v}");
}
#[tokio::test]
async fn delete_emits_deleted_tag() {
let server = MockServer::start().await;
Mock::given(method("DELETE"))
.and(path(format!("{BASE}/v1.0.0")))
.respond_with(ResponseTemplate::new(204))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&["release", "delete", "v1.0.0", "--repo", REPO],
);
let v = stdout_json(&out);
assert_eq!(
v,
serde_json::json!({"ok": true, "deleted": "v1.0.0"}),
"{v}"
);
}
#[tokio::test]
async fn delete_asset_emits_deleted_id() {
let server = MockServer::start().await;
Mock::given(method("DELETE"))
.and(path(format!("{BASE}/v1.0.0/assets/a1")))
.respond_with(ResponseTemplate::new(200))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&["release", "delete-asset", "v1.0.0", "a1", "--repo", REPO],
);
let v = stdout_json(&out);
assert_eq!(v, serde_json::json!({"ok": true, "deleted": "a1"}), "{v}");
}
#[tokio::test]
async fn assets_empty_is_empty_array() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path(format!("{BASE}/v1.0.0/assets")))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"assets": []
})))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&["release", "assets", "v1.0.0", "--repo", REPO],
);
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(
!stdout.contains("(none)"),
"human '(none)' leaked: {stdout}"
);
let v = stdout_json(&out);
assert_eq!(v, serde_json::json!([]), "{v}");
}
#[tokio::test]
async fn assets_null_collection_emits_empty_array() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path(format!("{BASE}/v1.0.0/assets")))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"assets": null
})))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&["release", "assets", "v1.0.0", "--repo", REPO],
);
let v = stdout_json(&out);
assert!(
v.is_array() && v.as_array().unwrap().is_empty(),
"expected []: {v}"
);
assert_eq!(exit_code(&out), 0);
}
#[tokio::test]
async fn upload_wrapped_asset_is_not_double_nested() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path(format!("{BASE}/v1.0.0/assets")))
.respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({
"asset": {"filename": "art.bin", "size_bytes": 10, "short_id": "as1"}
})))
.mount(&server)
.await;
let file = std::env::temp_dir().join(format!("anvil-adv-upload-{}.bin", unique_suffix()));
std::fs::write(&file, b"0123456789").unwrap();
let out = run_json(
&server.uri(),
&[
"release",
"upload",
"v1.0.0",
file.to_str().unwrap(),
"--repo",
REPO,
],
);
let _ = std::fs::remove_file(&file);
let v = stdout_json(&out);
assert_eq!(v["ok"], Value::Bool(true), "{v}");
assert!(v["asset"]["asset"].is_null(), "double-nested: {v}");
assert_eq!(v["asset"]["filename"], "art.bin");
}
#[tokio::test]
async fn upload_missing_file_is_error_envelope_nonzero() {
let server = MockServer::start().await;
let missing = std::env::temp_dir().join(format!("anvil-adv-nope-{}.bin", unique_suffix()));
let out = run_json(
&server.uri(),
&[
"release",
"upload",
"v1.0.0",
missing.to_str().unwrap(),
"--repo",
REPO,
],
);
let v = stdout_json(&out);
assert_eq!(v["ok"], Value::Bool(false), "{v}");
assert!(v.get("error").is_some(), "{v}");
assert_ne!(exit_code(&out), 0);
}
#[tokio::test]
async fn download_emits_ok_downloaded_field() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path(format!("{BASE}/v1.0.0/assets")))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"assets": [{"filename": "art.bin", "short_id": "as1"}]
})))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path(format!("{BASE}/v1.0.0/assets/as1/download")))
.respond_with(ResponseTemplate::new(200).set_body_bytes(b"payloadbytes".to_vec()))
.mount(&server)
.await;
let dest = std::env::temp_dir().join(format!("anvil-adv-dl-{}.bin", unique_suffix()));
let out = run_json(
&server.uri(),
&[
"release",
"download",
"v1.0.0",
"art.bin",
"--repo",
REPO,
"--output",
dest.to_str().unwrap(),
],
);
let file_written = dest.exists();
let _ = std::fs::remove_file(&dest);
let v = stdout_json(&out);
assert_eq!(v["ok"], Value::Bool(true), "{v}");
assert!(v["downloaded"]["file"].is_string(), "{v}");
assert!(
file_written,
"download should have written the file to --output"
);
}
#[tokio::test]
async fn changelog_text_is_json_not_raw_stdout() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path(format!("{BASE}/changelog")))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"changelog": "- fix a bug\n- add a feature"
})))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&["release", "changelog", "v1.0.0", "v2.0.0", "--repo", REPO],
);
let v = stdout_json(&out);
assert_eq!(v["changelog"], "- fix a bug\n- add a feature", "{v}");
}
#[tokio::test]
async fn changelog_server_error_is_error_envelope_nonzero() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path(format!("{BASE}/changelog")))
.respond_with(
ResponseTemplate::new(500)
.insert_header("content-type", "application/json")
.set_body_json(serde_json::json!({"error": "boom"})),
)
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&["release", "changelog", "v1.0.0", "v2.0.0", "--repo", REPO],
);
let v = stdout_json(&out);
assert_eq!(v["ok"], Value::Bool(false), "{v}");
assert_ne!(exit_code(&out), 0);
}