"use strict"; const events = require("events"); const assert = require("assert").strict; const lib = require("@clusterio/lib"); const { MockController, MockConnector, MockControl } = require("../mock"); const addr = lib.Address.fromShorthand; describe("lib/subscriptions", function() { class UnregisteredEvent { static type = "event"; static src = ["controller", "control"]; static dst = ["controller", "control"]; static permission = null; } class RegisteredEvent { static type = "event"; static src = ["controller", "control"]; static dst = ["controller", "control"]; static permission = null; } lib.Link.register(RegisteredEvent); class ChannelEvent { static type = "event"; static src = ["controller", "control"]; static dst = ["controller", "control"]; static permission = null; get subscriptionChannel() { return "channelOne"; } } lib.Link.register(ChannelEvent); class StringPermissionEvent { static type = "event"; static src = ["controller", "control"]; static dst = ["controller", "control"]; static permission = "StringPermission"; } lib.Link.register(StringPermissionEvent); class FunctionPermissionEvent { static type = "event"; static src = ["controller", "control"]; static dst = ["controller", "control"]; static permission(user, message) { user.checkPermission("FunctionPermission"); }; } lib.Link.register(FunctionPermissionEvent); class MockUser { checkPermission(permission) { this.lastPermissionCheck = permission; } } describe("class SubscriptionResponse", function() { it("should be round trip json serialisable without an event replay", function() { const response = new lib.SubscriptionResponse(); const json = JSON.stringify(response); assert.equal(typeof json, "string"); const reconstructed = lib.SubscriptionResponse.fromJSON(JSON.parse(json)); assert.deepEqual(reconstructed, response); }); it("should be round trip json serialisable with an event replay", function() { const response = new lib.SubscriptionResponse(new RegisteredEvent()); const json = JSON.stringify(response); assert.equal(typeof json, "string"); const reconstructed = lib.SubscriptionResponse.fromJSON(JSON.parse(json)); assert.deepEqual(reconstructed, response); }); it("should be round trip json serialisable with a null event replay", function() { const response = new lib.SubscriptionResponse(null); const json = JSON.stringify(response); assert.equal(typeof json, "string"); const reconstructed = lib.SubscriptionResponse.fromJSON(JSON.parse(json)); assert.deepEqual(reconstructed, response); }); it("should be throw when given an unregistered event in a json", function() { assert.throws( () => lib.SubscriptionResponse.fromJSON([UnregisteredEvent.name, {}]), new Error(`Unregistered Event class ${UnregisteredEvent.name}`) ); }); it("should be throw when given an unregistered event", function() { assert.throws( () => new lib.SubscriptionResponse(new UnregisteredEvent()), new Error(`Unregistered Event class ${UnregisteredEvent.name}`) ); }); }); describe("class SubscriptionRequest", function() { describe("permission()", function() { function newSubscriptionRequestMessage(request) { return new lib.MessageRequest( 0, addr({ controlId: 0 }), addr("controller"), lib.SubscriptionRequest.name, request.toJSON() ); } it("should do nothing when the event has no permission property", function() { const mockUser = new MockUser(); const request = new lib.SubscriptionRequest(RegisteredEvent.name, true); lib.SubscriptionRequest.permission(mockUser, newSubscriptionRequestMessage(request)); assert.equal(mockUser.lastPermissionCheck, undefined); }); it("should check user permission when the permission property is a string", function() { const mockUser = new MockUser(); const request = new lib.SubscriptionRequest(StringPermissionEvent.name, true); lib.SubscriptionRequest.permission(mockUser, newSubscriptionRequestMessage(request)); assert.equal(mockUser.lastPermissionCheck, "StringPermission"); }); it("should call the permission property when it is a function", function() { const mockUser = new MockUser(); const request = new lib.SubscriptionRequest(FunctionPermissionEvent.name, true); lib.SubscriptionRequest.permission(mockUser, newSubscriptionRequestMessage(request)); assert.equal(mockUser.lastPermissionCheck, "FunctionPermission"); }); }); it("should be round trip json serialisable", function() { const request = new lib.SubscriptionRequest(RegisteredEvent.name, true, ["channelOne"], 123); const json = JSON.stringify(request); assert.equal(typeof json, "string"); const reconstructed = lib.SubscriptionRequest.fromJSON(JSON.parse(json)); assert.deepEqual(reconstructed, request); }); it("should be throw when given an unregistered event", function() { assert.throws( () => new lib.SubscriptionRequest(UnregisteredEvent.name, true), new Error(`Unregistered Event class ${UnregisteredEvent.name}`) ); }); }); describe("class SubscriptionController", function() { let mockController, subscriptions; const connectorSetupDate = [{ id: 0, request: new lib.SubscriptionRequest(RegisteredEvent.name, true), src: addr({ controlId: 0 }), dst: addr("controller"), }, { id: 1, request: new lib.SubscriptionRequest(RegisteredEvent.name, true), src: addr({ controlId: 1 }), dst: addr("controller"), }, { id: 2, request: new lib.SubscriptionRequest(ChannelEvent.name, true), src: addr({ controlId: 2 }), dst: addr("controller"), }, { id: 3, request: new lib.SubscriptionRequest(ChannelEvent.name, false, ["channelOne"]), src: addr({ controlId: 3 }), dst: addr("controller"), }, { id: 4, request: new lib.SubscriptionRequest(ChannelEvent.name, false, ["channelTwo"]), src: addr({ controlId: 4 }), dst: addr("controller"), }]; function onceConnectorSend(id) { const link = mockController.wsServer.controlConnections.get(id); return events.once(link.connector, "send"); } function assertLastEvent(connectorId, Event) { const link = mockController.wsServer.controlConnections.get(connectorId); const lastMessage = link.connector.sentMessages.at(-1); const name = Event.plugin ? `${Event.plugin}:${Event.name}` : Event.name; assert.equal(lastMessage.name, name); } function assertNoEvent(connectorId) { const link = mockController.wsServer.controlConnections.get(connectorId); assert.equal(link.connector.sentMessages.length, 0); } beforeEach(function() { mockController = new MockController(); mockController.wsServer = { controlConnections: new Map(connectorSetupDate.map(function (connectorData) { return [connectorData.id, new lib.Link(new MockConnector(connectorData.src, connectorData.dst))]; })), }; subscriptions = new lib.SubscriptionController(mockController); }); it("should handle the SubscriptionRequest event", function() { assert.equal(mockController.handles.has(lib.SubscriptionRequest), true); }); describe("handle()", function() { it("should accept registered events", function() { subscriptions.handle(RegisteredEvent); }); it("should not accept unregistered events", function() { assert.throws( () => subscriptions.handle(UnregisteredEvent), new Error(`Unregistered Event class ${UnregisteredEvent.name}`) ); }); it("should not accept events already handled by the class", function() { subscriptions.handle(RegisteredEvent); assert.throws( () => subscriptions.handle(RegisteredEvent), new Error(`Event ${RegisteredEvent.name} is already registered`) ); }); }); describe("broadcast()", function() { beforeEach(function() { subscriptions.handle(RegisteredEvent); subscriptions.handle(ChannelEvent); for (let connectorData of connectorSetupDate) { subscriptions._handleRequest(connectorData.request, connectorData.src, connectorData.dst); } }); it("should not accept unregistered events", function() { assert.throws( () => subscriptions.broadcast(new UnregisteredEvent()), new Error(`Unregistered Event class ${UnregisteredEvent.name}`) ); }); it("should not accept events not handled by the class", function() { assert.throws( () => subscriptions.broadcast(new StringPermissionEvent()), new Error(`Event ${StringPermissionEvent.name} is not a registered as subscribable`) ); }); it("should notify all links who subscribed to all channels", async function() { subscriptions.broadcast(new RegisteredEvent()); await Promise.all([onceConnectorSend(0), onceConnectorSend(1)]); assertLastEvent(0, RegisteredEvent); // RegisteredEvent: All assertLastEvent(1, RegisteredEvent); // RegisteredEvent: All assertNoEvent(2); // ChannelEvent: All assertNoEvent(3); // ChannelEvent: channelOne assertNoEvent(4); // ChannelEvent: channelTwo }); it("should not notify a link who unsubscribed from all all channels", async function() { const connectorData = connectorSetupDate[0]; const request = new lib.SubscriptionRequest(RegisteredEvent.name, false); await subscriptions._handleRequest(request, connectorData.src, connectorData.dst); mockController.wsServer.controlConnections.get(0).connector.sentMessages.pop(); // Response to request subscriptions.broadcast(new RegisteredEvent()); await onceConnectorSend(1); assertNoEvent(0); assertLastEvent(1, RegisteredEvent); assertNoEvent(2); assertNoEvent(3); assertNoEvent(4); }); it("should notify all links who subscribed the specific channel", async function() { subscriptions.broadcast(new ChannelEvent()); await Promise.all([onceConnectorSend(2), onceConnectorSend(3)]); assertNoEvent(0); assertNoEvent(1); assertLastEvent(2, ChannelEvent); assertLastEvent(3, ChannelEvent); assertNoEvent(4); }); it("should not notify a link who unsubscribed from the specific channel", async function() { const connectorData = connectorSetupDate[3]; const request = new lib.SubscriptionRequest(ChannelEvent.name, false, []); await subscriptions._handleRequest(request, connectorData.src, connectorData.dst); mockController.wsServer.controlConnections.get(3).connector.sentMessages.pop(); // Response to request subscriptions.broadcast(new ChannelEvent()); await onceConnectorSend(2); assertNoEvent(0); assertNoEvent(1); assertLastEvent(2, ChannelEvent); assertNoEvent(3); assertNoEvent(4); }); it("should not notify links which are closed or closing", async function() { const link = mockController.wsServer.controlConnections.get(0); link.connector.closing = true; subscriptions.broadcast(new RegisteredEvent()); await onceConnectorSend(1); assertNoEvent(0); assertLastEvent(1, RegisteredEvent); assertNoEvent(2); assertNoEvent(3); assertNoEvent(4); }); }); describe("_handleRequest()", function() { beforeEach(function() { subscriptions.handle(RegisteredEvent); subscriptions.handle(ChannelEvent); }); it("should not accept subscriptions to unregistered events", function() { const request = new lib.SubscriptionRequest(RegisteredEvent.name, true); request.eventName = UnregisteredEvent.name; assert.rejects( subscriptions._handleRequest(request, addr({ controlId: 0 }), addr("controller")), new Error(`Unregistered Event class ${UnregisteredEvent.name}`) ); }); it("should not accept subscriptions to events not handled by the class", function() { const request = new lib.SubscriptionRequest(RegisteredEvent.name, true); request.eventName = StringPermissionEvent.name; assert.rejects( subscriptions._handleRequest(request, addr({ controlId: 0 }), addr("controller")), new Error(`Event ${StringPermissionEvent.eventName} is not a registered as subscribable`) ); }); it("should accept a subscription to all channels", async function() { const request = new lib.SubscriptionRequest(RegisteredEvent.name, true); await subscriptions._handleRequest(request, connectorSetupDate[0].src, connectorSetupDate[0].dst); subscriptions.broadcast(new RegisteredEvent()); await onceConnectorSend(0); assertLastEvent(0, RegisteredEvent); }); it("should accept a unsubscription from all channels", async function() { const request = new lib.SubscriptionRequest(RegisteredEvent.name, true); await subscriptions._handleRequest(request, connectorSetupDate[0].src, connectorSetupDate[0].dst); subscriptions.broadcast(new RegisteredEvent()); await onceConnectorSend(0); assertLastEvent(0, RegisteredEvent); const link = mockController.wsServer.controlConnections.get(0); link.connector.sentMessages.pop(); const unsubRequest = new lib.SubscriptionRequest(RegisteredEvent.name, false); await subscriptions._handleRequest(unsubRequest, connectorSetupDate[0].src, connectorSetupDate[0].dst); subscriptions.broadcast(new RegisteredEvent()); assertNoEvent(0); }); it("should accept a subscription to a specific channel", async function() { const request = new lib.SubscriptionRequest(ChannelEvent.name, false, ["channelOne"]); await subscriptions._handleRequest(request, connectorSetupDate[0].src, connectorSetupDate[0].dst); subscriptions.broadcast(new ChannelEvent()); await onceConnectorSend(0); assertLastEvent(0, ChannelEvent); }); it("should accept a unsubscription from a specific channel", async function() { const request = new lib.SubscriptionRequest(ChannelEvent.name, false, ["channelOne"]); await subscriptions._handleRequest(request, connectorSetupDate[0].src, connectorSetupDate[0].dst); subscriptions.broadcast(new ChannelEvent()); await onceConnectorSend(0); assertLastEvent(0, ChannelEvent); const link = mockController.wsServer.controlConnections.get(0); link.connector.sentMessages.pop(); const unsubRequest = new lib.SubscriptionRequest(ChannelEvent.name, false, []); await subscriptions._handleRequest(unsubRequest, connectorSetupDate[0].src, connectorSetupDate[0].dst); subscriptions.broadcast(new ChannelEvent()); assertNoEvent(0); }); it("should accept a respond with an event replay when returned by the handler", async function() { subscriptions.handle(StringPermissionEvent, undefined, async function() { return new StringPermissionEvent(); }); const request = new lib.SubscriptionRequest(StringPermissionEvent.name, true); await subscriptions._handleRequest(request, connectorSetupDate[0].src, connectorSetupDate[0].dst); subscriptions.broadcast(new StringPermissionEvent()); await onceConnectorSend(0); assertLastEvent(0, StringPermissionEvent); const link = mockController.wsServer.controlConnections.get(0); const lastMessage = link.connector.sentMessages.pop(); const eventEntry = lib.Link._eventsByClass.get(StringPermissionEvent); const response = eventEntry.eventFromJSON(lastMessage.data); assert.deepEqual(response, new StringPermissionEvent()); }); }); }); describe("class EventSubscriber", function() { let channelEvent, mockControl, registeredEvent; beforeEach(function() { mockControl = new MockControl(new MockConnector( addr({ controlId: 0 }), addr("controller"), )); registeredEvent = new lib.EventSubscriber(RegisteredEvent, undefined, mockControl); channelEvent = new lib.EventSubscriber(ChannelEvent, undefined, mockControl); }); function assertLastRequest(request) { const Request = request.constructor; const lastMessage = mockControl.connector.sentMessages.at(-1); const name = Request.plugin ? `${Request.plugin}:${Request.name}` : Request.name; assert.equal(lastMessage.name, name); assert.deepEqual(lastMessage.data, request); } describe("constructor()", function() { it("should not accept unregistered events", function() { assert.throws( () => new lib.EventSubscriber(UnregisteredEvent), new Error(`Unregistered Event class ${UnregisteredEvent.name}`) ); }); it("should handle the provided event, if a control link is provided", function() { assert.equal(mockControl._eventHandlers.has(RegisteredEvent), true); assert.equal(mockControl._eventHandlers.has(ChannelEvent), true); }); it("should call and use the return of a pre-handler, if provided", function() { let calledWith = null; function prehandler() { return "PreHandlerReturn"; } const eventSubscriber = new lib.EventSubscriber(StringPermissionEvent, prehandler, mockControl); eventSubscriber.subscribe(function(value) { calledWith = value; }); eventSubscriber._handle(new StringPermissionEvent()); assert.equal(calledWith, "PreHandlerReturn"); }); }); describe("connectControl()", function() { it("should handle the provided event", function() { const control = new MockControl(new MockConnector(addr({ controlId: 0 }), addr("controller"))); registeredEvent.connectControl(control); assert.equal(control._eventHandlers.has(RegisteredEvent), true); }); it("should do nothing if the control is already connected", function() { assert.equal(mockControl._eventHandlers.has(RegisteredEvent), true); registeredEvent.connectControl(mockControl); assert.equal(mockControl._eventHandlers.has(RegisteredEvent), true); }); }); describe("subscribe()", function() { it("should allow subscriptions to an event", function() { let calledWith = null; let calledWithTwo = null; const event = new RegisteredEvent(); registeredEvent.subscribe(function(value) { calledWith = value; }); registeredEvent.subscribe(function(value) { calledWithTwo = value; }); registeredEvent._handle(event); assert.deepEqual(calledWith, event); assert.deepEqual(calledWithTwo, event); }); }); describe("subscribeToChannel()", function() { it("should allow subscriptions to a channel for an event", function() { let calledWith = null; let calledWithTwo = null; const event = new ChannelEvent(); channelEvent.subscribeToChannel("channelOne", function(value) { calledWith = value; }); channelEvent.subscribeToChannel("channelTwo", function(value) { calledWithTwo = value; }); channelEvent._handle(event); assert.deepEqual(calledWith, event); assert.deepEqual(calledWithTwo, null); }); }); describe("unsubscribe()", function() { it("should allow unsubscribing from an event", function() { let calledWith = null; let event = new RegisteredEvent(); function callback(value) { calledWith = value; } registeredEvent.subscribe(callback); registeredEvent._handle(event); assert.deepEqual(calledWith, event); calledWith = null; event = new RegisteredEvent(); registeredEvent.unsubscribe(callback); registeredEvent._handle(event); assert.equal(calledWith, null); }); it("should throw an error if the handler was not subscribed", function() { function callback(value) { calledWith = value; } assert.rejects( () => registeredEvent.unsubscribe(callback), new Error("handler is not registered") ); }); }); describe("unsubscribeFromChannel()", function() { it("should allow unsubscribing from a channel for an event", function() { let calledWith = null; let event = new ChannelEvent(); function callback(value) { calledWith = value; } channelEvent.subscribeToChannel("channelOne", callback); channelEvent._handle(event); assert.deepEqual(calledWith, event); calledWith = null; event = new ChannelEvent(); channelEvent.unsubscribeFromChannel("channelOne", callback); channelEvent._handle(event); assert.equal(calledWith, null); let calledWithTwo = null; function callbackTwo(value) { calledWithTwo = value; } channelEvent.subscribeToChannel("channelOne", callback); channelEvent.subscribeToChannel("channelOne", callbackTwo); channelEvent._handle(event); assert.equal(calledWith, event); assert.equal(calledWithTwo, event); calledWith = null; calledWithTwo = null; event = new ChannelEvent(); channelEvent.unsubscribeFromChannel("channelOne", callback); channelEvent._handle(event); assert.equal(calledWith, null); assert.equal(calledWithTwo, event); }); it("should throw an error if the handler was not subscribed", function() { function callback() {} assert.rejects( () => channelEvent.unsubscribeFromChannel("channelOne", callback), new Error("handler is not registered") ); let calledWith = null; function callbackTwo(value) { calledWith = value; } channelEvent.subscribeToChannel("channelOne", callbackTwo); assert.rejects( () => channelEvent.unsubscribeFromChannel("channelOne", callback), new Error("handler is not registered") ); const event = new ChannelEvent(); channelEvent._handle(event); assert.equal(calledWith, event); }); }); describe("_updateSubscription()", function() { it("should correctly request a subscription for all channels", async function() { const expected = new lib.SubscriptionRequest(RegisteredEvent.name, true); registeredEvent.subscribe(() => true); await events.once(mockControl.connector, "send"); assertLastRequest(expected); }); it("should correctly request a subscription for some channels", async function() { const expected = new lib.SubscriptionRequest(ChannelEvent.name, false, ["channelOne"]); channelEvent.subscribeToChannel("channelOne", () => true); await events.once(mockControl.connector, "send"); assertLastRequest(expected); }); it("should correctly request a subscription for no channels", async function() { const expected = new lib.SubscriptionRequest(RegisteredEvent.name, false); function callback() {} registeredEvent.subscribe(callback); await events.once(mockControl.connector, "send"); registeredEvent.unsubscribe(callback); await events.once(mockControl.connector, "send"); assertLastRequest(expected); }); it("should call handlers when a replay event is returned", async function() { let calledWith = null; const event = new RegisteredEvent(); const request = registeredEvent.subscribe(function(value) { calledWith = value; }); await events.once(mockControl.connector, "send"); mockControl.connector.emit("message", new lib.MessageResponse( 0, mockControl.connector.dst, new lib.Address(lib.Address.control, 0, mockControl._nextRequestId - 1), new lib.SubscriptionResponse(event).toJSON() )); await request; assert.deepEqual(calledWith, event); }); }); }); });