UNPKG

vumigo_v02

Version:

Javascript toolkit and examples for Vumi's Javascript sandbox

1,077 lines (911 loc) 38.1 kB
var _ = require('lodash'); var assert = require('assert'); var vumigo = require('../lib'); var fixtures = vumigo.fixtures; var test_utils = vumigo.test_utils; var App = vumigo.App; var AppTester = vumigo.AppTester; var State = vumigo.states.State; var FreeText = vumigo.states.FreeText; var EndState = vumigo.states.EndState; var ApiError = vumigo.interaction_machine.ApiError; var ReplyEvent = vumigo.interaction_machine.ReplyEvent; var InboundMessageEvent = vumigo.interaction_machine.InboundMessageEvent; var UnknownCommandEvent = vumigo.interaction_machine.UnknownCommandEvent; describe("interaction_machine", function() { describe("InteractionMachine", function () { var im; var api; var msg; var app; var start_state; var end_state; beforeEach(function() { msg = fixtures.msg('2'); app = new App('start'); start_state = new FreeText('start', { question: test_utils.$('hello?'), next: 'end' }); app.states.add(start_state); end_state = new EndState('end', { text: test_utils.$('goodbye') }); app.states.add(end_state); helper_meta_state = new EndState('helper_meta', { text: test_utils.$('goodbye'), helper_metadata: {'grass': 'green'}, }); app.states.add(helper_meta_state); return test_utils.make_im({ app: app, msg: msg }).then(function(new_im) { im = new_im; api = im.api; }); }); describe(".setup", function() { beforeEach(function() { var p = test_utils.make_im({setup: false}); return p.then(function(new_im) { im = new_im; }); }); it("should setup its sandbox config", function() { var p = im.sandbox_config.once.resolved('setup'); return im.setup(msg).thenResolve(p); }); it("should setup its config", function() { var p = im.config.do.once.resolved('setup'); return im.setup(msg).thenResolve(p); }); it("should setup its metric store", function() { var p = im.metrics.once.resolved('setup'); return im.setup(msg).thenResolve(p); }); it("should setup its outbound helper", function() { var p = im.outbound.once.resolved('setup'); return im.setup(msg).then(function() { assert(p.isFulfilled()); }); }); it("should setup its contacts store", function() { var p = im.contacts.once.resolved('setup'); return im.setup(msg).then(function() { assert(p.isFulfilled()); }); }); it("should setup its app", function() { var p = im.app.once.resolved('setup'); return im.setup(msg).thenResolve(p); }); it("should emit the user's creation event", function() { var p = im.user.once.resolved('user:load'); return im.setup(msg).thenResolve(p); }); it("should emit a 'setup' event", function() { var p = im.once.resolved('setup'); return im.setup(msg).thenResolve(p); }); describe("if no user exists for the message address", function() { it("should create a new user", function() { msg.from_addr = '+27123456NEW'; var p = im.user.once.resolved('user:new'); return im.setup(msg).thenResolve(p); }); }); describe("if a user exists for the message address", function() { it("should load the user", function() { var p = im.user.once.resolved('user:load'); return im.setup(msg).thenResolve(p); }); }); describe("if the restart option is true", function() { it("should reset the existing user", function() { return im.setup(msg).thenResolve(function() { assert(!im.user.state.exists()); }); }); }); }); describe(".teardown", function() { it("should remove its event listeners", function() { im.on('foo', function() {}); im.on('teardown', function() {}); assert.equal(im.listeners('foo').length, 1); assert.equal(im.listeners('teardown').length, 1); return im.teardown().then(function() { assert.equal(im.listeners('foo').length, 0); assert.equal(im.listeners('teardown').length, 0); }); }); it("should teardown its app", function(done) { app.on('teardown', function() { done(); }); im.teardown(); }); it("should emit a 'teardown' event", function(done) { im.on('teardown', function() { done(); }); im.teardown(); }); }); describe(".attach", function() { beforeEach(function() { delete api.on_unknown_command; delete api.on_inbound_message; delete api.on_inbound_event; delete app.im; }); it("should attach the im to the app", function() { im.attach(); assert.strictEqual(im, app.im); }); describe("when api.on_unknown_command is invoked", function() { var cmd; beforeEach(function() { cmd = {bad: 'cmd'}; }); it("should emit an 'unknown_command' event", function() { im.attach(); var p = im.once.resolved('unknown_command'); api.on_unknown_command(cmd); return p; }); it("should shutdown the im after event handling", function() { im.attach(); var p = im.once.resolved('im:shutdown'); api.on_unknown_command(cmd); return p; }); it("should handle errors thrown by the event listeners", function() { im.attach(); var error = new Error(); im.on('unknown_command', function() { throw error; }); var p = im.once.resolved('im:error'); api.on_unknown_command(cmd); return p.then(function(event) { assert.strictEqual(event.error, error); }); }); }); describe("when api.on_inbound_event is invoked", function() { var cmd; beforeEach(function() { cmd = { msg: { user_message_id: '1', event_type: 'ack' } }; }); it("should emit an 'inbound_event' event", function() { im.attach(); var p = im.once.resolved('inbound_event'); api.on_inbound_event(cmd); return p; }); it("should shutdown the im after event handling", function() { im.attach(); var p = im.once.resolved('im:shutdown'); api.on_inbound_event(cmd); return p; }); it("should handle any errors thrown by the event listeners", function() { im.attach(); var error = new Error(); im.on('inbound_event', function() { throw error; }); var p = im.once.resolved('im:error'); api.on_inbound_event(cmd); return p.then(function(event) { assert.strictEqual(event.error, error); }); }); }); describe("when api.on_inbound_message is invoked", function() { var cmd; beforeEach(function() { cmd = {msg: msg}; }); it("should emit an 'inbound_message' event", function() { im.attach(); var p = im.once.resolved('inbound_message'); api.on_inbound_message(cmd); return p; }); it("should shutdown the im after event handling", function() { im.attach(); var p = im.once.resolved('im:shutdown'); api.on_inbound_message(cmd); return p; }); it("should handle any errors thrown by the event listeners", function() { im.attach(); var error = new Error(); var p = im.once.resolved('im:error'); im.on('inbound_message', function() { throw error; }); api.on_inbound_message(cmd); return p.then(function(event) { assert.strictEqual(event.error, error); }); }); }); }); describe(".set_state", function() { it("should set the given state as the user's state", function() { var s = new State('foo'); im.set_state(s); assert(im.user.state.is(s)); }); it("should set the given state as the im's state", function() { var s = new State('foo'); im.set_state(s); assert.strictEqual(im.state, s); }); it("unset the state if null is given", function() { im.set_state(new State('foo')); assert.notStrictEqual(im.state, null); assert(im.user.state.exists()); im.set_state(null); assert.strictEqual(im.state, null); assert(!im.user.state.exists()); }); }); describe(".create_state", function() { it("should create the state from a state name", function() { var expected = new State('foo'); im.app.states.add(expected); return im.create_state('foo') .then(function(state) { assert.strictEqual(state, expected); }); }); it("should create the state from state data", function() { im.app.states.add('foo', function(name, opts) { var s = new State('foo'); s.creator_opts = opts; return s; }); return im.create_state({ name: 'foo', metadata: {bar: 'baz'}, creator_opts: {quux: 'corge'} }) .then(function(state) { assert.equal(state.name, 'foo'); assert.deepEqual(state.metadata, {bar: 'baz'}); assert.deepEqual(state.creator_opts, {quux: 'corge'}); }); }); }); describe(".create_and_set_state", function() { it("should create the given state as the current state", function() { var expected = new State('foo'); im.app.states.add(expected); return im.create_and_set_state('foo') .then(function(state) { assert.strictEqual(im.state, expected); }); }); }); describe(".resume_state", function() { it("should set the given state as the current state", function() { var expected = new State('foo'); im.app.states.add(expected); return im.resume_state('foo') .then(function(state) { assert.strictEqual(im.state, expected); }); }); it("should emit a 'state:resume' event for the dest state", function() { var p = im.once.resolved('state:resume'); var expected = new State('foo'); im.app.states.add(expected); return im.resume_state('foo') .thenResolve(p) .then(function(e) { assert.strictEqual(e.state, expected); }); }); it("should emit a 'state:enter' event if a different state is created", function() { var p = im.once.resolved('state:enter'); var expected = new State('bar'); im.app.states.add('foo', function() { return expected; }); return im.resume_state('foo') .thenResolve(p) .then(function(e) { assert.strictEqual(e.state, expected); }); }); }); describe(".enter_state", function() { it("should set the given state as the current state", function() { var expected = new State('foo'); im.app.states.add(expected); return im.enter_state('foo') .then(function(state) { assert.strictEqual(im.state, expected); }); }); it("should emit a 'state:enter' event for the dest state", function() { var p = im.once.resolved('state:enter'); var expected = new State('foo'); im.app.states.add(expected); return im.enter_state('foo') .thenResolve(p) .then(function(e) { assert.strictEqual(e.state, expected); }); }); }); describe(".exit_state", function() { it("should act as a noop if there is no current state", function() { assert.strictEqual(im.state, null); return im.exit_state() .then(function() { assert.strictEqual(im.state, null); }); }); it("should unset the current state", function() { return im.resume_state('start') .then(function() { return im.exit_state(); }) .then(function() { assert.strictEqual(im.state, null); }); }); it("should emit a 'state:exit' event for the dest state", function() { return im.resume_state('start') .then(function() { var p = im.once.resolved('state:exit'); return im.exit_state().thenResolve(p); }) .then(function(e) { assert.strictEqual(e.state, start_state); }); }); }); describe(".switch_state", function() { beforeEach(function() { return im.resume_state('start'); }); it("should not switch states if the src and dest are the same", function() { return im.switch_state('start') .then(function() { assert.strictEqual(im.state, start_state); }); }); it("should not switch states if the dest does not exist", function() { return im.switch_state('i-do-not-exist') .then(function() { assert.strictEqual(im.state, start_state); }); }); it("should exit the current state", function() { var p = im.once.resolved('state:exit'); return im.switch_state('end') .thenResolve(p) .then(function(e) { assert.strictEqual(e.state, start_state); }); }); it("should enter the dest state", function() { var p = im.once.resolved('state:enter'); var dest = new State('dest'); im.app.states.add(dest); return im.switch_state('dest') .thenResolve(p) .then(function(e) { assert.strictEqual(e.state, dest); }); }); }); describe(".fetch_translation", function() { it("should construct a translator with the fetched language data", function() { return im.fetch_translation('jp').then(function(i18n) { assert.equal(i18n(test_utils.$('yes')), 'hai'); }); }); }); describe(".log", function() { it("should log the requested message", function() { assert(!_.includes(api.log.info, 'wah wah')); return im.log('wah wah').then(function() { assert(_.includes(api.log.info, 'wah wah')); }); }); }); describe(".err", function() { it("should log the error", function() { assert(!_.includes(api.log.error, ':(')); return im.err(new Error(':(')).then(function() { assert(_.includes(api.log.error, ':(')); }); }); it("should terminate the sandbox", function() { assert.equal(api.done_calls, 0); return im.err(new Error(':(')).then(function() { assert.equal(api.done_calls, 1); }); }); }); describe(".done", function() { it("should save the user", function(done) { im.user.on('user:save', function() { done(); }); im.done(); }); it("should tear down the interaction machine", function(done) { im.on('teardown', function() { done(); }); im.done(); }); it("should terminate the sandbox", function() { assert.equal(api.done_calls, 0); return im.done().then(function() { assert.equal(api.done_calls, 1); }); }); }); describe(".api_request", function() { it("should make a promise-based api request", function() { assert(!_.includes(api.log.info, 'arrg')); im.api_request('log.info', {msg: 'arrg'}).then(function() { assert(_.includes(api.log.info, 'arrg')); }); }); it("should reject the reply if the api gave a failure reply", function() { im.api.reply = function() { return { success: false, reason: 'No apparent reason' }; }; im.api_request('log.info', {msg: 'arrg'}).catch(function() { assert(e instanceof ApiError); assert.deepEqual(e.reply, { success: false, reason: 'No apparent reason' }); assert.equal(e.message, 'No apparent reason'); }); }); }); describe(".reply", function() { beforeEach(function() { return im.resume_state('start') .then(function() { im.next_state.reset('end'); }); }); it("should switch to the user's next state", function() { assert.strictEqual(im.state.name, 'start'); return im.reply(msg).then(function() { assert.equal(im.state.name, 'end'); }); }); it("should use the state's display content in the reply", function() { return im.reply(msg).then(function() { var reply = api.outbound.store[0]; assert.deepEqual(reply.content, 'goodbye'); }); }); it("should use the state's helper metadata in the reply", function() { im.next_state.reset('helper_meta'); return im.reply(msg).then(function() { var reply = api.outbound.store[0]; assert.deepEqual(reply.helper_metadata, {grass: 'green'}); }); }); it("should emit an event after sending the reply", function() { im.next_state.reset('helper_meta'); var p = im.once.resolved('reply').then(function(e) { assert(api.outbound.store.length); assert(e instanceof ReplyEvent); assert.equal(e.content, 'goodbye'); assert(!e.continue_session); assert.deepEqual(e.helper_metadata, {grass: 'green'}); }); im.reply(msg); return p; }); describe("if the translate option is true", function() { beforeEach(function() { return im.user.set_lang('af'); }); it("should translate the state's display content in the reply", function() { return im.reply(msg, {translate: true}).then(function() { var reply = api.outbound.store[0]; assert.deepEqual(reply.content, 'totsiens'); }); }); }); describe("if the state does not want to continue the session", function() { beforeEach(function() { im.user.state.reset(end_state); }); it("should emit a 'session:close' event", function() { var p = im.once.resolved('session:close'); return im.reply(msg).thenResolve(p); }); it("should set the reply message to not continue the session", function() { return im.reply(msg).then(function() { var reply = api.outbound.store[0]; assert(!reply.continue_session); }); }); it("should set the user to not be in a session", function() { im.user.in_session = true; return im.reply(msg).then(function() { assert(!im.user.in_session); }); }); }); describe("if the state does not want to send a reply", function() { beforeEach(function() { var state = new EndState('a_new_end', { text: 'goodbye', send_reply: false }); im.app.states.add(state); return im.resume_state('start') .then(function() { im.next_state.reset('a_new_end'); }); }); it("should not send a reply", function() { return im.reply(msg).then(function() { assert.equal(api.outbound.store.length, 0); }); }); }); }); describe(".emit.state.exit", function() { it("should emit a 'state:exit' event on the im", function() { var state = new State('foo'); var p = im.once.resolved('state:exit'); return im.emit.state.exit(state) .then(function() { assert(p.isFulfilled()); }); }); it("should emit a 'state:exit' event on the current state", function() { var state = new State('foo'); var p = state.once.resolved('state:exit'); return im.emit.state.exit(state) .then(function() { assert(p.isFulfilled()); }); }); }); describe(".emit.state.enter", function() { it("should emit n 'state:enter' event on the im", function() { var state = new State('foo'); var p = im.once.resolved('state:enter'); return im.emit.state.enter(state) .then(function() { assert(p.isFulfilled()); }); }); it("should emit a 'state:enter' event on the new state", function() { var state = new State('foo'); var p = state.once.resolved('state:enter'); return im.emit.state.enter(state) .then(function() { assert(p.isFulfilled()); }); }); }); describe(".emit.state.resume", function() { it("should emit a 'state:resume' event on the im", function() { var state = new State('foo'); var p = im.once.resolved('state:resume'); return im.emit.state.resume(state) .then(function() { assert(p.isFulfilled()); }); }); it("should emit an 'state:resume' event on the new state", function() { var state = new State('foo'); var p = state.once.resolved('state:resume'); return im.emit.state.resume(state) .then(function() { assert(p.isFulfilled()); }); }); }); describe("on 'unknown_command'", function() { it("should log the command", function() { assert(!_.includes( api.log.error, 'Received unknown command: {"bad":"cmd"}')); var e = new UnknownCommandEvent(im, {bad: 'cmd'}); return im.emit(e).then(function() { assert(_.includes( api.log.error, 'Received unknown command: {"bad":"cmd"}')); }); }); }); describe("on 'inbound_message'", function() { var event; beforeEach(function() { event = new InboundMessageEvent(im, {msg: msg}); }); it("should set up the im", function() { var p = im.once.resolved('setup'); return im.emit(event).thenResolve(p); }); describe("if the message content is set to '!reset'", function() { beforeEach(function() { msg.content = '!reset'; }); it("should reset the message content to an empty string", function() { return im.emit(event).then(function() { assert.strictEqual(im.msg.content, ''); }); }); it("should reset the user", function() { var p = im.user.once.resolved('user:reset'); return im.emit(event).thenResolve(p); }); }); describe("if the user is currently in a state", function() { it("should switch to the user's current state", function() { assert.strictEqual(im.state, null); return im.emit(event).then(function() { assert.equal(im.state.name, im.user.state.name); }); }); }); describe("if the user is not in a state", function() { beforeEach(function() { msg.from_addr = '+27123456NEW'; }); it("should switch to the start state", function() { assert.strictEqual(im.state, null); return im.emit(event).then(function() { assert.equal(im.state.name, 'start'); }); }); }); describe("if the user is not in a session", function() { it("should use session start for non-session-based messages", function() { msg.session_event = null; var p = im.once.resolved('session:new'); return im.emit(event).then(function() { assert(p.isFulfilled()); }); }); it("should set the user to be in a session", function() { assert(!im.user.in_session); return im.emit(event).then(function() { assert(im.user.in_session); }); }); }); describe("if the message's session event was 'close'", function() { beforeEach(function() { msg.session_event = 'close'; }); it("should emit a 'session:close' event", function() { var p = im.once.resolved('session:close'); return im.emit(event).thenResolve(p); }); }); describe("if the message's session event was 'new'", function() { beforeEach(function() { msg.session_event = 'new'; }); it("should emit a 'session:new' event on the im", function() { var p = im.once.resolved('session:new'); return im.emit(event).thenResolve(p); }); it("should reply to the message", function() { return im.emit(event).then(function() { assert.deepEqual(api.outbound.store, [{ content: 'hello?', in_reply_to: '2', continue_session: true, }]); }); }); }); describe("if the message's session event was not 'close' or 'new'", function() { beforeEach(function() { msg.session_event = 'resume'; }); it("should emit a 'session:resume' event on the im", function() { var p = im.once.resolved('session:resume'); return im.emit(event).thenResolve(p); }); it("should reply to the message", function() { return im.emit(event).then(function() { assert.deepEqual(api.outbound.store, [{ content: 'goodbye', in_reply_to: '2', continue_session: false, }]); }); }); describe("if the message has truthy content", function() { it("should emit a 'state:input' event on the current state", function() { var p = start_state.once.resolved('state:input'); return im.emit(event).thenResolve(p); }); }); }); }); it("should emit state lifecycle events sensibly", function() { var app = new App('a'); var events = []; function push(e) { events.push({ event: e.name, state: e.state.name, message: app.im.msg.content }); } app.events = { 'im state:enter': push, 'im state:resume': push, 'im state:exit': push }; app.states.add(new FreeText('a', { question: 'hello?', next: 'b' })); app.states.add(new FreeText('b', { question: 'you are in the middle, say something', next: 'c' })); app.states.add(new EndState('c', { text: 'bye', next: 'a' })); return new AppTester(app) .inputs( null, 'hi', 'foo', null, 'hi again', 'bar', null) .check(function() { assert.deepEqual(events, [{ state: 'a', message: null, event: 'state:enter' }, { state: 'a', message: 'hi', event: 'state:resume' }, { state: 'a', message: 'hi', event: 'state:exit' }, { state: 'b', message: 'hi', event: 'state:enter' }, { state: 'b', message: 'foo', event: 'state:resume' }, { state: 'b', message: 'foo', event: 'state:exit' }, { state: 'c', message: 'foo', event: 'state:enter' }, { state: 'c', message: null, event: 'state:resume' }, { state: 'c', message: null, event: 'state:exit' }, { state: 'a', message: null, event: 'state:enter' }, { state: 'a', message: 'hi again', event: 'state:resume' }, { state: 'a', message: 'hi again', event: 'state:exit' }, { state: 'b', message: 'hi again', event: 'state:enter' }, { state: 'b', message: 'bar', event: 'state:resume' }, { state: 'b', message: 'bar', event: 'state:exit' }, { state: 'c', message: 'bar', event: 'state:enter' }, { state: 'c', message: null, event: 'state:resume' }, { state: 'c', message: null, event: 'state:exit' }, { state: 'a', message: null, event: 'state:enter' }]); }) .run(); }); }); describe("interact", function () { var app; var app_creator; beforeEach(function() { app = new App('start'); app_creator = function () { return app; }; }); describe("when the api is defined", function() { it("should create an interaction machine", function() { var api = {}; var im = vumigo.interact(api, app_creator); assert.strictEqual(im.app, app); }); it("should support passing in an App class", function() { var MyApp = App.extend(function(self) { App.call(self, 'start'); self.name = 'my_app'; }); var api = {}; var im = vumigo.interact(api, MyApp); assert.strictEqual(im.app.name, 'my_app'); }); }); describe("when the api is not defined", function() { it("should not create an interaction machine", function() { var api; var im = vumigo.interact(api, app_creator); assert.strictEqual(im, null); }); }); }); });