feat(iot): repackaged Odoo iot modules + Fusion Plating sensor wrapper
Phase A of the IoT initiative — gets the server-side infrastructure
in place before the Raspberry Pi hardware arrives, so the iot admin
UI + /fp/iot/ingest endpoint are ready to accept the first real
temperature reading as soon as the Pi is wired up.
New top-level folder: fusion_iot/
1. **iot_base/** — Odoo S.A. iot_base module, copied from
RePackaged-Odoo verbatim. LGPL-3 upstream, no changes needed.
2. **iot/** — Odoo S.A. iot module, repackaged:
- `models/update.py` neutralised (removed the publisher_warranty
IoT-Box-counting report that phones home to odoo.com for
enterprise licence enforcement)
- `iot_handlers/lib/load_worldline_library.sh` deleted (proprietary
Worldline payment lib fetch from download.odoo.com, not needed)
- `wizard/add_iot_box.py._connect_iot_box_with_pairing_code` —
upstream called odoo.com's iot-proxy to resolve pairing codes;
replaced with a no-op. Pi-side iot_drivers proxy registers
directly with this Odoo server instead.
- Manifest rebranded with an explicit changelog preamble.
3. **fusion_plating_iot/** — new plating-specific wrapper:
- `fp.tank.sensor` — maps an iot.device (or a direct-HTTP-ingest
sensor) to a fusion.plating.tank + fusion.plating.bath.parameter.
Supports DS18B20, PT100/1000, pH, conductivity, level. Per-sensor
alert_min/max overrides.
- `fp.tank.reading` — append-only time-series. On create, evaluates
against sensor's alert range. On in-spec → out-of-spec TRANSITION,
auto-raises a fusion.plating.quality.hold (once per excursion,
no spam during sustained out-of-spec).
- `POST /fp/iot/ingest` — shared-secret HTTP endpoint for sensors
bypassing the Pi proxy. Token via X-FP-IOT-Token header OR body.
Accepts single-reading or batch payloads.
- Menu under Plating → Operations → Sensors & Readings.
- Tank form inherits get a Sensors tab inline.
Deployed to entech. Verified end-to-end:
- Install: iot_base + iot + fusion_plating_iot all 'installed'
- Smoke test: in-spec → out-of-spec → hold raised (HOLD-0010);
continued excursion → NO duplicate hold; back-in-spec → NEW
excursion → NEW hold (HOLD-0011) ✓
- HTTP endpoint: correct token → 200 accepted; wrong token → 401;
unknown device_serial → 404; batch payload → 200 accepted=N ✓
Phase B (when Raspberry Pi hardware arrives): DS18B20 iot_handler
driver for the Pi-side iot_drivers proxy + systemd service on
vanilla Raspberry Pi OS + first live reading from physical probe.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,273 @@
|
||||
import { beforeEach, describe, expect, test } from "@odoo/hoot";
|
||||
import {
|
||||
makeMockEnv,
|
||||
models,
|
||||
defineModels,
|
||||
patchWithCleanup,
|
||||
} from "@web/../tests/web_test_helpers";
|
||||
import { uuid } from "@web/core/utils/strings";
|
||||
import { browser } from "@web/core/browser/browser";
|
||||
|
||||
import { IotHttpService } from "@iot/network_utils/iot_http_service";
|
||||
|
||||
class IotChannel extends models.Model {
|
||||
get_iot_channel() {
|
||||
return "mockChannel";
|
||||
}
|
||||
}
|
||||
|
||||
defineModels({ IotChannel });
|
||||
|
||||
class DummyOrm {
|
||||
async searchRead(model, domain, _fields) {
|
||||
const [[, , iotBoxId]] = domain;
|
||||
if (iotBoxId === 1) {
|
||||
return [{ id: iotBoxId, ip: "127.0.0.1", identifier: "box-123" }];
|
||||
}
|
||||
return [{ id: iotBoxId, ip: "127.0.0.1", identifier: "box-456" }];
|
||||
}
|
||||
}
|
||||
|
||||
let iotHttpService;
|
||||
let websocketMessages;
|
||||
let notification;
|
||||
let longpolling;
|
||||
let websocket;
|
||||
let webRtc;
|
||||
let orm;
|
||||
let onSuccess;
|
||||
let onFailure;
|
||||
let calledCallback = "pending";
|
||||
|
||||
beforeEach(async () => {
|
||||
await makeMockEnv();
|
||||
websocketMessages = [];
|
||||
notification = {
|
||||
added: [],
|
||||
add: function (msg, opts) {
|
||||
this.added.push({ msg, opts });
|
||||
},
|
||||
};
|
||||
|
||||
orm = new DummyOrm({ ip: "127.0.0.1", identifier: "box-123" });
|
||||
patchWithCleanup(browser, {
|
||||
fetch: async (_url) => {
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
// if we should receive a failed response from the IoT Box
|
||||
let webRtcShouldFail = false;
|
||||
let longpollingShouldFail = false;
|
||||
let websocketShouldFail = false;
|
||||
|
||||
// if calling the action should fail
|
||||
let webRtcShouldThrow = false;
|
||||
let longpollingShouldThrow = false;
|
||||
let websocketShouldThrow = false;
|
||||
|
||||
webRtc = {
|
||||
sendMessage: async (identifier, payload, actionId, _messageType) => {
|
||||
if (webRtcShouldThrow) {
|
||||
throw new Error("webrtc sendMessage failed");
|
||||
}
|
||||
return actionId || uuid();
|
||||
},
|
||||
onMessage: async (identifier, deviceIdentifier, actionId, onSuccess, onFailure) => {
|
||||
if (webRtcShouldThrow) {
|
||||
throw new Error("webrtc onMessage failed");
|
||||
}
|
||||
if (webRtcShouldFail) {
|
||||
onFailure({ status: "disconnected" }, deviceIdentifier);
|
||||
return;
|
||||
}
|
||||
onSuccess({ status: "success", device_identifier: deviceIdentifier }, deviceIdentifier);
|
||||
},
|
||||
setThrow: (v) => {
|
||||
webRtcShouldThrow = v;
|
||||
},
|
||||
setFail: (v) => {
|
||||
webRtcShouldFail = v;
|
||||
},
|
||||
};
|
||||
|
||||
longpolling = {
|
||||
sendMessage: async (ip, payload, actionId, _hasFallback) => {
|
||||
if (longpollingShouldThrow) {
|
||||
throw new Error("longpolling sendMessage failed");
|
||||
}
|
||||
return actionId || uuid();
|
||||
},
|
||||
onMessage: (ip, deviceIdentifier, onSuccess, onFailure, actionId) => {
|
||||
if (longpollingShouldThrow) {
|
||||
throw new Error("longpolling onMessage failed");
|
||||
}
|
||||
if (longpollingShouldFail) {
|
||||
onFailure({ status: "disconnected" }, deviceIdentifier);
|
||||
return;
|
||||
}
|
||||
onSuccess({ status: "success", device_identifier: deviceIdentifier }, deviceIdentifier);
|
||||
},
|
||||
setThrow: (v) => {
|
||||
longpollingShouldThrow = v;
|
||||
},
|
||||
setFail: (v) => {
|
||||
longpollingShouldFail = v;
|
||||
},
|
||||
};
|
||||
|
||||
websocket = {
|
||||
sendMessage: async (identifier, payload, actionId, messageType) => {
|
||||
if (websocketShouldThrow) {
|
||||
throw new Error("websocket sendMessage failed");
|
||||
}
|
||||
websocketMessages.push({ identifier, payload, actionId, messageType });
|
||||
return actionId || uuid();
|
||||
},
|
||||
onMessage: (
|
||||
identifier,
|
||||
deviceIdentifier,
|
||||
onSuccess,
|
||||
onFailure,
|
||||
_messageType,
|
||||
_actionId
|
||||
) => {
|
||||
if (websocketShouldThrow) {
|
||||
throw new Error("websocket onMessage failed");
|
||||
}
|
||||
if (websocketShouldFail) {
|
||||
onFailure({ status: "disconnected" }, deviceIdentifier);
|
||||
return;
|
||||
}
|
||||
onSuccess({ status: "success", device_identifier: deviceIdentifier }, deviceIdentifier);
|
||||
},
|
||||
setThrow: (v) => {
|
||||
websocketShouldThrow = v;
|
||||
},
|
||||
setFail: (v) => {
|
||||
websocketShouldFail = v;
|
||||
},
|
||||
};
|
||||
|
||||
onSuccess = () => {
|
||||
calledCallback = "onSuccess";
|
||||
};
|
||||
onFailure = () => {
|
||||
calledCallback = "onFailure";
|
||||
};
|
||||
|
||||
iotHttpService = new IotHttpService({ iot_longpolling: longpolling, websocket, webRtc, notification, orm } );
|
||||
});
|
||||
|
||||
describe("action", () => {
|
||||
test("uses WebRTC first and succeeds", async () => {
|
||||
await iotHttpService.action(1, "device-1", { foo: "bar" }, onSuccess, onFailure);
|
||||
expect(calledCallback).toBe("onSuccess");
|
||||
expect(iotHttpService.connectionStatus).toBe("webrtc");
|
||||
});
|
||||
|
||||
test("fallback to longpolling when WebRTC fails", async () => {
|
||||
webRtc.setThrow(true);
|
||||
await iotHttpService.action(1, "device-2", { a: "b" }, onSuccess, onFailure);
|
||||
|
||||
expect(calledCallback).toBe("onSuccess");
|
||||
expect(iotHttpService.connectionStatus).toBe("longpolling");
|
||||
});
|
||||
|
||||
test("fallback to websocket when both WebRTC and longpolling fail", async () => {
|
||||
webRtc.setThrow(true);
|
||||
longpolling.setThrow(true);
|
||||
|
||||
await iotHttpService.action(1, "device-3", { x: "y" }, onSuccess, onFailure);
|
||||
expect(calledCallback).toBe("onSuccess");
|
||||
expect(iotHttpService.connectionStatus).toBe("websocket");
|
||||
});
|
||||
|
||||
test("all methods fail and onFailure is invoked with disconnected status", async () => {
|
||||
webRtc.setThrow(true);
|
||||
longpolling.setThrow(true);
|
||||
websocket.setThrow(true);
|
||||
|
||||
await iotHttpService.action(1, "device-4", { something: "else" }, onSuccess, onFailure);
|
||||
expect(calledCallback).toBe("onFailure");
|
||||
expect(iotHttpService.connectionStatus).toBe("offline");
|
||||
});
|
||||
|
||||
test("invalid iotBoxId (array unwraps Many2one)", async () => {
|
||||
await iotHttpService.action([1], "device-array", { foo: "bar" }, onSuccess);
|
||||
expect(calledCallback).toBe("onSuccess");
|
||||
});
|
||||
|
||||
test("recent longpolling failure short-circuits longpolling path", async () => {
|
||||
// simulate that longpolling just failed
|
||||
iotHttpService.longpollingFailedTimestamp = Date.now();
|
||||
webRtc.setThrow(true);
|
||||
// longpolling should be skipped due to recent failure, so websocket is used
|
||||
await iotHttpService.action(1, "device-5", { test: "val" }, onSuccess);
|
||||
expect(calledCallback).toBe("onSuccess");
|
||||
expect(iotHttpService.connectionStatus).toBe("websocket");
|
||||
});
|
||||
|
||||
test("webrtc onMessage calls back onFailure", async () => {
|
||||
webRtc.setFail(true); // make WebRTC onMessage report failure
|
||||
await iotHttpService.action(1, "device-webrtc-fail", { foo: "bar" }, onSuccess, onFailure);
|
||||
expect(calledCallback).toBe("onFailure");
|
||||
expect(iotHttpService.connectionStatus).toBe("webrtc"); // received failure callback from webrtc
|
||||
});
|
||||
|
||||
test("longpolling onMessage calls back onFailure", async () => {
|
||||
webRtc.setThrow(true);
|
||||
longpolling.setFail(true); // make longpolling onMessage report failure
|
||||
await iotHttpService.action(
|
||||
1,
|
||||
"device-longpolling-fail",
|
||||
{ foo: "bar" },
|
||||
onSuccess,
|
||||
onFailure
|
||||
);
|
||||
expect(calledCallback).toBe("onFailure");
|
||||
expect(iotHttpService.connectionStatus).toBe("longpolling"); // received failure callback from longpolling
|
||||
});
|
||||
|
||||
test("websocket onMessage calls back onFailure", async () => {
|
||||
webRtc.setThrow(true);
|
||||
longpolling.setThrow(true);
|
||||
websocket.setFail(true); // make websocket onMessage report failure
|
||||
await iotHttpService.action(
|
||||
1,
|
||||
"device-websocket-fail",
|
||||
{ foo: "bar" },
|
||||
onSuccess,
|
||||
onFailure
|
||||
);
|
||||
expect(calledCallback).toBe("onFailure");
|
||||
expect(iotHttpService.connectionStatus).toBe("websocket"); // received failure callback from websocket
|
||||
});
|
||||
|
||||
test("IoT Box records were cached after success, then removed after failure", async () => {
|
||||
await iotHttpService.action(
|
||||
1,
|
||||
"device-2",
|
||||
{ a: "attempt-1-succeeds" },
|
||||
onSuccess,
|
||||
onFailure
|
||||
);
|
||||
await iotHttpService.action(
|
||||
2,
|
||||
"device-2",
|
||||
{ a: "attempt-1-succeeds" },
|
||||
onSuccess,
|
||||
onFailure
|
||||
);
|
||||
expect(iotHttpService.cachedIotBoxes[1]?.identifier).toBe("box-123");
|
||||
expect(iotHttpService.cachedIotBoxes[2]?.identifier).toBe("box-456");
|
||||
|
||||
// ensure that the second record (and only this one) is removed after failure
|
||||
webRtc.setThrow(true);
|
||||
longpolling.setThrow(true);
|
||||
websocket.setThrow(true);
|
||||
await iotHttpService.action(2, "device-2", { a: "attempt-2-fails" }, onSuccess, onFailure);
|
||||
expect(iotHttpService.cachedIotBoxes[1]?.identifier).toBe("box-123");
|
||||
expect(iotHttpService.cachedIotBoxes[2]).toBe(undefined);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,247 @@
|
||||
import { IotWebRtc } from "@iot/network_utils/iot_webrtc";
|
||||
import { beforeEach, describe, expect, test } from "@odoo/hoot";
|
||||
import {
|
||||
defineModels,
|
||||
makeMockEnv,
|
||||
models,
|
||||
patchWithCleanup,
|
||||
} from "@web/../tests/web_test_helpers";
|
||||
import { EventBus } from "@odoo/owl";
|
||||
|
||||
class IotChannel extends models.Model {
|
||||
get_iot_channel() {
|
||||
return "mockChannel";
|
||||
}
|
||||
}
|
||||
|
||||
defineModels({ IotChannel });
|
||||
|
||||
class MockRtcDataChannel extends EventTarget {
|
||||
constructor(id) {
|
||||
super();
|
||||
this.id = id;
|
||||
this.readyState = "open";
|
||||
this._messagesSent = [];
|
||||
}
|
||||
|
||||
send(message) {
|
||||
this._messagesSent.push(message);
|
||||
}
|
||||
|
||||
close() {}
|
||||
}
|
||||
|
||||
class MockRtcPeerConnection extends EventTarget {
|
||||
static _instances = 0;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
MockRtcPeerConnection._instances += 1;
|
||||
}
|
||||
|
||||
get sctp() {
|
||||
return { maxMessageSize: 100 };
|
||||
}
|
||||
|
||||
createDataChannel(id) {
|
||||
return new MockRtcDataChannel(id);
|
||||
}
|
||||
|
||||
createOffer() {
|
||||
this.signalingState = "have-local-offer";
|
||||
return "mockOffer";
|
||||
}
|
||||
|
||||
setLocalDescription(description) {
|
||||
this.localDescription = description;
|
||||
return;
|
||||
}
|
||||
|
||||
setRemoteDescription(description) {
|
||||
this.remoteDescription = description;
|
||||
this.connectionState = "connected";
|
||||
return;
|
||||
}
|
||||
|
||||
close() {}
|
||||
}
|
||||
|
||||
const websocketMessages = [];
|
||||
|
||||
const setupWebRtc = () => {
|
||||
const bus = new EventBus();
|
||||
const busCallbacks = new Map();
|
||||
const mockBusService = {
|
||||
addChannel: () => {},
|
||||
subscribe: (type, callback) => {
|
||||
busCallbacks.set(callback, (event) => callback(event.detail));
|
||||
bus.addEventListener(type, busCallbacks.get(callback));
|
||||
},
|
||||
unsubscribe: (type, callback) => bus.removeEventListener(type, busCallbacks.get(callback)),
|
||||
trigger: bus.trigger.bind(bus),
|
||||
};
|
||||
const mockWebsocket = {
|
||||
iotChannel: "mockChannel",
|
||||
sendMessage: (iotIdentifier, message, messageId, messageType) => {
|
||||
websocketMessages.push({ iotIdentifier, message, messageId, messageType });
|
||||
},
|
||||
};
|
||||
MockRtcPeerConnection._instances = 0;
|
||||
return { webRtc: new IotWebRtc(mockBusService, mockWebsocket), bus };
|
||||
};
|
||||
|
||||
const setupWebRtcWithConnection = async (identifier) => {
|
||||
const { webRtc, bus } = setupWebRtc();
|
||||
await webRtc.openConnection(identifier);
|
||||
bus.trigger("webrtc_answer", { iot_box_identifier: identifier, answer: "mockAnswer" });
|
||||
|
||||
return { webRtc, bus };
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
await makeMockEnv();
|
||||
websocketMessages.splice(0, websocketMessages.length);
|
||||
|
||||
patchWithCleanup(window, {
|
||||
RTCPeerConnection: MockRtcPeerConnection,
|
||||
});
|
||||
});
|
||||
|
||||
describe("opening connection", () => {
|
||||
test("sends webrtc offer via the websocket", async () => {
|
||||
const { webRtc } = setupWebRtc();
|
||||
|
||||
await webRtc.openConnection("iot");
|
||||
|
||||
expect(websocketMessages).toHaveLength(1);
|
||||
expect(websocketMessages[0].messageType).toBe("webrtc_offer");
|
||||
expect(websocketMessages[0].message).toEqual({ offer: "mockOffer" });
|
||||
});
|
||||
|
||||
test("saves connection to connections dict", async () => {
|
||||
const { webRtc } = setupWebRtc();
|
||||
|
||||
await webRtc.openConnection("iot");
|
||||
|
||||
expect(webRtc.connections["iot"]).toBeOfType("object");
|
||||
expect(webRtc.connections["iot"].id).toBeOfType("string");
|
||||
expect(webRtc.connections["iot"].connection).toBeInstanceOf(MockRtcPeerConnection);
|
||||
expect(webRtc.connections["iot"].channel).toBeInstanceOf(MockRtcDataChannel);
|
||||
});
|
||||
|
||||
test("sets local offer", async () => {
|
||||
const { webRtc } = setupWebRtc();
|
||||
|
||||
await webRtc.openConnection("iot");
|
||||
|
||||
expect(webRtc.connections["iot"].connection).toBeInstanceOf(MockRtcPeerConnection);
|
||||
expect(webRtc.connections["iot"].connection.localDescription).toBe("mockOffer");
|
||||
});
|
||||
|
||||
test("sets remote offer received via bus", async () => {
|
||||
const { webRtc, bus } = setupWebRtc();
|
||||
|
||||
await webRtc.openConnection("iot");
|
||||
bus.trigger("webrtc_answer", { iot_box_identifier: "iot", answer: "mockAnswer" });
|
||||
|
||||
expect(webRtc.connections["iot"].connection.remoteDescription).toBe("mockAnswer");
|
||||
});
|
||||
|
||||
test("only opens the connection once", async () => {
|
||||
const { webRtc } = setupWebRtc();
|
||||
|
||||
const openConnectionPromise1 = webRtc.openConnection("iot");
|
||||
const openConnectionPromise2 = webRtc.openConnection("iot");
|
||||
await Promise.all([openConnectionPromise1, openConnectionPromise2]);
|
||||
|
||||
expect(MockRtcPeerConnection._instances).toBe(1);
|
||||
});
|
||||
|
||||
test("opens separate connections per IoT box", async () => {
|
||||
const { webRtc, bus } = setupWebRtc();
|
||||
|
||||
const openConnectionPromise1 = webRtc.openConnection("iot");
|
||||
const openConnectionPromise2 = webRtc.openConnection("iot2");
|
||||
await Promise.all([openConnectionPromise1, openConnectionPromise2]);
|
||||
bus.trigger("webrtc_answer", { iot_box_identifier: "iot", answer: "mockAnswer" });
|
||||
bus.trigger("webrtc_answer", { iot_box_identifier: "iot2", answer: "mockAnswer2" });
|
||||
|
||||
expect(MockRtcPeerConnection._instances).toBe(2);
|
||||
expect(webRtc.connections["iot"].connection.remoteDescription).toBe("mockAnswer");
|
||||
expect(webRtc.connections["iot2"].connection.remoteDescription).toBe("mockAnswer2");
|
||||
});
|
||||
});
|
||||
|
||||
describe("sending message", () => {
|
||||
test("message is sent", async () => {
|
||||
const { webRtc } = await setupWebRtcWithConnection("iot");
|
||||
const testMessage = { testKey: "testValue" };
|
||||
|
||||
await webRtc.sendMessage("iot", testMessage);
|
||||
|
||||
expect(webRtc.connections["iot"].channel._messagesSent).toHaveLength(1);
|
||||
const sentMessage = JSON.parse(webRtc.connections["iot"].channel._messagesSent[0]);
|
||||
expect(sentMessage).toMatchObject(testMessage);
|
||||
expect(sentMessage.session_id).toBeOfType("string");
|
||||
});
|
||||
|
||||
test("session_id is set if not provided", async () => {
|
||||
const { webRtc } = await setupWebRtcWithConnection("iot");
|
||||
const testMessage = { testKey: "testValue" };
|
||||
|
||||
await webRtc.sendMessage("iot", testMessage);
|
||||
|
||||
expect(webRtc.connections["iot"].channel._messagesSent).toHaveLength(1);
|
||||
const sentMessage = JSON.parse(webRtc.connections["iot"].channel._messagesSent[0]);
|
||||
expect(sentMessage.session_id).toBeOfType("string");
|
||||
});
|
||||
|
||||
test("session_id and message_type are included in message", async () => {
|
||||
const { webRtc } = await setupWebRtcWithConnection("iot");
|
||||
const testMessage = { testKey: "testValue" };
|
||||
|
||||
await webRtc.sendMessage("iot", testMessage, "testId", "test_type");
|
||||
|
||||
expect(webRtc.connections["iot"].channel._messagesSent).toHaveLength(1);
|
||||
const sentMessage = JSON.parse(webRtc.connections["iot"].channel._messagesSent[0]);
|
||||
expect(sentMessage.session_id).toBe("testId");
|
||||
expect(sentMessage.message_type).toBe("test_type");
|
||||
});
|
||||
|
||||
test("large messages are chunked", async () => {
|
||||
const { webRtc } = await setupWebRtcWithConnection("iot");
|
||||
const testMessage = { testKey: "testLongMessageToMakeChunkingBeRequired" };
|
||||
|
||||
await webRtc.sendMessage("iot", testMessage, "testId", "test_type");
|
||||
|
||||
expect(webRtc.connections["iot"].channel._messagesSent).toHaveLength(4);
|
||||
expect(webRtc.connections["iot"].channel._messagesSent[0]).toBe("chunked_start");
|
||||
expect(webRtc.connections["iot"].channel._messagesSent[3]).toBe("chunked_end");
|
||||
const fullMessage = JSON.parse(
|
||||
webRtc.connections["iot"].channel._messagesSent.slice(1, 3).join("")
|
||||
);
|
||||
expect(fullMessage).toMatchObject(testMessage);
|
||||
});
|
||||
|
||||
test("throws if connection is disconnected", async () => {
|
||||
const { webRtc } = await setupWebRtcWithConnection("iot");
|
||||
const testMessage = { testKey: "testValue" };
|
||||
|
||||
webRtc.connections["iot"].connection.connectionState = "disconnected";
|
||||
|
||||
await expect(webRtc.sendMessage("iot", testMessage)).rejects.toMatch(
|
||||
"WebRTC connection for iot is 'disconnected'"
|
||||
);
|
||||
});
|
||||
|
||||
test("throws if data channel is closed", async () => {
|
||||
const { webRtc } = await setupWebRtcWithConnection("iot");
|
||||
const testMessage = { testKey: "testValue" };
|
||||
|
||||
webRtc.connections["iot"].channel.readyState = "closed";
|
||||
|
||||
await expect(webRtc.sendMessage("iot", testMessage)).rejects.toMatch(
|
||||
"WebRTC channel for iot is 'closed'"
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user