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("failed to run anvil binary")
}
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 requirement_list_emits_json() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/test-org/test-repo/requirements"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"requirements": [{
"requirement_id": "REQ-ACCT-001",
"title": "Users can register",
"category": "functional",
"priority": "high",
"status": "active",
"test_count": 2
}]
})))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&["requirement", "list", "--repo", "test-org/test-repo"],
);
assert!(
out.status.success(),
"expected success; stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let v = stdout_json(&out);
assert!(v["requirements"].is_array(), "got: {v}");
assert_eq!(v["requirements"][0]["requirement_id"], "REQ-ACCT-001");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn standard_list_emits_json() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/test-org/standards"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"standards": [{
"requirement_id": "STD-GDPR-017",
"title": "Right to erasure",
"framework": "GDPR",
"citation": "Art. 17",
"mandatory": true,
"status": "active"
}]
})))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&[
"requirement",
"list",
"--kind",
"standard",
"--organization",
"test-org",
],
);
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let v = stdout_json(&out);
assert_eq!(v["standards"][0]["requirement_id"], "STD-GDPR-017");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn requirement_view_emits_json() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/test-org/test-repo/requirements/REQ-ACCT-001"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"id": "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
"requirement_id": "REQ-ACCT-001",
"title": "Users can register",
"category": "functional",
"priority": "high",
"status": "active"
})))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&[
"requirement",
"view",
"REQ-ACCT-001",
"--repo",
"test-org/test-repo",
],
);
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let v = stdout_json(&out);
assert_eq!(v["requirement_id"], "REQ-ACCT-001");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn standard_view_emits_json() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/test-org/standards/STD-GDPR-017"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"id": "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb",
"requirement_id": "STD-GDPR-017",
"title": "Right to erasure",
"framework": "GDPR",
"mandatory": true,
"status": "active"
})))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&[
"requirement",
"view",
"STD-GDPR-017",
"--organization",
"test-org",
],
);
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let v = stdout_json(&out);
assert_eq!(v["requirement_id"], "STD-GDPR-017");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn status_emits_json_and_passes_when_covered() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/test-org/test-repo/requirements/matrix"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"matrix": [
{"coverage_status": "covered",
"requirement": {"requirement_id": "REQ-A", "title": "A"}}
]
})))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&["requirement", "status", "--repo", "test-org/test-repo"],
);
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let v = stdout_json(&out);
assert_eq!(v["passed"], true);
assert_eq!(v["strict"], false);
assert_eq!(v["counts"]["covered"], 1);
assert_eq!(v["counts"]["total"], 1);
assert!(v["failing"].as_array().unwrap().is_empty());
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn status_strict_emits_json_and_exits_nonzero_when_failing() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/test-org/test-repo/requirements/matrix"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"matrix": [
{"coverage_status": "covered",
"requirement": {"requirement_id": "REQ-A", "title": "A"}},
{"coverage_status": "partial",
"requirement": {"requirement_id": "REQ-B", "title": "B"}},
{"coverage_status": "uncovered",
"requirement": {"requirement_id": "REQ-C", "title": "C"}}
]
})))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&[
"requirement",
"status",
"--strict",
"--repo",
"test-org/test-repo",
],
);
assert!(
!out.status.success(),
"strict status with uncovered/partial must exit non-zero"
);
let v = stdout_json(&out);
assert_eq!(v["passed"], false);
assert_eq!(v["strict"], true);
assert_eq!(v["counts"]["covered"], 1);
assert_eq!(v["counts"]["partial"], 1);
assert_eq!(v["counts"]["uncovered"], 1);
assert_eq!(v["counts"]["total"], 3);
let failing = v["failing"].as_array().unwrap();
assert_eq!(failing.len(), 2, "got: {v}");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn strict_standards_emits_json_and_passes() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/test-org/standards/strict"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({"status": "ok"})))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&[
"requirement",
"status",
"--strict-standards",
"--organization",
"test-org",
],
);
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let v = stdout_json(&out);
assert_eq!(v["passed"], true);
assert!(v["uncovered"].as_array().unwrap().is_empty());
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn strict_standards_emits_json_and_exits_nonzero_when_uncovered() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/test-org/standards/strict"))
.respond_with(ResponseTemplate::new(422).set_body_json(json!({
"status": "uncovered",
"uncovered": [{
"standard": {"requirement_id": "STD-GDPR-017", "title": "Right to erasure"},
"repo": {"slug": "customer-app"},
"status": "uncovered"
}]
})))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&[
"requirement",
"status",
"--strict-standards",
"--organization",
"test-org",
],
);
assert!(
!out.status.success(),
"uncovered mandatory standards must exit non-zero"
);
let v = stdout_json(&out);
assert_eq!(v["passed"], false);
let uncovered = v["uncovered"].as_array().unwrap();
assert_eq!(uncovered.len(), 1, "got: {v}");
assert_eq!(uncovered[0]["standard"]["requirement_id"], "STD-GDPR-017");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn applicability_list_emits_json() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path(
"/api/v1/test-org/standards/STD-GDPR-017/applicabilities",
))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"repositories": [{"slug": "app", "name": "App", "visibility": "private"}]
})))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&[
"requirement",
"applicability",
"list",
"STD-GDPR-017",
"--organization",
"test-org",
],
);
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let v = stdout_json(&out);
assert_eq!(v["repositories"][0]["slug"], "app");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn create_requirement_emits_ok_envelope() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/v1/test-org/test-repo/requirements"))
.respond_with(ResponseTemplate::new(201).set_body_json(json!({
"id": "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
"requirement_id": "REQ-NEW-001",
"title": "New requirement"
})))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&[
"requirement",
"create",
"--requirement-id",
"REQ-NEW-001",
"--title",
"New requirement",
"--repo",
"test-org/test-repo",
],
);
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let v = stdout_json(&out);
assert_eq!(v["ok"], true);
assert_eq!(v["requirement"]["requirement_id"], "REQ-NEW-001");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn delete_requirement_emits_ok_envelope() {
let server = MockServer::start().await;
Mock::given(method("DELETE"))
.and(path("/api/v1/test-org/test-repo/requirements/REQ-OLD-001"))
.respond_with(ResponseTemplate::new(204))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&[
"requirement",
"delete",
"REQ-OLD-001",
"--repo",
"test-org/test-repo",
],
);
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let v = stdout_json(&out);
assert_eq!(v["ok"], true);
assert_eq!(v["deleted"], "REQ-OLD-001");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn applicability_add_emits_ok_envelope() {
let server = MockServer::start().await;
Mock::given(method("PUT"))
.and(path(
"/api/v1/test-org/standards/STD-GDPR-017/applicabilities/app",
))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({})))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&[
"requirement",
"applicability",
"add",
"STD-GDPR-017",
"--organization",
"test-org",
"--repo",
"app",
],
);
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let v = stdout_json(&out);
assert_eq!(v["ok"], true);
assert_eq!(v["applicability"]["opted_in"], true);
assert_eq!(v["applicability"]["repo"], "app");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn link_test_emits_ok_envelope() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path(
"/api/v1/test-org/test-repo/requirements/REQ-ACCT-001/links",
))
.respond_with(ResponseTemplate::new(201).set_body_json(json!({
"id": "cccccccc-cccc-cccc-cccc-cccccccccccc",
"test_name": "Anvil.AccountsTest.login",
"requirement_id": "REQ-ACCT-001"
})))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&[
"requirement",
"link",
"REQ-ACCT-001",
"--test",
"Anvil.AccountsTest.login",
"--repo",
"test-org/test-repo",
],
);
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let v = stdout_json(&out);
assert_eq!(v["ok"], true);
assert_eq!(v["link"]["test_name"], "Anvil.AccountsTest.login");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn unlink_test_encodes_name_and_emits_ok_envelope() {
let server = MockServer::start().await;
Mock::given(method("DELETE"))
.and(path(
"/api/v1/test-org/test-repo/requirements/REQ-ACCT-001/links/user%20can%20log%20in",
))
.respond_with(ResponseTemplate::new(204))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&[
"requirement",
"unlink",
"REQ-ACCT-001",
"--test",
"user can log in",
"--repo",
"test-org/test-repo",
],
);
assert!(
out.status.success(),
"expected the encoded DELETE to match; stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let v = stdout_json(&out);
assert_eq!(v["ok"], true);
assert_eq!(v["unlinked"]["test"], "user can log in");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn issue_link_req_emits_ok_envelope() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path(
"/api/v1/test-org/test-repo/issues/7/requirement-links",
))
.respond_with(ResponseTemplate::new(201).set_body_json(json!({
"linked": {"requirement_id": "REQ-A-001", "title": "Auth", "kind": "requirement"}
})))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&[
"issue",
"link-req",
"7",
"REQ-A-001",
"--repo",
"test-org/test-repo",
],
);
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let v = stdout_json(&out);
assert_eq!(v["ok"], true);
assert_eq!(v["linked"]["requirement_id"], "REQ-A-001");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn issue_unlink_req_emits_ok_envelope() {
let server = MockServer::start().await;
Mock::given(method("DELETE"))
.and(path(
"/api/v1/test-org/test-repo/issues/7/requirement-links/STD-B-002",
))
.respond_with(ResponseTemplate::new(204))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&[
"issue",
"unlink-req",
"7",
"STD-B-002",
"--repo",
"test-org/test-repo",
],
);
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let v = stdout_json(&out);
assert_eq!(v["ok"], true);
assert_eq!(v["unlinked"]["requirement_id"], "STD-B-002");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn label_edit_emits_ok_envelope() {
let server = MockServer::start().await;
Mock::given(method("PATCH"))
.and(path("/api/v1/test-org/test-repo/labels/bug"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"name": "defect", "color": "#00ff00", "description": null
})))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&[
"label",
"edit",
"bug",
"--new-name",
"defect",
"--color",
"#00ff00",
"--repo",
"test-org/test-repo",
],
);
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let v = stdout_json(&out);
assert_eq!(v["ok"], true);
assert_eq!(v["label"]["name"], "defect");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn label_delete_emits_ok_envelope() {
let server = MockServer::start().await;
Mock::given(method("DELETE"))
.and(path("/api/v1/test-org/test-repo/labels/bug"))
.respond_with(ResponseTemplate::new(204))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&["label", "delete", "bug", "--repo", "test-org/test-repo"],
);
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let v = stdout_json(&out);
assert_eq!(v["ok"], true);
assert_eq!(v["deleted"], "bug");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn label_list_emits_json() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/test-org/test-repo/labels"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"labels": [{"name": "bug", "color": "#ff0000", "description": null}]
})))
.mount(&server)
.await;
let out = run_json(&server.uri(), &["label", "list", "test-org/test-repo"]);
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let v = stdout_json(&out);
assert_eq!(v["labels"][0]["name"], "bug");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn milestone_edit_close_delete_emit_ok_envelopes() {
let server = MockServer::start().await;
Mock::given(method("PATCH"))
.and(path("/api/v1/test-org/test-repo/milestones/abc123"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"milestone": {"short_id": "abc123", "title": "v1.1", "state": "open"}
})))
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path("/api/v1/test-org/test-repo/milestones/abc123/close"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"milestone": {"short_id": "abc123", "title": "v1.1", "state": "closed"}
})))
.mount(&server)
.await;
Mock::given(method("DELETE"))
.and(path("/api/v1/test-org/test-repo/milestones/abc123"))
.respond_with(ResponseTemplate::new(204))
.mount(&server)
.await;
let edit = run_json(
&server.uri(),
&[
"milestone",
"edit",
"abc123",
"--title",
"v1.1",
"--repo",
"test-org/test-repo",
],
);
assert!(edit.status.success());
let v = stdout_json(&edit);
assert_eq!(v["ok"], true);
assert_eq!(v["milestone"]["title"], "v1.1");
let close = run_json(
&server.uri(),
&[
"milestone",
"close",
"abc123",
"--repo",
"test-org/test-repo",
],
);
assert!(close.status.success());
assert_eq!(stdout_json(&close)["milestone"]["state"], "closed");
let del = run_json(
&server.uri(),
&[
"milestone",
"delete",
"abc123",
"--repo",
"test-org/test-repo",
],
);
assert!(del.status.success());
assert_eq!(stdout_json(&del)["deleted"], "abc123");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn board_init_and_issue_move_emit_ok_envelopes() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/v1/test-org/test-repo/board/initialize"))
.respond_with(ResponseTemplate::new(201).set_body_json(json!({
"columns": [{"id": "c1", "short_id": "aaa", "name": "Backlog", "position": 0}]
})))
.mount(&server)
.await;
Mock::given(method("PATCH"))
.and(path("/api/v1/test-org/test-repo/issues/7"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"number": 7, "column_id": "c1"
})))
.mount(&server)
.await;
let init = run_json(
&server.uri(),
&["board", "init", "--repo", "test-org/test-repo"],
);
assert!(
init.status.success(),
"stderr: {}",
String::from_utf8_lossy(&init.stderr)
);
let v = stdout_json(&init);
assert_eq!(v["ok"], true);
assert_eq!(v["columns"][0]["name"], "Backlog");
let mv = run_json(
&server.uri(),
&[
"issue",
"move",
"7",
"--column",
"c1",
"--repo",
"test-org/test-repo",
],
);
assert!(
mv.status.success(),
"stderr: {}",
String::from_utf8_lossy(&mv.stderr)
);
assert_eq!(stdout_json(&mv)["ok"], true);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn requirement_import_dry_run_round_trips() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/v1/test-org/test-repo/requirements/import"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"dry_run": true,
"preview": {
"create": [{"requirement_id": "REQ-A-001", "title": "A"}],
"update": [], "unchanged": [], "errors": []
}
})))
.mount(&server)
.await;
let dir = std::env::temp_dir().join(format!("anvil-import-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let file = dir.join("reqs.yml");
std::fs::write(&file, "- requirement_id: REQ-A-001\n title: A\n").unwrap();
let out = run_json(
&server.uri(),
&[
"requirement",
"import",
file.to_str().unwrap(),
"--dry-run",
"--repo",
"test-org/test-repo",
],
);
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let v = stdout_json(&out);
assert_eq!(v["dry_run"], true);
assert_eq!(v["preview"]["create"][0]["requirement_id"], "REQ-A-001");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn label_names_with_slashes_are_path_encoded() {
let server = MockServer::start().await;
Mock::given(method("DELETE"))
.and(path("/api/v1/test-org/test-repo/labels/area%2Fci"))
.respond_with(ResponseTemplate::new(204))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&["label", "delete", "area/ci", "--repo", "test-org/test-repo"],
);
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
assert_eq!(stdout_json(&out)["deleted"], "area/ci");
}