Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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()
Loading
Loading