use httpmock::prelude::*;
use shelly_core::ShellyClient;
use switchkit::guardrail::{Hazard, classify};
use switchkit::{DeviceTarget, PowerAction, RelayState, SmartDevice, Vendor};
#[tokio::test]
async fn gen2_metering_plug_maps_every_field_honestly() {
let server = MockServer::start_async().await;
server
.mock_async(|when, then| {
when.method(GET).path("/shelly");
then.status(200).json_body(serde_json::json!({
"id": "shellyplus1pm-aabbccddeeff",
"mac": "AABBCCDDEEFF",
"model": "SNSW-001P16EU",
"gen": 2,
"ver": "1.2.3",
"app": "Plus1PM",
"auth_en": false
}));
})
.await;
server
.mock_async(|when, then| {
when.method(GET).path("/rpc/Shelly.GetStatus");
then.status(200).json_body(serde_json::json!({
"switch:0": {
"id": 0,
"output": true,
"apower": 42.5,
"voltage": 230.0,
"current": 0.18,
"aenergy": { "total": 12300.0 }
},
"wifi": { "rssi": -58 }
}));
})
.await;
let client = ShellyClient::default();
let target = DeviceTarget::new(server.address().to_string());
let snapshot = client
.status(&target)
.await
.expect("status should succeed against the mock");
assert_eq!(snapshot.relays.len(), 1);
assert_eq!(snapshot.relays[0].state, RelayState::On);
let energy = snapshot
.energy
.expect("a metering switch must produce Some(Energy)");
assert_eq!(energy.power_w, Some(42.5));
assert_eq!(energy.voltage_v, Some(230.0));
assert_eq!(energy.current_a, Some(0.18));
assert_eq!(
energy.total_kwh,
Some(12.3),
"12300 Wh must convert to 12.3 kWh, not pass through as raw Wh"
);
assert_eq!(
energy.today_kwh, None,
"Shelly's status response has no daily counter; must not be fabricated"
);
let signal = snapshot
.signal
.expect("a real rssi reading must produce Some(Signal)");
assert_eq!(signal.rssi_dbm, Some(-58));
assert_eq!(
signal.quality_percent, None,
"must never invent a percentage from a dBm reading"
);
assert!(snapshot.capabilities.metering);
assert!(snapshot.capabilities.console);
assert_eq!(
snapshot.firmware.and_then(|f| f.version).as_deref(),
Some("1.2.3")
);
}
#[tokio::test]
async fn set_power_returns_confirmed_readback_not_was_on() {
let server = MockServer::start_async().await;
server
.mock_async(|when, then| {
when.method(GET).path("/shelly");
then.status(200).json_body(serde_json::json!({
"id": "shellyplus1-aabbccddeeff",
"mac": "AABBCCDDEEFF",
"model": "SNSW-001X16EU",
"gen": 2,
"ver": "1.0.0"
}));
})
.await;
server
.mock_async(|when, then| {
when.method(POST).path("/rpc/Switch.Set");
then.status(200)
.json_body(serde_json::json!({ "was_on": false }));
})
.await;
server
.mock_async(|when, then| {
when.method(POST).path("/rpc/Switch.GetStatus");
then.status(200)
.json_body(serde_json::json!({ "id": 0, "output": true }));
})
.await;
let client = ShellyClient::default();
let target = DeviceTarget::new(server.address().to_string());
let relay = client
.set_power(&target, Some(0), PowerAction::On)
.await
.expect("set_power should succeed against the mock");
assert_eq!(
relay.state,
RelayState::On,
"must report the confirmed readback (output: true), not was_on (false)"
);
}
#[tokio::test]
async fn gen2_switch_without_metering_yields_no_energy() {
let server = MockServer::start_async().await;
server
.mock_async(|when, then| {
when.method(GET).path("/shelly");
then.status(200).json_body(serde_json::json!({
"id": "shellyplus1-aabbccddeeff",
"mac": "AABBCCDDEEFF",
"model": "SNSW-001X16EU",
"gen": 2,
"ver": "1.0.0"
}));
})
.await;
server
.mock_async(|when, then| {
when.method(GET).path("/rpc/Shelly.GetStatus");
then.status(200).json_body(serde_json::json!({
"switch:0": { "id": 0, "output": true }
}));
})
.await;
let client = ShellyClient::default();
let target = DeviceTarget::new(server.address().to_string());
let snapshot = client
.status(&target)
.await
.expect("status should succeed against the mock");
assert_eq!(snapshot.relays.len(), 1);
assert_eq!(snapshot.relays[0].state, RelayState::On);
assert_eq!(
snapshot.energy, None,
"a non-metering switch must not produce a zeroed Energy"
);
assert!(!snapshot.capabilities.metering);
}
#[tokio::test]
async fn gen1_device_maps_relay_and_signal_console_false() {
let server = MockServer::start_async().await;
server
.mock_async(|when, then| {
when.method(GET).path("/shelly");
then.status(200).json_body(serde_json::json!({
"type": "SHSW-1",
"mac": "112233445566",
"auth": false,
"fw": "20230913-114003/v1.14.0-gcb84623",
"num_outputs": 1,
"num_meters": 0
}));
})
.await;
server
.mock_async(|when, then| {
when.method(GET).path("/status");
then.status(200).json_body(serde_json::json!({
"relays": [
{ "ison": true, "source": "http", "has_timer": false }
],
"wifi_sta": {
"connected": true,
"ssid": "TestNet",
"ip": "198.51.100.5",
"rssi": -60
}
}));
})
.await;
let client = ShellyClient::default();
let target = DeviceTarget::new(server.address().to_string());
let snapshot = client
.status(&target)
.await
.expect("status should succeed against the mock");
assert!(!snapshot.capabilities.console, "Gen1 has no RPC console");
assert_eq!(snapshot.relays.len(), 1);
assert_eq!(snapshot.relays[0].state, RelayState::On);
let signal = snapshot
.signal
.expect("a real Gen1 rssi reading must produce Some(Signal)");
assert_eq!(signal.rssi_dbm, Some(-60));
assert_eq!(signal.quality_percent, None);
}
#[tokio::test]
async fn probe_reachable_non_shelly_returns_ok_none() {
let server = MockServer::start_async().await;
server
.mock_async(|when, then| {
when.method(GET).path("/shelly");
then.status(200)
.json_body(serde_json::json!({ "hello": "world" }));
})
.await;
let client = ShellyClient::default();
let target = DeviceTarget::new(server.address().to_string());
let result = client.probe(&target).await;
assert!(
matches!(result, Ok(None)),
"a non-Shelly response must probe as Ok(None), got {result:?}"
);
}
#[tokio::test]
async fn status_offline_target_returns_network_error() {
let client = ShellyClient::default();
let target = DeviceTarget::new("127.0.0.1:1".to_string());
let err = client
.status(&target)
.await
.expect_err("an unreachable target must not produce a snapshot");
assert!(
matches!(err, switchkit::Error::Network { .. }),
"expected Error::Network, got {err:?}"
);
}
#[test]
fn guardrail_classifies_shelly_commands_by_hazard() {
assert!(
matches!(
classify(Vendor::Shelly, "Shelly.FactoryReset"),
Hazard::Destructive(_)
),
"a factory reset must be classified destructive"
);
assert!(
matches!(
classify(Vendor::Shelly, "Shelly.Update"),
Hazard::Destructive(_)
),
"a firmware update must be classified destructive"
);
assert!(
matches!(
classify(Vendor::Shelly, "Sys.SetConfig"),
Hazard::Destructive(_)
),
"a config write must be classified destructive"
);
assert_eq!(
classify(Vendor::Shelly, "Shelly.GetStatus"),
Hazard::Safe,
"a getter must be classified safe"
);
assert_eq!(
classify(Vendor::Shelly, "Switch.Set"),
Hazard::Safe,
"basic reversible relay control must be classified safe"
);
}