use std::process::Command;
fn unifi() -> Command {
let mut cmd = Command::new(env!("CARGO_BIN_EXE_unifi"));
cmd.arg("--host").arg("127.0.0.1:1");
cmd.arg("--api-key").arg("not-a-real-key");
cmd
}
fn error_envelope(stderr: &[u8]) -> serde_json::Value {
let text = String::from_utf8_lossy(stderr);
let last = text
.lines()
.rfind(|l| !l.trim().is_empty())
.unwrap_or_default();
serde_json::from_str(last)
.unwrap_or_else(|e| panic!("last stderr line is not a JSON envelope: {last:?} ({e})"))
}
#[test]
fn clients_list_rejects_unknown_field() {
let out = unifi()
.args(["clients", "list", "--fields", "bogus"])
.output()
.expect("failed to run binary");
assert_eq!(
out.status.code(),
Some(2),
"expected usage exit code 2, stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
assert!(
out.stdout.is_empty(),
"stdout must stay clean on error, got: {}",
String::from_utf8_lossy(&out.stdout)
);
let envelope = error_envelope(&out.stderr);
let message = envelope["error"]["message"].as_str().unwrap_or_default();
assert_eq!(envelope["error"]["kind"], "config_error");
assert!(
message.contains("bogus"),
"error should name the offending field, got: {message}"
);
assert!(
message.contains("ssid"),
"error should list the valid fields, got: {message}"
);
}
#[test]
fn clients_list_rejects_unknown_field_among_valid_ones() {
let out = unifi()
.args(["clients", "list", "--fields", "mac,bogus,ip"])
.output()
.expect("failed to run binary");
assert_eq!(out.status.code(), Some(2));
let envelope = error_envelope(&out.stderr);
let message = envelope["error"]["message"].as_str().unwrap_or_default();
assert!(message.contains("bogus"), "got: {message}");
assert!(
!message.contains("'mac'"),
"valid fields must not be reported as invalid, got: {message}"
);
}
#[test]
fn devices_list_rejects_unknown_field() {
let out = unifi()
.args(["devices", "list", "--fields", "nope"])
.output()
.expect("failed to run binary");
assert_eq!(out.status.code(), Some(2));
assert!(
error_envelope(&out.stderr)["error"]["message"]
.as_str()
.unwrap_or_default()
.contains("nope")
);
}
#[test]
fn events_list_rejects_unknown_field() {
let out = unifi()
.args(["events", "list", "--fields", "nope"])
.output()
.expect("failed to run binary");
assert_eq!(out.status.code(), Some(2));
assert!(
error_envelope(&out.stderr)["error"]["message"]
.as_str()
.unwrap_or_default()
.contains("nope")
);
}
#[test]
fn fields_validation_happens_before_any_network_call() {
let out = unifi()
.args(["clients", "list", "--fields", "bogus"])
.output()
.expect("failed to run binary");
assert_eq!(
out.status.code(),
Some(2),
"validation must precede the HTTP request"
);
}
#[test]
fn clients_list_accepts_every_documented_field() {
for field in [
"name",
"mac",
"ip",
"type",
"ssid",
"signal",
"uptime",
"network",
"vlan",
"tx_bytes",
"rx_bytes",
"blocked",
"connected_at",
] {
let out = unifi()
.args(["clients", "list", "--fields", field])
.output()
.expect("failed to run binary");
assert_ne!(
out.status.code(),
Some(2),
"field {field:?} was rejected as invalid: {}",
String::from_utf8_lossy(&out.stderr)
);
}
}
#[test]
fn networks_has_a_list_subcommand() {
let out = unifi()
.args(["networks", "list", "--help"])
.output()
.expect("failed to run binary");
assert!(
out.status.success(),
"`unifi networks list` should exist like clients/devices/events; stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
}
#[test]
fn networks_without_subcommand_still_lists() {
let out = unifi()
.args(["networks", "--help"])
.output()
.expect("failed to run binary");
assert!(out.status.success());
}
fn schema_json() -> serde_json::Value {
let out = Command::new(env!("CARGO_BIN_EXE_unifi"))
.arg("schema")
.output()
.expect("failed to run binary");
assert!(out.status.success());
serde_json::from_slice(&out.stdout).expect("schema is JSON")
}
fn schema_command<'a>(schema: &'a serde_json::Value, name: &str) -> &'a serde_json::Value {
schema["commands"]
.as_array()
.expect("schema.commands is an array")
.iter()
.find(|c| c["name"] == name)
.unwrap_or_else(|| panic!("schema has no command named {name:?}"))
}
#[test]
fn schema_advertises_networks_list() {
let schema = schema_json();
let names: Vec<&str> = schema["commands"]
.as_array()
.unwrap()
.iter()
.filter_map(|c| c["name"].as_str())
.collect();
assert!(
names.contains(&"networks list"),
"schema must advertise `networks list`, found: {names:?}"
);
}
#[test]
fn every_published_output_field_is_accepted_by_fields() {
let schema = schema_json();
for command in ["clients list", "devices list", "events list"] {
let fields: Vec<String> = schema_command(&schema, command)["output_fields"]
.as_array()
.unwrap_or_else(|| panic!("{command} publishes output_fields"))
.iter()
.map(|f| f["name"].as_str().unwrap().to_string())
.collect();
assert!(!fields.is_empty());
let spec = fields.join(",");
let mut args: Vec<&str> = command.split(' ').collect();
args.push("--fields");
args.push(&spec);
let out = unifi().args(&args).output().expect("failed to run binary");
assert_ne!(
out.status.code(),
Some(2),
"`{command}` rejected its own published output_fields ({spec}): {}",
String::from_utf8_lossy(&out.stderr)
);
}
}