diff --git a/src/lib/alexaActions.js b/src/lib/alexaActions.js index 8d0a4c2..ea49d73 100644 --- a/src/lib/alexaActions.js +++ b/src/lib/alexaActions.js @@ -26,7 +26,8 @@ module.exports = { alexaChannelController: alexaChannelController, alexaInputController: alexaInputController, alexaRangeController: alexaRangeController, - alexaModeController: alexaModeController + alexaModeController: alexaModeController, + destroy: destroy }; function hapDiscovery(options) { @@ -48,6 +49,10 @@ function hapDiscovery(options) { // debug("Event Relay - 1", homebridge); } +function destroy() { + homebridge.destroy(); +} + function registerEvents(message) { // debug("registerEvents", message); @@ -932,7 +937,7 @@ function alexaMessage(message, callback) { // For performance HAP GET Characteristices supports getting multiple in one call // debug("alexaMessage - statusArray", statusArray); - processStatusArray(statusArray, message).then(response => { + processStatusArray.call(this, statusArray, message).then(response => { debug("alexaMessage: Response", JSON.stringify(response, null, 2)); callback(null, response); @@ -984,6 +989,9 @@ async function processStatusArray(statusArray, message) { return (alexaMessages.alexaStateResponse(resultArray, message)); } catch (err) { + if(this.deviceCleanup) { + reportDeviceError(message); + } return (alexaMessages.alexaStateResponse(err, message)); } } @@ -1046,6 +1054,30 @@ function alexaEvent(events) { }); } +function reportDeviceError(message) { + alexaLocal.alexaEvent({ + "event": { + "header": { + "namespace": "Alexa.Discovery", + "name": "DeleteReport", + "messageId": messages.createMessageId(), + "payloadVersion": "3" + }, + "payload": { + "endpoints": [ + { + "endpointId": message.endpoint.endpointId, + } + ], + "scope": { + "type": "BearerToken", + "token": "OAuth2.0 bearer token" + } + } + } + }); +}; + /* Utility functions diff --git a/src/lib/alexaActions.test.ts b/src/lib/alexaActions.test.ts new file mode 100644 index 0000000..8399670 --- /dev/null +++ b/src/lib/alexaActions.test.ts @@ -0,0 +1,95 @@ +const { processStatusArray } = require('/Users/sgracey/Code/homebridge-alexa/src/lib/alexaActions.js'); +const alexaMessages = require('/Users/sgracey/Code/homebridge-alexa/src/lib/alexaMessages.js'); +const homebridge = require('hap-node-client').HAPNodeJSClient; + +jest.mock('hap-node-client'); +jest.mock('/Users/sgracey/Code/homebridge-alexa/src/lib/alexaMessages.js'); + +describe('processStatusArray', () => { + let statusArray; + let message; + + beforeEach(() => { + statusArray = [ + { + deviceID: 'device1', + body: '?id=1.1', + interface: 'Alexa.PowerController', + spacer: ',', + elements: [ + { interface: 'Alexa.PowerController', aid: 1, iid: 1 } + ] + }, + { + deviceID: 'device2', + body: '?id=2.1', + interface: 'Alexa.BrightnessController', + spacer: ',', + elements: [ + { interface: 'Alexa.BrightnessController', aid: 2, iid: 1 } + ] + } + ]; + message = { directive: { header: { messageId: '123' } } }; + + homebridge.HAPstatusByDeviceID.mockImplementation((deviceID, body, callback) => { + if (deviceID === 'device1') { + callback(null, { characteristics: [{ aid: 1, iid: 1, value: true }] }); + } else if (deviceID === 'device2') { + callback(null, { characteristics: [{ aid: 2, iid: 1, value: 50 }] }); + } else { + callback(new Error('Device not found')); + } + }); + + alexaMessages.alexaStateResponse.mockImplementation((resultArray, message) => { + return { event: { header: { messageId: message.directive.header.messageId }, payload: { properties: resultArray } } }; + }); + }); + + test.skip('should process status array successfully', async () => { + const result = await processStatusArray(statusArray, message); + expect(result).toEqual({ + event: { + header: { messageId: '123' }, + payload: { + properties: [ + { interface: 'Alexa.PowerController', aid: 1, iid: 1, value: true }, + { interface: 'Alexa.BrightnessController', aid: 2, iid: 1, value: 50 } + ] + } + } + }); + }); + + test.skip('should handle errors during processing', async () => { + homebridge.HAPstatusByDeviceID.mockImplementationOnce((deviceID, body, callback) => { + callback(new Error('Device not found')); + }); + + const result = await processStatusArray(statusArray, message); + expect(result).toEqual({ + event: { + header: { messageId: '123' }, + payload: { + properties: [ + { interface: 'Alexa.PowerController', aid: 1, iid: 1, value: true }, + { interface: 'Alexa.BrightnessController', aid: 2, iid: 1, value: 50 } + ] + } + } + }); + }); + + test.skip('should return correct response format', async () => { + const result = await processStatusArray(statusArray, message); + expect(result).toHaveProperty('event.header.messageId', '123'); + expect(result).toHaveProperty('event.payload.properties'); + expect(result.event.payload.properties).toHaveLength(2); + }); + + afterAll(() => { + jest.clearAllMocks(); + homebridge.destroy(); + }); +}); \ No newline at end of file diff --git a/src/lib/alexaLocal.test.js b/src/lib/alexaLocal.test.js new file mode 100644 index 0000000..176ad6a --- /dev/null +++ b/src/lib/alexaLocal.test.js @@ -0,0 +1,160 @@ +const mqtt = require('mqtt'); +const debug = require('debug'); +const Bottleneck = require('bottleneck'); +const { alexaLocal, alexaEvent, alexaPriorityEvent } = require('/Users/sgracey/Code/homebridge-alexa/src/lib/alexaLocal'); + +jest.mock('mqtt'); +jest.mock('debug'); +jest.mock('bottleneck'); + +describe.skip('alexaLocal', () => { + let options; + let mockClient; + let mockLimiter; + + beforeEach(() => { + options = { + mqttURL: 'mqtt://test.mosquitto.org', + mqttOptions: { username: 'testUser' }, + alexaService: { + setCharacteristic: jest.fn() + }, + Characteristic: { + ContactSensorState: { + CONTACT_DETECTED: 'CONTACT_DETECTED', + CONTACT_NOT_DETECTED: 'CONTACT_NOT_DETECTED' + } + }, + eventBus: { + listenerCount: jest.fn().mockReturnValue(1), + emit: jest.fn() + }, + log: jest.fn() + }; + + mockClient = { + on: jest.fn(), + publish: jest.fn(), + subscribe: jest.fn(), + removeAllListeners: jest.fn(), + end: jest.fn() + }; + + mqtt.connect.mockReturnValue(mockClient); + + mockLimiter = { + submit: jest.fn(), + on: jest.fn() + }; + + Bottleneck.mockImplementation(() => mockLimiter); + }); + + test('should connect to MQTT broker and set up event handlers', () => { + alexaLocal(options); + + expect(mqtt.connect).toHaveBeenCalledWith(options.mqttURL, options.mqttOptions); + expect(mockClient.on).toHaveBeenCalledWith('connect', expect.any(Function)); + expect(mockClient.on).toHaveBeenCalledWith('offline', expect.any(Function)); + expect(mockClient.on).toHaveBeenCalledWith('reconnect', expect.any(Function)); + expect(mockClient.on).toHaveBeenCalledWith('error', expect.any(Function)); + }); + + test('should handle successful connection', () => { + alexaLocal(options); + + const connectHandler = mockClient.on.mock.calls[0][1]; + connectHandler(); + + expect(mockClient.subscribe).toHaveBeenCalledWith('command/testUser/#'); + expect(mockClient.publish).toHaveBeenCalledWith('presence/testUser/1', expect.any(String)); + expect(options.alexaService.setCharacteristic).toHaveBeenCalledWith( + options.Characteristic.ContactSensorState, + options.Characteristic.ContactSensorState.CONTACT_DETECTED + ); + }); + + test('should handle offline event', () => { + alexaLocal(options); + + const offlineHandler = mockClient.on.mock.calls[1][1]; + offlineHandler(); + + expect(options.alexaService.setCharacteristic).toHaveBeenCalledWith( + options.Characteristic.ContactSensorState, + options.Characteristic.ContactSensorState.CONTACT_NOT_DETECTED + ); + }); + + test('should handle reconnect event', () => { + alexaLocal(options); + + const reconnectHandler = mockClient.on.mock.calls[2][1]; + reconnectHandler(); + + expect(options.alexaService.setCharacteristic).toHaveBeenCalledWith( + options.Characteristic.ContactSensorState, + options.Characteristic.ContactSensorState.CONTACT_NOT_DETECTED + ); + }); + + test('should handle error event', () => { + alexaLocal(options); + + const errorHandler = mockClient.on.mock.calls[3][1]; + const error = new Error('Test error'); + error.code = 5; + errorHandler(error); + + expect(options.alexaService.setCharacteristic).toHaveBeenCalledWith( + options.Characteristic.ContactSensorState, + options.Characteristic.ContactSensorState.CONTACT_NOT_DETECTED + ); + expect(options.log.error).toHaveBeenCalledWith(expect.stringContaining('Login to homebridge.ca failed')); + }); + + test('should publish alexaEvent', () => { + alexaLocal(options); + const message = { test: 'message' }; + + alexaEvent(message); + + expect(mockLimiter.submit).toHaveBeenCalledWith(expect.any(Function)); + }); + + test('should publish alexaPriorityEvent', () => { + alexaLocal(options); + const message = { test: 'priorityMessage' }; + + alexaPriorityEvent(message); + + expect(mockLimiter.submit).toHaveBeenCalledWith({ priority: 4 }, expect.any(Function)); + }); + + test('should generate alexaErrorResponse', () => { + const message = { + directive: { + header: { + messageId: 'testMessageId' + } + } + }; + + const response = _alexaErrorResponse(message); + + expect(response).toEqual({ + event: { + header: { + name: 'ErrorResponse', + namespace: 'Alexa', + payloadVersion: '3', + messageId: 'testMessageId' + }, + payload: { + type: 'INVALID_DIRECTIVE', + message: 'No listener for directive' + } + } + }); + }); +}); \ No newline at end of file diff --git a/src/lib/alexaMessages.test.js b/src/lib/alexaMessages.test.js new file mode 100644 index 0000000..a2ccc9f --- /dev/null +++ b/src/lib/alexaMessages.test.js @@ -0,0 +1,146 @@ +const { alexaResponse, alexaStateResponse, eventMessage } = require('/Users/sgracey/Code/homebridge-alexa/src/lib/alexaMessages'); +const messages = require('/Users/sgracey/Code/homebridge-alexa/src/lib/parse/messages'); + +jest.mock('/Users/sgracey/Code/homebridge-alexa/src/lib/parse/messages'); + +describe('alexaMessages', () => { + describe('alexaResponse', () => { + test('should return ErrorResponse when there is an error', () => { + const message = { + directive: { + header: { + messageId: '123', + namespace: 'Alexa', + name: 'TurnOn' + }, + endpoint: { + endpointId: 'endpoint-001' + } + } + }; + const err = new Error('Test error'); + const response = alexaResponse(message, null, err, null); + expect(response.event.header.name).toBe('ErrorResponse'); + expect(response.event.payload.type).toBe('ENDPOINT_UNREACHABLE'); + }); + + test('should return ErrorResponse when HomeBridge returns an error', () => { + const message = { + directive: { + header: { + messageId: '123', + namespace: 'Alexa', + name: 'TurnOn' + }, + endpoint: { + endpointId: 'endpoint-001' + } + } + }; + const hbResponse = { characteristics: [{ status: -70402 }] }; + const response = alexaResponse(message, hbResponse, null, null); + expect(response.event.header.name).toBe('ErrorResponse'); + expect(response.event.payload.type).toBe('INVALID_VALUE'); + }); + + test('should return correct response for Alexa.PowerController', () => { + const message = { + directive: { + header: { + messageId: '123', + namespace: 'Alexa.PowerController', + name: 'TurnOn' + }, + endpoint: { + endpointId: 'endpoint-001' + } + } + }; + const response = alexaResponse(message, { characteristics: [{ status: 0 }] }, null, null); + expect(response.context.properties[0].namespace).toBe('Alexa.PowerController'); + expect(response.context.properties[0].name).toBe('powerState'); + expect(response.context.properties[0].value).toBe('ON'); + }); + + // Add more test cases for other namespaces and scenarios + }); + + describe('alexaStateResponse', () => { + test('should return ErrorResponse when properties is an error', () => { + const properties = new Error('Test error'); + const message = { + directive: { + header: { + messageId: '123', + name: 'ReportState' + }, + endpoint: { + endpointId: 'endpoint-001' + } + } + }; + const response = alexaStateResponse(properties, message); + expect(response.event.header.name).toBe('ErrorResponse'); + expect(response.event.payload.type).toBe('ENDPOINT_UNREACHABLE'); + }); + + test('should return StateReport for ReportState directive', () => { + const properties = [{ + namespace: 'Alexa.PowerController', + name: 'powerState', + value: 'ON', + timeOfSample: new Date().toISOString(), + uncertaintyInMilliseconds: 500 + }]; + const message = { + directive: { + header: { + messageId: '123', + name: 'ReportState' + }, + endpoint: { + endpointId: 'endpoint-001' + } + } + }; + const response = alexaStateResponse(properties, message); + expect(response.event.header.name).toBe('StateReport'); + expect(response.context.properties).toEqual(properties[0]); + }); + + // Add more test cases for other scenarios + }); + + describe('eventMessage', () => { + beforeEach(() => { + messages.createMessageId.mockReturnValue('123'); + }); + + test('should return DoorbellPress event for DoorbellEventSource template', () => { + const event = {}; + const device = { + template: 'DoorbellEventSource', + endpointID: 'endpoint-001' + }; + const response = eventMessage(event, device); + expect(response.event.header.namespace).toBe('Alexa.DoorbellEventSource'); + expect(response.event.header.name).toBe('DoorbellPress'); + }); + + test('should return ChangeReport event for ContactSensor template', () => { + const event = { value: 'DETECTED' }; + const device = { + template: 'ContactSensor', + endpointID: 'endpoint-001', + DETECTED: 'DETECTED' + }; + const response = eventMessage(event, device); + expect(response.event.header.namespace).toBe('Alexa'); + expect(response.event.header.name).toBe('ChangeReport'); + expect(response.event.payload.change.properties[0].namespace).toBe('Alexa.ContactSensor'); + expect(response.event.payload.change.properties[0].value).toBe('DETECTED'); + }); + + // Add more test cases for other templates and scenarios + }); +}); \ No newline at end of file diff --git a/src/plugin.js b/src/plugin.js index b79f055..63942eb 100644 --- a/src/plugin.js +++ b/src/plugin.js @@ -46,6 +46,7 @@ function alexaHome(log, config, api) { this.LegacyCloudTransport = config['LegacyCloudTransport'] || false; // Default to new Transport ( Setting from discarded beta ) var mqttKeepalive = config['keepalive'] || 5; // MQTT Connection Keepalive this.enhancedSkip = config['enhancedSkip'] || false; // Use enhanced skip for appletv-enhanced plugin + this.deviceCleanup = config['deviceCleanup'] || false; // Cleanup devices when not found if (mqttKeepalive < 60) { this.keepalive = mqttKeepalive * 60; diff --git a/src/plugin.test.js b/src/plugin.test.js new file mode 100644 index 0000000..31fde12 --- /dev/null +++ b/src/plugin.test.js @@ -0,0 +1,148 @@ +const EventEmitter = require('events').EventEmitter; +const alexaActions = require('./lib/alexaActions.js'); +const { alexaLocal } = require('./lib/alexaLocal.js'); +const packageConfig = require('../package.json'); +const alexaHome = require('./plugin.js'); + +jest.mock('events'); +jest.mock('./lib/alexaActions.js'); +jest.mock('./lib/alexaLocal.js'); + +describe.skip('alexaHome', () => { + let log, config, api, homebridge, alexaHomeInstance; + + beforeEach(() => { + log = { info: jest.fn(), error: jest.fn(), warn: jest.fn() }; + config = { + pin: '031-45-154', + username: 'testUser', + password: 'testPass', + filter: 'testFilter', + beta: false, + routines: false, + combine: false, + oldParser: false, + refresh: 900, + speakers: false, + inputs: false, + channel: false, + blind: false, + thermostatTurnOn: 0, + deviceListHandling: [], + deviceList: [], + door: false, + name: 'Alexa', + mergeServiceName: false, + CloudTransport: 'mqtts', + LegacyCloudTransport: false, + keepalive: 5, + enhancedSkip: false, + debug: false + }; + api = { on: jest.fn(), serverVersion: '1.0.0' }; + homebridge = { hap: { Service: jest.fn(), Characteristic: jest.fn() }, registerPlatform: jest.fn() }; + + alexaHomeInstance = new alexaHome(log, config, api); + }); + + test('should initialize correctly', () => { + expect(alexaHomeInstance.log).toBe(log); + expect(alexaHomeInstance.config).toBe(config); + expect(alexaHomeInstance.api).toBe(api); + expect(alexaHomeInstance.pin).toBe(config.pin); + expect(alexaHomeInstance.username).toBe(config.username); + expect(alexaHomeInstance.password).toBe(config.password); + expect(alexaHomeInstance.filter).toBe(config.filter); + expect(alexaHomeInstance.beta).toBe(config.beta); + expect(alexaHomeInstance.events).toBe(config.routines); + expect(alexaHomeInstance.combine).toBe(config.combine); + expect(alexaHomeInstance.oldParser).toBe(config.oldParser); + expect(alexaHomeInstance.refresh).toBe(config.refresh); + expect(alexaHomeInstance.speakers).toBe(config.speakers); + expect(alexaHomeInstance.inputs).toBe(config.inputs); + expect(alexaHomeInstance.channel).toBe(config.channel); + expect(alexaHomeInstance.blind).toBe(config.blind); + expect(alexaHomeInstance.thermostatTurnOn).toBe(config.thermostatTurnOn); + expect(alexaHomeInstance.deviceListHandling).toBe(config.deviceListHandling); + expect(alexaHomeInstance.deviceList).toBe(config.deviceList); + expect(alexaHomeInstance.door).toBe(config.door); + expect(alexaHomeInstance.name).toBe(config.name); + expect(alexaHomeInstance.mergeServiceName).toBe(config.mergeServiceName); + expect(alexaHomeInstance.CloudTransport).toBe(config.CloudTransport); + expect(alexaHomeInstance.LegacyCloudTransport).toBe(config.LegacyCloudTransport); + expect(alexaHomeInstance.keepalive).toBe(config.keepalive * 60); + expect(alexaHomeInstance.enhancedSkip).toBe(config.enhancedSkip); + }); + + test('should log error if username or password is missing', () => { + config.username = false; + config.password = false; + alexaHomeInstance = new alexaHome(log, config, api); + expect(log.error).toHaveBeenCalledWith("Missing username and password"); + }); + + test('should log error if oldParser is true', () => { + config.oldParser = true; + alexaHomeInstance = new alexaHome(log, config, api); + expect(log.error).toHaveBeenCalledWith("ERROR: oldParser was deprecated with version 0.5.0, defaulting to new Parser."); + }); + + test('should log info on didFinishLaunching', () => { + alexaHomeInstance.didFinishLaunching(); + expect(log.info).toHaveBeenCalledWith( + '%s v%s, node %s, homebridge v%s', + packageConfig.name, packageConfig.version, process.version, api.serverVersion + ); + }); + + test('should call hapDiscovery on didFinishLaunching', () => { + alexaHomeInstance.didFinishLaunching(); + expect(alexaActions.hapDiscovery).toHaveBeenCalledWith(expect.objectContaining({ + log: log, + debug: config.debug, + mqttURL: expect.any(String), + transport: config.CloudTransport, + mqttOptions: expect.objectContaining({ + username: config.username, + password: config.password, + reconnectPeriod: expect.any(Number), + keepalive: expect.any(Number), + rejectUnauthorized: false + }), + pin: config.pin, + refresh: config.refresh, + eventBus: expect.any(EventEmitter), + oldParser: config.oldParser, + combine: config.combine, + speakers: config.speakers, + filter: config.filter, + alexaService: expect.anything(), + Characteristic: expect.anything(), + inputs: config.inputs, + channel: config.channel, + thermostatTurnOn: config.thermostatTurnOn, + blind: config.blind, + deviceListHandling: config.deviceListHandling, + deviceList: config.deviceList, + door: config.door, + mergeServiceName: config.mergeServiceName, + events: config.routines, + enhancedSkip: config.enhancedSkip + })); + }); + + test('should return correct accessories', () => { + const callback = jest.fn(); + alexaHomeInstance.accessories(callback); + expect(callback).toHaveBeenCalledWith([expect.any(Object)]); + }); + + test('should return correct services in AlexaService', () => { + const name = 'Test Alexa'; + const alexaServiceInstance = new AlexaService(name, log); + const services = alexaServiceInstance.getServices(); + expect(services).toHaveLength(2); + expect(services[0]).toBeInstanceOf(homebridge.hap.Service.AccessoryInformation); + expect(services[1]).toBeInstanceOf(homebridge.hap.Service.ContactSensor); + }); +}); \ No newline at end of file diff --git a/test/client.test.ts b/test/client.test.ts deleted file mode 100644 index 82d523b..0000000 --- a/test/client.test.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { Server } from 'net'; -import { MqttServer, MqttSecureServer, MqttServerListener } from './server' -import serverBuilder from './server_helpers_for_client_tests'; -var AlexaLocal = require('../lib/alexaLocal.js').alexaLocal; - -jest.setTimeout(30000); - -// const ports = getPorts(2) -var server: MqttServer; -var alexa: any; - -describe('MQTT Client', () => { - beforeAll(async () => { - var version: number; - server = serverBuilder('mqtt', (serverClient) => { - serverClient.on('connect', () => { - const connack = - version === 5 ? { reasonCode: 0 } : { returnCode: 0 } - serverClient.connack(connack) - }) - serverClient.on('publish', (packet: { payload: { toString: () => any; }; qos: number; messageId: any; }) => { - console.log('publish', packet.payload.toString()); - if (packet.qos !== 0) { - serverClient.puback({ messageId: packet.messageId }) - } - }) - serverClient.on('auth', (packet: any) => { - console.log('auth'); - if (serverClient.writable) return false - const rc = 'reasonCode' - const connack = {} - connack[rc] = 0 - serverClient.connack(connack) - }) - serverClient.on('subscribe', (packet: { subscriptions: any[]; messageId: any; }) => { - console.log('subscribe', packet.subscriptions); - if (!serverClient.writable) return false - serverClient.suback({ - messageId: packet.messageId, - granted: packet.subscriptions.map((e: { qos: any; }) => e.qos), - }) - }) - }) - console.log('MQTT Server starting listener') - await server.listen(1883) - console.log('MQTT Server listening') - var options = { - // MQTT Options - log: console.log, - mqttURL: 'mqtt://localhost:1883', - transport: 'mqtt', - mqttOptions: { - username: 'TEST', - password: 'PASSWORD', - reconnectPeriod: 33, // Increased reconnect period to allow DDOS protection to reset - keepalive: 55, - rejectUnauthorized: false - }, - }; - alexa = new AlexaLocal(options); - }, 300000); - - afterAll(async () => { - console.log('MQTT Server exiting', server.listening) - if (server.listening) { - await server.close() - } - }); - - describe('Validate Inital Startup', () => { - test('Discover Devices', async () => { - server.publish('command/TEST/1', {"directive":{"header":{"namespace":"Alexa.Discovery","name":"Discover","payloadVersion":"3","messageId":"e68a6720-1222-4030-b044-f01360935f18"},"payload":{}}}); - expect(server).toReceiveMessage('junk'); - // await sleep(10000) - }); - - - }); - - -}); - -async function sleep(ms: number) { - return new Promise(resolve => setTimeout(resolve, ms)); -} diff --git a/test/matchers.ts b/test/matchers.ts deleted file mode 100644 index 49baa34..0000000 --- a/test/matchers.ts +++ /dev/null @@ -1,166 +0,0 @@ -import { diff } from "jest-diff"; -import WS from "./websocket"; -import { DeserializedMessage } from "./websocket"; - -type ReceiveMessageOptions = { - timeout?: number; -}; - -declare global { - namespace jest { - interface Matchers { - toReceiveMessage( - message: DeserializedMessage, - options?: ReceiveMessageOptions, - ): Promise; - toHaveReceivedMessages( - messages: Array>, - ): R; - } - } -} - -const WAIT_DELAY = 1000; -const TIMEOUT = Symbol("timoeut"); - -const makeInvalidWsMessage = function makeInvalidWsMessage( - this: jest.MatcherUtils, - ws: WS, - matcher: string, -) { - return ( - this.utils.matcherHint( - this.isNot ? `.not.${matcher}` : `.${matcher}`, - "WS", - "expected", - ) + - "\n\n" + - `Expected the websocket object to be a valid WS mock.\n` + - `Received: ${typeof ws}\n` + - ` ${this.utils.printReceived(ws)}` - ); -}; - -expect.extend({ - async toReceiveMessage( - ws: WS, - expected: DeserializedMessage, - options?: ReceiveMessageOptions, - ) { - const isWS = ws instanceof WS; - if (!isWS) { - return { - pass: !!this.isNot, // always fail - message: makeInvalidWsMessage.bind(this, ws, "toReceiveMessage"), - }; - } - - const waitDelay = options?.timeout ?? WAIT_DELAY; - - let timeoutId; - const messageOrTimeout = await Promise.race([ - ws.nextMessage, - new Promise((resolve) => { - timeoutId = setTimeout(() => resolve(TIMEOUT), waitDelay); - }), - ]); - clearTimeout(timeoutId); - - if (messageOrTimeout === TIMEOUT) { - return { - pass: !!this.isNot, // always fail - message: () => - this.utils.matcherHint( - this.isNot ? ".not.toReceiveMessage" : ".toReceiveMessage", - "WS", - "expected", - ) + - "\n\n" + - `Expected the websocket server to receive a message,\n` + - `but it didn't receive anything in ${waitDelay}ms.`, - }; - } - const received = messageOrTimeout; - - const pass = this.equals(received, expected); - - const message = pass - ? () => - this.utils.matcherHint(".not.toReceiveMessage", "WS", "expected") + - "\n\n" + - `Expected the next received message to not equal:\n` + - ` ${this.utils.printExpected(expected)}\n` + - `Received:\n` + - ` ${this.utils.printReceived(received)}` - : () => { - const diffString = diff(expected, received, { expand: this.expand }); - return ( - this.utils.matcherHint(".toReceiveMessage", "WS", "expected") + - "\n\n" + - `Expected the next received message to equal:\n` + - ` ${this.utils.printExpected(expected)}\n` + - `Received:\n` + - ` ${this.utils.printReceived(received)}\n\n` + - `Difference:\n\n${diffString}` - ); - }; - - return { - actual: received, - expected, - message, - name: "toReceiveMessage", - pass, - }; - }, - - toHaveReceivedMessages(ws: WS, messages: Array) { - const isWS = ws instanceof WS; - if (!isWS) { - return { - pass: !!this.isNot, // always fail - message: makeInvalidWsMessage.bind(this, ws, "toHaveReceivedMessages"), - }; - } - - const received = messages.map((expected) => - // object comparison to handle JSON protocols - ws.messages.some((actual) => this.equals(actual, expected)), - ); - const pass = this.isNot ? received.some(Boolean) : received.every(Boolean); - const message = pass - ? () => - this.utils.matcherHint( - ".not.toHaveReceivedMessages", - "WS", - "expected", - ) + - "\n\n" + - `Expected the WS server to not have received the following messages:\n` + - ` ${this.utils.printExpected(messages)}\n` + - `But it received:\n` + - ` ${this.utils.printReceived(ws.messages)}` - : () => { - return ( - this.utils.matcherHint( - ".toHaveReceivedMessages", - "WS", - "expected", - ) + - "\n\n" + - `Expected the WS server to have received the following messages:\n` + - ` ${this.utils.printExpected(messages)}\n` + - `Received:\n` + - ` ${this.utils.printReceived(ws.messages)}\n\n` - ); - }; - - return { - actual: ws.messages, - expected: messages, - message, - name: "toHaveReceivedMessages", - pass, - }; - }, -}); \ No newline at end of file diff --git a/test/server.ts b/test/server.ts deleted file mode 100644 index b182163..0000000 --- a/test/server.ts +++ /dev/null @@ -1,95 +0,0 @@ -import * as net from 'net' -import { TlsOptions } from 'tls' -import * as tls from 'tls' -import * as Connection from 'mqtt-connection' -import { Duplex } from 'stream' - -export type MqttServerListener = (client: Connection) => void - -/** - * MqttServer - * - * @param {Function} listener - fired on client connection - */ -export class MqttServer extends net.Server { - connectionList: Duplex[] - - constructor(listener: MqttServerListener) { - super() - this.connectionList = [] - - this.on('connection', (duplex) => { - this.connectionList.push(duplex) - const connection = new Connection(duplex, () => { - this.emit('client', connection) - }) - }) - - if (listener) { - this.on('client', listener) - } - } -} - -/** - * MqttServerNoWait (w/o waiting for initialization) - * - * @param {Function} listener - fired on client connection - */ -export class MqttServerNoWait extends net.Server { - connectionList: Duplex[] - - constructor(listener: MqttServerListener) { - super() - this.connectionList = [] - - this.on('connection', (duplex) => { - this.connectionList.push(duplex) - const connection = new Connection(duplex) - // do not wait for connection to return to send it to the client. - this.emit('client', connection) - }) - - if (listener) { - this.on('client', listener) - } - } -} - -/** - * MqttSecureServer - * - * @param {Object} opts - server options - * @param {Function} listener - */ -export class MqttSecureServer extends tls.Server { - connectionList: Duplex[] - - constructor(opts: TlsOptions, listener: MqttServerListener) { - if (typeof opts === 'function') { - listener = opts - opts = {} - } - - // sets a listener for the 'connection' event - super(opts) - this.connectionList = [] - - this.on('secureConnection', (socket) => { - this.connectionList.push(socket) - const connection = new Connection(socket, () => { - this.emit('client', connection) - }) - }) - - if (listener) { - this.on('client', listener) - } - } - - setupConnection(duplex: Duplex) { - const connection = new Connection(duplex, () => { - this.emit('client', connection) - }) - } -} diff --git a/test/server_helpers_for_client_tests.ts b/test/server_helpers_for_client_tests.ts deleted file mode 100644 index 27cea55..0000000 --- a/test/server_helpers_for_client_tests.ts +++ /dev/null @@ -1,183 +0,0 @@ -import { MqttServer, MqttSecureServer, MqttServerListener } from './server' -import _debug from 'debug' - -import * as path from 'path' -import * as fs from 'fs' - -import * as http from 'http' -import WebSocket from 'ws' -import MQTTConnection from 'mqtt-connection' -import { Server } from 'net' - -const KEY = path.join(__dirname, 'helpers', 'tls-key.pem') -const CERT = path.join(__dirname, 'helpers', 'tls-cert.pem') - -const debug = _debug('mqttjs:server_helpers_for_client_tests') - -/** - * This will build the client for the server to use during testing, and set up the - * server side client based on mqtt-connection for handling MQTT messages. - * @param {String} protocol - 'mqtt', 'mqtts' or 'ws' - * @param {Function} handler - event handler - */ -export default function serverBuilder( - protocol: string, - handler?: MqttServerListener, -): Server { - const sockets = [] - - - const defaultHandler: MqttServerListener = (serverClient) => { - - sockets.push(serverClient) - - serverClient.on('auth', (packet) => { - console.log('auth'); - if (serverClient.writable) return false - const rc = 'reasonCode' - const connack = {} - connack[rc] = 0 - serverClient.connack(connack) - }) - - serverClient.on('connect', (packet) => { - if (!serverClient.writable) return false - let rc = 'returnCode' - const connack = {} - if (serverClient.options.protocolVersion >= 4) { - connack['sessionPresent'] = false - } - if ( - serverClient.options && - serverClient.options.protocolVersion === 5 - ) { - rc = 'reasonCode' - if (packet.clientId === 'invalid') { - connack[rc] = 128 - } else { - connack[rc] = 0 - } - } else if (packet.clientId === 'invalid') { - connack[rc] = 2 - } else { - connack[rc] = 0 - } - if (packet.properties && packet.properties.authenticationMethod) { - return false - } - serverClient.connack(connack) - }) - - serverClient.on('publish', (packet) => { - console.log('publish'); - if (!serverClient.writable) return false - setImmediate(() => { - switch (packet.qos) { - case 0: - break - case 1: - serverClient.puback(packet) - break - case 2: - serverClient.pubrec(packet) - break - } - }) - }) - - serverClient.on('pubrel', (packet) => { - if (!serverClient.writable) return false - serverClient.pubcomp(packet) - }) - - serverClient.on('pubrec', (packet) => { - if (!serverClient.writable) return false - serverClient.pubrel(packet) - }) - - serverClient.on('pubcomp', () => { - // Nothing to be done - }) - - serverClient.on('subscribe', (packet) => { - if (!serverClient.writable) return false - serverClient.suback({ - messageId: packet.messageId, - granted: packet.subscriptions.map((e) => e.qos), - }) - }) - - serverClient.on('unsubscribe', (packet) => { - if (!serverClient.writable) return false - packet.granted = packet.unsubscriptions.map(() => 0) - serverClient.unsuback(packet) - }) - - serverClient.on('pingreq', () => { - if (!serverClient.writable) return false - serverClient.pingresp() - }) - - serverClient.on('end', () => { - debug('disconnected from server') - const index = sockets.findIndex((s) => s === serverClient) - if (index !== -1) { - sockets.splice(index, 1) - } - }) - } - - if (!handler) { - handler = defaultHandler - } - - let mqttServer = null - - if (protocol === 'mqtt') { - mqttServer = new MqttServer(handler) - } - if (protocol === 'mqtts') { - mqttServer = new MqttSecureServer( - { - key: fs.readFileSync(KEY), - cert: fs.readFileSync(CERT), - }, - handler, - ) - } - if (protocol === 'ws') { - const attachWebsocketServer = (server) => { - const webSocketServer = new WebSocket.Server({ - server, - perMessageDeflate: false, - }) - - webSocketServer.on('connection', (ws) => { - // server.connectionList.push(ws) - const stream = WebSocket.createWebSocketStream(ws) - const connection = new MQTTConnection(stream) - connection.protocol = ws.protocol - server.emit('client', connection) - stream.on('error', () => {}) - connection.on('error', () => {}) - connection.on('close', () => {}) - }) - } - - const httpServer = http.createServer() - // httpServer.connectionList = [] - attachWebsocketServer(httpServer) - httpServer.on('client', handler) - mqttServer = httpServer - } - - const originalClose = mqttServer.close - mqttServer.close = (cb) => { - sockets.forEach((socket) => { - socket.destroy() - }) - originalClose.call(mqttServer, cb) - } - - return mqttServer -}