use wiremock::matchers::{body_json, method, path, path_regex};
use wiremock::{Mock, MockServer, ResponseTemplate};
async fn mock_client(server: &MockServer) -> unifi_cli::api::UnifiClient {
unifi_cli::api::UnifiClient::new(&server.uri(), "test-api-key").unwrap()
}
async fn mount_site_discovery(server: &MockServer) {
Mock::given(method("GET"))
.and(path("/proxy/network/integration/v1/sites"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"offset": 0,
"limit": 25,
"count": 1,
"totalCount": 1,
"data": [{"id": "test-site-uuid"}]
})))
.expect(1..)
.mount(server)
.await;
}
mod client_api {
use super::*;
#[tokio::test]
async fn list_clients_returns_paginated_results() {
let server = MockServer::start().await;
mount_site_discovery(&server).await;
Mock::given(method("GET"))
.and(path_regex(r"/proxy/network/integration/v1/sites/.*/clients"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"offset": 0, "limit": 200, "count": 2, "totalCount": 2,
"data": [
{"macAddress": "aa:bb:cc:dd:ee:ff", "ipAddress": "10.0.0.1", "name": "Device1", "type": "WIRED"},
{"macAddress": "11:22:33:44:55:66", "ipAddress": "10.0.0.2", "hostname": "host2", "type": "WIRELESS"}
]
})))
.mount(&server)
.await;
let mut client = mock_client(&server).await;
let clients = client.list_clients().await.unwrap();
assert_eq!(clients.len(), 2);
assert_eq!(clients[0].name.as_deref(), Some("Device1"));
assert_eq!(clients[1].hostname.as_deref(), Some("host2"));
}
#[tokio::test]
async fn list_clients_handles_pagination() {
let server = MockServer::start().await;
mount_site_discovery(&server).await;
Mock::given(method("GET"))
.and(path_regex(
r"/proxy/network/integration/v1/sites/.*/clients",
))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"offset": 0, "limit": 200, "count": 200, "totalCount": 201,
"data": (0..200).map(|i| serde_json::json!({
"macAddress": format!("aa:bb:cc:dd:{:02x}:{:02x}", i / 256, i % 256),
"type": "WIRED"
})).collect::<Vec<_>>()
})))
.up_to_n_times(1)
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path_regex(
r"/proxy/network/integration/v1/sites/.*/clients",
))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"offset": 200, "limit": 200, "count": 1, "totalCount": 201,
"data": [{"macAddress": "ff:ff:ff:ff:ff:ff", "type": "WIRED"}]
})))
.mount(&server)
.await;
let mut client = mock_client(&server).await;
let clients = client.list_clients().await.unwrap();
assert_eq!(clients.len(), 201);
}
#[tokio::test]
async fn get_client_detail_finds_by_mac() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/proxy/network/api/s/default/stat/sta"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"meta": {"rc": "ok"},
"data": [
{"_id": "abc", "mac": "aa:bb:cc:dd:ee:ff", "ip": "10.0.0.1", "name": "Target", "is_wired": true, "uptime": 7200},
{"_id": "def", "mac": "11:22:33:44:55:66", "ip": "10.0.0.2"}
]
})))
.mount(&server)
.await;
let client = mock_client(&server).await;
let detail = client.get_client_detail("AA:BB:CC:DD:EE:FF").await.unwrap();
assert_eq!(detail.display_name(), "Target");
assert!(detail.is_wired);
assert_eq!(detail.uptime, Some(7200));
}
#[tokio::test]
async fn get_client_detail_not_found() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/proxy/network/api/s/default/stat/sta"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"meta": {"rc": "ok"},
"data": [
{"_id": "abc", "mac": "aa:bb:cc:dd:ee:ff"}
]
})))
.mount(&server)
.await;
let client = mock_client(&server).await;
let err = client
.get_client_detail("00:00:00:00:00:00")
.await
.unwrap_err();
assert!(err.to_string().contains("Not found"));
}
#[tokio::test]
async fn get_client_detail_accepts_dash_format() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/proxy/network/api/s/default/stat/sta"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"meta": {"rc": "ok"},
"data": [{"_id": "abc", "mac": "aa:bb:cc:dd:ee:ff", "name": "Found"}]
})))
.mount(&server)
.await;
let client = mock_client(&server).await;
let detail = client.get_client_detail("AA-BB-CC-DD-EE-FF").await.unwrap();
assert_eq!(detail.display_name(), "Found");
}
#[tokio::test]
async fn set_fixed_ip_via_put() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/proxy/network/api/s/default/stat/sta"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"meta": {"rc": "ok"},
"data": [{"_id": "client123", "mac": "aa:bb:cc:dd:ee:ff"}]
})))
.mount(&server)
.await;
Mock::given(method("PUT"))
.and(path("/proxy/network/api/s/default/rest/user/client123"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"meta": {"rc": "ok"},
"data": [{}]
})))
.expect(1)
.mount(&server)
.await;
let client = mock_client(&server).await;
client
.set_fixed_ip("aa:bb:cc:dd:ee:ff", "10.0.0.50", None)
.await
.unwrap();
}
#[tokio::test]
async fn set_fixed_ip_falls_back_to_post_on_404() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/proxy/network/api/s/default/stat/sta"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"meta": {"rc": "ok"},
"data": [{"_id": "newclient", "mac": "aa:bb:cc:dd:ee:ff"}]
})))
.mount(&server)
.await;
Mock::given(method("PUT"))
.and(path("/proxy/network/api/s/default/rest/user/newclient"))
.respond_with(ResponseTemplate::new(404).set_body_json(serde_json::json!({
"meta": {"rc": "error", "msg": "api.err.ObjectNotFound"},
"data": []
})))
.expect(1)
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path("/proxy/network/api/s/default/rest/user"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"meta": {"rc": "ok"},
"data": [{}]
})))
.expect(1)
.mount(&server)
.await;
let client = mock_client(&server).await;
client
.set_fixed_ip("aa:bb:cc:dd:ee:ff", "10.0.0.99", Some("NewDevice"))
.await
.unwrap();
}
#[tokio::test]
async fn set_fixed_ip_client_not_found() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/proxy/network/api/s/default/stat/sta"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"meta": {"rc": "ok"},
"data": []
})))
.mount(&server)
.await;
let client = mock_client(&server).await;
let err = client
.set_fixed_ip("00:00:00:00:00:00", "10.0.0.1", None)
.await
.unwrap_err();
assert!(err.to_string().contains("Not found"));
}
#[tokio::test]
async fn block_client_sends_correct_command() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/proxy/network/api/s/default/cmd/stamgr"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"meta": {"rc": "ok"},
"data": []
})))
.expect(1)
.mount(&server)
.await;
let client = mock_client(&server).await;
client.block_client("AABBCCDDEEFF").await.unwrap();
}
#[tokio::test]
async fn unblock_client_sends_correct_command() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/proxy/network/api/s/default/cmd/stamgr"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"meta": {"rc": "ok"},
"data": []
})))
.expect(1)
.mount(&server)
.await;
let client = mock_client(&server).await;
client.unblock_client("aa:bb:cc:dd:ee:ff").await.unwrap();
}
#[tokio::test]
async fn kick_client_sends_correct_command() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/proxy/network/api/s/default/cmd/stamgr"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"meta": {"rc": "ok"},
"data": []
})))
.expect(1)
.mount(&server)
.await;
let client = mock_client(&server).await;
client.kick_client("aa:bb:cc:dd:ee:ff").await.unwrap();
}
#[tokio::test]
async fn list_devices_returns_devices() {
let server = MockServer::start().await;
mount_site_discovery(&server).await;
Mock::given(method("GET"))
.and(path_regex(r"/proxy/network/integration/v1/sites/.*/devices"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"offset": 0, "limit": 200, "count": 2, "totalCount": 2,
"data": [
{"macAddress": "9c:05:d6:bc:06:43", "ipAddress": "192.168.1.1", "name": "UCG Ultra", "model": "UCG Ultra", "state": "ONLINE", "firmwareVersion": "5.0.12"},
{"macAddress": "60:22:32:58:b8:00", "ipAddress": "192.168.1.190", "name": "U6-Lite", "model": "U6 Lite", "state": "ONLINE", "firmwareVersion": "6.7.41"}
]
})))
.mount(&server)
.await;
let mut client = mock_client(&server).await;
let devices = client.list_devices().await.unwrap();
assert_eq!(devices.len(), 2);
assert_eq!(devices[0].name.as_deref(), Some("UCG Ultra"));
assert_eq!(devices[1].firmware_version.as_deref(), Some("6.7.41"));
}
#[tokio::test]
async fn restart_device_sends_correct_command() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/proxy/network/api/s/default/cmd/devmgr"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"meta": {"rc": "ok"},
"data": []
})))
.expect(1)
.mount(&server)
.await;
let client = mock_client(&server).await;
client.restart_device("aa:bb:cc:dd:ee:ff").await.unwrap();
}
#[tokio::test]
async fn power_cycle_port_sends_correct_command() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/proxy/network/api/s/default/cmd/devmgr"))
.and(body_json(serde_json::json!({
"cmd": "power-cycle",
"mac": "aa:bb:cc:dd:ee:ff",
"port_idx": 5
})))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"meta": {"rc": "ok"},
"data": []
})))
.expect(1)
.mount(&server)
.await;
let client = mock_client(&server).await;
client
.power_cycle_port("AA-BB-CC-DD-EE-FF", 5)
.await
.unwrap();
}
#[tokio::test]
async fn upgrade_device_sends_correct_command() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/proxy/network/api/s/default/cmd/devmgr"))
.respond_with(
ResponseTemplate::new(200)
.set_body_json(serde_json::json!({"meta": {"rc": "ok"}, "data": []})),
)
.mount(&server)
.await;
let client = mock_client(&server).await;
client.upgrade_device("aa:bb:cc:dd:ee:ff").await.unwrap();
}
#[tokio::test]
async fn locate_device_enable() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/proxy/network/api/s/default/cmd/devmgr"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"meta": {"rc": "ok"},
"data": []
})))
.expect(1)
.mount(&server)
.await;
let client = mock_client(&server).await;
client
.locate_device("aa:bb:cc:dd:ee:ff", true)
.await
.unwrap();
}
#[tokio::test]
async fn locate_device_disable() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/proxy/network/api/s/default/cmd/devmgr"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"meta": {"rc": "ok"},
"data": []
})))
.expect(1)
.mount(&server)
.await;
let client = mock_client(&server).await;
client
.locate_device("aa:bb:cc:dd:ee:ff", false)
.await
.unwrap();
}
#[tokio::test]
async fn list_networks_returns_networks() {
let server = MockServer::start().await;
mount_site_discovery(&server).await;
Mock::given(method("GET"))
.and(path_regex(
r"/proxy/network/integration/v1/sites/.*/networks",
))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"offset": 0, "limit": 200, "count": 3, "totalCount": 3,
"data": [
{"name": "Default", "enabled": true, "vlanId": 1, "default": true},
{"name": "IoT", "enabled": true, "vlanId": 20, "default": false},
{"name": "Guest", "enabled": false, "vlanId": 10, "default": false}
]
})))
.mount(&server)
.await;
let mut client = mock_client(&server).await;
let networks = client.list_networks().await.unwrap();
assert_eq!(networks.len(), 3);
assert_eq!(networks[0].name.as_deref(), Some("Default"));
assert!(networks[0].default);
assert_eq!(networks[1].vlan_id, Some(20));
assert!(!networks[2].enabled);
}
#[tokio::test]
async fn get_health_returns_subsystems() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/proxy/network/api/s/default/stat/health"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"meta": {"rc": "ok"},
"data": [
{"subsystem": "wan", "status": "ok", "wan_ip": "81.172.153.156", "isp_name": "Caiway"},
{"subsystem": "wlan", "status": "ok", "num_ap": 3, "num_sta": 15},
{"subsystem": "lan", "status": "ok", "num_sw": 4, "num_sta": 20}
]
})))
.mount(&server)
.await;
let client = mock_client(&server).await;
let health = client.get_health().await.unwrap();
assert_eq!(health.len(), 3);
assert_eq!(health[0].wan_ip.as_deref(), Some("81.172.153.156"));
assert_eq!(health[1].num_ap, Some(3));
assert_eq!(health[2].num_switches, Some(4));
}
#[tokio::test]
async fn get_sysinfo_returns_info() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/proxy/network/api/s/default/stat/sysinfo"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"meta": {"rc": "ok"},
"data": [{
"hostname": "UCG-Ultra",
"version": "10.1.85",
"timezone": "Europe/Amsterdam",
"uptime": 1737960
}]
})))
.mount(&server)
.await;
let client = mock_client(&server).await;
let info = client.get_sysinfo().await.unwrap();
assert_eq!(info.hostname.as_deref(), Some("UCG-Ultra"));
assert_eq!(info.version.as_deref(), Some("10.1.85"));
assert_eq!(info.uptime, Some(1737960));
}
#[tokio::test]
async fn get_sysinfo_empty_data() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/proxy/network/api/s/default/stat/sysinfo"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"meta": {"rc": "ok"},
"data": []
})))
.mount(&server)
.await;
let client = mock_client(&server).await;
let err = client.get_sysinfo().await.unwrap_err();
assert!(err.to_string().contains("No sysinfo returned"));
}
#[tokio::test]
async fn list_all_device_ports_returns_every_device() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/proxy/network/api/s/default/stat/device"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"meta": {"rc": "ok"},
"data": [
{"mac": "aa:bb:cc:dd:ee:ff", "name": "SwitchA",
"port_table": [{"port_idx": 1, "port_poe": true}]},
{"mac": "11:22:33:44:55:66", "name": "SwitchB",
"port_table": [{"port_idx": 1}, {"port_idx": 2}]}
]
})))
.expect(1)
.mount(&server)
.await;
let client = mock_client(&server).await;
let devices = client.list_all_device_ports().await.unwrap();
assert_eq!(devices.len(), 2);
assert_eq!(devices[1].port_table.len(), 2);
}
}
mod error_handling {
use super::*;
#[tokio::test]
async fn api_returns_401_unauthorized() {
let server = MockServer::start().await;
mount_site_discovery(&server).await;
Mock::given(method("GET"))
.and(path_regex(
r"/proxy/network/integration/v1/sites/.*/clients",
))
.respond_with(ResponseTemplate::new(401).set_body_string("Unauthorized"))
.mount(&server)
.await;
let mut client = mock_client(&server).await;
let err = client.list_clients().await.unwrap_err();
let msg = err.to_string();
assert!(msg.contains("Authentication error:"));
assert!(msg.contains("Hint:"));
}
#[tokio::test]
async fn api_returns_500_server_error() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/proxy/network/api/s/default/stat/health"))
.respond_with(ResponseTemplate::new(500).set_body_string("Internal Server Error"))
.mount(&server)
.await;
let client = mock_client(&server).await;
let err = client.get_health().await.unwrap_err();
assert!(err.to_string().contains("API error (500)"));
}
#[tokio::test]
async fn legacy_api_returns_error_rc() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/proxy/network/api/s/default/stat/health"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"meta": {"rc": "error", "msg": "api.err.LoginRequired"},
"data": []
})))
.mount(&server)
.await;
let client = mock_client(&server).await;
let err = client.get_health().await.unwrap_err();
assert!(err.to_string().contains("api.err.LoginRequired"));
}
#[tokio::test]
async fn legacy_api_error_without_message() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/proxy/network/api/s/default/stat/health"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"meta": {"rc": "error"},
"data": []
})))
.mount(&server)
.await;
let client = mock_client(&server).await;
let err = client.get_health().await.unwrap_err();
assert!(err.to_string().contains("unknown error"));
}
#[tokio::test]
async fn no_sites_found() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/proxy/network/integration/v1/sites"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"offset": 0, "limit": 25, "count": 0, "totalCount": 0,
"data": []
})))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path_regex(
r"/proxy/network/integration/v1/sites/.*/clients",
))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"offset": 0, "limit": 200, "count": 0, "totalCount": 0, "data": []
})))
.mount(&server)
.await;
let mut client = mock_client(&server).await;
let err = client.list_clients().await.unwrap_err();
let msg = err.to_string();
assert!(msg.contains("No sites found") && msg.contains("API key"));
}
#[tokio::test]
async fn post_command_returns_error() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/proxy/network/api/s/default/cmd/stamgr"))
.respond_with(ResponseTemplate::new(403).set_body_string("Forbidden"))
.mount(&server)
.await;
let client = mock_client(&server).await;
let err = client.block_client("aa:bb:cc:dd:ee:ff").await.unwrap_err();
assert!(err.to_string().contains("Authentication error:"));
}
#[tokio::test]
async fn list_events_returns_events() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/proxy/network/api/s/default/stat/event"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"meta": {"rc": "ok"},
"data": [
{"key": "EVT_WU_Connected", "msg": "User connected", "subsystem": "wlan", "time": 1700000000, "datetime": "2024-01-01T00:00:00Z"},
{"key": "EVT_LU_Disconnected", "msg": "User disconnected", "subsystem": "lan", "time": 1700000001}
]
})))
.mount(&server)
.await;
let client = mock_client(&server).await;
let events = client.list_events(10).await.unwrap();
assert_eq!(events.len(), 2);
assert_eq!(events[0].key.as_deref(), Some("EVT_WU_Connected"));
assert_eq!(events[1].subsystem.as_deref(), Some("lan"));
}
#[tokio::test]
async fn list_clients_legacy_returns_clients() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/proxy/network/api/s/default/stat/sta"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"meta": {"rc": "ok"},
"data": [
{"_id": "c1", "mac": "aa:bb:cc:dd:ee:ff", "ip": "10.0.0.1", "name": "Desktop", "is_wired": true, "tx_bytes": 1000000, "rx_bytes": 2000000},
{"_id": "c2", "mac": "11:22:33:44:55:66", "ip": "10.0.0.2", "hostname": "phone", "is_wired": false, "tx_bytes": 500, "rx_bytes": 300}
]
})))
.mount(&server)
.await;
let client = mock_client(&server).await;
let clients = client.list_clients_legacy().await.unwrap();
assert_eq!(clients.len(), 2);
assert_eq!(clients[0].tx_bytes, Some(1000000));
assert_eq!(clients[1].display_name(), "phone");
}
#[tokio::test]
async fn get_device_ports_finds_by_mac() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/proxy/network/api/s/default/stat/device"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"meta": {"rc": "ok"},
"data": [{
"mac": "9c:05:d6:bc:06:43", "name": "USW-24-PoE",
"model": "USW-24-PoE",
"port_table": [
{"port_idx": 1, "name": "Port 1", "media": "GE", "up": true, "speed": 1000, "full_duplex": true, "poe_enable": true, "poe_power": 5.2, "port_poe": true, "tx_bytes": 123456, "rx_bytes": 654321},
{"port_idx": 2, "name": "Port 2", "media": "GE", "up": false, "speed": 0, "full_duplex": false, "poe_enable": false, "port_poe": true, "tx_bytes": 0, "rx_bytes": 0}
]
}]
})))
.mount(&server)
.await;
let client = mock_client(&server).await;
let device = client.get_device_ports("9c:05:d6:bc:06:43").await.unwrap();
assert_eq!(device.port_table.len(), 2);
assert!(device.port_table[0].up);
assert!(!device.port_table[1].up);
assert_eq!(device.port_table[0].poe_power, Some(5.2));
}
#[tokio::test]
async fn get_device_ports_not_found() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/proxy/network/api/s/default/stat/device"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"meta": {"rc": "ok"},
"data": []
})))
.mount(&server)
.await;
let client = mock_client(&server).await;
let err = client
.get_device_ports("00:00:00:00:00:00")
.await
.unwrap_err();
assert!(err.to_string().contains("Not found"));
}
#[tokio::test]
async fn list_clients_legacy_sorted_by_bandwidth_descending() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/proxy/network/api/s/default/stat/sta"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"meta": {"rc": "ok"},
"data": [
{"_id": "c1", "mac": "aa:bb:cc:dd:ee:01", "name": "Light", "is_wired": true, "tx_bytes": 100, "rx_bytes": 200},
{"_id": "c2", "mac": "aa:bb:cc:dd:ee:02", "name": "Heavy", "is_wired": true, "tx_bytes": 5000000, "rx_bytes": 10000000},
{"_id": "c3", "mac": "aa:bb:cc:dd:ee:03", "name": "Medium", "is_wired": false, "tx_bytes": 50000, "rx_bytes": 60000}
]
})))
.mount(&server)
.await;
let client = mock_client(&server).await;
let mut clients = client.list_clients_legacy().await.unwrap();
clients
.sort_by_key(|c| std::cmp::Reverse(c.tx_bytes.unwrap_or(0) + c.rx_bytes.unwrap_or(0)));
assert_eq!(clients[0].display_name(), "Heavy");
assert_eq!(clients[1].display_name(), "Medium");
assert_eq!(clients[2].display_name(), "Light");
}
#[tokio::test]
async fn get_device_ports_field_values() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/proxy/network/api/s/default/stat/device"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"meta": {"rc": "ok"},
"data": [{
"mac": "aa:bb:cc:dd:ee:ff", "name": "TestSwitch",
"port_table": [
{"port_idx": 1, "name": "Uplink", "media": "GE", "up": true, "speed": 1000, "full_duplex": true, "poe_enable": true, "poe_power": 12.5, "port_poe": true, "tx_bytes": 999999, "rx_bytes": 888888},
{"port_idx": 2, "up": false, "port_poe": false},
{"port_idx": 3, "up": true, "speed": 100, "full_duplex": false, "poe_enable": false, "port_poe": true}
]
}]
})))
.mount(&server)
.await;
let client = mock_client(&server).await;
let device = client.get_device_ports("aa:bb:cc:dd:ee:ff").await.unwrap();
assert_eq!(device.name.as_deref(), Some("TestSwitch"));
let p1 = &device.port_table[0];
assert_eq!(p1.port_idx, Some(1));
assert_eq!(p1.name.as_deref(), Some("Uplink"));
assert!(p1.up);
assert_eq!(p1.speed, Some(1000));
assert!(p1.full_duplex);
assert!(p1.poe_enable);
assert_eq!(p1.poe_power, Some(12.5));
assert_eq!(p1.tx_bytes, Some(999999));
assert_eq!(p1.rx_bytes, Some(888888));
let p2 = &device.port_table[1];
assert!(!p2.up);
assert!(p2.name.is_none());
assert!(!p2.port_poe);
let p3 = &device.port_table[2];
assert!(p3.up);
assert_eq!(p3.speed, Some(100));
assert!(!p3.full_duplex);
assert!(!p3.poe_enable);
assert!(p3.port_poe);
}
}
mod command_output {
use super::*;
use unifi_cli::output::OutputConfig;
fn out_table() -> OutputConfig {
OutputConfig::new(unifi_cli::output::OutputFormat::Text, false)
}
fn out_json() -> OutputConfig {
OutputConfig::new(unifi_cli::output::OutputFormat::Json, false)
}
fn default_pagination() -> unifi_cli::commands::clients::Pagination {
unifi_cli::commands::clients::Pagination {
limit: 100,
offset: 0,
fields: None,
}
}
fn default_devices_pagination() -> unifi_cli::commands::devices::Pagination {
unifi_cli::commands::devices::Pagination {
limit: 100,
offset: 0,
fields: None,
}
}
fn default_events_pagination(limit: usize) -> unifi_cli::commands::events::Pagination {
unifi_cli::commands::events::Pagination {
limit,
offset: 0,
fields: None,
}
}
async fn mount_clients_list(server: &MockServer) {
mount_site_discovery(server).await;
Mock::given(method("GET"))
.and(path_regex(r"/proxy/network/integration/v1/sites/.*/clients"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"offset": 0, "limit": 200, "count": 2, "totalCount": 2,
"data": [
{"macAddress": "aa:bb:cc:dd:ee:ff", "ipAddress": "10.0.0.1", "name": "Device1", "type": "WIRED"},
{"macAddress": "11:22:33:44:55:66", "ipAddress": "10.0.0.2", "hostname": "host2", "type": "WIRELESS"}
]
})))
.mount(server)
.await;
Mock::given(method("GET"))
.and(path("/proxy/network/api/s/default/stat/sta"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"meta": {"rc": "ok"},
"data": [
{"_id": "1", "mac": "aa:bb:cc:dd:ee:ff", "ip": "10.0.0.99",
"is_wired": true, "network": "Default", "vlan": 1},
{"_id": "2", "mac": "11:22:33:44:55:66", "essid": "Notwork",
"signal": -55, "uptime": 100, "network": "IoT", "vlan": 20}
]
})))
.mount(server)
.await;
}
async fn mount_empty_legacy_clients(server: &MockServer) {
Mock::given(method("GET"))
.and(path("/proxy/network/api/s/default/stat/sta"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"meta": {"rc": "ok"}, "data": []
})))
.mount(server)
.await;
}
fn no_filter() -> unifi_cli::commands::clients::ListFilter {
unifi_cli::commands::clients::ListFilter {
wired: false,
wireless: false,
name: None,
}
}
#[tokio::test]
async fn clients_list_table() {
let server = MockServer::start().await;
mount_clients_list(&server).await;
let mut client = mock_client(&server).await;
unifi_cli::commands::clients::list(
&mut client,
out_table(),
no_filter(),
None,
default_pagination(),
)
.await
.unwrap();
}
#[tokio::test]
async fn clients_list_json() {
let server = MockServer::start().await;
mount_clients_list(&server).await;
let mut client = mock_client(&server).await;
unifi_cli::commands::clients::list(
&mut client,
out_json(),
no_filter(),
None,
default_pagination(),
)
.await
.unwrap();
}
#[tokio::test]
async fn clients_show_wired_table() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/proxy/network/api/s/default/stat/sta"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"meta": {"rc": "ok"},
"data": [{
"_id": "abc", "mac": "aa:bb:cc:dd:ee:ff", "ip": "10.0.0.1",
"name": "WiredDevice", "is_wired": true, "uptime": 86400,
"tx_bytes": 1048576, "rx_bytes": 2097152
}]
})))
.mount(&server)
.await;
let client = mock_client(&server).await;
unifi_cli::commands::clients::show(&client, "aa:bb:cc:dd:ee:ff", out_table())
.await
.unwrap();
}
#[tokio::test]
async fn clients_show_wireless_table() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/proxy/network/api/s/default/stat/sta"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"meta": {"rc": "ok"},
"data": [{
"_id": "def", "mac": "11:22:33:44:55:66", "ip": "10.0.0.2",
"name": "WirelessDevice", "is_wired": false, "uptime": 3600,
"tx_bytes": 512000, "rx_bytes": 1024000,
"signal": -55, "essid": "Notwork", "ap_mac": "60:22:32:58:b8:00"
}]
})))
.mount(&server)
.await;
let client = mock_client(&server).await;
unifi_cli::commands::clients::show(&client, "11:22:33:44:55:66", out_table())
.await
.unwrap();
}
#[tokio::test]
async fn clients_show_json() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/proxy/network/api/s/default/stat/sta"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"meta": {"rc": "ok"},
"data": [{
"_id": "abc", "mac": "aa:bb:cc:dd:ee:ff", "ip": "10.0.0.1",
"name": "Device", "is_wired": true
}]
})))
.mount(&server)
.await;
let client = mock_client(&server).await;
unifi_cli::commands::clients::show(&client, "aa:bb:cc:dd:ee:ff", out_json())
.await
.unwrap();
}
#[tokio::test]
async fn clients_set_fixed_ip_output() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/proxy/network/api/s/default/stat/sta"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"meta": {"rc": "ok"},
"data": [{"_id": "c1", "mac": "aa:bb:cc:dd:ee:ff"}]
})))
.mount(&server)
.await;
Mock::given(method("PUT"))
.and(path("/proxy/network/api/s/default/rest/user/c1"))
.respond_with(
ResponseTemplate::new(200)
.set_body_json(serde_json::json!({"meta": {"rc": "ok"}, "data": [{}]})),
)
.mount(&server)
.await;
let client = mock_client(&server).await;
unifi_cli::commands::clients::set_fixed_ip(
&client,
"aa:bb:cc:dd:ee:ff",
"10.0.0.50",
None,
out_table(),
)
.await
.unwrap();
}
#[tokio::test]
async fn clients_set_fixed_ip_with_name_output() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/proxy/network/api/s/default/stat/sta"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"meta": {"rc": "ok"},
"data": [{"_id": "c1", "mac": "aa:bb:cc:dd:ee:ff"}]
})))
.mount(&server)
.await;
Mock::given(method("PUT"))
.and(path("/proxy/network/api/s/default/rest/user/c1"))
.respond_with(
ResponseTemplate::new(200)
.set_body_json(serde_json::json!({"meta": {"rc": "ok"}, "data": [{}]})),
)
.mount(&server)
.await;
let client = mock_client(&server).await;
unifi_cli::commands::clients::set_fixed_ip(
&client,
"aa:bb:cc:dd:ee:ff",
"10.0.0.50",
Some("MyDevice"),
out_table(),
)
.await
.unwrap();
}
#[tokio::test]
async fn clients_block_output() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/proxy/network/api/s/default/cmd/stamgr"))
.respond_with(
ResponseTemplate::new(200)
.set_body_json(serde_json::json!({"meta": {"rc": "ok"}, "data": []})),
)
.mount(&server)
.await;
let client = mock_client(&server).await;
unifi_cli::commands::clients::block(&client, "aa:bb:cc:dd:ee:ff", out_table())
.await
.unwrap();
}
#[tokio::test]
async fn clients_unblock_output() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/proxy/network/api/s/default/cmd/stamgr"))
.respond_with(
ResponseTemplate::new(200)
.set_body_json(serde_json::json!({"meta": {"rc": "ok"}, "data": []})),
)
.mount(&server)
.await;
let client = mock_client(&server).await;
unifi_cli::commands::clients::unblock(&client, "aa:bb:cc:dd:ee:ff", out_table())
.await
.unwrap();
}
#[tokio::test]
async fn clients_kick_output() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/proxy/network/api/s/default/cmd/stamgr"))
.respond_with(
ResponseTemplate::new(200)
.set_body_json(serde_json::json!({"meta": {"rc": "ok"}, "data": []})),
)
.mount(&server)
.await;
let client = mock_client(&server).await;
unifi_cli::commands::clients::kick(&client, "aa:bb:cc:dd:ee:ff", out_table())
.await
.unwrap();
}
#[tokio::test]
async fn devices_list_table() {
let server = MockServer::start().await;
mount_site_discovery(&server).await;
Mock::given(method("GET"))
.and(path_regex(r"/proxy/network/integration/v1/sites/.*/devices"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"offset": 0, "limit": 200, "count": 1, "totalCount": 1,
"data": [{"macAddress": "9c:05:d6:bc:06:43", "ipAddress": "192.168.1.1", "name": "UCG Ultra", "model": "UCG Ultra", "state": "ONLINE", "firmwareVersion": "5.0.12"}]
})))
.mount(&server)
.await;
let mut client = mock_client(&server).await;
unifi_cli::commands::devices::list(
&mut client,
out_table(),
None,
default_devices_pagination(),
)
.await
.unwrap();
}
#[tokio::test]
async fn devices_list_json() {
let server = MockServer::start().await;
mount_site_discovery(&server).await;
Mock::given(method("GET"))
.and(path_regex(r"/proxy/network/integration/v1/sites/.*/devices"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"offset": 0, "limit": 200, "count": 1, "totalCount": 1,
"data": [{"macAddress": "9c:05:d6:bc:06:43", "name": "UCG Ultra", "model": "UCG Ultra", "state": "ONLINE", "firmwareVersion": "5.0.12"}]
})))
.mount(&server)
.await;
let mut client = mock_client(&server).await;
unifi_cli::commands::devices::list(
&mut client,
out_json(),
None,
default_devices_pagination(),
)
.await
.unwrap();
}
#[tokio::test]
async fn devices_restart_output() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/proxy/network/api/s/default/cmd/devmgr"))
.respond_with(
ResponseTemplate::new(200)
.set_body_json(serde_json::json!({"meta": {"rc": "ok"}, "data": []})),
)
.mount(&server)
.await;
let client = mock_client(&server).await;
unifi_cli::commands::devices::restart(&client, "aa:bb:cc:dd:ee:ff", out_table())
.await
.unwrap();
}
#[tokio::test]
async fn devices_locate_on_output() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/proxy/network/api/s/default/cmd/devmgr"))
.respond_with(
ResponseTemplate::new(200)
.set_body_json(serde_json::json!({"meta": {"rc": "ok"}, "data": []})),
)
.mount(&server)
.await;
let client = mock_client(&server).await;
unifi_cli::commands::devices::locate(&client, "aa:bb:cc:dd:ee:ff", false, out_table())
.await
.unwrap();
}
#[tokio::test]
async fn devices_locate_off_output() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/proxy/network/api/s/default/cmd/devmgr"))
.respond_with(
ResponseTemplate::new(200)
.set_body_json(serde_json::json!({"meta": {"rc": "ok"}, "data": []})),
)
.mount(&server)
.await;
let client = mock_client(&server).await;
unifi_cli::commands::devices::locate(&client, "aa:bb:cc:dd:ee:ff", true, out_table())
.await
.unwrap();
}
#[tokio::test]
async fn networks_list_table() {
let server = MockServer::start().await;
mount_site_discovery(&server).await;
Mock::given(method("GET"))
.and(path_regex(
r"/proxy/network/integration/v1/sites/.*/networks",
))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"offset": 0, "limit": 200, "count": 2, "totalCount": 2,
"data": [
{"name": "Default", "enabled": true, "vlanId": 1, "default": true},
{"name": "IoT", "enabled": true, "vlanId": 20, "default": false}
]
})))
.mount(&server)
.await;
let mut client = mock_client(&server).await;
unifi_cli::commands::networks::list(&mut client, out_table())
.await
.unwrap();
}
#[tokio::test]
async fn networks_list_json() {
let server = MockServer::start().await;
mount_site_discovery(&server).await;
Mock::given(method("GET"))
.and(path_regex(
r"/proxy/network/integration/v1/sites/.*/networks",
))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"offset": 0, "limit": 200, "count": 2, "totalCount": 2,
"data": [
{"name": "Default", "enabled": true, "vlanId": 1, "default": true},
{"name": "IoT", "enabled": true, "vlanId": 20, "default": false}
]
})))
.mount(&server)
.await;
let mut client = mock_client(&server).await;
unifi_cli::commands::networks::list(&mut client, out_json())
.await
.unwrap();
}
#[tokio::test]
async fn system_health_table() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/proxy/network/api/s/default/stat/health"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"meta": {"rc": "ok"},
"data": [
{"subsystem": "wan", "status": "ok", "wan_ip": "1.2.3.4", "isp_name": "ISP"},
{"subsystem": "wlan", "status": "ok", "num_ap": 2, "num_sta": 10},
{"subsystem": "lan", "status": "ok", "num_sw": 3, "num_sta": 5},
{"subsystem": "vpn", "status": "unknown"}
]
})))
.mount(&server)
.await;
let client = mock_client(&server).await;
unifi_cli::commands::system::health(&client, out_table())
.await
.unwrap();
}
#[tokio::test]
async fn system_health_json() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/proxy/network/api/s/default/stat/health"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"meta": {"rc": "ok"},
"data": [
{"subsystem": "wan", "status": "ok", "wan_ip": "1.2.3.4", "isp_name": "ISP"}
]
})))
.mount(&server)
.await;
let client = mock_client(&server).await;
unifi_cli::commands::system::health(&client, out_json())
.await
.unwrap();
}
#[tokio::test]
async fn system_info_table() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/proxy/network/api/s/default/stat/sysinfo"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"meta": {"rc": "ok"},
"data": [{"hostname": "UCG-Ultra", "version": "10.1.85", "timezone": "Europe/Amsterdam", "uptime": 1737960}]
})))
.mount(&server)
.await;
let client = mock_client(&server).await;
unifi_cli::commands::system::info(&client, out_table())
.await
.unwrap();
}
#[tokio::test]
async fn system_info_json() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/proxy/network/api/s/default/stat/sysinfo"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"meta": {"rc": "ok"},
"data": [{"hostname": "UCG-Ultra", "version": "10.1.85", "timezone": "Europe/Amsterdam", "uptime": 1737960}]
})))
.mount(&server)
.await;
let client = mock_client(&server).await;
unifi_cli::commands::system::info(&client, out_json())
.await
.unwrap();
}
#[tokio::test]
async fn system_info_partial_fields() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/proxy/network/api/s/default/stat/sysinfo"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"meta": {"rc": "ok"},
"data": [{"hostname": "UCG-Ultra"}]
})))
.mount(&server)
.await;
let client = mock_client(&server).await;
unifi_cli::commands::system::info(&client, out_table())
.await
.unwrap();
}
#[tokio::test]
async fn devices_show_table() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/proxy/network/api/s/default/stat/device"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"meta": {"rc": "ok"},
"data": [{
"mac": "9c:05:d6:bc:06:43", "ip": "192.168.1.1",
"name": "UCG Ultra", "model": "UCG Ultra",
"state": 1, "version": "5.0.12", "uptime": 86400, "num_sta": 42
}]
})))
.mount(&server)
.await;
let client = mock_client(&server).await;
unifi_cli::commands::devices::show(&client, "9c:05:d6:bc:06:43", out_table())
.await
.unwrap();
}
#[tokio::test]
async fn devices_show_json() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/proxy/network/api/s/default/stat/device"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"meta": {"rc": "ok"},
"data": [{
"mac": "9c:05:d6:bc:06:43", "ip": "192.168.1.1",
"name": "UCG Ultra", "model": "UCG Ultra",
"state": 1, "version": "5.0.12"
}]
})))
.mount(&server)
.await;
let client = mock_client(&server).await;
unifi_cli::commands::devices::show(&client, "9c:05:d6:bc:06:43", out_json())
.await
.unwrap();
}
#[tokio::test]
async fn devices_show_not_found() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/proxy/network/api/s/default/stat/device"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"meta": {"rc": "ok"},
"data": []
})))
.mount(&server)
.await;
let client = mock_client(&server).await;
let err = unifi_cli::commands::devices::show(&client, "00:00:00:00:00:00", out_table())
.await
.unwrap_err();
assert!(err.to_string().contains("Not found"));
}
#[tokio::test]
async fn clients_list_wired_filter() {
let server = MockServer::start().await;
mount_site_discovery(&server).await;
mount_empty_legacy_clients(&server).await;
Mock::given(method("GET"))
.and(path_regex(
r"/proxy/network/integration/v1/sites/.*/clients",
))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"offset": 0, "limit": 200, "count": 3, "totalCount": 3,
"data": [
{"macAddress": "aa:bb:cc:dd:ee:01", "name": "WiredDevice", "type": "WIRED"},
{"macAddress": "aa:bb:cc:dd:ee:02", "name": "WirelessDevice", "type": "WIRELESS"},
{"macAddress": "aa:bb:cc:dd:ee:03", "name": "AnotherWired", "type": "WIRED"}
]
})))
.mount(&server)
.await;
let mut client = mock_client(&server).await;
let filter = unifi_cli::commands::clients::ListFilter {
wired: true,
wireless: false,
name: None,
};
unifi_cli::commands::clients::list(
&mut client,
out_json(),
filter,
None,
default_pagination(),
)
.await
.unwrap();
}
#[tokio::test]
async fn clients_list_wireless_filter() {
let server = MockServer::start().await;
mount_site_discovery(&server).await;
mount_empty_legacy_clients(&server).await;
Mock::given(method("GET"))
.and(path_regex(
r"/proxy/network/integration/v1/sites/.*/clients",
))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"offset": 0, "limit": 200, "count": 2, "totalCount": 2,
"data": [
{"macAddress": "aa:bb:cc:dd:ee:01", "name": "WiredDevice", "type": "WIRED"},
{"macAddress": "aa:bb:cc:dd:ee:02", "name": "WirelessDevice", "type": "WIRELESS"}
]
})))
.mount(&server)
.await;
let mut client = mock_client(&server).await;
let filter = unifi_cli::commands::clients::ListFilter {
wired: false,
wireless: true,
name: None,
};
unifi_cli::commands::clients::list(
&mut client,
out_json(),
filter,
None,
default_pagination(),
)
.await
.unwrap();
}
#[tokio::test]
async fn clients_list_name_filter() {
let server = MockServer::start().await;
mount_site_discovery(&server).await;
mount_empty_legacy_clients(&server).await;
Mock::given(method("GET"))
.and(path_regex(
r"/proxy/network/integration/v1/sites/.*/clients",
))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"offset": 0, "limit": 200, "count": 3, "totalCount": 3,
"data": [
{"macAddress": "aa:bb:cc:dd:ee:01", "name": "iPhone", "type": "WIRELESS"},
{"macAddress": "aa:bb:cc:dd:ee:02", "name": "Desktop", "type": "WIRED"},
{"macAddress": "aa:bb:cc:dd:ee:03", "name": "iPad", "type": "WIRELESS"}
]
})))
.mount(&server)
.await;
let mut client = mock_client(&server).await;
let filter = unifi_cli::commands::clients::ListFilter {
wired: false,
wireless: false,
name: Some("phone".into()),
};
unifi_cli::commands::clients::list(
&mut client,
out_json(),
filter,
None,
default_pagination(),
)
.await
.unwrap();
}
#[tokio::test]
async fn events_list_table() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/proxy/network/api/s/default/stat/event"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"meta": {"rc": "ok"},
"data": [
{"key": "EVT_WU_Connected", "msg": "User[aa:bb:cc:dd:ee:ff] has connected", "subsystem": "wlan", "datetime": "2024-01-15T10:30:00Z"},
{"key": "EVT_SW_PoeOverload", "msg": "PoE overload on port 5", "subsystem": "lan", "datetime": "2024-01-15T10:29:00Z"}
]
})))
.mount(&server)
.await;
let client = mock_client(&server).await;
unifi_cli::commands::events::list(&client, out_table(), default_events_pagination(10))
.await
.unwrap();
}
#[tokio::test]
async fn events_list_json() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/proxy/network/api/s/default/stat/event"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"meta": {"rc": "ok"},
"data": [
{"key": "EVT_WU_Connected", "msg": "User connected", "subsystem": "wlan", "time": 1700000000}
]
})))
.mount(&server)
.await;
let client = mock_client(&server).await;
unifi_cli::commands::events::list(&client, out_json(), default_events_pagination(5))
.await
.unwrap();
}
#[tokio::test]
async fn clients_top_table() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/proxy/network/api/s/default/stat/sta"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"meta": {"rc": "ok"},
"data": [
{"_id": "c1", "mac": "aa:bb:cc:dd:ee:01", "ip": "10.0.0.1", "name": "Heavy User", "is_wired": true, "tx_bytes": 5000000000_u64, "rx_bytes": 10000000000_u64},
{"_id": "c2", "mac": "aa:bb:cc:dd:ee:02", "ip": "10.0.0.2", "name": "Light User", "is_wired": false, "tx_bytes": 1000, "rx_bytes": 2000},
{"_id": "c3", "mac": "aa:bb:cc:dd:ee:03", "ip": "10.0.0.3", "hostname": "medium-host", "is_wired": true, "tx_bytes": 500000, "rx_bytes": 600000}
]
})))
.mount(&server)
.await;
let client = mock_client(&server).await;
unifi_cli::commands::clients::top(&client, out_table(), 2)
.await
.unwrap();
}
#[tokio::test]
async fn clients_top_json() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/proxy/network/api/s/default/stat/sta"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"meta": {"rc": "ok"},
"data": [
{"_id": "c1", "mac": "aa:bb:cc:dd:ee:01", "ip": "10.0.0.1", "name": "User1", "is_wired": true, "tx_bytes": 100, "rx_bytes": 200}
]
})))
.mount(&server)
.await;
let client = mock_client(&server).await;
unifi_cli::commands::clients::top(&client, out_json(), 10)
.await
.unwrap();
}
#[tokio::test]
async fn devices_ports_table() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/proxy/network/api/s/default/stat/device"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"meta": {"rc": "ok"},
"data": [{
"mac": "9c:05:d6:bc:06:43", "name": "USW-24-PoE", "model": "USW-24-PoE",
"port_table": [
{"port_idx": 1, "name": "Port 1", "media": "GE", "up": true, "speed": 1000, "full_duplex": true, "poe_enable": true, "poe_power": 5.2, "port_poe": true, "tx_bytes": 123456789, "rx_bytes": 987654321},
{"port_idx": 2, "name": "Port 2", "media": "GE", "up": true, "speed": 100, "full_duplex": false, "poe_enable": false, "port_poe": true, "tx_bytes": 1000, "rx_bytes": 2000},
{"port_idx": 3, "name": "Port 3", "media": "GE", "up": false, "poe_enable": false, "port_poe": false}
]
}]
})))
.mount(&server)
.await;
let client = mock_client(&server).await;
unifi_cli::commands::devices::ports(&client, "9c:05:d6:bc:06:43", out_table())
.await
.unwrap();
}
#[tokio::test]
async fn devices_ports_json() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/proxy/network/api/s/default/stat/device"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"meta": {"rc": "ok"},
"data": [{
"mac": "9c:05:d6:bc:06:43", "name": "USW-Lite-8",
"port_table": [
{"port_idx": 1, "name": "Port 1", "media": "GE", "up": true, "speed": 1000, "full_duplex": true, "poe_enable": true, "poe_power": 3.8, "port_poe": true, "tx_bytes": 100, "rx_bytes": 200}
]
}]
})))
.mount(&server)
.await;
let client = mock_client(&server).await;
unifi_cli::commands::devices::ports(&client, "9c:05:d6:bc:06:43", out_json())
.await
.unwrap();
}
#[tokio::test]
async fn devices_ports_empty_port_table() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/proxy/network/api/s/default/stat/device"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"meta": {"rc": "ok"},
"data": [{
"mac": "aa:bb:cc:dd:ee:ff", "name": "UAP-AC-Pro",
"port_table": []
}]
})))
.mount(&server)
.await;
let client = mock_client(&server).await;
unifi_cli::commands::devices::ports(&client, "aa:bb:cc:dd:ee:ff", out_table())
.await
.unwrap();
}
#[tokio::test]
async fn devices_upgrade_output() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/proxy/network/api/s/default/cmd/devmgr"))
.respond_with(
ResponseTemplate::new(200)
.set_body_json(serde_json::json!({"meta": {"rc": "ok"}, "data": []})),
)
.mount(&server)
.await;
let client = mock_client(&server).await;
unifi_cli::commands::devices::upgrade(&client, "aa:bb:cc:dd:ee:ff", out_table())
.await
.unwrap();
}
#[tokio::test]
async fn devices_upgrade_json() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/proxy/network/api/s/default/cmd/devmgr"))
.respond_with(
ResponseTemplate::new(200)
.set_body_json(serde_json::json!({"meta": {"rc": "ok"}, "data": []})),
)
.mount(&server)
.await;
let client = mock_client(&server).await;
unifi_cli::commands::devices::upgrade(&client, "aa:bb:cc:dd:ee:ff", out_json())
.await
.unwrap();
}
#[tokio::test]
async fn devices_ports_not_found() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/proxy/network/api/s/default/stat/device"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"meta": {"rc": "ok"},
"data": []
})))
.mount(&server)
.await;
let client = mock_client(&server).await;
let err = unifi_cli::commands::devices::ports(&client, "00:00:00:00:00:00", out_table())
.await
.unwrap_err();
assert!(err.to_string().contains("Not found"));
}
#[tokio::test]
async fn devices_ports_falls_back_to_device_label_when_name_and_model_absent() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/proxy/network/api/s/default/stat/device"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"meta": {"rc": "ok"},
"data": [{
"mac": "aa:bb:cc:dd:ee:ff",
"port_table": [{"port_idx": 1}]
}]
})))
.mount(&server)
.await;
let output = std::process::Command::new(env!("CARGO_BIN_EXE_unifi"))
.args([
"--host",
&server.uri(),
"--api-key",
"test-key",
"devices",
"ports",
"aa:bb:cc:dd:ee:ff",
"-o",
"json",
])
.output()
.expect("failed to run the unifi binary");
assert!(
output.status.success(),
"devices ports failed: {}",
String::from_utf8_lossy(&output.stderr)
);
let body: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
let items = body
.as_array()
.expect("devices ports must emit a bare JSON array");
assert_eq!(
items[0]["device_name"], "Device",
"devices ports must keep its historical \"Device\" fallback, not \"-\": {items:?}"
);
}
#[tokio::test]
async fn ports_show_table() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/proxy/network/api/s/default/stat/device"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"meta": {"rc": "ok"},
"data": [{
"mac": "9c:05:d6:bc:06:43", "name": "USW-24-PoE",
"port_table": [
{"port_idx": 1, "name": "Port 1", "media": "GE", "up": true},
{
"port_idx": 5, "name": "Port 5", "media": "GE", "up": true,
"speed": 1000, "full_duplex": true, "autoneg": true, "enable": true,
"is_uplink": false, "stp_state": "forwarding",
"port_poe": true, "poe_enable": true, "poe_mode": "auto",
"poe_class": "4", "poe_power": 5.2, "poe_voltage": 53.5,
"poe_current": 120.3, "poe_good": true,
"last_connection": {"mac": "aabbccddeeff", "connected": true},
"tx_bytes": 100, "rx_bytes": 200, "tx_errors": 0, "rx_errors": 2
}
]
}]
})))
.mount(&server)
.await;
let client = mock_client(&server).await;
unifi_cli::commands::ports::show(&client, "9c:05:d6:bc:06:43", 5, out_table())
.await
.unwrap();
}
#[tokio::test]
async fn ports_show_text_output_renders_expected_fields() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/proxy/network/api/s/default/stat/device"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"meta": {"rc": "ok"},
"data": [{
"mac": "9c:05:d6:bc:06:43", "name": "USW-24-PoE",
"port_table": [{
"port_idx": 5, "name": "Port 5", "media": "GE", "up": true,
"speed": 1000, "full_duplex": true,
"port_poe": true, "poe_enable": true, "poe_mode": "auto",
"poe_class": "4", "poe_power": 5.2, "poe_voltage": 53.5,
"poe_current": 120.3,
"last_connection": {"mac": "aabbccddeeff", "connected": true}
}]
}]
})))
.mount(&server)
.await;
let output = std::process::Command::new(env!("CARGO_BIN_EXE_unifi"))
.args([
"--host",
&server.uri(),
"--api-key",
"test-key",
"ports",
"show",
"9c:05:d6:bc:06:43",
"5",
"--output",
"text",
])
.output()
.expect("failed to run the unifi binary");
assert!(
output.status.success(),
"ports show failed: {}",
String::from_utf8_lossy(&output.stderr)
);
let text = String::from_utf8_lossy(&output.stdout);
assert!(
text.contains("Port 5 on USW-24-PoE (9c:05:d6:bc:06:43)"),
"title line: {text}"
);
assert!(text.contains("Port 5"), "port name: {text}");
assert!(text.contains("1000FD"), "speed+duplex formatting: {text}");
assert!(text.contains("GE"), "media: {text}");
assert!(text.contains("5.2W"), "PoE wattage: {text}");
assert!(text.contains("auto"), "PoE mode: {text}");
assert!(text.contains("53.50 V"), "PoE voltage: {text}");
assert!(text.contains("120.30 mA"), "PoE current: {text}");
assert!(text.contains("aa:bb:cc:dd:ee:ff"), "attached MAC: {text}");
}
#[tokio::test]
async fn ports_show_json_matches_schema_output_fields() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/proxy/network/api/s/default/stat/device"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"meta": {"rc": "ok"},
"data": [{
"mac": "9c:05:d6:bc:06:43", "name": "USW-24-PoE",
"port_table": [{
"port_idx": 5, "name": "Port 5", "media": "GE", "up": true,
"speed": 1000, "full_duplex": true, "autoneg": true, "enable": true,
"is_uplink": false, "stp_state": "forwarding",
"port_poe": true, "poe_enable": true, "poe_mode": "auto",
"poe_class": "4", "poe_power": 5.2, "poe_voltage": 53.5,
"poe_current": 120.3, "poe_good": true,
"last_connection": {"mac": "aabbccddeeff", "connected": true},
"tx_bytes": 100, "rx_bytes": 200, "tx_errors": 0, "rx_errors": 2
}]
}]
})))
.mount(&server)
.await;
let output = std::process::Command::new(env!("CARGO_BIN_EXE_unifi"))
.args([
"--host",
&server.uri(),
"--api-key",
"test-key",
"ports",
"show",
"9c:05:d6:bc:06:43",
"5",
"--output",
"json",
])
.output()
.expect("failed to run the unifi binary");
assert!(
output.status.success(),
"ports show failed: {}",
String::from_utf8_lossy(&output.stderr)
);
let body: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap_or_else(|e| {
panic!(
"stdout was not valid JSON ({e}): {}",
String::from_utf8_lossy(&output.stdout)
)
});
let obj = body
.as_object()
.expect("ports show must emit a JSON object");
assert_eq!(obj["device_mac"], "9c:05:d6:bc:06:43");
assert_eq!(obj["port_idx"], 5);
assert_eq!(obj["poe_mode"], "auto");
assert_eq!(obj["poe_class"], "4");
assert_eq!(obj["poe_voltage"], 53.5);
assert_eq!(obj["poe_current"], 120.3);
assert_eq!(obj["poe_good"], true);
assert_eq!(
obj["attached_mac"], "aa:bb:cc:dd:ee:ff",
"attached_mac must be read from last_connection.mac and formatted"
);
assert_eq!(obj["tx_errors"], 0);
assert_eq!(obj["rx_errors"], 2);
let schema_output = std::process::Command::new(env!("CARGO_BIN_EXE_unifi"))
.arg("schema")
.output()
.expect("failed to run unifi schema");
let schema: serde_json::Value = serde_json::from_slice(&schema_output.stdout)
.expect("unifi schema must print valid JSON");
let ports_show = schema["commands"]
.as_array()
.expect("schema must have a commands array")
.iter()
.find(|c| c["name"] == "ports show")
.expect("schema must publish a \"ports show\" command");
let mut declared: Vec<&str> = ports_show["output_fields"]
.as_array()
.expect("ports show must declare output_fields")
.iter()
.map(|f| f["name"].as_str().expect("output field must have a name"))
.collect();
declared.sort_unstable();
let mut emitted: Vec<&str> = obj.keys().map(String::as_str).collect();
emitted.sort_unstable();
assert_eq!(
emitted, declared,
"ports show output_fields in the schema must exactly match the JSON branch's keys"
);
}
#[tokio::test]
async fn ports_show_omitted_tri_state_fields_serialize_as_null() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/proxy/network/api/s/default/stat/device"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"meta": {"rc": "ok"},
"data": [{
"mac": "aa:bb:cc:dd:ee:ff", "name": "USW-Lite-8",
"port_table": [
{"port_idx": 3, "name": "Port 3", "media": "GE", "up": false}
]
}]
})))
.mount(&server)
.await;
let output = std::process::Command::new(env!("CARGO_BIN_EXE_unifi"))
.args([
"--host",
&server.uri(),
"--api-key",
"test-key",
"ports",
"show",
"aa:bb:cc:dd:ee:ff",
"3",
"--output",
"json",
])
.output()
.expect("failed to run the unifi binary");
assert!(output.status.success());
let body: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
for field in ["autoneg", "enable", "is_uplink", "poe_good", "attached_mac"] {
assert!(
body[field].is_null(),
"{field} must be null when firmware omits it, not false: {body}"
);
}
}
#[tokio::test]
async fn ports_show_reports_a_stale_last_connection_as_unattached() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/proxy/network/api/s/default/stat/device"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"meta": {"rc": "ok"},
"data": [{
"mac": "9c:05:d6:bc:06:43", "name": "USW-24-PoE",
"port_table": [{
"port_idx": 5, "name": "Port 5", "media": "GE", "up": false,
"port_poe": true, "poe_enable": true, "poe_mode": "auto",
"last_connection": {"mac": "aabbccddeeff", "connected": false}
}]
}]
})))
.mount(&server)
.await;
let run = |format: &str| {
std::process::Command::new(env!("CARGO_BIN_EXE_unifi"))
.args([
"--host",
&server.uri(),
"--api-key",
"test-key",
"ports",
"show",
"9c:05:d6:bc:06:43",
"5",
"--output",
format,
])
.output()
.expect("failed to run the unifi binary")
};
let json_out = run("json");
assert!(
json_out.status.success(),
"ports show failed: {}",
String::from_utf8_lossy(&json_out.stderr)
);
let body: serde_json::Value = serde_json::from_slice(&json_out.stdout).unwrap();
assert!(
body["attached_mac"].is_null(),
"a stale last_connection must not be published as attached: {body}"
);
assert_eq!(
body["attached_last_seen_mac"], "aa:bb:cc:dd:ee:ff",
"the stale MAC must stay available as history: {body}"
);
assert_eq!(
body["attached_connected"], false,
"the controller's own flag must be reported as it stands: {body}"
);
let text_out = run("text");
assert!(text_out.status.success());
let text = String::from_utf8_lossy(&text_out.stdout);
assert!(
text.contains("- (last seen aa:bb:cc:dd:ee:ff)"),
"the text branch must qualify a stale MAC rather than print it bare: {text}"
);
}
#[tokio::test]
async fn ports_show_distinguishes_an_unreported_connection_from_a_stale_one() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/proxy/network/api/s/default/stat/device"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"meta": {"rc": "ok"},
"data": [{
"mac": "9c:05:d6:bc:06:43", "name": "USW-24-PoE",
"port_table": [{
"port_idx": 5, "name": "Port 5", "media": "GE", "up": true,
"last_connection": {"mac": "aabbccddeeff"}
}]
}]
})))
.mount(&server)
.await;
let run = |format: &str| {
std::process::Command::new(env!("CARGO_BIN_EXE_unifi"))
.args([
"--host",
&server.uri(),
"--api-key",
"test-key",
"ports",
"show",
"9c:05:d6:bc:06:43",
"5",
"--output",
format,
])
.output()
.expect("failed to run the unifi binary")
};
let json_out = run("json");
assert!(json_out.status.success());
let body: serde_json::Value = serde_json::from_slice(&json_out.stdout).unwrap();
assert!(
body["attached_mac"].is_null(),
"an unreported connection must not be claimed as attached: {body}"
);
assert!(
body["attached_connected"].is_null(),
"a missing connected flag must stay null, not become false: {body}"
);
assert_eq!(body["attached_last_seen_mac"], "aa:bb:cc:dd:ee:ff");
let text = String::from_utf8_lossy(&run("text").stdout).to_string();
assert!(
text.contains("unknown (last seen aa:bb:cc:dd:ee:ff)"),
"the text branch must say the state is unknown, not that the device is gone: {text}"
);
}
#[tokio::test]
async fn ports_show_not_found() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/proxy/network/api/s/default/stat/device"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"meta": {"rc": "ok"},
"data": [{
"mac": "aa:bb:cc:dd:ee:ff", "name": "USW-Lite-8",
"port_table": [{"port_idx": 1}]
}]
})))
.mount(&server)
.await;
let output = std::process::Command::new(env!("CARGO_BIN_EXE_unifi"))
.args([
"--host",
&server.uri(),
"--api-key",
"test-key",
"ports",
"show",
"aa:bb:cc:dd:ee:ff",
"99",
])
.output()
.expect("failed to run the unifi binary");
assert_eq!(
output.status.code(),
Some(4),
"a nonexistent port must exit 4 (not found), got {:?}\nstderr: {}",
output.status.code(),
String::from_utf8_lossy(&output.stderr)
);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("Not found"),
"stderr must explain the port was not found: {stderr}"
);
}
#[tokio::test]
async fn ports_list_trailer_is_singular_for_exactly_one_row() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/proxy/network/api/s/default/stat/device"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"meta": {"rc": "ok"},
"data": [{"mac": "aa:bb:cc:dd:ee:01", "name": "SwitchA",
"port_table": [{"port_idx": 1}]}]
})))
.mount(&server)
.await;
let output = std::process::Command::new(env!("CARGO_BIN_EXE_unifi"))
.args([
"--host",
&server.uri(),
"--api-key",
"test-key",
"ports",
"list",
"--output",
"text",
])
.output()
.expect("failed to run the unifi binary");
assert!(
output.status.success(),
"ports list failed: {}",
String::from_utf8_lossy(&output.stderr)
);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.trim_end().ends_with("1 port"),
"a single row must be reported as \"1 port\", not \"1 ports\": {stderr:?}"
);
}
#[tokio::test]
async fn ports_list_trailer_is_plural_for_multiple_rows() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/proxy/network/api/s/default/stat/device"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"meta": {"rc": "ok"},
"data": [{"mac": "aa:bb:cc:dd:ee:01", "name": "SwitchA",
"port_table": [{"port_idx": 1}, {"port_idx": 2}]}]
})))
.mount(&server)
.await;
let output = std::process::Command::new(env!("CARGO_BIN_EXE_unifi"))
.args([
"--host",
&server.uri(),
"--api-key",
"test-key",
"ports",
"list",
"--output",
"text",
])
.output()
.expect("failed to run the unifi binary");
assert!(
output.status.success(),
"ports list failed: {}",
String::from_utf8_lossy(&output.stderr)
);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.trim_end().ends_with("2 ports"),
"two rows must be reported as \"2 ports\": {stderr:?}"
);
}
#[tokio::test]
async fn ports_list_pagination_reports_full_total_and_truncated_items() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/proxy/network/api/s/default/stat/device"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"meta": {"rc": "ok"},
"data": [
{"mac": "aa:bb:cc:dd:ee:01", "name": "SwitchA",
"port_table": [{"port_idx": 1}, {"port_idx": 2}]},
{"mac": "aa:bb:cc:dd:ee:02", "name": "SwitchB",
"port_table": [{"port_idx": 1}, {"port_idx": 2}, {"port_idx": 3}]}
]
})))
.mount(&server)
.await;
let output = std::process::Command::new(env!("CARGO_BIN_EXE_unifi"))
.args([
"--host",
&server.uri(),
"--api-key",
"test-key",
"ports",
"list",
"--output",
"json",
"--limit",
"3",
"--offset",
"1",
])
.output()
.expect("failed to run the unifi binary");
assert!(
output.status.success(),
"ports list failed: {}",
String::from_utf8_lossy(&output.stderr)
);
let body: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap_or_else(|e| {
panic!(
"stdout was not valid JSON ({e}): {}",
String::from_utf8_lossy(&output.stdout)
)
});
let items = body["items"]
.as_array()
.expect("envelope must have an items array");
assert_eq!(
items.len(),
3,
"the page must be truncated to the requested limit"
);
assert_eq!(
body["total"], 5,
"total must reflect every port across every device, not just this page"
);
assert_ne!(
body["total"].as_u64().unwrap(),
items.len() as u64,
"an agent must be able to tell a truncated page from a complete result"
);
assert_eq!(body["limit"], 3);
assert_eq!(body["offset"], 1);
}
#[tokio::test]
async fn ports_list_device_column_width_is_stable_across_pages() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/proxy/network/api/s/default/stat/device"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"meta": {"rc": "ok"},
"data": [
{"mac": "aa:bb:cc:dd:ee:01", "name": "SwitchA",
"port_table": [{"port_idx": 1}, {"port_idx": 2}]},
{"mac": "aa:bb:cc:dd:ee:02", "name": "A-Very-Long-Switch-Name",
"port_table": [{"port_idx": 1}]}
]
})))
.mount(&server)
.await;
let run_text = |limit: &str, offset: &str| -> String {
let output = std::process::Command::new(env!("CARGO_BIN_EXE_unifi"))
.args([
"--host",
&server.uri(),
"--api-key",
"test-key",
"ports",
"list",
"--output",
"text",
"--limit",
limit,
"--offset",
offset,
])
.output()
.expect("failed to run the unifi binary");
assert!(output.status.success());
String::from_utf8_lossy(&output.stdout).into_owned()
};
let page1 = run_text("2", "0");
let page2 = run_text("1", "2");
fn header(s: &str) -> &str {
s.lines()
.find(|l| l.contains("Device"))
.expect("text output must have a header row containing \"Device\"")
}
assert_eq!(
header(&page1),
header(&page2),
"the Device column width must come from the full result set, not the page, \
so two --offset pages of the same query render an identical header:\n\
page1: {page1}\npage2: {page2}"
);
}
#[tokio::test]
async fn devices_ports_bare_array_vs_ports_list_envelope() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/proxy/network/api/s/default/stat/device"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"meta": {"rc": "ok"},
"data": [{
"mac": "9c:05:d6:bc:06:43", "name": "USW-24-PoE",
"port_table": [
{"port_idx": 1, "name": "Port 1", "media": "GE", "up": true, "speed": 1000, "full_duplex": true, "poe_enable": true, "poe_power": 5.2, "port_poe": true, "tx_bytes": 123456789, "rx_bytes": 987654321},
{"port_idx": 2, "name": "Port 2", "media": "GE", "up": true, "speed": 100, "full_duplex": false, "poe_enable": false, "port_poe": true, "tx_bytes": 1000, "rx_bytes": 2000}
]
}]
})))
.mount(&server)
.await;
let run_json = |args: &[&str]| -> serde_json::Value {
let output = std::process::Command::new(env!("CARGO_BIN_EXE_unifi"))
.args(["--host", &server.uri(), "--api-key", "test-key"])
.args(args)
.output()
.expect("failed to run the unifi binary");
assert!(
output.status.success(),
"{args:?} failed: {}",
String::from_utf8_lossy(&output.stderr)
);
serde_json::from_slice(&output.stdout).unwrap_or_else(|e| {
panic!(
"{args:?} stdout was not valid JSON ({e}): {}",
String::from_utf8_lossy(&output.stdout)
)
})
};
let alias = run_json(&["devices", "ports", "9c:05:d6:bc:06:43", "-o", "json"]);
let canonical = run_json(&["ports", "list", "9c:05:d6:bc:06:43", "-o", "json"]);
let alias_items = alias
.as_array()
.unwrap_or_else(|| panic!("devices ports must emit a bare JSON array, got: {alias}"));
assert!(
!alias_items.is_empty(),
"expected at least one port row from devices ports"
);
let alias_row = alias_items[0]
.as_object()
.expect("devices ports row must be a JSON object");
assert!(
alias_row.contains_key("device_mac"),
"devices ports row must carry device_mac: {alias_row:?}"
);
assert!(
alias_row.contains_key("device_name"),
"devices ports row must carry device_name: {alias_row:?}"
);
assert!(
canonical.is_object(),
"ports list must emit an {{items,total,limit,offset}} envelope object, got: {canonical}"
);
let items = canonical["items"]
.as_array()
.expect("ports list envelope must have an items array");
assert!(
canonical.get("total").is_some(),
"ports list envelope must have a total field"
);
assert!(
canonical.get("limit").is_some(),
"ports list envelope must have a limit field"
);
assert!(
canonical.get("offset").is_some(),
"ports list envelope must have an offset field"
);
assert!(
!items.is_empty(),
"expected at least one port row from ports list"
);
let mut alias_keys: Vec<&str> = alias_row.keys().map(String::as_str).collect();
alias_keys.sort_unstable();
let mut canonical_keys: Vec<&str> = items[0]
.as_object()
.expect("ports list row must be a JSON object")
.keys()
.map(String::as_str)
.collect();
canonical_keys.sort_unstable();
assert_eq!(
alias_keys, canonical_keys,
"devices ports and ports list must share the same per-row field set"
);
}
#[tokio::test]
async fn ports_find_by_mac_sorts_connected_first_and_skips_client_lookup() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/proxy/network/api/s/default/stat/device"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"meta": {"rc": "ok"},
"data": [{
"mac": "9c:05:d6:bc:06:43", "name": "USW-24-PoE",
"port_table": [
{"port_idx": 2, "last_connection": {"mac": "aa:bb:cc:dd:ee:10", "connected": false}},
{"port_idx": 7, "last_connection": {"mac": "aa:bb:cc:dd:ee:10", "connected": true}},
{"port_idx": 9, "last_connection": {"mac": "11:22:33:44:55:66", "connected": true}}
]
}]
})))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/proxy/network/api/s/default/stat/sta"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"meta": {"rc": "ok"}, "data": []
})))
.expect(0)
.mount(&server)
.await;
let output = std::process::Command::new(env!("CARGO_BIN_EXE_unifi"))
.args([
"--host",
&server.uri(),
"--api-key",
"test-key",
"ports",
"find",
"aa:bb:cc:dd:ee:10",
"-o",
"json",
])
.output()
.expect("failed to run the unifi binary");
assert!(
output.status.success(),
"ports find failed: {}",
String::from_utf8_lossy(&output.stderr)
);
let body: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap_or_else(|e| {
panic!(
"stdout was not valid JSON ({e}): {}",
String::from_utf8_lossy(&output.stdout)
)
});
let items = body
.as_array()
.expect("ports find must emit a bare JSON array, like `networks list`");
assert_eq!(items.len(), 2, "the device appears on two ports");
assert_eq!(
items[0]["port_idx"], 7,
"the connected port must sort first"
);
assert_eq!(items[0]["connected"], true);
assert_eq!(items[1]["port_idx"], 2, "the stale record sorts last");
assert_eq!(items[1]["connected"], false);
let mut emitted: Vec<&str> = items[0]
.as_object()
.expect("row must be a JSON object")
.keys()
.map(String::as_str)
.collect();
emitted.sort_unstable();
let mut declared: Vec<&str> = unifi_cli::fields::names(unifi_cli::fields::PORTS_FIND);
declared.sort_unstable();
assert_eq!(
emitted, declared,
"ports find rows must carry exactly the PORTS_FIND field set"
);
}
#[tokio::test]
async fn ports_find_ambiguous_name_exits_with_conflict() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/proxy/network/api/s/default/stat/sta"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"meta": {"rc": "ok"},
"data": [
{"_id": "1", "mac": "aa:bb:cc:dd:ee:20", "name": "office-ap", "ip": "10.0.0.6"},
{"_id": "2", "mac": "aa:bb:cc:dd:ee:21", "name": "Main-Office", "ip": "10.0.0.7"}
]
})))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/proxy/network/api/s/default/stat/device"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"meta": {"rc": "ok"},
"data": [{
"mac": "9c:05:d6:bc:06:43", "name": "USW Pro XG 8 PoE",
"port_table": [
{"port_idx": 3, "last_connection": {"mac": "aa:bb:cc:dd:ee:20", "connected": true}},
{"port_idx": 4, "last_connection": {"mac": "aa:bb:cc:dd:ee:21", "connected": true}}
]
}]
})))
.mount(&server)
.await;
let output = std::process::Command::new(env!("CARGO_BIN_EXE_unifi"))
.args([
"--host",
&server.uri(),
"--api-key",
"test-key",
"ports",
"find",
"office",
])
.output()
.expect("failed to run the unifi binary");
assert_eq!(
output.status.code(),
Some(6),
"an ambiguous name must exit 6 (conflict), got {:?}\nstderr: {}",
output.status.code(),
String::from_utf8_lossy(&output.stderr)
);
let stderr = String::from_utf8_lossy(&output.stderr);
let last_line = stderr.trim_end().lines().last().unwrap_or("");
let envelope: serde_json::Value =
serde_json::from_str(last_line).expect("last stderr line must be valid JSON");
assert_eq!(envelope["error"]["kind"], "conflict");
let message = envelope["error"]["message"]
.as_str()
.expect("error envelope must carry a message");
assert!(message.contains("office-ap"), "got: {message}");
assert!(message.contains("Main-Office"), "got: {message}");
}
#[tokio::test]
async fn ports_find_name_matches_two_clients_only_one_on_a_port_resolves_without_conflict() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/proxy/network/api/s/default/stat/sta"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"meta": {"rc": "ok"},
"data": [
{"_id": "1", "mac": "aa:bb:cc:dd:ee:10", "name": "garage-pi", "ip": "10.0.0.5"},
{"_id": "2", "mac": "aa:bb:cc:dd:ee:11", "name": "garage-pi", "ip": "10.0.0.9"}
]
})))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/proxy/network/api/s/default/stat/device"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"meta": {"rc": "ok"},
"data": [{
"mac": "9c:05:d6:bc:06:43", "name": "USW Pro XG 8 PoE",
"port_table": [
{"port_idx": 5, "last_connection": {"mac": "aa:bb:cc:dd:ee:10", "connected": true}}
]
}]
})))
.mount(&server)
.await;
let output = std::process::Command::new(env!("CARGO_BIN_EXE_unifi"))
.args([
"--host",
&server.uri(),
"--api-key",
"test-key",
"ports",
"find",
"garage-pi",
"-o",
"json",
])
.output()
.expect("failed to run the unifi binary");
assert!(
output.status.success(),
"ports find must resolve the single ported candidate, not conflict: {}",
String::from_utf8_lossy(&output.stderr)
);
let items: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap_or_else(|e| {
panic!(
"stdout was not valid JSON ({e}): {}",
String::from_utf8_lossy(&output.stdout)
)
});
let items = items
.as_array()
.expect("ports find must emit a bare JSON array");
assert_eq!(items.len(), 1, "only the wired interface is on a port");
assert_eq!(items[0]["port_idx"], 5);
assert_eq!(items[0]["connected"], true);
}
#[tokio::test]
async fn ports_find_name_matches_clients_none_on_a_port_is_not_found() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/proxy/network/api/s/default/stat/sta"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"meta": {"rc": "ok"},
"data": [
{"_id": "1", "mac": "aa:bb:cc:dd:ee:10", "name": "lobby-display", "ip": "10.0.0.15"},
{"_id": "2", "mac": "aa:bb:cc:dd:ee:11", "name": "lobby-display", "ip": "10.0.0.16"}
]
})))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/proxy/network/api/s/default/stat/device"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"meta": {"rc": "ok"},
"data": [{
"mac": "9c:05:d6:bc:06:43", "name": "USW Pro XG 8 PoE",
"port_table": [
{"port_idx": 1, "last_connection": {"mac": "11:22:33:44:55:66", "connected": true}}
]
}]
})))
.mount(&server)
.await;
let output = std::process::Command::new(env!("CARGO_BIN_EXE_unifi"))
.args([
"--host",
&server.uri(),
"--api-key",
"test-key",
"ports",
"find",
"lobby-display",
])
.output()
.expect("failed to run the unifi binary");
assert_eq!(
output.status.code(),
Some(4),
"neither candidate is on any port, so this must exit 4 (not_found), got {:?}\nstderr: {}",
output.status.code(),
String::from_utf8_lossy(&output.stderr)
);
let stderr = String::from_utf8_lossy(&output.stderr);
let last_line = stderr.trim_end().lines().last().unwrap_or("");
let envelope: serde_json::Value =
serde_json::from_str(last_line).expect("last stderr line must be valid JSON");
assert_eq!(envelope["error"]["kind"], "not_found");
let message = envelope["error"]["message"]
.as_str()
.expect("error envelope must carry a message");
assert!(message.contains("lobby-display"), "got: {message}");
}
#[tokio::test]
async fn ports_find_text_output_shows_connected_column() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/proxy/network/api/s/default/stat/device"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"meta": {"rc": "ok"},
"data": [
{"mac": "aa:bb:cc:dd:ee:01", "name": "SwitchConnected",
"port_table": [
{"port_idx": 7, "last_connection": {"mac": "aa:bb:cc:dd:ee:10", "connected": true}}
]},
{"mac": "aa:bb:cc:dd:ee:02", "name": "SwitchStale",
"port_table": [
{"port_idx": 2, "last_connection": {"mac": "aa:bb:cc:dd:ee:10", "connected": false}}
]}
]
})))
.mount(&server)
.await;
let output = std::process::Command::new(env!("CARGO_BIN_EXE_unifi"))
.args([
"--host",
&server.uri(),
"--api-key",
"test-key",
"ports",
"find",
"aa:bb:cc:dd:ee:10",
"-o",
"text",
])
.output()
.expect("failed to run the unifi binary");
assert!(
output.status.success(),
"ports find failed: {}",
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8_lossy(&output.stdout).into_owned();
let header = stdout
.lines()
.find(|l| l.contains("Device"))
.expect("text output must have a header row containing \"Device\"");
assert!(
header.contains("Connected"),
"find's header must carry a Connected column: {header}"
);
let connected_row = stdout
.lines()
.find(|l| l.contains("SwitchConnected"))
.expect("expected a row for the connected device");
let stale_row = stdout
.lines()
.find(|l| l.contains("SwitchStale"))
.expect("expected a row for the stale device");
assert!(
connected_row.trim_end().ends_with("yes"),
"the connected row's Connected column must render \"yes\": {connected_row}"
);
assert!(
stale_row.trim_end().ends_with('-'),
"the stale row's Connected column must render \"-\": {stale_row}"
);
}
#[tokio::test]
async fn ports_cycle_confirmed_cycles_the_port() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/proxy/network/api/s/default/stat/device"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"meta": {"rc": "ok"},
"data": [{
"mac": "9c:05:d6:bc:06:43", "name": "USW-24-PoE",
"port_table": [{
"port_idx": 5, "port_poe": true, "poe_mode": "auto",
"poe_enable": true
}]
}]
})))
.expect(1)
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path("/proxy/network/api/s/default/cmd/devmgr"))
.and(body_json(serde_json::json!({
"cmd": "power-cycle",
"mac": "9c:05:d6:bc:06:43",
"port_idx": 5
})))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"meta": {"rc": "ok"},
"data": []
})))
.expect(1)
.mount(&server)
.await;
let client = mock_client(&server).await;
let outcome =
unifi_cli::commands::ports::cycle(&client, "9c:05:d6:bc:06:43", 5, out_table(), |_| {
Ok(true)
})
.await
.unwrap();
assert_eq!(outcome, unifi_cli::commands::ports::CycleOutcome::Cycled);
}
#[tokio::test]
async fn ports_cycle_declined_never_posts() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/proxy/network/api/s/default/stat/device"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"meta": {"rc": "ok"},
"data": [{
"mac": "9c:05:d6:bc:06:43", "name": "USW-24-PoE",
"port_table": [{
"port_idx": 5, "port_poe": true, "poe_mode": "auto",
"poe_enable": true
}]
}]
})))
.expect(1)
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path("/proxy/network/api/s/default/cmd/devmgr"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"meta": {"rc": "ok"},
"data": []
})))
.expect(0)
.mount(&server)
.await;
let client = mock_client(&server).await;
let outcome =
unifi_cli::commands::ports::cycle(&client, "9c:05:d6:bc:06:43", 5, out_table(), |_| {
Ok(false)
})
.await
.unwrap();
assert_eq!(outcome, unifi_cli::commands::ports::CycleOutcome::Declined);
}
#[tokio::test]
async fn ports_cycle_non_poe_port_is_conflict_and_never_posts() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/proxy/network/api/s/default/stat/device"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"meta": {"rc": "ok"},
"data": [{
"mac": "9c:05:d6:bc:06:43", "name": "USW-Lite-8",
"port_table": [{"port_idx": 9, "port_poe": false}]
}]
})))
.expect(1)
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path("/proxy/network/api/s/default/cmd/devmgr"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"meta": {"rc": "ok"},
"data": []
})))
.expect(0)
.mount(&server)
.await;
let client = mock_client(&server).await;
let err =
unifi_cli::commands::ports::cycle(&client, "9c:05:d6:bc:06:43", 9, out_table(), |_| {
Ok(true)
})
.await
.unwrap_err();
let api_err = err
.downcast_ref::<unifi_cli::api::ApiError>()
.unwrap_or_else(|| {
panic!("cycle must reject a non-PoE port as an ApiError, got {err}")
});
assert!(
matches!(api_err, unifi_cli::api::ApiError::Conflict(_)),
"expected Conflict, got {api_err:?}"
);
}
#[tokio::test]
async fn ports_cycle_poe_enable_false_is_conflict_and_never_posts() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/proxy/network/api/s/default/stat/device"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"meta": {"rc": "ok"},
"data": [{
"mac": "aa:bb:cc:dd:ee:fe", "name": "USW Lite 8 PoE",
"port_table": [{
"port_idx": 4, "port_poe": true, "poe_mode": "auto",
"poe_enable": false, "poe_power": 0.0, "up": false
}]
}]
})))
.expect(1)
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path("/proxy/network/api/s/default/cmd/devmgr"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"meta": {"rc": "ok"},
"data": []
})))
.expect(0)
.mount(&server)
.await;
let client = mock_client(&server).await;
let err =
unifi_cli::commands::ports::cycle(&client, "aa:bb:cc:dd:ee:fe", 4, out_table(), |_| {
Ok(true)
})
.await
.unwrap_err();
let api_err = err
.downcast_ref::<unifi_cli::api::ApiError>()
.unwrap_or_else(|| {
panic!("cycle must reject a poe_enable=false port as an ApiError, got {err}")
});
assert!(
matches!(api_err, unifi_cli::api::ApiError::Conflict(_)),
"expected Conflict, got {api_err:?}"
);
}
#[tokio::test]
async fn ports_cycle_missing_port_is_not_found_and_never_posts() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/proxy/network/api/s/default/stat/device"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"meta": {"rc": "ok"},
"data": [{
"mac": "9c:05:d6:bc:06:43", "name": "USW-24-PoE",
"port_table": [{"port_idx": 1, "port_poe": true}]
}]
})))
.expect(1)
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path("/proxy/network/api/s/default/cmd/devmgr"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"meta": {"rc": "ok"},
"data": []
})))
.expect(0)
.mount(&server)
.await;
let client = mock_client(&server).await;
let err = unifi_cli::commands::ports::cycle(
&client,
"9c:05:d6:bc:06:43",
99,
out_table(),
|_| Ok(true),
)
.await
.unwrap_err();
let api_err = err
.downcast_ref::<unifi_cli::api::ApiError>()
.unwrap_or_else(|| {
panic!("cycle must report a missing port as an ApiError, got {err}")
});
assert!(
matches!(api_err, unifi_cli::api::ApiError::NotFound(_)),
"expected NotFound, got {api_err:?}"
);
}
#[tokio::test]
async fn list_events_returns_stat_event_records() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/proxy/network/api/s/default/stat/event"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"meta": {"rc": "ok"},
"data": [
{"key": "EVT_AP_Connected", "msg": "AP connected", "subsystem": "wlan", "time": 200, "datetime": "2026-07-07T16:00:00Z"},
{"key": "EVT_SW_LostContact", "msg": "Switch lost contact", "subsystem": "lan", "time": 100, "datetime": "2026-07-07T15:00:00Z"}
]
})))
.mount(&server)
.await;
let client = mock_client(&server).await;
let events = client.list_events(10).await.unwrap();
assert_eq!(events.len(), 2);
assert_eq!(events[0].key.as_deref(), Some("EVT_AP_Connected"));
}
#[tokio::test]
async fn list_events_falls_back_to_alarms_on_404() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/proxy/network/api/s/default/stat/event"))
.respond_with(ResponseTemplate::new(404).set_body_json(serde_json::json!({
"meta": {"rc": "error", "msg": "api.err.NotFound"},
"data": []
})))
.expect(1)
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/proxy/network/api/s/default/rest/alarm"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"meta": {"rc": "ok"},
"data": [
{"key": "EVT_GW_Older", "msg": "older", "time": 100, "datetime": "a"},
{"key": "EVT_GW_Newest", "msg": "newest", "time": 300, "datetime": "c"},
{"key": "EVT_GW_Middle", "msg": "middle", "time": 200, "datetime": "b"}
]
})))
.expect(1)
.mount(&server)
.await;
let client = mock_client(&server).await;
let events = client.list_events(2).await.unwrap();
assert_eq!(events.len(), 2);
assert_eq!(events[0].msg.as_deref(), Some("newest"));
assert_eq!(events[1].msg.as_deref(), Some("middle"));
}
#[tokio::test]
async fn list_events_propagates_non_404_errors() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/proxy/network/api/s/default/stat/event"))
.respond_with(ResponseTemplate::new(500).set_body_json(serde_json::json!({
"meta": {"rc": "error", "msg": "api.err.ServerError"},
"data": []
})))
.mount(&server)
.await;
let client = mock_client(&server).await;
assert!(client.list_events(10).await.is_err());
}
}
mod client_construction {
#[test]
fn new_with_https_host() {
let client = unifi_cli::api::UnifiClient::new("https://unifi.example.com", "key123");
assert!(client.is_ok());
}
#[test]
fn new_with_http_host() {
let client = unifi_cli::api::UnifiClient::new("http://localhost:8443", "key123");
assert!(client.is_ok());
}
#[test]
fn new_with_bare_host() {
let client = unifi_cli::api::UnifiClient::new("unifi.local", "key123");
assert!(client.is_ok());
}
#[test]
fn new_strips_trailing_slash() {
let client = unifi_cli::api::UnifiClient::new("https://unifi.local/", "key123");
assert!(client.is_ok());
}
#[test]
fn new_with_invalid_api_key() {
let client = unifi_cli::api::UnifiClient::new("host", "bad\nkey");
assert!(client.is_err());
}
}