From e6637ad41aee137712c29a5ba7a81ae9bd8b5d1c Mon Sep 17 00:00:00 2001 From: cjswedes Date: Fri, 30 Oct 2026 14:52:22 -0500 Subject: [PATCH] LAN integration test base for wemo/harman/sonos This was done as a part of the review for the LAN integration test framework which is not yet released. A few things that would be helpful that each driver agent hand rolled: - Helper for the step from create to added: Takes create metadata and returns the mock device after building it, and registering it with the framework. It would optionally queue added/init events - Log wrapping to catch "Failed to invoke capability command handler" is needed by every test not just hue, so it should be a part of the framework with an ability to opt out - First class time control: Add test.let_time_pass(seconds), and something like test.advance_to_next_timer(). That way a test can say "the reply comes 4 s after the search" without polling os.time() with max_turns = 500. Also clarify how wait_until's time jumps interact with cosock.socket.sleep and with driver:call_with_delay/call_on_schedule (the old mock_timer_channel). These are just suggestions for things that would allow for easier writing of tests that will need to be done for all lan drivers --- .../src/test/review_lan_http_shim.lua | 62 +++ .../src/test/review_lan_ws_helpers.lua | 85 ++++ .../src/test/test_review_lan_discovery.lua | 135 ++++++ .../src/test/test_review_lan_ws.lua | 131 ++++++ .../sonos/src/test/review_lan_helpers.lua | 388 ++++++++++++++++++ .../src/test/test_review_lan_bringup.lua | 38 ++ .../src/test/test_review_lan_connectivity.lua | 82 ++++ .../src/test/test_review_lan_discovery.lua | 42 ++ .../src/test/test_review_lan_rediscovery.lua | 47 +++ .../test_review_lan_websocket_messages.lua | 87 ++++ .../wemo/src/test/review_helpers.lua | 132 ++++++ .../wemo/src/test/review_http_shim.lua | 100 +++++ .../src/test/test_review_lan_connectivity.lua | 100 +++++ .../src/test/test_review_lan_discovery.lua | 143 +++++++ .../wemo/src/test/test_review_lan_notify.lua | 65 +++ .../test/test_review_lan_plain_handles.lua | 67 +++ 16 files changed, 1704 insertions(+) create mode 100644 drivers/SmartThings/harman-luxury/src/test/review_lan_http_shim.lua create mode 100644 drivers/SmartThings/harman-luxury/src/test/review_lan_ws_helpers.lua create mode 100644 drivers/SmartThings/harman-luxury/src/test/test_review_lan_discovery.lua create mode 100644 drivers/SmartThings/harman-luxury/src/test/test_review_lan_ws.lua create mode 100644 drivers/SmartThings/sonos/src/test/review_lan_helpers.lua create mode 100644 drivers/SmartThings/sonos/src/test/test_review_lan_bringup.lua create mode 100644 drivers/SmartThings/sonos/src/test/test_review_lan_connectivity.lua create mode 100644 drivers/SmartThings/sonos/src/test/test_review_lan_discovery.lua create mode 100644 drivers/SmartThings/sonos/src/test/test_review_lan_rediscovery.lua create mode 100644 drivers/SmartThings/sonos/src/test/test_review_lan_websocket_messages.lua create mode 100644 drivers/SmartThings/wemo/src/test/review_helpers.lua create mode 100644 drivers/SmartThings/wemo/src/test/review_http_shim.lua create mode 100644 drivers/SmartThings/wemo/src/test/test_review_lan_connectivity.lua create mode 100644 drivers/SmartThings/wemo/src/test/test_review_lan_discovery.lua create mode 100644 drivers/SmartThings/wemo/src/test/test_review_lan_notify.lua create mode 100644 drivers/SmartThings/wemo/src/test/test_review_lan_plain_handles.lua diff --git a/drivers/SmartThings/harman-luxury/src/test/review_lan_http_shim.lua b/drivers/SmartThings/harman-luxury/src/test/review_lan_http_shim.lua new file mode 100644 index 0000000000..bd2e967702 --- /dev/null +++ b/drivers/SmartThings/harman-luxury/src/test/review_lan_http_shim.lua @@ -0,0 +1,62 @@ +-- WORKAROUND for a framework gap: integration_test.mock_socket stubs luasocket's protect/skip/try/ +-- newtry/source/sink/sourcet/sinkt as no-ops, so `cosock.asyncify "socket.http"` (LuaSocket's HTTP client, +-- used by this driver) loads without `request`. This re-implements the pure-Lua parts so the real +-- LuaSocket http.lua can run over the mock TCP socket. Require AFTER integration_test, BEFORE the driver. +local mock_socket = require "integration_test.mock_socket" +local cosock_socket = require "cosock.socket" + +local function protect(f) + return function(...) + local r = table.pack(pcall(f, ...)) + if r[1] then return table.unpack(r, 2, r.n) end + if type(r[2]) == "table" then return nil, r[2][1] end + error(r[2], 0) + end +end +local function newtry(finalizer) + return function(...) + local first = ... + if first then return ... end + local _, err = ... + if finalizer then finalizer() end + error({ err }, 2) + end +end +local function skip(n, ...) return select(n + 1, ...) end + +local sinkt = { + ["keep-open"] = function(sock) return function(chunk, err) if chunk then return sock:send(chunk) end return 1 end end, + ["close-when-done"] = function(sock) + return function(chunk, err) if chunk then return sock:send(chunk) end sock:close() return 1 end + end, +} +local sourcet = { + ["by-length"] = function(sock, length) + return function() + if length <= 0 then return nil end + local chunk, err = sock:receive(length) + if err then return nil, err end + length = length - #chunk + return chunk + end + end, + ["until-closed"] = function(sock) + local done + return function() + if done then return nil end + local chunk, err, partial = sock:receive("*a") + if err == "closed" or err == nil then done = true; return (chunk and #chunk > 0 and chunk) or partial or nil end + return nil, err + end + end, +} +local function sink(mode, sock) return sinkt[mode](sock) end +local function source(mode, sock, length) return sourcet[mode](sock, length) end +local function try(...) return newtry()(...) end + +for _, m in ipairs({ mock_socket, cosock_socket }) do + rawset(m, "protect", protect); rawset(m, "newtry", newtry); rawset(m, "skip", skip) + rawset(m, "try", try); rawset(m, "sink", sink); rawset(m, "source", source) + rawset(m, "sinkt", sinkt); rawset(m, "sourcet", sourcet) +end +return true diff --git a/drivers/SmartThings/harman-luxury/src/test/review_lan_ws_helpers.lua b/drivers/SmartThings/harman-luxury/src/test/review_lan_ws_helpers.lua new file mode 100644 index 0000000000..592216a87c --- /dev/null +++ b/drivers/SmartThings/harman-luxury/src/test/review_lan_ws_helpers.lua @@ -0,0 +1,85 @@ +-- Helpers shared by the review tests: a harman speaker device fixture + a minimal websocket server side +-- built on the mock TCP connection (the framework has no websocket support, so handshake and framing are by hand). +local test = require "integration_test" +test.set_test_coroutine_priority(true) +test.set_capability_batch_receive(true) +require "test.review_lan_http_shim" + +local t_utils = require "integration_test.utils" +local connection_scenario = require "integration_test.connection_scenario" +local http = require "integration_test.connection_scenario_http" +local key_lib = require "lustre.handshake.key" +local json = require "st.json" +require("log").err = function(...) print("LOG.ERR", ...) end + +local H = {} +H.IP = "192.168.1.60" +H.TOKEN = "tok-1" + +function H.new_device(dni) + local dev = test.mock_device.build_test_lan_device({ + label = "Kitchen", device_network_id = dni or "aabbccddee10", + profile = t_utils.get_profile_definition("harman-luxury.yaml"), + }) + return dev +end + +--- Seed the fields an earlier `added` persisted (a driver restart), as the Hue tests do. +function H.seed(dev, ip) + dev:set_field("device_ipv4", ip or H.IP, { persist = true }) + dev:set_field("credential", H.TOKEN, { persist = true }) +end + +function H.ws_scenario(ip) + local scenario = connection_scenario.new({ host = ip or H.IP, port = 50002 }) + local ws = scenario:connection("ws", { matcher = http.matcher("GET", "^/$") }) + return scenario, ws +end + +--- Complete the websocket upgrade for the request the driver just sent (a masked-client / unmasked-server ws). +--- The driver's thread connects during startup, before the test body runs, so the upgrade request is +--- registered (deferred) from test_init via H.expect_upgrade(ws) and awaited here. +function H.expect_upgrade(ws) + return http.expect_request_for_test(ws, "GET", "/", { defer = true }) +end + +function H.accept_upgrade(exp) + local req = exp:await() + req:respond({ status = 101, headers = { + Upgrade = "websocket", Connection = "Upgrade", + ["Sec-WebSocket-Accept"] = key_lib.build_accept_from(req.headers["sec-websocket-key"]), + } }) + return req +end + +--- server -> client text frame (unmasked) +function H.text_frame(payload) + if type(payload) == "table" then payload = json.encode(payload) end + local n = #payload + local hdr + if n < 126 then hdr = string.char(0x81, n) + else hdr = string.char(0x81, 126, n >> 8, n & 0xff) end + return hdr .. payload +end + +--- decode the (masked) text frames the driver sent on a connection handle (after the HTTP upgrade request) +function H.client_frames(conn_handle) + local sent = conn_handle:sent() + local pos = sent:find("\r\n\r\n", 1, true) + local out = {} + if not pos then return out end + local i = pos + 4 + while i <= #sent do + local b0, b1 = sent:byte(i, i + 1) + local opcode, len, p = b0 & 0x0f, b1 & 0x7f, i + 2 + if len == 126 then len = (sent:byte(p) << 8) | sent:byte(p + 1); p = p + 2 end + local mask = { sent:byte(p, p + 3) }; p = p + 4 + local chars = {} + for k = 1, len do chars[k] = string.char(sent:byte(p + k - 1) ~ mask[(k - 1) % 4 + 1]) end + table.insert(out, { opcode = opcode, text = table.concat(chars) }) + i = p + len + end + return out +end + +return H diff --git a/drivers/SmartThings/harman-luxury/src/test/test_review_lan_discovery.lua b/drivers/SmartThings/harman-luxury/src/test/test_review_lan_discovery.lua new file mode 100644 index 0000000000..1c0464e274 --- /dev/null +++ b/drivers/SmartThings/harman-luxury/src/test/test_review_lan_discovery.lua @@ -0,0 +1,135 @@ +local test = require "integration_test" +test.set_test_coroutine_priority(true) +test.set_capability_batch_receive(true) +require "test.review_lan_http_shim" + +local connection_scenario = require "integration_test.connection_scenario" +local http = require "integration_test.connection_scenario_http" +local mock_mdns = test.mock_mdns +local mock_devices_api = test.mock_devices_api + +local SERVICE_TYPE, DOMAIN = "_sue-st._tcp", "local" + +local function speaker_event(ip, mac, name) + local function bytes(s) return { s:byte(1, -1) } end + return mock_mdns.build_event({ + name = name or "Speaker", service_type = SERVICE_TYPE, domain = DOMAIN, address = ip, port = 80, + txt = { bytes("mac=" .. mac), bytes("mnid=0BE8"), bytes("setupid=604") }, + }) +end + +--- a harman speaker's HTTP API at ip; returns scenario, api connection +local function speaker_at(ip, label, opts) + opts = opts or {} + local scenario = connection_scenario.new({ host = ip, port = 80 }) + local api = scenario:connection("api", { matcher = http.matcher("GET", "^/api/"), ordering = "relaxed" }) + http.expect_request(api, "GET", "/api/setData", { + query = nil, body = string.format("%q", "cred-" .. ip), reusable = true, optional = opts.optional, + }) + http.expect_request(api, "GET", "/api/getData", { + body = { { type = "string_", string_ = label } }, reusable = true, + }) + scenario:activate() + return scenario, api +end + +local function expect_create(dni, label) + mock_devices_api.__expect_create_device({ + deviceNetworkId = dni, label = label, profileReference = "maX10", manufacturer = "JBL", + model = "MA510", vendorProvidedLabel = label, type = "LAN", + }) +end + +test.register_coroutine_test("discovery of a speaker with a ready HTTP API creates the device", function() + local _, api = speaker_at("192.168.1.50", "Kitchen") + expect_create("aabbccddee01", "Kitchen") + mock_mdns.__queue_response(SERVICE_TYPE, DOMAIN, { found = { speaker_event("192.168.1.50", "AA:BB:CC:DD:EE:01") } }) + test.socket.discovery:__queue_receive({ "start", {} }) + test.wait_for_events() + test.socket.discovery:__queue_receive({ "stop" }) + test.wait_for_events() + print("requests", http.count_requests(api, "GET", "/api/setData"), http.count_requests(api, "GET", "/api/getData")) +end) + + +local function start() test.socket.discovery:__queue_receive({ "start", {} }) end +local function stop() test.socket.discovery:__queue_receive({ "stop" }) end +local function scans() return #mock_mdns.__get_discover_calls() end + +test.register_coroutine_test("mDNS finds nothing for the first scans, then the speaker appears", function() + local _, api = speaker_at("192.168.1.51", "Den") + expect_create("aabbccddee02", "Den") + mock_mdns.__queue_response(SERVICE_TYPE, DOMAIN, { found = {} }) + mock_mdns.__queue_response(SERVICE_TYPE, DOMAIN, { found = {} }) + mock_mdns.__queue_response(SERVICE_TYPE, DOMAIN, { found = { speaker_event("192.168.1.51", "aa:bb:cc:dd:ee:02") } }) + start() + test.wait_until(function() return scans() >= 2 end, { message = "two empty mDNS scans" }) + assert(http.count_requests(api, "GET", "/api/setData") == 0, "no HTTP before the device appears") + test.wait_until(function() return http.count_requests(api, "GET", "/api/getData") >= 1 end, + { message = "driver queried the speaker" }) + stop() + test.wait_for_events() +end) + +test.register_coroutine_test("mDNS returns the speaker but its HTTP API refuses connections at first", function() + local scenario, api = speaker_at("192.168.1.52", "Patio") + scenario:fail_connects(2, "connection refused") + expect_create("aabbccddee03", "Patio") + mock_mdns.__set_default_response(SERVICE_TYPE, DOMAIN, { found = { speaker_event("192.168.1.52", "aa:bb:cc:dd:ee:03") } }) + start() + test.wait_until(function() return http.count_requests(api, "GET", "/api/getData") >= 1 end, + { message = "HTTP API became ready and the driver queried it" }) + assert(scans() >= 3, "expected at least 3 scans (2 refused + 1 good), got " .. scans()) + stop() + test.wait_for_events() +end) + +test.register_coroutine_test("two speakers appear at different times", function() + local _, api_a = speaker_at("192.168.1.53", "Left") + local _, api_b = speaker_at("192.168.1.54", "Right") + expect_create("aabbccddee04", "Left") + expect_create("aabbccddee05", "Right") + local a = speaker_event("192.168.1.53", "aa:bb:cc:dd:ee:04") + local b = speaker_event("192.168.1.54", "aa:bb:cc:dd:ee:05") + mock_mdns.__queue_response(SERVICE_TYPE, DOMAIN, { found = { a } }) + mock_mdns.__queue_response(SERVICE_TYPE, DOMAIN, { found = { a } }) + mock_mdns.__queue_response(SERVICE_TYPE, DOMAIN, { found = { a } }) + mock_mdns.__set_default_response(SERVICE_TYPE, DOMAIN, { found = { a, b } }) + start() + test.wait_until(function() return http.count_requests(api_b, "GET", "/api/getData") >= 1 end, + { message = "second speaker queried" }) + assert(http.count_requests(api_a, "GET", "/api/setData") == 1, "first speaker credential requested exactly once") + stop() + test.wait_for_events() +end) + +test.register_coroutine_test("handoff: discovery's create_device does not make a device; the test adds it and sends `added`", function() + local H = require "test.review_lan_ws_helpers" + local IP = "192.168.1.70" + local _, api = speaker_at(IP, "Office") + local ws_scenario, ws = H.ws_scenario(IP) + ws_scenario:activate() + local upgrade = H.expect_upgrade(ws) + -- the driver re-checks the active credential token before reconnecting (after the idle websocket times out) + http.expect_request(api, "GET", "/api/getData", { prepend = true, reusable = true, optional = true, + query = "path=settings%3A%2Fsmartthings%2FuserToken&roles=value", + body = { { type = "string_", string_ = "cred-" .. IP } } }) + expect_create("aabbccddee20", "Office") + mock_mdns.__queue_response(SERVICE_TYPE, DOMAIN, { found = { speaker_event(IP, "aa:bb:cc:dd:ee:20") } }) + start() + test.wait_until(function() return http.count_requests(api, "GET", "/api/getData") >= 1 end, { message = "discovered" }) + stop() + test.wait_for_events() + -- Nothing created a device: the hub would now create it and send `added`; the test has to play the hub. + local dev = H.new_device("aabbccddee20") + test.mock_device.add_test_device(dev) + test.socket.device_lifecycle:__queue_receive({ dev.id, "added" }) + H.accept_upgrade(upgrade) + test.wait_until(function() return test.mock_devices_api.__is_device_online(dev.id) == true end, + { message = "added -> init -> websocket -> online" }) + assert(dev:get_field("device_ipv4") == IP and dev:get_field("credential") == "cred-" .. IP, + "added should have copied the discovery cache into the device fields") + ws_scenario:fail_connects(math.huge, "connection refused") +end) + +test.run_registered_tests() diff --git a/drivers/SmartThings/harman-luxury/src/test/test_review_lan_ws.lua b/drivers/SmartThings/harman-luxury/src/test/test_review_lan_ws.lua new file mode 100644 index 0000000000..78cd6f70e0 --- /dev/null +++ b/drivers/SmartThings/harman-luxury/src/test/test_review_lan_ws.lua @@ -0,0 +1,131 @@ +local H = require "test.review_lan_ws_helpers" +local test = require "integration_test" +local capabilities = require "st.capabilities" +local connection_scenario = require "integration_test.connection_scenario" +local http = require "integration_test.connection_scenario_http" +local json = require "st.json" + +local mock_device = H.new_device() +local scenario, ws = H.ws_scenario() + +-- the device's HTTP API (the driver asks it for the active credential token before it reconnects) +local api_scenario = connection_scenario.new({ host = H.IP, port = 80 }) +local api = api_scenario:connection("api", { matcher = http.matcher("GET", "^/api/") }) +http.expect_request(api, "GET", "/api/getData", { + body = { { type = "string_", string_ = H.TOKEN } }, reusable = true, optional = true, +}) + +local upgrade +test.set_test_init_function(function() + test.mock_device.add_test_device(mock_device) + H.seed(mock_device) + scenario:activate() + api_scenario:activate() + upgrade = H.expect_upgrade(ws) +end) + +local function online() return test.mock_devices_api.__is_device_online(mock_device.id) == true end +local function offline() return test.mock_devices_api.__is_device_online(mock_device.id) == false end + +--- Park the driver: nothing is listening any more, so its reconnect loop keeps failing until the +--- scheduler gives up on the timers (otherwise the websocket keepalive runs on forever). +local function park() scenario:fail_connects(math.huge, "connection refused") end + +local function connect() + H.accept_upgrade(upgrade) + test.wait_until(online, { message = "device online" }) +end + +local function sent_frames() return H.client_frames(ws:connection()) end +local function sent_json(i) return json.decode(sent_frames()[i].text) end + +test.register_coroutine_test("UC1 (protocol->capability): websocket state updates become capability events", function() + connect() + test.socket.capability:__set_channel_ordering("relaxed") -- the handler's emit order is an implementation detail + local function expect(event) test.socket.capability:__expect_send(mock_device:generate_test_message("main", event)) end + expect(capabilities.switch.switch.on()) + expect(capabilities.audioVolume.volume(30)) + expect(capabilities.audioMute.mute.muted()) + expect(capabilities.mediaPlayback.playbackStatus.playing()) + ws:connection():queue_bytes(H.text_frame({ switch = "on", audioVolume = 30, audioMute = true, + mediaPlayback = "play" })) + test.wait_for_events() + park() +end) + +test.register_coroutine_test("UC1 (capability->protocol): switch on / setVolume are sent over the websocket", function() + connect() + test.socket.capability:__queue_receive({ mock_device.id, + { capability = "switch", component = "main", command = "on", args = {} } }) + test.socket.capability:__queue_receive({ mock_device.id, + { capability = "audioVolume", component = "main", command = "setVolume", args = { 42 } } }) + test.wait_until(function() return #sent_frames() >= 3 end, { message = "refresh + 2 commands sent" }) + local f1, f2, f3 = sent_json(1), sent_json(2), sent_json(3) + assert(f1.message.command == "refresh", "first frame is the post-connect refresh") + assert(f2.message.capability == "switch" and f2.message.command == "on" and f2.credential == H.TOKEN) + assert(f3.message.capability == "audioVolume" and f3.message.command == "setVolume" and f3.message.arg.volume == 42) + park() +end) + +test.register_coroutine_test("UC2: no pong for the keepalive ping -> device offline; reconnects -> online", function() + connect() + local second_upgrade = http.expect_request_for_test(ws, "GET", "/", { defer = true }) + test.mock_devices_api.__clear_device_status_history() + test.wait_until(offline, { max_turns = 500, message = "device offline after missed keepalive pong" }) + print("MOCK TIME when marked offline:", os.time()) + H.accept_upgrade(second_upgrade) + test.wait_until(online, { max_turns = 500, message = "device back online after websocket reconnect" }) + assert(ws:connection_count() == 2, "expected 2 websocket connections, got " .. ws:connection_count()) + assert(test.mock_devices_api.__count_device_status_calls(mock_device.id, false) == 1, "offline exactly once") + assert(test.mock_devices_api.__count_device_status_calls(mock_device.id, true) >= 1, "online again") + park() +end) + +test.register_coroutine_test("UC2: server drops the websocket -> reconnect", function() + connect() + local second_upgrade = http.expect_request_for_test(ws, "GET", "/", { defer = true }) + ws:connection():close_connection() + H.accept_upgrade(second_upgrade) + test.wait_until(function() return ws:connection_count() == 2 end, { max_turns = 500, message = "reconnected" }) + test.wait_until(online, { message = "online" }) + park() +end) + +test.register_coroutine_test("UC2: reconnect attempts are refused twice (device rebooting) -> offline, then back online", function() + connect() + test.mock_devices_api.__clear_device_status_history() + local second_upgrade = http.expect_request_for_test(ws, "GET", "/", { defer = true }) + scenario:fail_connects(2, "connection refused") + ws:connection():close_connection() + test.wait_until(offline, { max_turns = 500, message = "offline while connects are refused" }) + H.accept_upgrade(second_upgrade) + test.wait_until(online, { max_turns = 500, message = "online once the device accepts connections" }) + park() +end) + +test.register_coroutine_test("UC2: device comes back at a NEW IP found by mDNS (expected to work in a correct driver)", function() + local NEW_IP = "192.168.1.61" + local new_scenario, new_ws = H.ws_scenario(NEW_IP) + new_scenario:activate() + local new_api = connection_scenario.new({ host = NEW_IP, port = 80 }) + local napi = new_api:connection("api", { matcher = http.matcher("GET", "^/api/") }) + http.expect_request(napi, "GET", "/api/getData", { body = { { type = "string_", string_ = H.TOKEN } }, reusable = true, optional = true }) + new_api:activate() + local new_upgrade = http.expect_request_for_test(new_ws, "GET", "/", { defer = true }) + connect() + -- old IP: HTTP and websocket are both dead; mDNS now reports the speaker at the new address + api_scenario:fail_connects(math.huge, "No route to host") + scenario:fail_connects(math.huge, "No route to host") + local bytes = function(s) return { s:byte(1, -1) } end + test.mock_mdns.__set_default_response("_sue-st._tcp", "local", { found = { test.mock_mdns.build_event({ + name = "Kitchen", service_type = "_sue-st._tcp", domain = "local", address = NEW_IP, port = 80, + txt = { bytes("mac=aa:bb:cc:dd:ee:10"), bytes("mnid=0BE8"), bytes("setupid=604") } }) } }) + ws:connection():close_connection() + H.accept_upgrade(new_upgrade) + test.wait_until(function() return new_ws:connection_count() == 1 and online() end, + { max_turns = 500, message = "device reconnected at the new IP" }) + assert(mock_device:get_field("device_ipv4") == NEW_IP, "driver should have persisted the new IP") + new_scenario:fail_connects(math.huge, "connection refused") +end) + +test.run_registered_tests() diff --git a/drivers/SmartThings/sonos/src/test/review_lan_helpers.lua b/drivers/SmartThings/sonos/src/test/review_lan_helpers.lua new file mode 100644 index 0000000000..ed534ba95f --- /dev/null +++ b/drivers/SmartThings/sonos/src/test/review_lan_helpers.lua @@ -0,0 +1,388 @@ +-- Framework-evaluation helpers for the Sonos review tests (not a driver test itself). +-- +-- Everything WebSocket-specific in here (frame encode/decode, upgrade response, the framer/parser +-- that lets connection_scenario carry a WebSocket connection) is NOT provided by the framework. +local test = require "integration_test" +local json = require "st.json" +local base64 = require "base64" +local sha1 = require "sha1" + +-- LAN driver tests need test coroutine priority and (for current hubs) batched capability receive. +test.set_test_coroutine_priority(true) +test.set_capability_batch_receive(true) + +-- The driver loads its API keys from an `app_key` module that is not checked in. Supply one so the +-- driver's own code paths run; this does not modify driver source. +package.preload["app_key"] = function() + return { s1_key = "test-s1-key", oauth_key = "test-oauth-key" } +end + +-- stus.softwareGeneration is a driver-package capability (sonos/capabilities/softwareGeneration.yaml). +test.add_package_capability("softwareGeneration.yaml") + +local connection_scenario = require "integration_test.connection_scenario" +local http = require "integration_test.connection_scenario_http" + +local M = {} + +M.PLAYER_IP = "192.168.1.50" +M.PLAYER_PORT = 1443 +M.MAC = "AABBCCDDEEFF" +M.HOUSEHOLD = "Sonos_HH1" +M.PLAYER_ID = "RINCON_AABBCCDDEEFF01400" +M.GROUP_ID = M.PLAYER_ID .. ":1" +M.SEARCH_TERM = "urn:smartspeaker-audio:service:SpeakerGroup:1" + +--#region WebSocket frames (server side builds unmasked frames, decodes the driver's masked ones) + +--- Build an unmasked server -> client frame. +function M.ws_frame(opcode, payload, fin) + payload = payload or "" + local b1 = (fin == false and 0 or 0x80) | opcode + local len = #payload + local header + if len < 126 then + header = string.char(b1, len) + elseif len < 65536 then + header = string.char(b1, 126, len >> 8, len & 0xff) + else + header = string.char(b1, 127, 0, 0, 0, 0, (len >> 24) & 0xff, (len >> 16) & 0xff, (len >> 8) & 0xff, len & 0xff) + end + return header .. payload +end + +--- A text frame carrying a Sonos `[header, body]` JSON message. +function M.ws_json(header, body) + return M.ws_frame(0x1, json.encode({ header, body or {} })) +end + +function M.ws_close(code, reason) + code = code or 1000 + return M.ws_frame(0x8, string.char(code >> 8, code & 0xff) .. (reason or "")) +end + +--- Decode as many complete client frames as `bytes` holds. +--- @return table[] frames `{ opcode, payload, masked }` +--- @return number consumed +function M.decode_client_frames(bytes) + local frames, pos = {}, 1 + while true do + if #bytes - pos + 1 < 2 then break end + local b1, b2 = bytes:byte(pos, pos + 1) + local opcode, masked, len = b1 & 0x0f, (b2 & 0x80) ~= 0, b2 & 0x7f + local p = pos + 2 + if len == 126 then + if #bytes - p + 1 < 2 then break end + local hi, lo = bytes:byte(p, p + 1) + len, p = (hi << 8) | lo, p + 2 + elseif len == 127 then + if #bytes - p + 1 < 8 then break end + len = 0 + for i = 0, 7 do len = (len << 8) | bytes:byte(p + i) end + p = p + 8 + end + local mask + if masked then + if #bytes - p + 1 < 4 then break end + mask = { bytes:byte(p, p + 3) } + p = p + 4 + end + if #bytes - p + 1 < len then break end + local payload = bytes:sub(p, p + len - 1) + if masked then + local out = {} + for i = 1, #payload do + out[i] = string.char(payload:byte(i) ~ mask[((i - 1) % 4) + 1]) + end + payload = table.concat(out) + end + table.insert(frames, { opcode = opcode, payload = payload, masked = masked }) + pos = p + len + end + return frames, pos - 1 +end + +local OPCODE_NAMES = { [0x0] = "cont", [0x1] = "text", [0x2] = "binary", [0x8] = "close", [0x9] = "ping", [0xA] = "pong" } + +--#endregion + +--#region connection_scenario glue for a WebSocket connection + +--- Framer: HTTP until the upgrade request is seen, then WebSocket frames. connection_scenario +--- hands a framer only the bytes it hasn't consumed yet and keeps no per-connection state for it, +--- so this sniffs the first bytes ("GET " = HTTP). The "request" string it returns for a frame is +--- a *decoded* rendering ("WS text ") so patterns/predicates can see through the masking. +local function ws_framer(bytes) + if bytes:match("^%s*%u+ /") then + return http.frame_requests(bytes) + end + local frames, consumed = M.decode_client_frames(bytes) + local requests = {} + for _, f in ipairs(frames) do + table.insert(requests, string.format("WS %s %s", OPCODE_NAMES[f.opcode] or f.opcode, f.payload)) + end + return requests, consumed +end + +local function ws_parser(request) + if request:match("^WS ") then + local kind, payload = request:match("^WS (%S+) (.*)$") + local info = { ws_kind = kind, ws_payload = payload } + if kind == "text" then + local ok, decoded = pcall(json.decode, payload) + if ok then + info.ws_json = decoded + info.header, info.body = decoded[1], decoded[2] + end + end + return info + end + return http.parse_request(request) +end + +local GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11" + +--- 101 Switching Protocols response for an upgrade request (computes Sec-WebSocket-Accept). +function M.upgrade_response(req) + local key = req.headers["sec-websocket-key"] + assert(key, "upgrade request had no Sec-WebSocket-Key") + local accept = base64.encode(sha1.binary(key .. GUID)) + return table.concat({ + "HTTP/1.1 101 Switching Protocols", + "Upgrade: websocket", + "Connection: Upgrade", + "Sec-WebSocket-Accept: " .. accept, + "Sec-WebSocket-Protocol: v1.api.smartspeaker.audio", + "", "", + }, "\r\n") +end + +--- Expect Sonos message(s) from the driver on a websocket logical connection. +--- @param conn LogicalConnection +--- @param spec table `{ namespace=, command=, check=function(header, body) -> ok, reason }` plus +--- any `expect` option (reusable, times, optional, ...) +function M.expect_ws_message(conn, spec) + local s = {} + for k, v in pairs(spec) do s[k] = v end + local ns, cmd, check = s.namespace, s.command, s.check + s.namespace, s.command, s.check = nil, nil, nil + s.request = { + pattern = "^WS text ", + description = string.format("ws %s.%s", tostring(ns), tostring(cmd)), + predicate = function(req) + local payload = req:match("^WS text (.*)$") + local ok, decoded = pcall(json.decode, payload) + if not ok or type(decoded) ~= "table" then return false, "not JSON" end + local header, body = decoded[1], decoded[2] + if ns and header.namespace ~= ns then return false, "namespace " .. tostring(header.namespace) end + if cmd and header.command ~= cmd then return false, "command " .. tostring(header.command) end + if check then return check(header, body) end + return true + end, + } + s.description = s.request.description + s.responses = s.responses or {} + return conn:expect(s) +end + +--- All Sonos messages the driver sent on a websocket logical connection so far, decoded. +function M.sent_ws_messages(conn) + local out = {} + for i = 1, conn:connection_count() do + local sent = conn:connection(i):sent() + local http_end = sent:find("\r\n\r\n", 1, true) + local frames = M.decode_client_frames(sent:sub((http_end or #sent) + 4)) + for _, f in ipairs(frames) do + if f.opcode == 0x1 then + local d = json.decode(f.payload) + table.insert(out, { header = d[1], body = d[2] }) + end + end + end + return out +end + +--#endregion + +--#region Sonos payloads + +function M.ssdp_reply(opts) + opts = opts or {} + local ip = opts.ip or M.PLAYER_IP + return table.concat({ + "HTTP/1.1 200 OK", + "CACHE-CONTROL: max-age = 1800", + "EXT:", + string.format("LOCATION: http://%s:1400/xml/device_description.xml", ip), + "SERVER: Linux UPnP/1.0 Sonos/70.3-88200 (ZPS9)", + "ST: " .. M.SEARCH_TERM, + string.format("USN: uuid:%s::%s", opts.player_id or M.PLAYER_ID, M.SEARCH_TERM), + "HOUSEHOLD.SMARTSPEAKER.AUDIO: " .. (opts.household or M.HOUSEHOLD), + string.format('GROUPINFO.SMARTSPEAKER.AUDIO: gc=1; gid=%s; gname="Kitchen"', opts.group_id or M.GROUP_ID), + string.format("WEBSOCK.SMARTSPEAKER.AUDIO: wss://%s:%d/websocket/api", ip, M.PLAYER_PORT), + "", "", + }, "\r\n") +end + +function M.player_info(opts) + opts = opts or {} + local ip = opts.ip or M.PLAYER_IP + return { + _objectType = "playerInfo", + playerId = M.PLAYER_ID, + groupId = M.GROUP_ID, + websocketUrl = string.format("wss://%s:%d/websocket/api", ip, M.PLAYER_PORT), + restUrl = string.format("https://%s:%d/api/v1", ip, M.PLAYER_PORT), + householdId = M.HOUSEHOLD, + device = { + id = M.PLAYER_ID, + name = "Kitchen", + model = "sonos-one", + modelDisplayName = "Sonos One", + serialNumber = "AA-BB-CC-DD-EE-FF:1", + swGen = 2, + primaryDeviceId = M.PLAYER_ID, + }, + } +end + +function M.groups_response(opts) + opts = opts or {} + local ip = opts.ip or M.PLAYER_IP + return { + _objectType = "groups", + groups = { + { id = M.GROUP_ID, name = "Kitchen", coordinatorId = M.PLAYER_ID, playbackState = "PLAYBACK_STATE_IDLE", + playerIds = { M.PLAYER_ID } }, + }, + players = { + { id = M.PLAYER_ID, name = "Kitchen", websocketUrl = string.format("wss://%s:%d/websocket/api", ip, M.PLAYER_PORT), + devices = { { id = M.PLAYER_ID } } }, + }, + } +end + +--#endregion + +--- Build the scenario for the player's address: a "rest" connection (one-shot HTTP GETs under +--- /api/) and a "ws" connection (the upgrade to /websocket/api, then WebSocket frames). +function M.create_scenario(ip) + local scenario = connection_scenario.new({ host = ip or M.PLAYER_IP, port = M.PLAYER_PORT }) + local rest = scenario:connection("rest", { matcher = http.matcher("GET", "^/api/") }) + local ws = scenario:connection("ws", { + matcher = http.matcher("GET", "^/websocket/api"), + framer = ws_framer, + parser = ws_parser, + ordering = "relaxed", + }) + return scenario, rest, ws +end + +--- Register the persistent expectations: REST answers, the upgrade, and the subscribe messages. +function M.register_standard_expectations(rest, ws, opts) + opts = opts or {} + http.expect_request(rest, "GET", "/api/v1/players/local/info", { + body = M.player_info(opts), reusable = true, + }) + http.expect_request(rest, "GET", "/api/v1/households/" .. M.HOUSEHOLD .. "/groups", { + body = M.groups_response(opts), reusable = true, + }) + ws:expect({ + request = { pattern = "^GET /websocket/api" }, + description = "websocket upgrade", + responses = { { generator = M.upgrade_response } }, + reusable = true, + }) + -- The driver answers a server Close frame (and closes on shutdown) with its own Close frame. + ws:expect({ request = { pattern = "^WS close" }, description = "ws close frame", responses = {}, reusable = true }) + for _, ns in ipairs({ "groups", "playerVolume", "audioClip", "groupVolume", "playback", "favorites", "playbackMetadata" }) do + M.expect_ws_message(ws, { namespace = ns, command = "subscribe", reusable = true }) + end +end + +--- The sonos-player profile, built by hand: `t_utils.get_profile_definition("sonos-player.yml")` +--- fails to parse this driver's profile because the test framework's yaml parser can't read the +--- escaped quotes in the `queueAction` preference title. +function M.mock_device(overrides) + local caps = {} + for _, id in ipairs({ "mediaPlayback", "mediaGroup", "mediaPresets", "mediaTrackControl", "audioMute", + "audioNotification", "audioTrackData", "audioVolume", "stus.softwareGeneration", "refresh" }) do + table.insert(caps, { id = id, version = 1 }) + end + local d = { + profile = { components = { main = { id = "main", capabilities = caps } }, preferences = {} }, + device_network_id = M.MAC, + label = "Kitchen", + } + for k, v in pairs(overrides or {}) do d[k] = v end + return test.mock_device.build_test_lan_device(d) +end + +--- The Sonos driver keeps process-wide state in module locals (the websocket router's socket table +--- and its `cosock.spawn`ed task are created when the module loads; SonosState is a singleton). +--- The framework resets cosock between tests but not `package.loaded`, so from the second test in a +--- process on, the router's task is dead and its socket table is stale. Work around it by +--- unloading every module that comes from the driver's own source tree before each test. +function M.unload_driver_modules() + local names = {} + for name in pairs(package.loaded) do + if not name:match("^test%.") and not name:match("^cosock") and name ~= "init" then + local found = package.searchpath(name, package.path) + if found and found:sub(1, 2) == "./" then table.insert(names, name) end + end + end + for _, name in ipairs(names) do package.loaded[name] = nil end +end + +--- Everything a test file needs: scenario + connections, the mock device, and the SSDP peer. +--- Call `ctx.init` from set_test_init_function and `ctx.bring_up()` from a coroutine test body. +function M.new_context(opts) + local capabilities = require "st.capabilities" + local mock_lan_socket = require "integration_test.mock_lan_socket" + local mock_devices_api = require "integration_test.mock_devices_api" + opts = opts or {} + local ctx = { opts = opts } + ctx.scenario, ctx.rest, ctx.ws = M.create_scenario(opts.ip) + M.register_standard_expectations(ctx.rest, ctx.ws, opts) + ctx.device = M.mock_device() + + function ctx.event(cap_event) + test.socket.capability:__expect_send(ctx.device:generate_test_message("main", cap_event)) + end + + function ctx.init() + M.unload_driver_modules() + ctx.scenario:activate() + ctx.ssdp = mock_lan_socket.udp_peer("239.255.255.250", 1900) + if opts.rescan_timer then + -- cosock.timer is mocked and never fires unless the test queues a timer for the driver's + -- next create_interval(); this is the persistent SSDP task's 600 s rescan. + test.timer.__create_and_queue_test_time_advance_timer(600, "interval", 1) + end + test.socket.capability:__set_channel_ordering("relaxed") + if not opts.no_device then + test.mock_device.add_test_device(ctx.device) + ctx.event(capabilities.mediaPlayback.supportedPlaybackCommands({ "play", "pause", "stop" })) + ctx.event(capabilities.mediaTrackControl.supportedTrackControlCommands({ "nextTrack", "previousTrack" })) + end + end + + function ctx.is_online() + return mock_devices_api.__is_device_online(ctx.device.id) + end + + --- SSDP search -> reply -> REST info/groups -> websocket upgrade -> subscribes -> online. + function ctx.bring_up() + ctx.event(capabilities["stus.softwareGeneration"].generation("2")) + ctx.event(capabilities.mediaGroup.groupRole("ungrouped")) + ctx.event(capabilities.mediaGroup.groupPrimaryDeviceId(M.PLAYER_ID)) + test.wait_until(function() return #ctx.ssdp:sent() > 0 end, { message = "the driver's M-SEARCH" }) + ctx.ssdp:reply(M.ssdp_reply(), { from_host = M.PLAYER_IP, from_port = 1900 }) + test.wait_until(ctx.is_online, { message = "the device online", max_turns = 200 }) + test.wait_for_events() + end + + return ctx +end + +return M diff --git a/drivers/SmartThings/sonos/src/test/test_review_lan_bringup.lua b/drivers/SmartThings/sonos/src/test/test_review_lan_bringup.lua new file mode 100644 index 0000000000..6b91c3ec62 --- /dev/null +++ b/drivers/SmartThings/sonos/src/test/test_review_lan_bringup.lua @@ -0,0 +1,38 @@ +local helpers = require "test.review_lan_helpers" +local test = require "integration_test" +local capabilities = require "st.capabilities" +local mock_lan_socket = require "integration_test.mock_lan_socket" +local mock_devices_api = require "integration_test.mock_devices_api" + +local scenario, rest, ws = helpers.create_scenario() +helpers.register_standard_expectations(rest, ws) +local mock_device = helpers.mock_device() +local ssdp_peer + +test.set_test_init_function(function() + scenario:activate() + ssdp_peer = mock_lan_socket.udp_peer("239.255.255.250", 1900) + test.mock_device.add_test_device(mock_device) + test.socket.capability:__set_channel_ordering("relaxed") + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.mediaPlayback.supportedPlaybackCommands({"play", "pause", "stop"}))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.mediaTrackControl.supportedTrackControlCommands({"nextTrack", "previousTrack"}))) +end) + +local function expect_discovery_events() + local function ev(c) test.socket.capability:__expect_send(mock_device:generate_test_message("main", c)) end + ev(capabilities["stus.softwareGeneration"].generation("2")) + ev(capabilities.mediaGroup.groupRole("ungrouped")) + ev(capabilities.mediaGroup.groupPrimaryDeviceId(helpers.PLAYER_ID)) +end + +test.register_coroutine_test("bringup", function() + expect_discovery_events() + test.wait_until(function() return #ssdp_peer:sent() > 0 end, { message = "M-SEARCH" }) + print(ssdp_peer:sent()[1]) + ssdp_peer:reply(helpers.ssdp_reply(), { from_host = helpers.PLAYER_IP, from_port = 1900 }) + test.wait_until(function() return mock_devices_api.__is_device_online(mock_device.id) end, {message = "online", max_turns = 200}) +end) + +test.run_registered_tests() diff --git a/drivers/SmartThings/sonos/src/test/test_review_lan_connectivity.lua b/drivers/SmartThings/sonos/src/test/test_review_lan_connectivity.lua new file mode 100644 index 0000000000..bf80e00906 --- /dev/null +++ b/drivers/SmartThings/sonos/src/test/test_review_lan_connectivity.lua @@ -0,0 +1,82 @@ +-- Use case 2: websocket closed by the device / player unreachable, driver goes offline and reconnects. +local helpers = require "test.review_lan_helpers" +local test = require "integration_test" +local mock_devices_api = require "integration_test.mock_devices_api" + +local ctx = helpers.new_context() +test.set_test_init_function(ctx.init) + +local function history() return mock_devices_api.__get_device_status_history(ctx.device.id) end + +local function count_subscribes(conn, which) + local n = 0 + local sent = conn:connection(which):sent() + local frames = helpers.decode_client_frames(sent:sub(sent:find("\r\n\r\n", 1, true) + 4)) + for _, f in ipairs(frames) do + if f.payload:find('"subscribe"', 1, true) then n = n + 1 end + end + return n +end + +test.register_coroutine_test("TCP close by the player: offline, then reconnects and resubscribes", function() + ctx.bring_up() + assert(ctx.ws:connection_count() == 1) + mock_devices_api.__clear_device_status_history() + + ctx.ws:close_connection() + test.wait_until(function() return ctx.ws:connection_count() == 2 and ctx.is_online() end, + { message = "a second websocket connection and the device online again", max_turns = 200 }) + test.wait_for_events() + + local h = history() + assert(h[1] == false and h[#h] == true, "expected offline then online, got ") + assert(count_subscribes(ctx.ws, 2) == 7, "subscriptions re-sent on the new connection") +end) + +test.register_coroutine_test("WebSocket Close frame from the player: offline, then reconnects", function() + ctx.bring_up() + mock_devices_api.__clear_device_status_history() + ctx.ws:queue_bytes(helpers.ws_close(1001, "going away")) + -- Per RFC 6455 the driver echoes the Close and then the server drops the TCP connection. + test.wait_until(function() + local sent = ctx.ws:connection(1):sent() + local frames = helpers.decode_client_frames(sent:sub(sent:find("\r\n\r\n", 1, true) + 4)) + for _, f in ipairs(frames) do if f.opcode == 0x8 then return true end end + end, { message = "the driver's Close echo" }) + ctx.ws:close_connection() + test.wait_until(function() return ctx.ws:connection_count() == 2 and ctx.is_online() end, + { message = "reconnect after Close frame", max_turns = 200 }) + test.wait_for_events() + assert(mock_devices_api.__count_device_status_calls(ctx.device.id, false) >= 1, "was marked offline") + -- the driver answers a Close frame with its own (masked) Close frame + local sent = ctx.ws:connection(1):sent() + local frames = helpers.decode_client_frames(sent:sub(sent:find("\r\n\r\n", 1, true) + 4)) + local saw_close + for _, f in ipairs(frames) do if f.opcode == 0x8 then saw_close = true end end + assert(saw_close, "driver did not echo a Close frame") +end) + +test.register_coroutine_test("player unreachable: stays offline with connects refused, recovers when it returns", function() + ctx.bring_up() + mock_devices_api.__clear_device_status_history() + ctx.scenario:fail_connects(math.huge, "connection refused") + ctx.ws:close_connection() + test.wait_until(function() return ctx.is_online() == false end, { message = "offline", max_turns = 100 }) + -- let the backoff loop try (and fail) a few times + for _ = 1, 5 do + test.mock_time.advance_time(30) + test.wait_for_events() + end + assert(ctx.is_online() == false, "still offline while refusing connections") + assert(ctx.ws:connection_count() == 1, "no new websocket connection while refused") + ctx.scenario:clear_connect_failures() + for _ = 1, 4 do + test.mock_time.advance_time(30) + test.wait_for_events() + if ctx.is_online() then break end + end + test.wait_until(ctx.is_online, { message = "online again after the player returns", max_turns = 200 }) + assert(ctx.ws:connection_count() == 2) +end) + +test.run_registered_tests() diff --git a/drivers/SmartThings/sonos/src/test/test_review_lan_discovery.lua b/drivers/SmartThings/sonos/src/test/test_review_lan_discovery.lua new file mode 100644 index 0000000000..0ce959b713 --- /dev/null +++ b/drivers/SmartThings/sonos/src/test/test_review_lan_discovery.lua @@ -0,0 +1,42 @@ +-- Use case 3: SSDP discovery timing. +local helpers = require "test.review_lan_helpers" +local test = require "integration_test" +local mock_devices_api = require "integration_test.mock_devices_api" + +local ctx = helpers.new_context({ no_device = true, rescan_timer = true }) +test.set_test_init_function(ctx.init) + +test.register_coroutine_test("SSDP reply from an unknown player creates a LAN device", function() + mock_devices_api.__expect_create_device({ + type = "LAN", + deviceNetworkId = helpers.MAC, + label = "Kitchen", + profileReference = "sonos-player", + manufacturer = "Sonos", + model = "Sonos One", + vendorProvidedLabel = "sonos-one", + }) + test.wait_until(function() return #ctx.ssdp:sent() > 0 end, { message = "the driver's M-SEARCH" }) + local search = ctx.ssdp:sent()[1] + assert(search:find("M-SEARCH * HTTP/1.1", 1, true) and search:find("ST: " .. helpers.SEARCH_TERM, 1, true), search) + ctx.ssdp:reply(helpers.ssdp_reply(), { from_host = helpers.PLAYER_IP, from_port = 1900 }) + test.wait_for_events() + test.wait_until(function() return ctx.rest:connection_count() >= 2 end, { message = "REST info + groups" }) + test.wait_for_events() +end) + +test.register_coroutine_test("an SSDP reply whose source IP differs from its LOCATION is ignored", function() + test.wait_until(function() return #ctx.ssdp:sent() > 0 end, { message = "the driver's M-SEARCH" }) + ctx.ssdp:reply(helpers.ssdp_reply(), { from_host = "192.168.1.99", from_port = 1900 }) + test.wait_for_events() + test.wait_for_events() + assert(ctx.rest:connection_count() == 0, "no REST request for a spoofed reply") +end) + +test.register_coroutine_test("the persistent SSDP task re-searches every 10 minutes", function() + test.wait_until(function() return #ctx.ssdp:sent() > 0 end, { message = "the driver's M-SEARCH" }) + local before = #ctx.ssdp:sent() + test.wait_until(function() return #ctx.ssdp:sent() > before end, { message = "a periodic M-SEARCH", max_turns = 2000 }) +end) + +test.run_registered_tests() diff --git a/drivers/SmartThings/sonos/src/test/test_review_lan_rediscovery.lua b/drivers/SmartThings/sonos/src/test/test_review_lan_rediscovery.lua new file mode 100644 index 0000000000..addc19e995 --- /dev/null +++ b/drivers/SmartThings/sonos/src/test/test_review_lan_rediscovery.lua @@ -0,0 +1,47 @@ +-- Use case 3b: an already-added player is rediscovered at a new IP. +local helpers = require "test.review_lan_helpers" +local test = require "integration_test" +local capabilities = require "st.capabilities" + +local NEW_IP = "192.168.1.60" +local ctx = helpers.new_context({ rescan_timer = true }) +local scenario2, rest2, ws2 = helpers.create_scenario(NEW_IP) +helpers.register_standard_expectations(rest2, ws2, { ip = NEW_IP }) + +test.set_test_init_function(function() + ctx.init() + scenario2:activate() +end) + +local function rediscover() + ctx.bring_up() + local searches = #ctx.ssdp:sent() + test.wait_until(function() return #ctx.ssdp:sent() > searches end, + { message = "the 10 minute SSDP rescan", max_turns = 200 }) + ctx.ssdp:reply(helpers.ssdp_reply({ ip = NEW_IP }), { from_host = NEW_IP, from_port = 1900 }) + test.wait_until(function() return rest2:connection_count() >= 2 end, + { message = "REST info + groups at the new IP", max_turns = 100 }) +end + +test.register_coroutine_test("rediscovery at a new IP queries the new address", function() + rediscover() + assert(rest2:connection_count() >= 2) +end) + +-- Characterises the driver's behaviour: nothing reconnects by itself after the old socket drops (the +-- stopped SonosConnection has no keepalive), but the next refresh command re-opens the websocket +-- at the new IP. +test.register_coroutine_test("after rediscovery at a new IP, a refresh command opens the websocket there", function() + rediscover() + ctx.ws:close_connection() + test.wait_for_events() + test.wait_for_events() + assert(not ws2:was_opened(), "driver reconnected by itself (behaviour changed?)") + test.socket.capability:__queue_receive({ ctx.device.id, + { capability = "refresh", command = "refresh", args = {}, component = "main" } }) + test.wait_until(function() return ws2:was_opened() end, + { message = "a websocket connection to the new IP after refresh", max_turns = 200 }) + test.wait_until(ctx.is_online, { message = "online" }) +end) + +test.run_registered_tests() diff --git a/drivers/SmartThings/sonos/src/test/test_review_lan_websocket_messages.lua b/drivers/SmartThings/sonos/src/test/test_review_lan_websocket_messages.lua new file mode 100644 index 0000000000..62db9736cc --- /dev/null +++ b/drivers/SmartThings/sonos/src/test/test_review_lan_websocket_messages.lua @@ -0,0 +1,87 @@ +-- Use case 1: protocol message <-> capability over the Sonos WebSocket. +local helpers = require "test.review_lan_helpers" +local test = require "integration_test" +local capabilities = require "st.capabilities" + +local ctx = helpers.new_context() +test.set_test_init_function(ctx.init) + +local function header(type_, extra) + local h = { namespace = type_, type = type_, householdId = helpers.HOUSEHOLD, groupId = helpers.GROUP_ID, + playerId = helpers.PLAYER_ID } + for k, v in pairs(extra or {}) do h[k] = v end + return h +end + +test.register_coroutine_test("playbackStatus push emits mediaPlayback.playbackStatus", function() + ctx.bring_up() + ctx.event(capabilities.mediaPlayback.playbackStatus.playing()) + ctx.ws:queue_bytes(helpers.ws_json(header("playbackStatus"), { playbackState = "PLAYBACK_STATE_PLAYING" })) + test.wait_for_events() + ctx.event(capabilities.mediaPlayback.playbackStatus.paused()) + ctx.ws:queue_bytes(helpers.ws_json(header("playbackStatus"), { playbackState = "PLAYBACK_STATE_PAUSED" })) + test.wait_for_events() +end) + +test.register_coroutine_test("playerVolume push emits volume and mute", function() + ctx.bring_up() + ctx.event(capabilities.audioVolume.volume(35)) + ctx.event(capabilities.audioMute.mute.unmuted()) + ctx.ws:queue_bytes(helpers.ws_json(header("playerVolume"), { volume = 35, muted = false })) + test.wait_for_events() +end) + +test.register_coroutine_test("groupVolume push emits mediaGroup events (masked/fragmented delivery)", function() + ctx.bring_up() + ctx.event(capabilities.mediaGroup.groupVolume(12)) + ctx.event(capabilities.mediaGroup.groupMute.muted()) + -- Deliver one frame in two TCP chunks to check the driver's frame reassembly. + local frame = helpers.ws_json(header("groupVolume"), { volume = 12, muted = true }) + ctx.ws:queue_bytes(frame:sub(1, 10)) + test.wait_for_events() + ctx.ws:queue_bytes(frame:sub(11)) + test.wait_for_events() +end) + +test.register_coroutine_test("setVolume command sends playerVolume.setVolume over the websocket", function() + ctx.bring_up() + helpers.expect_ws_message(ctx.ws, { + namespace = "playerVolume", command = "setVolume", + check = function(h, b) + if h.playerId ~= helpers.PLAYER_ID then return false, "playerId " .. tostring(h.playerId) end + if h.householdId ~= helpers.HOUSEHOLD then return false, "householdId" end + return b.volume == 25, "volume " .. tostring(b.volume) + end, + }) + test.socket.capability:__queue_receive({ ctx.device.id, + { capability = "audioVolume", command = "setVolume", args = { 25 }, component = "main" } }) + test.wait_for_events() +end) + +test.register_coroutine_test("play command goes to the group (playback.play)", function() + ctx.bring_up() + helpers.expect_ws_message(ctx.ws, { + namespace = "playback", command = "play", + check = function(h) return h.groupId == helpers.GROUP_ID, "groupId " .. tostring(h.groupId) end, + }) + test.socket.capability:__queue_receive({ ctx.device.id, + { capability = "mediaPlayback", command = "play", args = {}, component = "main" } }) + test.wait_for_events() +end) + +test.register_coroutine_test("the upgrade request, TLS use and masked subscribe frames", function() + ctx.bring_up() + local sent = ctx.ws:connection():sent() + assert(sent:match("^GET /websocket/api HTTP/1.1"), "upgrade request line") + assert(sent:lower():find("upgrade: websocket", 1, true), "upgrade header") + assert(sent:lower():find("x-sonos-api-key: test-", 1, true), "api key header") + assert(ctx.ws:connection():tls() and ctx.ws:connection():tls().handshaken, "TLS handshake") + local frames = helpers.decode_client_frames(sent:sub(sent:find("\r\n\r\n", 1, true) + 4)) + assert(#frames == 7, "expected 7 subscribe frames, got " .. #frames) + for _, f in ipairs(frames) do assert(f.masked, "client frame must be masked") end + local namespaces = {} + for _, m in ipairs(helpers.sent_ws_messages(ctx.ws)) do namespaces[m.header.namespace] = m.header.command end + assert(namespaces.groups == "subscribe" and namespaces.playback == "subscribe") +end) + +test.run_registered_tests() diff --git a/drivers/SmartThings/wemo/src/test/review_helpers.lua b/drivers/SmartThings/wemo/src/test/review_helpers.lua new file mode 100644 index 0000000000..442f060456 --- /dev/null +++ b/drivers/SmartThings/wemo/src/test/review_helpers.lua @@ -0,0 +1,132 @@ +-- Shared fixture for the test_review_lan_*.lua files (review of the LAN integration test framework +-- against the wemo driver). +local test = require "integration_test" +test.set_test_coroutine_priority(true) -- required at file level for LAN tests; harmless to repeat +require "test.review_http_shim" -- socket.http is unusable under the mocks (see that file) + +local t_utils = require "integration_test.utils" +local mock_lan_socket = test.mock_lan_socket +local connection_scenario = require "integration_test.connection_scenario" +local http = require "integration_test.connection_scenario_http" +local mock_devices_api = require "integration_test.mock_devices_api" + +local H = {} + +H.DEVICE_IP, H.DEVICE_PORT = "192.168.1.50", 49153 +H.DNI = "EC1A5918ABCD" -- the MAC the device reports in setup.xml +H.SERIAL = "SER123" +H.SID = "uuid:Subscription-1" + +H.setup_xml = function(over) + over = over or {} + return string.format([[ + +%s%s +%s%s +]], over.name or "Wemo Mini", over.model or "Socket", over.mac or H.DNI, over.serial or H.SERIAL) +end + +H.ssdp_response = function(ip, port) + return table.concat({ + "HTTP/1.1 200 OK", + "CACHE-CONTROL: max-age=86400", + string.format("LOCATION: http://%s:%s/setup.xml", ip or H.DEVICE_IP, port or H.DEVICE_PORT), + "ST: urn:Belkin:device:**", + "", "", + }, "\r\n") +end + +H.notify = function(body, sid) + return table.concat({ + "NOTIFY / HTTP/1.1", + "HOST: 192.168.1.2:49152", + 'CONTENT-TYPE: text/xml; charset="utf-8"', + "NT: upnp:event", + "NTS: upnp:propchange", + "SID: " .. (sid or H.SID), + "CONTENT-LENGTH: " .. #body, + "", body, + }, "\r\n") +end + +H.property_set = function(inner) + return '' .. inner .. + "" +end + +--- Build the whole fixture. `opts.known_device` (default true) makes the device already know its +--- ip/port/serial (as after a previous discovery); false leaves them unset. +function H.new(opts) + opts = opts or {} + local f = {} + f.mock_device = test.mock_device.build_test_lan_device({ + label = "Wemo Plug", + device_network_id = H.DNI, + profile = t_utils.get_profile_definition("mini-smart-plug.yaml"), + }) + + f.scenario = connection_scenario.new({ host = H.DEVICE_IP, port = H.DEVICE_PORT }) + -- every luasocket-style request is its own TCP connection; one logical connection per endpoint + f.setup = f.scenario:connection("setup", { matcher = http.matcher("GET", "^/setup%.xml"), ordering = "relaxed" }) + f.control = f.scenario:connection("control", { matcher = http.matcher("POST", "^/upnp/control/"), ordering = "relaxed" }) + f.events = f.scenario:connection("events", + { matcher = http.matcher({ "SUBSCRIBE", "UNSUBSCRIBE" }, "^/upnp/event/"), ordering = "relaxed" }) + + test.add_test_env_setup_func(function(driver) + f.driver = driver + -- `discovery` is a module-level singleton that survives from one test to the next within a file; + -- a discovery task left over from the previous test would swallow this test's FindDevice + -- requests (and block on a dead channel). + require("discovery")._ctrl_tx = nil + -- WORKAROUND for a framework gap: the mocked `devices.create_device` returns nothing, so + -- wemo's `assert(driver:try_create_device(msg), "failed to create device record")` always fails. + local original = driver.try_create_device + driver.try_create_device = function(self, metadata) + original(self, metadata) + return true + end + end) + + function f.init() + f.scenario:activate() + -- UDP peers (like tcp_address) are forgotten when each test ends, so register them per test. + f.ssdp = mock_lan_socket.udp_peer("239.255.255.250", 1900) + -- protocol.subscribe finds its interface address by connecting a UDP socket to :9 + f.discard = mock_lan_socket.udp_peer(H.DEVICE_IP, 9) + -- The timer mock hands out queued timers in creation order (anything unqueued never fires). + -- device_init creates: a oneshot (initial subscribe, jitter <= 2s), a 3600s interval + -- (resubscribe) and a 60s interval (poll). + if not opts.no_device then + test.timer.__create_and_queue_test_time_advance_timer(2, "oneshot") + test.timer.__create_and_queue_test_time_advance_timer(3601, "interval") + test.timer.__create_and_queue_test_time_advance_timer(61, "interval") + end + if opts.no_device then return end + test.mock_device.add_test_device(f.mock_device) + -- mock fields are consumed when the driver builds its device: set them every test. + if opts.known_device ~= false then + f.mock_device:set_field("ip", H.DEVICE_IP, { persist = true }) + f.mock_device:set_field("port", H.DEVICE_PORT, { persist = true }) + f.mock_device:set_field("serial_num", H.SERIAL, { persist = true }) + end + end + + function f.is_online() return mock_devices_api.__is_device_online(f.mock_device.id) end + + --- The port the driver's subscription server listens on. + function f.server_port() + local _, port = f.driver.server.sock:getsockname() + return port + end + + --- Answer the device's own rediscovery search (device_init spawns it) and let it finish. + function f.answer_rediscovery() + test.wait_until(function() return #f.ssdp:sent() >= 1 end, { message = "M-SEARCH sent" }) + f.ssdp:reply(H.ssdp_response(), { from_host = H.DEVICE_IP, from_port = 1900 }) + end + + return f +end + +H.http = http +return H diff --git a/drivers/SmartThings/wemo/src/test/review_http_shim.lua b/drivers/SmartThings/wemo/src/test/review_http_shim.lua new file mode 100644 index 0000000000..6119fb6102 --- /dev/null +++ b/drivers/SmartThings/wemo/src/test/review_http_shim.lua @@ -0,0 +1,100 @@ +-- TEST-SIDE SHIM (review of the LAN integration test framework). +-- +-- The wemo driver does `cosock.asyncify "socket.http"` and calls `http.request{...}`. Under the +-- integration test framework `socket.protect` (and `newtry`, `try`, `sink`, `source`, `skip`) are +-- no-op mocks (integration_test/mock_socket.lua `func_mocks`), so luasocket's http.lua loads but +-- `http.request` is nil. This shim replaces `cosock.asyncify("socket.http")` with a small pure-Lua +-- HTTP/1.1 client over `cosock.socket.tcp`, matching the `request{}` contract wemo relies on +-- (`resp, code, headers, status_line`, `nil, err` on failure). It must be required before the driver loads. +local cosock = require "cosock" +local socket = require "cosock.socket" + +local real_asyncify = cosock.asyncify + +local function read_chunked(sock) + local parts = {} + while true do + local size_line, err = sock:receive("*l") + if not size_line then return nil, err end + local size = tonumber(size_line:match("^(%x+)"), 16) + if not size then return nil, "malformed chunk" end + if size == 0 then + sock:receive("*l") + return table.concat(parts) + end + local data, derr = sock:receive(size) + if not data then return nil, derr end + table.insert(parts, data) + sock:receive("*l") -- CRLF after chunk + end +end + +local http = {} + +function http.request(reqt) + local host, port, path = reqt.url:match("^http://([^:/]+):?(%d*)(/?.*)$") + if path == "" then path = "/" end + port = tonumber(port) or 80 + local sock = socket.tcp() + sock:settimeout(60) + local ok, cerr = sock:connect(host, port) + if not ok then sock:close() return nil, cerr end + + local body = "" + if reqt.source then + local parts = {} + while true do + local chunk = reqt.source() + if not chunk then break end + table.insert(parts, chunk) + end + body = table.concat(parts) + end + local lines = { string.format("%s %s HTTP/1.1", reqt.method or "GET", path) } + local has_host = false + for k, v in pairs(reqt.headers or {}) do + if k:lower() == "host" then has_host = true end + table.insert(lines, k .. ": " .. tostring(v)) + end + if not has_host then table.insert(lines, "Host: " .. host .. ":" .. port) end + local _, serr = sock:send(table.concat(lines, "\r\n") .. "\r\n\r\n" .. body) + if serr then sock:close() return nil, serr end + + local status_line, err = sock:receive("*l") + if not status_line then sock:close() return nil, err end + local code = tonumber(status_line:match("HTTP/%d%.%d (%d%d%d)")) + local headers = {} + while true do + local line, herr = sock:receive("*l") + if not line then sock:close() return nil, herr end + if line == "" then break end + local k, v = line:match("^(.-):%s*(.*)$") + if k then headers[k:lower()] = v end + end + + local resp_body, berr + if headers["transfer-encoding"] and headers["transfer-encoding"]:lower():find("chunked") then + resp_body, berr = read_chunked(sock) + elseif headers["content-length"] then + local n = tonumber(headers["content-length"]) + if n == 0 then resp_body = "" else resp_body, berr = sock:receive(n) end + else + local partial + resp_body, berr, partial = sock:receive("*a") + resp_body = resp_body or partial + berr = nil + end + sock:close() + if berr then return nil, berr end -- e.g. "closed" mid-chunked-body, as luasocket reports it + + if reqt.sink and resp_body and #resp_body > 0 then reqt.sink(resp_body) end + if reqt.sink then reqt.sink(nil) end + return 1, code, headers, status_line +end + +cosock.asyncify = function(name, ...) + if name == "socket.http" then return http end + return real_asyncify(name, ...) +end + +return http diff --git a/drivers/SmartThings/wemo/src/test/test_review_lan_connectivity.lua b/drivers/SmartThings/wemo/src/test/test_review_lan_connectivity.lua new file mode 100644 index 0000000000..7c05f16db1 --- /dev/null +++ b/drivers/SmartThings/wemo/src/test/test_review_lan_connectivity.lua @@ -0,0 +1,100 @@ +-- Review of the LAN integration test framework against the wemo driver. +-- Use case 2: connectivity loss and re-establishment. The driver polls every 60s +-- (protocol.poll); two failed requests mark the device offline, a successful one online. +local test = require "integration_test" +test.set_test_coroutine_priority(true) + +local H = require "test.review_helpers" +local http = H.http +local capabilities = require "st.capabilities" +local mock_devices_api = require "integration_test.mock_devices_api" + +local f = H.new() + +http.expect_request(f.setup, "GET", "/setup.xml", { body = H.setup_xml(), reusable = true }) +http.expect_request(f.events, "SUBSCRIBE", "/upnp/event/basicevent1", { headers = { SID = H.SID }, reusable = true }) +http.expect_request(f.events, "UNSUBSCRIBE", "/upnp/event/basicevent1", { reusable = true }) + +test.set_test_init_function(f.init) + +local function state_response(value) + return [[]] .. + [[]] .. value .. + [[]] +end + +--- Rediscover (marks the device online), then let the subscribe settle. +local function online_device() + f.answer_rediscovery() + test.wait_until(f.is_online, { message = "device online after rediscovery" }) + test.mock_time.advance_time(2) + test.wait_until(function() return f.mock_device:get_field("subscription_id") == H.SID end, + { message = "subscription_id stored" }) +end + +local function poll_now() + test.mock_time.advance_time(61) -- fires the 60s poll interval timer +end + +test.register_coroutine_test("refused connections on the poll mark the device offline, then it recovers", function() + online_device() + assert(f.is_online()) + + f.scenario:fail_connects(math.huge, "connection refused") + poll_now() + test.wait_until(function() return not f.is_online() end, { message = "device offline after failed polls" }) + assert(mock_devices_api.__count_device_status_calls(f.mock_device.id, false) >= 1) + + -- the device comes back: the next poll goes through, marks it online and reports its state + f.scenario:clear_connect_failures() + http.expect_request(f.control, "POST", "/upnp/control/basicevent1", { body = state_response(1) }) + test.socket.capability:__expect_send(f.mock_device:generate_test_message("main", capabilities.switch.switch.on())) + poll_now() + test.wait_for_events() + test.wait_until(f.is_online, { message = "device online after recovery" }) +end) + +test.register_coroutine_test("a request dropped mid-flight is retried once, then the device goes offline", function() + online_device() + + poll_now() + local first = http.await_request(f.control, "POST", "/upnp/control/basicevent1") + f.control:close_connection() -- the plug resets the connection without answering + local retry = http.await_request(f.control, "POST", "/upnp/control/basicevent1") + f.control:close_connection() + test.wait_until(function() return not f.is_online() end, { message = "device offline after two dropped polls" }) +end) + +test.register_coroutine_test("a failed first poll followed by a good retry keeps the device online", function() + online_device() + + poll_now() + http.await_request(f.control, "POST", "/upnp/control/basicevent1") + f.control:close_connection() + local retry = http.await_request(f.control, "POST", "/upnp/control/basicevent1") + test.socket.capability:__expect_send(f.mock_device:generate_test_message("main", capabilities.switch.switch.off())) + retry:respond({ body = state_response(0) }) + test.wait_for_events() + assert(f.is_online(), "device should still be online") +end) + +test.register_coroutine_test("refresh while the plug is unreachable goes offline; refresh after it returns resubscribes and recovers", function() + online_device() + local refresh = { f.mock_device.id, { capability = "refresh", component = "main", command = "refresh", args = {} } } + + f.scenario:fail_connects(math.huge, "connection refused") + test.socket.capability:__queue_receive(refresh) + test.wait_until(function() return not f.is_online() end, { message = "offline after refresh" }) + local subscribes_before = http.count_requests(f.events, "SUBSCRIBE", "/upnp/event/basicevent1") + + f.scenario:clear_connect_failures() + http.expect_request(f.control, "POST", "/upnp/control/basicevent1", { body = state_response(1) }) + test.socket.capability:__expect_send(f.mock_device:generate_test_message("main", capabilities.switch.switch.on())) + test.socket.capability:__queue_receive(refresh) + test.wait_for_events() + test.wait_until(function() + return f.is_online() and http.count_requests(f.events, "SUBSCRIBE", "/upnp/event/basicevent1") > subscribes_before + end, { message = "online again and resubscribed" }) +end) + +test.run_registered_tests() diff --git a/drivers/SmartThings/wemo/src/test/test_review_lan_discovery.lua b/drivers/SmartThings/wemo/src/test/test_review_lan_discovery.lua new file mode 100644 index 0000000000..8fc3b786f5 --- /dev/null +++ b/drivers/SmartThings/wemo/src/test/test_review_lan_discovery.lua @@ -0,0 +1,143 @@ +-- Review of the LAN integration test framework against the wemo driver. +-- Use case 3: SSDP discovery timing. The driver sends an M-SEARCH to 239.255.255.250:1900 one +-- second after a scan starts (so several requesters can coalesce), listens for 5 seconds, fetches +-- each responder's setup.xml, creates the device, and immediately rescans until told to stop. +local test = require "integration_test" +test.set_test_coroutine_priority(true) + +local H = require "test.review_helpers" +local http = H.http +local mock_devices_api = require "integration_test.mock_devices_api" + +local f = H.new({ no_device = true }) + +http.expect_request(f.setup, "GET", "/setup.xml", { body = H.setup_xml(), reusable = true }) + +test.set_test_init_function(f.init) + +local function expect_created(over) + over = over or {} + mock_devices_api.__expect_create_device({ + deviceNetworkId = over.mac or H.DNI, + label = over.name or "Wemo Mini", + profileReference = over.profile or "wemo.mini-smart-plug.v1", + manufacturer = "Belkin", + model = over.model or "Socket", + vendorProvidedLabel = over.name or "Wemo Mini", + type = "LAN", + }) +end + +local function start_scan() test.socket.discovery:__queue_receive({ "start", {} }) end +local function stop_scan() test.socket.discovery:__queue_receive({ "stop" }) end +local function searches_sent() return #f.ssdp:sent() end + +local function wait_for_search(n) + test.wait_until(function() return searches_sent() >= n end, { message = "M-SEARCH #" .. n }) +end + +test.register_coroutine_test("a scan multicasts an M-SEARCH, and a device's answer creates it", function() + expect_created() + start_scan() + wait_for_search(1) + local msg = f.ssdp:sent()[1] + assert(msg:find("M-SEARCH * HTTP/1.1", 1, true) and msg:find('MAN: "ssdp:discover"', 1, true) + and msg:find("ST: urn:Belkin:device:*", 1, true), msg) + f.ssdp:reply(H.ssdp_response(), { from_host = H.DEVICE_IP, from_port = 1900 }) + test.wait_until(function() return http.count_requests(f.setup, "GET", "/setup.xml") >= 1 end, + { message = "the driver fetches setup.xml" }) + test.wait_for_events() + stop_scan() + test.wait_for_events() +end) + +test.register_coroutine_test("a reply late in the 5s window still creates the device", function() + expect_created() + start_scan() + wait_for_search(1) + test.mock_time.advance_time(4.5) + f.ssdp:reply(H.ssdp_response(), { from_host = H.DEVICE_IP, from_port = 1900 }) + test.wait_until(function() return http.count_requests(f.setup, "GET", "/setup.xml") >= 1 end, + { message = "the driver fetches setup.xml" }) + test.wait_for_events() + stop_scan() + test.wait_for_events() +end) + +test.register_coroutine_test("a device answering only after a rescan began is found by the rescan", function() + expect_created() + start_scan() + wait_for_search(1) + -- let the first 5s window lapse with no answer; the handler loops and multicasts again + wait_for_search(2) + f.ssdp:reply(H.ssdp_response(), { from_host = H.DEVICE_IP, from_port = 1900 }) + test.wait_until(function() return http.count_requests(f.setup, "GET", "/setup.xml") >= 1 end, + { message = "the driver fetches setup.xml after the rescan" }) + test.wait_for_events() + stop_scan() + test.wait_for_events() +end) + +test.register_coroutine_test("an answer to a search whose window already closed is dropped, the next one counts", function() + expect_created() + start_scan() + wait_for_search(1) + wait_for_search(2) -- window 1 has closed and its socket is gone + -- Replying through the peer targets "the socket that most recently sent": the rescan's socket. + -- There is no way to address the stale first socket. + f.ssdp:reply(H.ssdp_response(), { from_host = H.DEVICE_IP, from_port = 1900 }) + test.wait_until(function() return http.count_requests(f.setup, "GET", "/setup.xml") >= 1 end, + { message = "setup.xml fetched" }) + test.wait_for_events() + stop_scan() + test.wait_for_events() +end) + +test.register_coroutine_test("a device answering in two scans is only created once", function() + expect_created() + start_scan() + wait_for_search(1) + f.ssdp:reply(H.ssdp_response(), { from_host = H.DEVICE_IP, from_port = 1900 }) + test.wait_until(function() return http.count_requests(f.setup, "GET", "/setup.xml") >= 1 end, + { message = "first setup.xml" }) + wait_for_search(2) + f.ssdp:reply(H.ssdp_response(), { from_host = H.DEVICE_IP, from_port = 1900 }) + test.wait_until(function() return http.count_requests(f.setup, "GET", "/setup.xml") >= 2 end, + { message = "second setup.xml" }) + test.wait_for_events() + stop_scan() + test.wait_for_events() + -- the framework fails the test if a second create_device had been issued unexpectedly +end) + +test.register_coroutine_test("a response whose LOCATION host differs from its source is ignored", function() + start_scan() + wait_for_search(1) + f.ssdp:reply(H.ssdp_response("192.168.1.99"), { from_host = H.DEVICE_IP, from_port = 1900 }) + test.wait_for_events() + stop_scan() + test.wait_for_events() + assert(http.count_requests(f.setup, "GET", "/setup.xml") == 0, "must not fetch a spoofed location") +end) + +test.register_coroutine_test("an unknown model is not created", function() + http.expect_request(f.setup, "GET", "/setup.xml", { + body = H.setup_xml({ model = "Frobnicator" }), prepend = true, reusable = true }) + start_scan() + wait_for_search(1) + f.ssdp:reply(H.ssdp_response(), { from_host = H.DEVICE_IP, from_port = 1900 }) + test.wait_until(function() return http.count_requests(f.setup, "GET", "/setup.xml") >= 1 end, + { message = "setup.xml fetched" }) + test.wait_for_events() + stop_scan() + test.wait_for_events() +end) + +test.register_coroutine_test("stopping the scan before any answer creates nothing", function() + start_scan() + wait_for_search(1) + stop_scan() + test.wait_for_events() +end) + +test.run_registered_tests() diff --git a/drivers/SmartThings/wemo/src/test/test_review_lan_notify.lua b/drivers/SmartThings/wemo/src/test/test_review_lan_notify.lua new file mode 100644 index 0000000000..db64047857 --- /dev/null +++ b/drivers/SmartThings/wemo/src/test/test_review_lan_notify.lua @@ -0,0 +1,65 @@ +-- Review of the LAN integration test framework against the wemo driver. +-- Use case 1: protocol message -> capability (UPnP NOTIFY into the subscription server, SOAP out). +local test = require "integration_test" +test.set_test_coroutine_priority(true) + +local H = require "test.review_helpers" +local http = H.http +local capabilities = require "st.capabilities" +local mock_lan_socket = test.mock_lan_socket + +local f = H.new() + +http.expect_request(f.setup, "GET", "/setup.xml", { body = H.setup_xml(), reusable = true }) +http.expect_request(f.events, "SUBSCRIBE", "/upnp/event/basicevent1", { headers = { SID = H.SID }, reusable = true }) +-- the port we remember is a number, the rediscovered one a string, so the driver resubscribes +http.expect_request(f.events, "UNSUBSCRIBE", "/upnp/event/basicevent1", { reusable = true }) +http.expect_request(f.control, "POST", "/upnp/control/basicevent1", { body = "", reusable = true }) + +test.set_test_init_function(f.init) + +--- Rediscover, then let the initial subscribe timer fire, and wait until the driver holds a SID. +local function subscribed_device() + f.answer_rediscovery() + test.wait_until(function() return f.is_online() end, { message = "device online after rediscovery" }) + test.mock_time.advance_time(2) + test.wait_until(function() return f.mock_device:get_field("subscription_id") == H.SID end, + { message = "subscription_id stored" }) +end + +test.register_coroutine_test("a UPnP NOTIFY with BinaryState 1 emits switch on", function() + subscribed_device() + local conn = mock_lan_socket.connect_to(f.server_port()) + conn:queue_bytes(H.notify(H.property_set("1"))) + test.socket.capability:__expect_send(f.mock_device:generate_test_message("main", capabilities.switch.switch.on())) + test.wait_for_events() + test.wait_until(function() return conn:sent():find("^HTTP/1.1 200") ~= nil end, { message = "200 reply to NOTIFY" }) +end) + +test.register_coroutine_test("a NOTIFY with BinaryState 0 emits switch off", function() + subscribed_device() + local conn = mock_lan_socket.connect_to(f.server_port()) + conn:queue_bytes(H.notify(H.property_set("0"))) + test.socket.capability:__expect_send(f.mock_device:generate_test_message("main", capabilities.switch.switch.off())) + test.wait_for_events() +end) + +test.register_coroutine_test("a NOTIFY for an unknown SID emits nothing", function() + subscribed_device() + local conn = mock_lan_socket.connect_to(f.server_port()) + conn:queue_bytes(H.notify(H.property_set("1"), "uuid:someone-else")) + test.wait_until(function() return conn:sent():find("^HTTP/1.1 200") ~= nil end, { message = "200 reply" }) + -- no capability message expected: the framework fails the test if one is sent +end) + +test.register_coroutine_test("switch on command sends a SetBinaryState SOAP request", function() + subscribed_device() + test.socket.capability:__queue_receive({ f.mock_device.id, { capability = "switch", component = "main", command = "on", args = {} } }) + local req = http.await_request(f.control, "POST", "/upnp/control/basicevent1") + assert(req.raw:find("SetBinaryState", 1, true) and req.body:find("1", 1, true), req.raw) + assert(req.headers["soapaction"] and req.headers["soapaction"]:find("SetBinaryState"), "SOAPAction header") + req:respond({ status = 200, body = "" }) + test.wait_for_events() +end) + +test.run_registered_tests() diff --git a/drivers/SmartThings/wemo/src/test/test_review_lan_plain_handles.lua b/drivers/SmartThings/wemo/src/test/test_review_lan_plain_handles.lua new file mode 100644 index 0000000000..67487d72d8 --- /dev/null +++ b/drivers/SmartThings/wemo/src/test/test_review_lan_plain_handles.lua @@ -0,0 +1,67 @@ +-- Review of the LAN integration test framework against the wemo driver: the same kinds of checks +-- written with plain mock_lan_socket handles (no connection_scenario), to compare fit. +-- The driver opens one short-lived TCP connection per HTTP request, so the plain handle's +-- "next default connection" model needs the test to know the connection order in advance. +local test = require "integration_test" +test.set_test_coroutine_priority(true) + +local H = require "test.review_helpers" -- only for constants/builders; the scenario in it is not activated here +local t_utils = require "integration_test.utils" +local capabilities = require "st.capabilities" +local mock_lan_socket = test.mock_lan_socket +local mock_devices_api = require "integration_test.mock_devices_api" + +local mock_device = test.mock_device.build_test_lan_device({ + label = "Wemo Plug", + device_network_id = H.DNI, + profile = t_utils.get_profile_definition("mini-smart-plug.yaml"), +}) + +local plug, ssdp, driver_under_test + +test.add_test_env_setup_func(function(driver) + driver_under_test = driver + require("discovery")._ctrl_tx = nil +end) + +test.set_test_init_function(function() + plug = mock_lan_socket.tcp_address(H.DEVICE_IP, H.DEVICE_PORT) + ssdp = mock_lan_socket.udp_peer("239.255.255.250", 1900) + mock_lan_socket.udp_peer(H.DEVICE_IP, 9) + test.mock_device.add_test_device(mock_device) + mock_device:set_field("ip", H.DEVICE_IP, { persist = true }) + mock_device:set_field("port", H.DEVICE_PORT, { persist = true }) + mock_device:set_field("serial_num", H.SERIAL, { persist = true }) +end) + +local OK = "HTTP/1.1 200 OK\r\ncontent-length: 0\r\n\r\n" + +test.register_coroutine_test("switch off command posts SetBinaryState 0 (plain tcp_address handle)", function() + plug:queue_bytes(OK) -- consumed by the first connection the driver makes: the command's POST + test.socket.capability:__queue_receive({ mock_device.id, { capability = "switch", component = "main", command = "off", args = {} } }) + test.wait_until(function() return plug:connection_count() >= 1 and plug:sent():find("\r\n\r\n", 1, true) ~= nil end, + { message = "the SOAP request" }) + local sent = plug:sent() + assert(sent:find("^POST /upnp/control/basicevent1 HTTP/1.1"), sent) + assert(sent:find("0", 1, true), sent) + assert(sent:find('SOAPAction: "urn:Belkin:service:basicevent:1#SetBinaryState"', 1, true), sent) +end) + +test.register_coroutine_test("a refresh against a refusing plug makes no state change (fail_connects)", function() + plug:fail_connects(math.huge, "connection refused") + test.socket.capability:__queue_receive({ mock_device.id, { capability = "refresh", component = "main", command = "refresh", args = {} } }) + test.wait_until(function() return plug:connection_count() == 0 and #ssdp:sent() >= 0 end, { message = "idle", max_turns = 3 }) + -- plug:connection_count() stays 0: a refused connect never produces a connection to inspect, and + -- the handle has no counter of refused attempts. + assert(plug:connection_count() == 0) +end) + +test.register_coroutine_test("NOTIFY from an unknown subscription is acknowledged (connect_to, no scenario)", function() + local _, port = driver_under_test.server.sock:getsockname() + local conn = mock_lan_socket.connect_to(port) + conn:queue_bytes(H.notify(H.property_set("1"))) + test.wait_until(function() return conn:sent():find("^HTTP/1.1 200") ~= nil end, { message = "200 reply" }) + assert(conn:closed_by_driver() or true) +end) + +test.run_registered_tests()