diff --git a/.firebaserc b/.firebaserc new file mode 100644 index 0000000..951bd1d --- /dev/null +++ b/.firebaserc @@ -0,0 +1,5 @@ +{ + "projects": { + "default": "water-log-5e42b" + } +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4dca693 --- /dev/null +++ b/.gitignore @@ -0,0 +1,58 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules/ +jspm_packages/ + +# Typescript v1 declaration files +typings/ + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variables file +.env \ No newline at end of file diff --git a/Dialogflow/WaterLog.zip b/Dialogflow/WaterLog.zip new file mode 100644 index 0000000..839f1fa Binary files /dev/null and b/Dialogflow/WaterLog.zip differ diff --git a/Dialogflow/WaterLog/agent.json b/Dialogflow/WaterLog/agent.json new file mode 100755 index 0000000..e02c8bc --- /dev/null +++ b/Dialogflow/WaterLog/agent.json @@ -0,0 +1,36 @@ +{ + "description": "Daily water logger", + "language": "en", + "googleAssistant": { + "googleAssistantCompatible": true, + "project": "water-log-5e42b", + "welcomeIntentSignInRequired": false, + "startIntents": [], + "systemIntents": [], + "endIntentIds": [ + "9d87e786-f42c-40bb-a711-7c8a9df612bb" + ], + "oAuthLinking": { + "required": false, + "grantType": "AUTH_CODE_GRANT" + }, + "voiceType": "MALE_1", + "capabilities": [], + "protocolVersion": "V2" + }, + "defaultTimezone": "Europe/Madrid", + "webhook": { + "url": "https://us-central1-water-log-5e42b.cloudfunctions.net/waterLog", + "headers": { + "": "" + }, + "available": true, + "useForDomains": false, + "cloudFunctionsEnabled": false, + "cloudFunctionsInitialized": false + }, + "isPrivate": true, + "customClassifierMode": "use.after", + "mlMinConfidence": 0.3, + "supportedLanguages": [] +} \ No newline at end of file diff --git a/Dialogflow/WaterLog/intents/Default Fallback Intent.json b/Dialogflow/WaterLog/intents/Default Fallback Intent.json new file mode 100755 index 0000000..844c74a --- /dev/null +++ b/Dialogflow/WaterLog/intents/Default Fallback Intent.json @@ -0,0 +1,41 @@ +{ + "id": "321bd139-faa9-4369-bb94-235695171a98", + "name": "Default Fallback Intent", + "auto": true, + "contexts": [], + "responses": [ + { + "resetContexts": false, + "action": "input.unknown", + "affectedContexts": [], + "parameters": [], + "messages": [ + { + "type": 0, + "lang": "en", + "speech": [ + "I didn\u0027t get that. Can you say it again?", + "I missed what you said. Say it again?", + "Sorry, could you say that again?", + "Sorry, can you say that again?", + "Can you say that again?", + "Sorry, I didn\u0027t get that.", + "Sorry, what was that?", + "One more time?", + "What was that?", + "Say that again?", + "I didn\u0027t get that.", + "I missed that." + ] + } + ], + "defaultResponsePlatforms": {}, + "speech": [] + } + ], + "priority": 500000, + "webhookUsed": false, + "webhookForSlotFilling": false, + "fallbackIntent": true, + "events": [] +} \ No newline at end of file diff --git a/Dialogflow/WaterLog/intents/Default Welcome Intent.json b/Dialogflow/WaterLog/intents/Default Welcome Intent.json new file mode 100755 index 0000000..1d69c9e --- /dev/null +++ b/Dialogflow/WaterLog/intents/Default Welcome Intent.json @@ -0,0 +1,36 @@ +{ + "id": "883cceb6-2005-4b04-adeb-7febd81f1a24", + "name": "Default Welcome Intent", + "auto": true, + "contexts": [], + "responses": [ + { + "resetContexts": false, + "action": "input.welcome", + "affectedContexts": [], + "parameters": [], + "messages": [ + { + "type": 0, + "lang": "en", + "speech": [] + } + ], + "defaultResponsePlatforms": {}, + "speech": [] + } + ], + "priority": 500000, + "webhookUsed": true, + "webhookForSlotFilling": false, + "lastUpdate": 1508308657, + "fallbackIntent": false, + "events": [ + { + "name": "WELCOME" + }, + { + "name": "GOOGLE_ASSISTANT_WELCOME" + } + ] +} \ No newline at end of file diff --git a/Dialogflow/WaterLog/intents/get_logged_water.json b/Dialogflow/WaterLog/intents/get_logged_water.json new file mode 100755 index 0000000..16623fc --- /dev/null +++ b/Dialogflow/WaterLog/intents/get_logged_water.json @@ -0,0 +1,29 @@ +{ + "id": "5cbc8cea-6ae1-48b7-a7be-95b8f2d76cdc", + "name": "get_logged_water", + "auto": true, + "contexts": [], + "responses": [ + { + "resetContexts": false, + "action": "get_logged_water", + "affectedContexts": [], + "parameters": [], + "messages": [ + { + "type": 0, + "lang": "en", + "speech": [] + } + ], + "defaultResponsePlatforms": {}, + "speech": [] + } + ], + "priority": 500000, + "webhookUsed": true, + "webhookForSlotFilling": false, + "lastUpdate": 1508433491, + "fallbackIntent": false, + "events": [] +} \ No newline at end of file diff --git a/Dialogflow/WaterLog/intents/get_logged_water_usersays_en.json b/Dialogflow/WaterLog/intents/get_logged_water_usersays_en.json new file mode 100755 index 0000000..3756abf --- /dev/null +++ b/Dialogflow/WaterLog/intents/get_logged_water_usersays_en.json @@ -0,0 +1,77 @@ +[ + { + "id": "f3fd2a0a-173d-4b24-bfa0-c2ce76c5caad", + "data": [ + { + "text": "How much did I drink?", + "userDefined": false + } + ], + "isTemplate": false, + "count": 0, + "updated": 0 + }, + { + "id": "c0b103f2-dbba-46bc-8c0a-3284165149fd", + "data": [ + { + "text": "How much water have I drunk ", + "userDefined": false + }, + { + "text": "today", + "meta": "@sys.ignore", + "userDefined": false + }, + { + "text": "?", + "userDefined": false + } + ], + "isTemplate": false, + "count": 0, + "updated": 0 + }, + { + "id": "d0b592c3-b46a-4836-86ff-1d80abc7535b", + "data": [ + { + "text": "How much water did I drink ", + "userDefined": false + }, + { + "text": "today", + "meta": "@sys.ignore", + "userDefined": false + }, + { + "text": "?", + "userDefined": false + } + ], + "isTemplate": false, + "count": 0, + "updated": 0 + }, + { + "id": "b2f8c850-5c3e-4229-9644-33c11352f84b", + "data": [ + { + "text": "Did I drink any water ", + "userDefined": false + }, + { + "text": "today", + "meta": "@sys.ignore", + "userDefined": false + }, + { + "text": "?", + "userDefined": false + } + ], + "isTemplate": false, + "count": 0, + "updated": 0 + } +] \ No newline at end of file diff --git a/Dialogflow/WaterLog/intents/log_water.json b/Dialogflow/WaterLog/intents/log_water.json new file mode 100755 index 0000000..5ec3923 --- /dev/null +++ b/Dialogflow/WaterLog/intents/log_water.json @@ -0,0 +1,48 @@ +{ + "id": "9d87e786-f42c-40bb-a711-7c8a9df612bb", + "name": "log_water", + "auto": true, + "contexts": [], + "responses": [ + { + "resetContexts": false, + "action": "log_water", + "affectedContexts": [], + "parameters": [ + { + "id": "a5f59c31-e560-4c57-8686-c21d48ba0bd4", + "required": true, + "dataType": "@sys.unit-volume", + "name": "water_volume", + "value": "$water_volume", + "prompts": [ + { + "lang": "en", + "value": "How much of water did you drink so far?" + }, + { + "lang": "en", + "value": "How much of water should I log?" + } + ], + "isList": false + } + ], + "messages": [ + { + "type": 0, + "lang": "en", + "speech": [] + } + ], + "defaultResponsePlatforms": {}, + "speech": [] + } + ], + "priority": 500000, + "webhookUsed": true, + "webhookForSlotFilling": false, + "lastUpdate": 1508442034, + "fallbackIntent": false, + "events": [] +} \ No newline at end of file diff --git a/Dialogflow/WaterLog/intents/log_water_usersays_en.json b/Dialogflow/WaterLog/intents/log_water_usersays_en.json new file mode 100755 index 0000000..4f9edab --- /dev/null +++ b/Dialogflow/WaterLog/intents/log_water_usersays_en.json @@ -0,0 +1,68 @@ +[ + { + "id": "dc227fa6-5ff0-4d72-ab9f-687d14fba54c", + "data": [ + { + "text": "I have drunk ", + "userDefined": false + }, + { + "text": "200 milliliters", + "alias": "water_volume", + "meta": "@sys.unit-volume", + "userDefined": false + }, + { + "text": " of water", + "userDefined": false + } + ], + "isTemplate": false, + "count": 0, + "updated": 1508067206 + }, + { + "id": "57dcac55-a8d9-4a95-9eb9-35e259ce9a2f", + "data": [ + { + "text": "Log 1 ", + "userDefined": false + }, + { + "text": "liter", + "alias": "water_volume", + "meta": "@sys.unit-volume", + "userDefined": true + }, + { + "text": " of water", + "userDefined": false + } + ], + "isTemplate": false, + "count": 0, + "updated": 1508442034 + }, + { + "id": "724057c5-065d-4c1e-9eec-5e0448601348", + "data": [ + { + "text": "Save ", + "userDefined": false + }, + { + "text": "100 mililiters", + "alias": "water_volume", + "meta": "@sys.unit-volume", + "userDefined": true + }, + { + "text": " of water", + "userDefined": false + } + ], + "isTemplate": false, + "count": 0, + "updated": 1508442034 + } +] \ No newline at end of file diff --git a/Dialogflow/WaterLog/package.json b/Dialogflow/WaterLog/package.json new file mode 100755 index 0000000..688e939 --- /dev/null +++ b/Dialogflow/WaterLog/package.json @@ -0,0 +1,3 @@ +{ + "version": "1.0.0" +} \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..9bf28fb --- /dev/null +++ b/README.md @@ -0,0 +1,23 @@ +# WaterLog + +Track your daily water intake with Google Assistant and voice interfaces. + +App is available live through Google Assistant directory on your device or under this link: https://assistant.google.com/services/a/id/12872514ba525cc6 + +# About this project + +The main goal for this project is to show full stack solution for voice-interface application. Source code will be developed over time to handle new features and platforms in the future. +How this project is different from guides or "hello world" projects at Actions on Google or Dialogflow? It's designed to be production-ready app, contains basic unit tests and clean code to be ready for further development or adaptation in similar apps. + +_If you have experience in Node.js/JavaScript development, you are more than welcome to contribute in this project. Especially when it comes to good practice in clean code architecture and scaling up this kind of code base. Author of this project is professional mobile developer (statically typed, class based languages) and doesn't have great experience with JavaScript development. + +Current tech stack: +- [Actions on Google](https://developers.google.com/actions/extending-the-assistant) +- [Firebase](https://firebase.google.com/): Cloud Functions and Realtime Database - app backend +- [Dialogflow](https://dialogflow.com/) - conversation definitions and natural language understanding +- Node.js - Cloud Function implementation + +Structure of project: +- `/functions` directory contains code for Firebase Cloud Functions +- `/Dialogflow` directory contains code and data for Dialogflow platform (conversation definitions, actions, intents) + diff --git a/assets/assets.sketch b/assets/assets.sketch new file mode 100644 index 0000000..a64108d Binary files /dev/null and b/assets/assets.sketch differ diff --git a/assets/bg.png b/assets/bg.png new file mode 100644 index 0000000..35d262d Binary files /dev/null and b/assets/bg.png differ diff --git a/assets/water-log-app-icon.png b/assets/water-log-app-icon.png new file mode 100644 index 0000000..978612b Binary files /dev/null and b/assets/water-log-app-icon.png differ diff --git a/database.rules.json b/database.rules.json new file mode 100644 index 0000000..8e33ddc --- /dev/null +++ b/database.rules.json @@ -0,0 +1,6 @@ +{ + "rules": { + ".read": "auth != null", + ".write": "auth != null" + } +} diff --git a/firebase.json b/firebase.json new file mode 100644 index 0000000..b6a1b1b --- /dev/null +++ b/firebase.json @@ -0,0 +1,5 @@ +{ + "database": { + "rules": "database.rules.json" + } +} diff --git a/functions/assistant-actions.js b/functions/assistant-actions.js new file mode 100644 index 0000000..642e653 --- /dev/null +++ b/functions/assistant-actions.js @@ -0,0 +1,5 @@ +module.exports = { + ACTION_WELCOME: 'input.welcome', + ACTION_LOG_WATER: 'log_water', + ACTION_GET_LOGGED_WATER: 'get_logged_water' +}; \ No newline at end of file diff --git a/functions/conversation.js b/functions/conversation.js new file mode 100644 index 0000000..d5a5e44 --- /dev/null +++ b/functions/conversation.js @@ -0,0 +1,64 @@ +const ARG_WATER_WOLUME = 'water_volume'; +const Str = require('./strings'); +const util = require('util'); + +class Conversation { + constructor(dialogflowApp, userManager, waterLog) { + this.dialogflowApp = dialogflowApp; + this.userManager = userManager; + this.waterLog = waterLog; + } + + actionWelcomeUser() { + return this.userManager.isFirstUsage(this._getCurrentUserId()) + .then(isFirstUsage => { + if (isFirstUsage) { + this.userManager.saveAssistantUser(this._getCurrentUserId()); + this._greetNewUser(); + } else { + this._greetExistingUser(); + } + + return isFirstUsage; + }); + } + + _greetNewUser() { + this.dialogflowApp.ask(Str.GREETING_NEW_USER, Str.GREETING_NEW_USER_NO_INPUT_PROMPT); + } + + _greetExistingUser() { + this.waterLog.getLoggedWaterForUser(this._getCurrentUserId()) + .then(loggedWater => { + this.dialogflowApp.ask( + util.format(Str.GREETING_EXISTING_USER, loggedWater), + Str.GREETING_EXISTING_USER_NO_INPUT_PROMPT); + }); + } + + actionLogWater() { + let waterToLog = this.dialogflowApp.getArgument(ARG_WATER_WOLUME); + this.waterLog.saveLoggedWater(this._getCurrentUserId(), waterToLog); + return this.waterLog.getLoggedWaterForUser(this._getCurrentUserId()) + .then(loggedWater => { + this.dialogflowApp.tell( + util.format(Str.WATER_LOGGED_NOW, + waterToLog.amount, + waterToLog.unit, + loggedWater + ) + ); + }); + } + + actionGetLoggedWater() { + return this.waterLog.getLoggedWaterForUser(this._getCurrentUserId()) + .then(loggedWater => this.dialogflowApp.tell(util.format(Str.WATER_LOGGED_OVERALL, loggedWater))); + } + + _getCurrentUserId() { + return this.dialogflowApp.getUser().userId; + } +} + +module.exports = Conversation; diff --git a/functions/index.js b/functions/index.js new file mode 100644 index 0000000..75a9b40 --- /dev/null +++ b/functions/index.js @@ -0,0 +1,29 @@ +'use strict'; + +process.env.DEBUG = 'actions-on-google:*'; +const DialogflowApp = require('actions-on-google').DialogflowApp; +const functions = require('firebase-functions'); +const firebase = require('firebase'); +const WaterLog = require('./water-log.js'); +const Conversation = require('./conversation.js'); +const UserManager = require('./user-manager.js'); +const Actions = require('./assistant-actions'); + +firebase.initializeApp(functions.config().firebase); + +exports.waterLog = functions.https.onRequest((request, response) => { + console.log('Request headers: ' + JSON.stringify(request.headers)); + console.log('Request body: ' + JSON.stringify(request.body)); + + const dialogflowApp = new DialogflowApp({request, response}); + + const userManager = new UserManager(firebase); + const waterLog = new WaterLog(firebase, userManager); + const conversation = new Conversation(dialogflowApp, userManager, waterLog); + + let actionMap = new Map(); + actionMap.set(Actions.ACTION_WELCOME, () => conversation.actionWelcomeUser()); + actionMap.set(Actions.ACTION_LOG_WATER, () => conversation.actionLogWater()); + actionMap.set(Actions.ACTION_GET_LOGGED_WATER, () => conversation.actionGetLoggedWater()); + dialogflowApp.handleRequest(actionMap); +}); diff --git a/functions/package.json b/functions/package.json new file mode 100644 index 0000000..dacb94d --- /dev/null +++ b/functions/package.json @@ -0,0 +1,20 @@ +{ + "name": "water-log", + "description": "Daily water logger", + "version": "0.0.1", + "author": "froger_mcs", + "dependencies": { + "actions-on-google": "^1.0.0", + "firebase": "^4.5.1", + "firebase-admin": "~5.4.0", + "firebase-functions": "^0.7.0" + }, + "devDependencies": { + "chai": "*", + "mocha": "*", + "sinon": "^4.0.1" + }, + "scripts": { + "test": "./node_modules/.bin/mocha --reporter spec" + } +} diff --git a/functions/strings.js b/functions/strings.js new file mode 100644 index 0000000..35a85b7 --- /dev/null +++ b/functions/strings.js @@ -0,0 +1,16 @@ +module.exports = { + GREETING_NEW_USER: 'Hey! Welcome to Water Log. Do you know that you should drink about 3 liters of water each day to stay healthy? How much did you drink so far?', + GREETING_NEW_USER_NO_INPUT_PROMPT: [ + 'How much water did you drink today?', + 'Please tell me how much water did you drink.', + 'See you later!' + ], + GREETING_EXISTING_USER: `Hey! You have drunk %sml today. How much water should I add now?`, + GREETING_EXISTING_USER_NO_INPUT_PROMPT: [ + `How much water did you drink since last time?`, + `How much water did you drink since last time?`, + `See you later!` + ], + WATER_LOGGED_NOW: `Ok, I’ve added %s%s of water to your daily log. In sum you have drunk $sml today. Let me know when you drink more! See you later.`, + WATER_LOGGED_OVERALL: `In sum you have drunk %sml today. Let me know when you drink more! See you later.` +}; \ No newline at end of file diff --git a/functions/test/conversation-test.js b/functions/test/conversation-test.js new file mode 100644 index 0000000..977725f --- /dev/null +++ b/functions/test/conversation-test.js @@ -0,0 +1,147 @@ +const sinon = require('sinon'); + +const DialogflowApp = require('actions-on-google').DialogflowApp; +const UserManager = require('../user-manager.js'); +const WaterLog = require('../water-log.js'); +const Conversation = require('../conversation'); +const Str = require('../strings'); +const util = require('util'); + +describe('Conversation', () => { + let conversationInstance; + let dialogFlowAppInstance; + let userManagerInstance; + let waterLogInstance; + + const expectedUser = {userId: "abc123"}; + + before(() => { + dialogFlowAppInstance = new DialogflowApp(); + userManagerInstance = new UserManager(); + waterLogInstance = new WaterLog(); + conversationInstance = new Conversation(dialogFlowAppInstance, userManagerInstance, waterLogInstance); + + sinon.stub(dialogFlowAppInstance, 'getUser').returns(expectedUser); + }); + + describe('actionWelcomeUser', () => { + before(() => { + + }); + + it('Should create new anonymous user', (done) => { + const userManagerStub = sinon.stub(userManagerInstance, 'isFirstUsage').resolves(true); + const userManagerMock = sinon.mock(userManagerInstance); + const dialogFlowStub = sinon.stub(dialogFlowAppInstance, 'ask').returns(true); + + userManagerMock + .expects('saveAssistantUser') + .once().withArgs(expectedUser.userId); + + conversationInstance.actionWelcomeUser().then(() => { + userManagerMock.verify(); + done(); + + dialogFlowStub.restore(); + userManagerStub.restore(); + }); + }); + + it('Should greet new user', (done) => { + const userManagerStub = sinon.stub(userManagerInstance, 'isFirstUsage').resolves(true); + const userManagerStub2 = sinon.stub(userManagerInstance, 'saveAssistantUser').returns(); + const dialogFlowAppMock = sinon.mock(dialogFlowAppInstance); + dialogFlowAppMock + .expects('ask') + .once().withArgs(Str.GREETING_NEW_USER, Str.GREETING_NEW_USER_NO_INPUT_PROMPT) + .returns(true); + + conversationInstance.actionWelcomeUser().then(() => { + dialogFlowAppMock.verify(); + done(); + + userManagerStub.restore(); + userManagerStub2.restore(); + }); + }); + + it('Should greet existing user', (done) => { + const expectedLoggedWater = 100; + const userManagerStub = sinon.stub(userManagerInstance, 'isFirstUsage').resolves(false); + const waterLogStub = sinon.stub(waterLogInstance, 'getLoggedWaterForUser').resolves(expectedLoggedWater); + const dialogFlowAppMock = sinon.mock(dialogFlowAppInstance); + + dialogFlowAppMock + .expects('ask') + .once().withArgs( + util.format(Str.GREETING_EXISTING_USER, expectedLoggedWater), + Str.GREETING_EXISTING_USER_NO_INPUT_PROMPT + ).returns(true); + + conversationInstance.actionWelcomeUser().then(() => { + dialogFlowAppMock.verify(); + done(); + + userManagerStub.restore(); + waterLogStub.restore(); + }); + }); + }); + + describe('actionGetLoggedWater', () => { + it('Should tell about logged water for given user', (done) => { + const expectedLoggedWater = 123; + const waterLogStub = sinon.stub(waterLogInstance, 'getLoggedWaterForUser').resolves(expectedLoggedWater); + const dialogFlowAppMock = sinon.mock(dialogFlowAppInstance); + + dialogFlowAppMock + .expects('tell') + .once().withArgs(util.format(Str.WATER_LOGGED_OVERALL, expectedLoggedWater)) + .returns(true); + + conversationInstance.actionGetLoggedWater().then(() => { + dialogFlowAppMock.verify(); + done(); + + waterLogStub.restore(); + }); + }); + }); + + describe('actionLogWater', () => { + it('Should save given amount of water and response with saved value', (done) => { + const expectedLoggedWaterBefore = {amount: 100, unit: 'ml'}; + const expectedLoggedWaterAfter = 100; + const dialogFlowAppMock = sinon.mock(dialogFlowAppInstance); + const waterLogMock = sinon.mock(waterLogInstance); + const waterLogStub = sinon.stub(waterLogInstance, 'getLoggedWaterForUser').resolves(expectedLoggedWaterAfter); + + dialogFlowAppMock + .expects('getArgument') + .once().withArgs('water_volume') + .returns(expectedLoggedWaterBefore); + + dialogFlowAppMock + .expects('tell') + .once().withArgs( + util.format(Str.WATER_LOGGED_NOW, + expectedLoggedWaterBefore.amount, + expectedLoggedWaterBefore.unit, + expectedLoggedWaterAfter + )) + .returns(true); + + waterLogMock + .expects('saveLoggedWater') + .once().withArgs(expectedUser.userId, expectedLoggedWaterBefore); + + conversationInstance.actionLogWater().then(() => { + dialogFlowAppMock.verify(); + waterLogMock.verify(); + done(); + + waterLogStub.restore(); + }); + }); + }); +}); \ No newline at end of file diff --git a/functions/test/index-test.js b/functions/test/index-test.js new file mode 100644 index 0000000..d3a9192 --- /dev/null +++ b/functions/test/index-test.js @@ -0,0 +1,72 @@ +const chai = require('chai'); +const sinon = require('sinon'); + +const Actions = require('../assistant-actions'); +const Conversation = require('../conversation'); +const DialogflowApp = require('actions-on-google').DialogflowApp; +const firebase = require('firebase'); +const functions = require('firebase-functions'); + +const { + MockResponse, + MockRequest, + basicBodyRequest, + basicHeaderRequest +} = require('./utils/mocking'); + +describe('Cloud Functions', () => { + let firebaseInitStub; + let configStub; + let mockResponse; + let mockRequest; + let waterLogFunctions; + + before(() => { + firebaseInitStub = sinon.stub(firebase, 'initializeApp'); + configStub = sinon.stub(functions, 'config').returns({ + firebase: { + databaseURL: 'https://not-a-project.firebaseio.com', + storageBucket: 'not-a-project.appspot.com', + } + }); + waterLogFunctions = require('../index'); + mockResponse = new MockResponse(); + mockRequest = new MockRequest(basicHeaderRequest, basicBodyRequest); + }); + + after(() => { + configStub.restore(); + firebaseInitStub.restore(); + }); + + describe('waterLog', () => { + it('Should displatch Dialogflow actions properly', (done) => { + //Disable console.log temporary + let consoleStub = sinon.stub(console, "log"); + //Prevent from warnings + let conversationStub1 = sinon.stub(Conversation.prototype, "actionWelcomeUser"); + let conversationStub2 = sinon.stub(Conversation.prototype, "actionLogWater"); + let conversationStub3 = sinon.stub(Conversation.prototype, "actionGetLoggedWater"); + + let actionMap = new Map(); + actionMap.set(Actions.ACTION_WELCOME, () => conversation.actionWelcomeUser()); + actionMap.set(Actions.ACTION_LOG_WATER, () => conversation.actionLogWater()); + actionMap.set(Actions.ACTION_GET_LOGGED_WATER, () => conversation.actionGetLoggedWater()); + const handleRequestSpy = sinon.spy(DialogflowApp.prototype, 'handleRequest'); + + waterLogFunctions.waterLog(mockRequest, mockResponse); + + const args = handleRequestSpy.args[0][0]; + actionMap.forEach((value, key) => { + chai.assert.equal(args.get(key).toString(), value.toString()); + }); + + done(); + + consoleStub.restore(); + conversationStub1.restore(); + conversationStub2.restore(); + conversationStub3.restore(); + }); + }); +}) \ No newline at end of file diff --git a/functions/test/user-manager-test.js b/functions/test/user-manager-test.js new file mode 100644 index 0000000..266fc8d --- /dev/null +++ b/functions/test/user-manager-test.js @@ -0,0 +1,103 @@ +const sinon = require('sinon'); +const chai = require('chai'); + +const UserManager = require('../user-manager.js'); +const firebase = require('firebase'); +const functions = require('firebase-functions'); + +describe('UserManager', () => { + let userManagerInstance; + let onAuthStateChangedStub; + let authStub; + + before(() => { + onAuthStateChangedStub = sinon.stub().callsArgWith(0, "ExampleUser"); + authStub = sinon.stub(firebase, 'auth').returns({onAuthStateChanged: onAuthStateChangedStub}); + userManagerInstance = new UserManager(firebase); + }); + + afterEach(() => { + userManagerInstance.user = null; + }); + + describe('isFirstUsage', () => { + const expectedUserId = "abc123"; + + it('Should return first usage when user doesnt exist in DB', (done) => { + const dataUserDoesntExist = new functions.database.DeltaSnapshot(null, null, null, null); + const fakeEvent = {data: dataUserDoesntExist}; + const onceStub = sinon.stub().withArgs('value').returns(Promise.resolve(fakeEvent.data)); + const refStub = sinon.stub().withArgs('users/' + expectedUserId).returns({once: onceStub}); + const databaseStub = sinon.stub(firebase, 'database').returns({ref: refStub}); + + userManagerInstance.isFirstUsage(expectedUserId).then(firstUsage => { + chai.assert.equal(firstUsage, true); + done(); + + databaseStub.restore(); + }); + }); + + it('Shouldnt return first usage when user exists in DB', (done) => { + const dataUserExists = new functions.database.DeltaSnapshot(null, null, null, "exampleUser"); + const fakeEvent = {data: dataUserExists}; + const onceStub = sinon.stub().withArgs('value').returns(Promise.resolve(fakeEvent.data)); + const refStub = sinon.stub().withArgs('users/' + expectedUserId).returns({once: onceStub}); + const databaseStub = sinon.stub(firebase, 'database').returns({ref: refStub}); + + userManagerInstance.isFirstUsage(expectedUserId).then(firstUsage => { + chai.assert.equal(firstUsage, false); + done(); + + databaseStub.restore(); + }); + }); + + }); + + describe('saveAssistantUser', () => { + const expectedUserId = "abc123"; + + it('Should save assistant user into DB', (done) => { + const setSpy = sinon.spy(); + const refStub = sinon.stub().withArgs('users/' + expectedUserId).returns({set: setSpy}); + const databaseStub = sinon.stub(firebase, 'database').returns({ref: refStub}); + + userManagerInstance.saveAssistantUser(expectedUserId).then(() => { + chai.assert(setSpy.calledWith({userId: expectedUserId})); + done(); + + databaseStub.restore(); + }); + }); + }); + + describe('ensureAuthUser', () => { + const expectedResponse = {user: "user1"}; + + it('Should authenticate anonymous user when user isnt authenticated', (done) => { + onAuthStateChangedStub.callsArgWith(0, null); + const signInAnonymouslyStub = sinon.stub().returns(Promise.resolve(expectedResponse)); + + authStub.restore(); + authStub = sinon.stub(firebase, 'auth').returns({ + onAuthStateChanged: onAuthStateChangedStub, + signInAnonymously: signInAnonymouslyStub + }); + + userManagerInstance.ensureAuthUser().then(result => { + chai.assert.equal(result, expectedResponse.user); + done(); + }); + }); + + it('Should return authenticated user', (done) => { + onAuthStateChangedStub.callsArgWith(0, expectedResponse.user); + + userManagerInstance.ensureAuthUser().then(result => { + chai.assert.equal(result, expectedResponse.user); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/functions/test/utils/mocking.js b/functions/test/utils/mocking.js new file mode 100644 index 0000000..7338473 --- /dev/null +++ b/functions/test/utils/mocking.js @@ -0,0 +1,217 @@ +'use strict'; + +const MockRequest = class { + constructor(headers, body) { + if (headers) { + this.headers = headers; + } else { + this.headers = {}; + } + if (body) { + this.body = body; + } else { + this.body = {}; + } + } + + get(header) { + return this.headers[header]; + } +}; + +const MockResponse = class { + constructor() { + this.statusCode = 200; + this.headers = {}; + } + + status(statusCode) { + this.statusCode = statusCode; + return this; + } + + send(body) { + this.body = body; + return this; + } + + append(header, value) { + this.headers[header] = value; + return this; + } +}; + +const basicHeaderRequest = { + "content-type": "application/json; charset=UTF-8", +} + +const basicBodyRequest = { + "originalRequest": { + "source": "google", + "version": "2", + "data": { + "isInSandbox": true, + "surface": { + "capabilities": [ + { + "name": "actions.capability.AUDIO_OUTPUT" + }, + { + "name": "actions.capability.SCREEN_OUTPUT" + } + ] + }, + "inputs": [ + { + "rawInputs": [ + { + "query": "500 ml", + "inputType": "VOICE" + } + ], + "arguments": [ + { + "rawText": "500 ml", + "textValue": "500 ml", + "name": "text" + } + ], + "intent": "actions.intent.TEXT" + } + ], + "user": { + "locale": "en-US", + "userId": "ABwppHFnJKhUEKfWAKnJYth8zyRZ3fKczsaCAwyCQ8L8X5F8Lhh-hOPLJitevhCnPrb_OYgqW98C_7sifF9Y" + }, + "device": {}, + "conversation": { + "conversationId": "1508455291994", + "type": "ACTIVE", + "conversationToken": "[\"_actions_on_google_\"]" + }, + "availableSurfaces": [ + { + "capabilities": [ + { + "name": "actions.capability.AUDIO_OUTPUT" + }, + { + "name": "actions.capability.SCREEN_OUTPUT" + } + ] + } + ] + } + }, + "id": "def8667b-6813-4ef6-8937-2c1d06ae08e0", + "timestamp": "2017-10-19T23:21:52.971Z", + "lang": "en-us", + "result": { + "source": "agent", + "resolvedQuery": "500 ml", + "speech": "", + "action": "log_water", + "actionIncomplete": false, + "parameters": { + "water_volume": { + "amount": 500, + "unit": "ml" + } + }, + "contexts": [ + { + "name": "actions_capability_screen_output", + "parameters": { + "water_volume": { + "amount": 500, + "unit": "ml" + }, + "water_volume.original": "500 ml" + }, + "lifespan": 0 + }, + { + "name": "_actions_on_google_", + "parameters": { + "water_volume": { + "amount": 500, + "unit": "ml" + }, + "water_volume.original": "500 ml" + }, + "lifespan": 98 + }, + { + "name": "google_assistant_input_type_voice", + "parameters": { + "water_volume": { + "amount": 500, + "unit": "ml" + }, + "water_volume.original": "500 ml" + }, + "lifespan": 0 + }, + { + "name": "actions_capability_audio_output", + "parameters": { + "water_volume": { + "amount": 500, + "unit": "ml" + }, + "water_volume.original": "500 ml" + }, + "lifespan": 0 + } + ], + "metadata": { + "matchedParameters": [ + { + "required": true, + "dataType": "@sys.unit-volume", + "name": "water_volume", + "value": "$water_volume", + "prompts": [ + { + "lang": "en", + "value": "How much of water did you drink so far?" + }, + { + "lang": "en", + "value": "How much of water should I log?" + } + ], + "isList": false + } + ], + "intentName": "log_water", + "intentId": "9d87e786-f42c-40bb-a711-7c8a9df612bb", + "webhookUsed": "true", + "webhookForSlotFillingUsed": "false", + "nluResponseTime": 52 + }, + "fulfillment": { + "speech": "", + "messages": [ + { + "type": 0, + "id": "49d7ca3c-c181-47e7-b5f4-818c561eb84e", + "speech": "" + } + ] + }, + "score": 0.9900000095367432 + }, + "status": { + "code": 200, + "errorType": "success" + }, + "sessionId": "1508455291994" +}; + +module.exports = { + MockRequest, + MockResponse, + basicHeaderRequest, + basicBodyRequest +}; \ No newline at end of file diff --git a/functions/test/water-log-test.js b/functions/test/water-log-test.js new file mode 100644 index 0000000..e2c4b46 --- /dev/null +++ b/functions/test/water-log-test.js @@ -0,0 +1,107 @@ +const sinon = require('sinon'); +const chai = require('chai'); + +const WaterLog = require('../water-log.js'); +const UserManager = require('../user-manager.js'); +const Utils = require('../utils'); +const firebase = require('firebase'); +const functions = require('firebase-functions'); + +describe('WaterLog', () => { + let userManagerInstance; + let waterLogInstance; + + const userId = "abc123"; + + before(() => { + userManagerInstance = new UserManager(); + waterLogInstance = new WaterLog(firebase, userManagerInstance); + sinon.stub(userManagerInstance, 'ensureAuthUser').returns(Promise.resolve(true)); + }); + + describe('saveLoggedWater', () => { + let dateStub; + const expectedWaterLogKey = "abc123"; + + beforeEach(() => { + dateStub = sinon.stub(Date, 'now').returns(123); + }); + + afterEach(() => { + dateStub.restore(); + }); + + it('Should save logged mililiters of water', (done) => { + const expectedMililiters = 1000; + const loggedWaterInput = {unit: "ml", amount: expectedMililiters}; + const expectedLoggedWater = {userId: userId, mililiters: expectedMililiters, timestamp: Date.now()}; + + const setSpy = sinon.spy(); + const pushStub = sinon.stub().withArgs().returns({key: expectedWaterLogKey}); + const childStub = sinon.stub().withArgs('waterLogs').returns({push: pushStub}); + const refStub = sinon.stub(); + refStub.withArgs('waterLogs/' + expectedWaterLogKey).returns({set: setSpy}); + refStub.returns({child: childStub}); + const databaseStub = sinon.stub(firebase, 'database').returns({ref: refStub}); + + waterLogInstance.saveLoggedWater(userId, loggedWaterInput).then(() => { + chai.assert(setSpy.calledWith(expectedLoggedWater)); + done(); + + databaseStub.restore(); + }); + }); + + it('Should save logged liters of water', (done) => { + const expectedMililiters = 1000; + const loggedWaterInput = {unit: "L", amount: 1}; + const expectedLoggedWater = {userId: userId, mililiters: expectedMililiters, timestamp: Date.now()}; + + const setSpy = sinon.spy(); + const pushStub = sinon.stub().withArgs().returns({key: expectedWaterLogKey}); + const childStub = sinon.stub().withArgs('waterLogs').returns({push: pushStub}); + const refStub = sinon.stub(); + refStub.withArgs('waterLogs/' + expectedWaterLogKey).returns({set: setSpy}); + refStub.returns({child: childStub}); + const databaseStub = sinon.stub(firebase, 'database').returns({ref: refStub}); + + waterLogInstance.saveLoggedWater(userId, loggedWaterInput).then(() => { + chai.assert(setSpy.calledWith(expectedLoggedWater)); + done(); + + dateStub.restore(); + databaseStub.restore(); + }); + }); + }); + + describe('getLoggedWaterForUser', () => { + it('Should load logged water for given user and present data from recent day', (done) => { + //Fake timestamps to distinguish "today" and "yesterday" + const timestampYesterday = 10; + const timestampMidnight = 50; + const timestampToday = 100; + const expectedRequestData = { + abc: {userId: userId, mililiters: 100, timestamp: timestampYesterday}, + def: {userId: userId, mililiters: 50, timestamp: timestampToday} + }; + const dataUserExists = new functions.database.DeltaSnapshot(null, null, null, expectedRequestData); + const fakeEvent = {data: dataUserExists}; + const onceStub = sinon.stub().withArgs('value').returns(Promise.resolve(fakeEvent.data)); + const equalToStub = sinon.stub().withArgs(userId).returns({once: onceStub}); + const orderByChildStub = sinon.stub().withArgs('userId').returns({equalTo: equalToStub}); + const refStub = sinon.stub().withArgs('waterLogs').returns({orderByChild: orderByChildStub}); + const databaseStub = sinon.stub(firebase, 'database').returns({ref: refStub}); + + const todayStartTimestampStub = sinon.stub(Utils, 'getTodayStartTimestamp').returns(timestampMidnight); + + waterLogInstance.getLoggedWaterForUser(userId).then(loggedMililiters => { + chai.assert.equal(50, loggedMililiters); + done(); + + databaseStub.restore(); + todayStartTimestampStub.restore(); + }); + }); + }); +}); \ No newline at end of file diff --git a/functions/user-manager.js b/functions/user-manager.js new file mode 100644 index 0000000..51fd04b --- /dev/null +++ b/functions/user-manager.js @@ -0,0 +1,49 @@ +class UserManager { + constructor(firebase) { + this.firebase = firebase; + this.user = null; + } + + ensureAuthUser() { + return new Promise((resolve, reject) => { + if (this.user !== null) { + resolve(this.user); + } + + this.firebase.auth().onAuthStateChanged(user => { + if (user) { + this.user = user; + resolve(user); + } else { + this.firebase.auth().signInAnonymously() + .then(result => { + this.user = result.user; + resolve(result.user); + }) + .catch(error => reject(error)); + } + }); + }); + } + + isFirstUsage(assistantUserId) { + return this.ensureAuthUser().then(() => { + return this.firebase.database() + .ref('users/' + assistantUserId) + .once('value').then(snapshot => { + let user = snapshot.val(); + return user === null; + }); + }); + } + + saveAssistantUser(assistantUserId) { + return this.ensureAuthUser().then(() => { + this.firebase.database() + .ref('users/' + assistantUserId) + .set({userId: assistantUserId}); + }); + } +} + +module.exports = UserManager; diff --git a/functions/utils.js b/functions/utils.js new file mode 100644 index 0000000..5d54e6a --- /dev/null +++ b/functions/utils.js @@ -0,0 +1,9 @@ +class Utils { + static getTodayStartTimestamp() { + let today = new Date(); + today.setHours(0, 0, 0, 0); + return today; + } +} + +module.exports = Utils; \ No newline at end of file diff --git a/functions/water-log.js b/functions/water-log.js new file mode 100644 index 0000000..1d87783 --- /dev/null +++ b/functions/water-log.js @@ -0,0 +1,51 @@ +const Utils = require('./utils'); + +class WaterLog { + constructor(firebase, userManager) { + this.firebase = firebase; + this.userManager = userManager; + } + + saveLoggedWater(assistantUserId, loggedWater) { + return this.userManager.ensureAuthUser() + .then(() => { + let mililiters = 0; + if (loggedWater.unit === "L") { + mililiters = loggedWater.amount * 1000; + } else if (loggedWater.unit === "ml") { + mililiters = loggedWater.amount; + } + + const waterLogData = { + userId: assistantUserId, + mililiters: mililiters, + timestamp: Date.now() + }; + + const newWaterLogKey = this.firebase.database().ref().child('waterLogs').push().key; + this.firebase.database() + .ref('waterLogs/' + newWaterLogKey) + .set(waterLogData); + }); + } + + getLoggedWaterForUser(assistantUserId) { + return this.userManager.ensureAuthUser().then(() => { + return this.firebase.database() + .ref('waterLogs') + .orderByChild("userId").equalTo(assistantUserId) + .once('value'); + }).then(waterLogs => { + let loggedMililiters = 0; + let todayStartsAt = Utils.getTodayStartTimestamp(); + waterLogs.forEach(waterLog => { + if (waterLog.val().timestamp >= todayStartsAt) { + loggedMililiters += waterLog.val().mililiters; + } + }); + return loggedMililiters; + }); + } +} + +module.exports = WaterLog;