use serde_json::{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 {
serde_json::from_slice(&out.stdout).unwrap_or_else(|e| {
panic!(
"stdout was not valid JSON: {e}\n--- stdout ---\n{}\n--- stderr ---\n{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr),
)
})
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn label_create_wrapped_body_no_double_nest() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/v1/o/r/labels"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"label": {"name": "bug", "color": "#f00", "description": "d"}
})))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&[
"label", "create", "--name", "bug", "--color", "#f00", "--repo", "o/r",
],
);
assert!(out.status.success(), "expected success exit");
let v = stdout_json(&out);
assert_eq!(v["ok"], json!(true));
assert!(v["label"]["label"].is_null(), "double-nest detected: {v}");
assert_eq!(v["label"]["name"], json!("bug"));
assert_eq!(v["label"]["color"], json!("#f00"));
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn label_create_flat_body_reachable() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/v1/o/r/labels"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"name": "bug", "color": "#f00"
})))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&[
"label", "create", "--name", "bug", "--color", "#f00", "--repo", "o/r",
],
);
assert!(out.status.success());
let v = stdout_json(&out);
assert_eq!(v["ok"], json!(true));
assert!(v["label"]["label"].is_null());
assert_eq!(v["label"]["name"], json!("bug"));
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn label_edit_wrapped_body_no_double_nest() {
let server = MockServer::start().await;
Mock::given(method("PATCH"))
.and(path("/api/v1/o/r/labels/bug"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"label": {"name": "defect", "color": "#0f0"}
})))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&[
"label",
"edit",
"bug",
"--new-name",
"defect",
"--repo",
"o/r",
],
);
assert!(out.status.success());
let v = stdout_json(&out);
assert!(v["label"]["label"].is_null(), "double-nest detected: {v}");
assert_eq!(v["label"]["name"], json!("defect"));
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn label_add_wrapped_body_no_double_nest() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/v1/o/r/issues/5/labels"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"label": {"name": "bug"}
})))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&[
"label", "add", "--issue", "5", "--name", "bug", "--repo", "o/r",
],
);
assert!(out.status.success());
let v = stdout_json(&out);
assert!(v["label"]["label"].is_null(), "double-nest detected: {v}");
assert_eq!(v["label"]["name"], json!("bug"));
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn label_delete_envelope() {
let server = MockServer::start().await;
Mock::given(method("DELETE"))
.and(path("/api/v1/o/r/labels/bug"))
.respond_with(ResponseTemplate::new(204))
.mount(&server)
.await;
let out = run_json(&server.uri(), &["label", "delete", "bug", "--repo", "o/r"]);
assert!(out.status.success());
let v = stdout_json(&out);
assert_eq!(v, json!({"ok": true, "deleted": "bug"}));
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn label_remove_envelope() {
let server = MockServer::start().await;
Mock::given(method("DELETE"))
.and(path("/api/v1/o/r/issues/5/labels/bug"))
.respond_with(ResponseTemplate::new(204))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&[
"label", "remove", "--issue", "5", "--name", "bug", "--repo", "o/r",
],
);
assert!(out.status.success());
let v = stdout_json(&out);
assert_eq!(v["ok"], json!(true));
assert_eq!(v["label_removed"]["issue"], json!(5));
assert_eq!(v["label_removed"]["name"], json!("bug"));
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn label_list_echoes_server_body() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/o/r/labels"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"labels": [{"name": "bug", "color": "#f00", "description": ""}]
})))
.mount(&server)
.await;
let out = run_json(&server.uri(), &["label", "list", "o/r"]);
assert!(out.status.success());
let v = stdout_json(&out);
assert!(v["labels"].is_array());
assert_eq!(v["labels"][0]["name"], json!("bug"));
assert!(v["ok"].is_null(), "read must not add ok envelope");
assert!(v["items"].is_null(), "read must not wrap in items");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn label_list_empty_no_human_leak() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/o/r/labels"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({"labels": []})))
.mount(&server)
.await;
let out = run_json(&server.uri(), &["label", "list", "o/r"]);
assert!(out.status.success());
let v = stdout_json(&out);
assert_eq!(v["labels"], json!([]));
let s = String::from_utf8_lossy(&out.stdout);
assert!(!s.contains("(none)"), "human (none) leaked: {s}");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn label_create_error_path() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/v1/o/r/labels"))
.respond_with(
ResponseTemplate::new(400)
.insert_header("content-type", "application/json")
.set_body_string(r#"{"error":"bad color"}"#),
)
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&["label", "create", "--name", "bug", "--repo", "o/r"],
);
assert!(!out.status.success(), "error must exit nonzero");
let v = stdout_json(&out);
assert_eq!(v["ok"], json!(false));
assert!(v["error"].is_string(), "error envelope missing: {v}");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn milestone_create_wrapped_body_no_double_nest() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/v1/o/r/milestones"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"milestone": {"short_id": "M-1", "title": "v1.0", "state": "open"}
})))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&["milestone", "create", "--title", "v1.0", "--repo", "o/r"],
);
assert!(out.status.success());
let v = stdout_json(&out);
assert_eq!(v["ok"], json!(true));
assert!(v["milestone"]["milestone"].is_null(), "double-nest: {v}");
assert_eq!(v["milestone"]["short_id"], json!("M-1"));
assert_eq!(v["milestone"]["title"], json!("v1.0"));
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn milestone_create_flat_body_reachable() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/v1/o/r/milestones"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"short_id": "M-2", "title": "v2.0"
})))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&["milestone", "create", "--title", "v2.0", "--repo", "o/r"],
);
assert!(out.status.success());
let v = stdout_json(&out);
assert!(v["milestone"]["milestone"].is_null());
assert_eq!(v["milestone"]["short_id"], json!("M-2"));
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn milestone_edit_wrapped_body_no_double_nest() {
let server = MockServer::start().await;
Mock::given(method("PATCH"))
.and(path("/api/v1/o/r/milestones/M-1"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"milestone": {"short_id": "M-1", "title": "renamed"}
})))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&[
"milestone",
"edit",
"M-1",
"--title",
"renamed",
"--repo",
"o/r",
],
);
assert!(out.status.success());
let v = stdout_json(&out);
assert!(v["milestone"]["milestone"].is_null(), "double-nest: {v}");
assert_eq!(v["milestone"]["title"], json!("renamed"));
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn milestone_close_wrapped_body_no_double_nest() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/v1/o/r/milestones/M-1/close"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"milestone": {"short_id": "M-1", "state": "closed"}
})))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&["milestone", "close", "M-1", "--repo", "o/r"],
);
assert!(out.status.success());
let v = stdout_json(&out);
assert!(v["milestone"]["milestone"].is_null(), "double-nest: {v}");
assert_eq!(v["milestone"]["state"], json!("closed"));
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn milestone_delete_envelope() {
let server = MockServer::start().await;
Mock::given(method("DELETE"))
.and(path("/api/v1/o/r/milestones/M-1"))
.respond_with(ResponseTemplate::new(204))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&["milestone", "delete", "M-1", "--repo", "o/r"],
);
assert!(out.status.success());
let v = stdout_json(&out);
assert_eq!(v, json!({"ok": true, "deleted": "M-1"}));
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn milestone_list_echoes_server_body() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/o/r/milestones"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"milestones": [{"short_id": "M-1", "title": "v1", "state": "open"}]
})))
.mount(&server)
.await;
let out = run_json(&server.uri(), &["milestone", "list", "o/r"]);
assert!(out.status.success());
let v = stdout_json(&out);
assert!(v["milestones"].is_array());
assert!(v["ok"].is_null());
assert!(v["items"].is_null());
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn milestone_list_empty_no_human_leak() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/o/r/milestones"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({"milestones": []})))
.mount(&server)
.await;
let out = run_json(&server.uri(), &["milestone", "list", "o/r"]);
assert!(out.status.success());
let v = stdout_json(&out);
assert_eq!(v["milestones"], json!([]));
let s = String::from_utf8_lossy(&out.stdout);
assert!(!s.contains("No milestones"), "human info leaked: {s}");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn milestone_view_echoes_server_body() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/o/r/milestones/M-1"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"milestone": {"short_id": "M-1", "title": "v1", "state": "open"}
})))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&["milestone", "view", "M-1", "--repo", "o/r"],
);
assert!(out.status.success());
let v = stdout_json(&out);
assert_eq!(v["milestone"]["short_id"], json!("M-1"));
assert!(v["ok"].is_null(), "read must not add ok envelope");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn milestone_view_error_path() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/o/r/milestones/nope"))
.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(),
&["milestone", "view", "nope", "--repo", "o/r"],
);
assert!(!out.status.success(), "404 must exit nonzero");
let v = stdout_json(&out);
assert_eq!(v["ok"], json!(false));
assert!(v["error"].is_string());
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn board_init_wrapped_body_no_double_nest() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/v1/o/r/board/initialize"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"columns": [{"name": "Backlog"}, {"name": "Done"}]
})))
.mount(&server)
.await;
let out = run_json(&server.uri(), &["board", "init", "--repo", "o/r"]);
assert!(out.status.success());
let v = stdout_json(&out);
assert_eq!(v["ok"], json!(true));
assert!(v["columns"].is_array(), "columns not unwrapped: {v}");
assert_eq!(v["columns"][0]["name"], json!("Backlog"));
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn board_create_column_wrapped_body_no_double_nest() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/v1/o/r/board/columns"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"column": {"short_id": "C-1", "name": "Backlog"}
})))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&["board", "create-column", "Backlog", "--repo", "o/r"],
);
assert!(out.status.success());
let v = stdout_json(&out);
assert!(v["column"]["column"].is_null(), "double-nest: {v}");
assert_eq!(v["column"]["name"], json!("Backlog"));
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn board_create_column_flat_body_reachable() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/v1/o/r/board/columns"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"short_id": "C-2", "name": "Doing"
})))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&["board", "create-column", "Doing", "--repo", "o/r"],
);
assert!(out.status.success());
let v = stdout_json(&out);
assert!(v["column"]["column"].is_null());
assert_eq!(v["column"]["name"], json!("Doing"));
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn board_edit_column_wrapped_body_no_double_nest() {
let server = MockServer::start().await;
Mock::given(method("PATCH"))
.and(path("/api/v1/o/r/board/columns/C-1"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"column": {"short_id": "C-1", "name": "Renamed"}
})))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&[
"board",
"edit-column",
"C-1",
"--name",
"Renamed",
"--repo",
"o/r",
],
);
assert!(out.status.success());
let v = stdout_json(&out);
assert!(v["column"]["column"].is_null(), "double-nest: {v}");
assert_eq!(v["column"]["name"], json!("Renamed"));
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn board_delete_column_envelope() {
let server = MockServer::start().await;
Mock::given(method("DELETE"))
.and(path("/api/v1/o/r/board/columns/C-1"))
.respond_with(ResponseTemplate::new(204))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&["board", "delete-column", "C-1", "--repo", "o/r"],
);
assert!(out.status.success());
let v = stdout_json(&out);
assert_eq!(v, json!({"ok": true, "deleted": "C-1"}));
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn board_list_empty_no_human_leak() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/o/r/board/columns"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({"columns": []})))
.mount(&server)
.await;
let out = run_json(&server.uri(), &["board", "list", "o/r"]);
assert!(out.status.success());
let v = stdout_json(&out);
assert_eq!(v["columns"], json!([]));
let s = String::from_utf8_lossy(&out.stdout);
assert!(!s.contains("No board columns"), "human info leaked: {s}");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn board_list_feature_disabled_error_envelope() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/o/r/board/columns"))
.respond_with(
ResponseTemplate::new(404)
.insert_header("content-type", "application/json")
.set_body_string(r#"{"error":"Feature not enabled"}"#),
)
.mount(&server)
.await;
let out = run_json(&server.uri(), &["board", "list", "o/r"]);
assert!(!out.status.success(), "feature-disabled must exit nonzero");
let v = stdout_json(&out);
assert_eq!(v["ok"], json!(false));
assert!(v["error"].is_string(), "error envelope missing: {v}");
}