├── .gitignore ├── .travis.yml ├── LICENSE ├── README.md ├── auth_providers ├── anon-user.json └── api-key.json ├── functions ├── processCsv │ ├── config.json │ └── source.js ├── removeBreakingCharacters │ ├── config.json │ └── source.js └── storeCsvInDb │ ├── config.json │ └── source.js ├── hosting ├── files │ ├── config.js │ └── index.html └── metadata.json ├── images ├── architecture.png ├── dashboard.png ├── upload.png └── webinar.png ├── package-lock.json ├── package.json ├── services ├── mongodb-atlas │ └── config.json └── uploadTweets │ ├── config.json │ └── incoming_webhooks │ └── webhook0 │ ├── config.json │ └── source.js ├── stitch.json └── tests ├── constants.js ├── integration ├── processCsvToDb.test.js └── storeCsvInDB.test.js ├── ui ├── files │ ├── singletweet.csv │ ├── threetweets_ken.csv │ ├── twotweets.csv │ └── twotweets_updated.csv ├── selenium-server-standalone-3.141.59.jar └── uploadTweetStats.test.js └── unit ├── functions ├── processCsv.test.js ├── removeBreakingCharacters.test.js └── storeCsvInDB.test.js └── incoming_webhooks └── uploadTweets.test.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .DS_Store 3 | config 4 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | dist: trusty 2 | language: node_js 3 | node_js: 4 | - '12' 5 | addons: 6 | # Installing dpkg to fix Chrome install errors 7 | apt: 8 | packages: 9 | - dpkg 10 | chrome: stable 11 | before_script: 12 | - java -jar tests/ui/selenium-server-standalone-3.141.59.jar & 13 | script: 14 | - npm install 15 | - CHROMEDRIVER_VERSION=LATEST npm install chromedriver 16 | # Run tests 17 | - if [ $TRAVIS_BRANCH == "master" ] ; then 18 | echo "Production build so only running unit tests, which don't hit the database"; 19 | ./node_modules/.bin/jest --runInBand ./tests/unit/; 20 | else 21 | echo "Non-production build so running all tests. The database will be cleared as part of these tests."; 22 | ./node_modules/.bin/jest --runInBand; 23 | fi 24 | after_success: 25 | # If the tests pass on Staging, push the commits to Production 26 | - if [ $TRAVIS_BRANCH == "staging" ] ; then 27 | git clone https://${GITHUB_TOKEN}@github.com/mongodb-developer/SocialStats.git; 28 | cd SocialStats; 29 | git checkout master; 30 | git merge origin/staging; 31 | git push origin master; 32 | fi 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Notice: Repository Deprecation 2 | This repository is deprecated and no longer actively maintained. It contains outdated code examples or practices that do not align with current MongoDB best practices. While the repository remains accessible for reference purposes, we strongly discourage its use in production environments. 3 | Users should be aware that this repository will not receive any further updates, bug fixes, or security patches. This code may expose you to security vulnerabilities, compatibility issues with current MongoDB versions, and potential performance problems. Any implementation based on this repository is at the user's own risk. 4 | For up-to-date resources, please refer to the [MongoDB Developer Center](https://mongodb.com/developer). 5 | 6 | 7 | # SocialStats 8 | 9 | This app allows you to track team stats for social media. 10 | 11 | Table of Contents 12 | * [Project Goals](#project-goals) 13 | * [Demo of App and CI/CD Pipeline](#demo-of-app-and-cicd-pipeline) 14 | * [Related Blog Series](#related-blog-series) 15 | * [About the Architecture](#about-the-architecture) 16 | * [Project Variables](#project-variables) 17 | * [Automated Tests](#automated-tests) 18 | * [CI/CD](#cicd-pipeline) 19 | * [Automated Deployments](#automated-deployments) 20 | * [Travis CI Builds](#travis-ci-builds) 21 | * [GitHub Repos](#github-repos) 22 | * [Configuring the App](#configuring-the-app) 23 | 24 | 25 | ## Project Goals 26 | 27 | 1. Allow members of our DevRel team to view Twitter statistics individually and in aggregate 28 | 1. Demonstrate how to test and build CI/CD (Continuous Integration/Continuous Delivery) Pipelines for serverless applications built on MongoDB Realm 29 | 30 | ## Demo of App and CI/CD Pipeline 31 | 32 | If you prefer to learn by video, [check out this recording](https://youtu.be/RlouET0cPsc) of a talk I gave at MongoDB.live in June 2020 entitled "DevOps + MongoDB Serverless = 😍". In the talk, I give a demo of this app and explain how the CI/CD pipeline is configured. 33 | 34 | Note: At the time of the recording, the GitHub repos were handled differently. The Realm auto-deployment feature only worked from the master branch, so I had separate repos for Dev, Staging, and Prod. Since then, the Realm team has updated the auto-deploy feature so you can deploy from any branch in a repo. Now Dev, Staging, and Prod are stored in their own branches in this repo. The sections below explain the new way the code is stored in more detail. 35 | 36 | [![DevOps + MongoDB Serverless = 😍 Webinar Screenshot](/images/webinar.png "DevOps + MongoDB Serverless = 😍 Webinar Screenshot")](https://youtu.be/RlouET0cPsc) 37 | 38 | ## Related Blog Series 39 | 40 | Check out the related blog series about DevOps for Realm Serverless Apps. More posts coming soon. 41 | 42 | * [How to Write Unit Tests for MongoDB Realm Serverless Functions](https://developer.mongodb.com/how-to/unit-test-realm-serverless-functions) 43 | * [How to Write Integration Tests for MongoDB Realm Serverless Apps](https://developer.mongodb.com/how-to/integration-test-realm-serverless-apps) 44 | * [How to Write End-to-End Tests for MongoDB Realm Serverless Apps](https://developer.mongodb.com/how-to/end-to-end-test-realm-serverless-apps) 45 | 46 | ## App Functionality 47 | 48 | This is a super basic, super ugly app. But it works. The app currently consists of a page that allows users to upload their Tweet statistics spreadsheet (users can get a copy of this spreadsheet by visiting https://analytics.twitter.com/user/YOUR_HANDLE_HERE/tweets and choosing to download 28 days of stats by Tweet) and a dashboard where you can view charts about the Tweets. 49 | 50 | The Upload page: 51 | 52 | ![Upload page](/images/upload.png "Upload page") 53 | 54 | The Charts Dashboard: 55 | 56 | ![Dashboard](/images/dashboard.png "Dashboard") 57 | 58 | 59 | ## About the Architecture 60 | 61 | The app is built using a serverless architecture using MongoDB Realm. The app consists of serverless functions, a static web page, and 62 | a dashboard built in MongoDB Charts. 63 | 64 | ![App Architecture Diagram](/images/architecture.png "App Architecture Diagram") 65 | 66 | When a user accesses the `index.html` page, they are accessing the `index.html` page that is hosted by Realm. 67 | 68 | When a user uploads their CSV file with all of their Twitter stats, `index.html` encodes the CSV files and calls the `processCSV` serverless function. That function is in charge of decoding the CSV file and passing the results to the `storeCsvInDb` serverless function. 69 | 70 | The `storeCsvInDb` function calls the `removeBreakingCharacters` function, which is a helper function that simply removes any bad characters from the Tweets. That function 71 | passes the results back to `storeCsvInDb`. 72 | 73 | `storeCsvInDB` then converts the cleaned Tweet statistics to JSON documents and stores them in MongoDB Atlas. 74 | 75 | Then the results are passed back up the chain and ultimately displayed on the webpage. 76 | 77 | At a high level, the `index.html` file hosted on Realm calls a series of Realm serverless functions to store information in a database hosted on Atlas. 78 | 79 | When we view the dashboard with all of the charts showing a summary of our Tweet statistics, we are accessing a MongoDB Charts dashboard. The dashboard pulls data from MongoDB Atlas and displays it. 80 | 81 | 82 | ## Project Variables 83 | 84 | The following is a list of variables you should add to config files and your Travis CI builds. An explanation of where to set these variables is described in detail in the following sections. 85 | 86 | * DB_USERNAME: the username for the MongoDB database you are using for development and testing 87 | * DB_PASSWORD: the password associated with the above account 88 | * CLUSTER_URI: the URI for your MongoDB cluster. For example: cluster0-ffee0.mongodb.net 89 | * REALM_APP_ID: the ID of the Realm app you are using for development and testing. For example: twitterstats-were 90 | * URL: the URL for your Realm app. For example: https://twitterstats-asdf.mongodbstitch.com 91 | 92 | ## Automated Tests 93 | 94 | The app is tested with a combination of automated unit, integration, and ui tests. For more information about how the tests are architected, check out the following blog posts: 95 | 96 | * [How to Write Unit Tests for MongoDB Realm Serverless Functions](https://developer.mongodb.com/how-to/unit-test-realm-serverless-functions) 97 | * [How to Write Integration Tests for MongoDB Realm Serverless Apps](https://developer.mongodb.com/how-to/integration-test-realm-serverless-apps) 98 | * [How to Write End-to-End Tests for MongoDB Realm Serverless Apps](https://developer.mongodb.com/how-to/end-to-end-test-realm-serverless-apps) 99 | 100 | All tests are located in the [tests](/tests) directory. Many of the tests utilize constants from [constants.js](/tests/constants.js). 101 | 102 | ### Local Test Execution 103 | 104 | To execute all of the tests locally, you will need to do the following: 105 | 106 | 1. Follow the steps in the [Configuring the App](#configuring-the-app) section below to setup your Dev environment. 107 | 1. Create a file named `test.env` inside of the `config` directory. The file should contain values for each of the variables in [Project Variables](#project-variables). 108 | 1. Start the Selenium Server if you will be running the UI tests: `java -jar tests/ui/selenium-server-standalone-3.141.59.jar &` 109 | 1. Run the tests using one of the following commands: 110 | - `npm run start` Run the tests using the watch option, which will run tests that have been recently updated. 111 | - `env-cmd -f ./config/test.env jest --runInBand` Run all of the tests. 112 | - `env-cmd -f ./config/test.env jest /tests/unit --runInBand` Run just the unit tests. 113 | 114 | ### Unit Tests 115 | 116 | Unit tests for the serverless functions and the webhook are located in [tests/unit](/tests/unit). 117 | 118 | The tests are built using [Jest](https://jestjs.io/). The tests are completely independent of each other and do not touch a real database. Instead, the tests use mocks to simulate interactions with the database as well as interactions with other pieces of the system. 119 | 120 | ### Integration Tests 121 | 122 | Integration tests are located in [tests/integration](/tests/integration). 123 | 124 | The tests are built using [Jest](https://jestjs.io/). The tests interact with the test database, so the tests cannot be run in parallel. These tests interact with various pieces of the app including functions and the database. 125 | 126 | ### UI Tests 127 | 128 | UI tests are located in [tests/ui](/tests/ui). 129 | 130 | The tests are built using [Jest](https://jestjs.io/) and [Selenium](https://www.selenium.dev). The tests interact with the test database, so the tests cannot be run in parallel. 131 | 132 | The tests currently test uploading a CSV file that contains stats about Tweets. The CSV files are stored in [tests/ui/files](/tests/ui/files). 133 | 134 | 135 | ## CI/CD Pipeline 136 | 137 | I have created a CI/CD (continuous integration/continuous deployment) pipeline for this app. 138 | 139 | My pipeline has four stages: 140 | 141 | 1. **Local Machine**: My local machine is where I do my development. I can write serverless functions and unit test them here. I can also create web pages and view them locally. 142 | 1. **Development**: When I’m ready to see how it all works together, I’m going to put the code in my Development stage. The Development stage is just for me and my code. Every developer will have their own Development stage. I can manually test my app in this stage. 143 | 1. **Staging**: When I feel good about my code, I can push it to Staging. Staging is a place for all of my teammates to merge our code together and see what it will look like in production. If we want to do manual testing of our team's code, this is where we do it. If all of the automated tests pass, the code will automatically be pushed to production. 144 | 1. **Production**: Production is the version of the app that the end users interact with. 145 | 146 | Below is a table the highlights what is happening at each stage and between stages. The following subsections go into more detail. 147 | 148 | . | Local | --> | Dev | --> | Staging | --> | Prod 149 | --- | --- | --- | --- | --- | --- | --- | --- 150 | **Git** | Local copy of the development branch (for example, dev-lauren) | `git push` | development branch (for example, `dev-lauren`) | Pull request | `staging` branch | `git push` via Travis CI Staging Build. (Or manual `git push`.) | `master` branch 151 | **Atlas** | n/a | Dev Project. (Or single Atlas Project with Dev cluster.) | Dev Project. (Or single Atlas Project with Dev cluster.) | Staging Project. (Or single Atlas Project with Staging cluster.) | Staging Project. (Or single Atlas Project with Staging cluster.) | Prod Project. (Or single Atlas Project with Prod cluster.) | Prod Project. (Or single Atlas Project with Prod cluster.) 152 | **Realm** | n/a | `git push` triggers deploy to Dev App | Dev App | Merging of pull request triggers deploy to Staging App | Staging App | Push from successful Staging Build triggers deploy to Prod App. (Or manual `git push` triggers build.) | Prod App 153 | **Travis CI (runs tests—does not deploy)** | n/a | `git push` triggers build | n/a | Merging of pull request triggers build | n/a | Push from successful Staging build triggers build. (Or manual `git push` triggers build.) | n/a 154 | **Automated Tests** | Unit | Unit, Integration, & UI run as part of Travis CI build | Unit, Integration, & UI | Unit, Integration, & UI run as part of Travis CI build | Unit, Integration, & UI | Unit run as part of Travis CI build | Unit 155 | 156 | ### Local 157 | 158 | I do my development work locally on my machine. 159 | * **Git**: I have a local copy of my development branch in the Git repo. The Git repo stores everything in my app including my hosted html files and my serverless functions. 160 | * **Tests**: I can run unit tests that test my serverless functions. Since I don’t have a way to run Realm locally on my machine, that’s all I can test. I need to push my code to Realm in order to run manual tests, integration tests, and UI tests. 161 | 162 | ### Moving from Local to Development 163 | 164 | When I'm ready to try out my code, I'm going to move from Local to Development. 165 | 166 | * **How to Move**: I’m going to push changes (`git push`) to my development branch. 167 | * **Git**: I have a development branch specific to me. Mine is named `dev-lauren`. My teammates have their own development branches. 168 | * **Realm and Atlas**: One of the nice things about Realm is that it has a [GitHub auto deploy feature](https://docs.mongodb.com/realm/deploy/deploy-automatically-with-github/) so that whenever I push changes to an associated GitHub repo, the code in that repo is automatically deployed to my Realm app. That Realm app will be associated with an Atlas project. The Atlas project is where my database lives. In my case, I chose to have separate Atlas projects for each stage, so I could take advantage of the free clusters in Atlas. If you are paying for clusters, you can easily use a single Atlas project for all of your stages. 169 | * **Travis CI and Tests**: The `git push` is going to trigger a Travis CI build. The build is responsible for running all of my automated tests. The build is going to run those tests against the Dev Realm App that was just deployed. If you have experience with CI/CD infrastructure, this might feel a little odd to you—Travis CI is responsible for running the tests but not for doing the deploy. So, even if the tests fail, the deploy has already occurred. This is OK since this is not production—it’s just my dev environment. 170 | 171 | ### Development 172 | 173 | Every developer has their own Development stage. 174 | * **Git**: My development branch is specific to me. My teammates have their own development branches. 175 | * **Realm and Atlas**: My code is deployed in my Dev Realm App, which is connected to my Dev Atlas Project. 176 | * **Tests**: I can choose to run manual tests against this deployment. I can use also my local machine to run automated tests against this deployment. 177 | 178 | ### Moving from Development to Staging 179 | 180 | When I feel like my code is well tested and I’m ready for a teammate to review it, I can move from Development to Staging. 181 | 182 | * **How to Move**: I’m going to create a pull request. Pull requests are a way for me to request that my code be reviewed and considered for merging into the team’s code. 183 | If my pull request is approved, the code changes will be merged into our team's Staging Repo. 184 | * **Git**: Pull Requests will request to merge code from a development branch to the `staging` branch. 185 | * **Realm and Atlas**: When my code is merged into the `staging` branch, it will be automatically deployed to my Staging Realm App that is associated with my Staging Atlas Project. 186 | * **Travis CI and Tests**: The merging of my pull request is also going to trigger a Travis CI build. That build is gong to run all of my automated tests. If the build passes—meaning that all of my automated tests pass—the build is going to automatically push the code changes to the `master` branch. I’ll discuss this more below in the section about [Moving from Staging to Prod](#moving-from-staging-to-production). 187 | 188 | ### Staging 189 | 190 | Staging is a place for all of my teammates to merge our code together and see what it will look like in production. 191 | 192 | * **Git**: The `staging` branch stores the code for this stage. 193 | * **Realm and Atlas**: My code is deployed in the Staging Realm App, which is connected to the Staging Atlas Project. 194 | * **Tests**: This stage is a simulation of Production, so it’s our place to do all of our QA testing. I can choose to run manual tests against this deployment. If I want to run the automated tests, I can use my local machine to run the tests against this deployment. 195 | 196 | ### Moving from Staging to Production 197 | 198 | Since we’re following the continuous deployment model, we have a ton of automated tests. Our team has agreed that we feel confident that, if the tests pass, we are ready to deploy. 199 | 200 | * **How to Move**: If the Staging build passes—meaning that all of the tests pass—the Staging build will automatically push the code changes to production. 201 | So instead of having a manual `git push` or a pull request trigger our move to Production, the Staging Build does the `git push` for us. 202 | * **Git**: The code is pushed to the `master` branch. 203 | * **Realm and Atlas**: We still have the GitHub auto deployment feature configured, so the push to the `master` branch is going to trigger a deployment to our Prod Realm App. That Prod Realm App is associated with a Prod Atlas Project where our prod data is stored. 204 | * **Travis CI and Tests**: The push to the Prod Repo is going to trigger our Prod Build. The Prod Build only runs the unit tests. Recall that our integration and UI tests interact with our database, and we don’t want to mess up our Prod database, so we’re only running our unit tests. 205 | In my case, the pipeline stops here. You may have monitoring or other tools or tests you want to run here. It all depends on what your team’s requirements are. 206 | 207 | ### Production 208 | 209 | Production is the version of the app that my end users interact with. 210 | 211 | * **Git**: The code is in the `master` branch. 212 | * **Realm and Atlas**: Our app is deployed in the Prod Realm App, and our Prod data is in the associated Prod Atlas Project. 213 | * **Tests**: If we want to run tests, we can use our local machines to execute unit tests against the code in the Prod Repo. 214 | 215 | ## Automated Deployments 216 | 217 | The code is deployed to Realm using [automated GitHub deployments](https://docs.mongodb.com/realm/deploy/deploy-automatically-with-github/). 218 | 219 | ## Travis CI Builds 220 | 221 | This project uses Travis CI for builds. You can view the builds at https://travis-ci.com/github/mongodb-developer/SocialStats/branches. 222 | 223 | The builds are responsible for running the appropriate automated tests and pushing code to the production GitHub repo. Note that the builds do NOT actually deploy the app. 224 | See the section above for how the app is deployed. 225 | 226 | ### Git Tips 227 | Before pushing your changes to your Development Repo, I recommend pulling the latest changes: `git pull`. 228 | 229 | When you want to squash commits in your development branch, rebase against the `staging` branch: 230 | 1. `git pull` 231 | 2. `git rebase -i staging` 232 | 233 | ## Configuring the App 234 | 235 | The following steps will walk you through configuring the app for Production, Staging, and Development. 236 | 237 | 1. **Git** 238 | 1. Fork this repo if you want to create your own version of the app. 239 | 1. Clone this repo or your forked copy as appropriate. 240 | 1. Create a branch for your own development work (for example, `dev-lauren`) 241 | 242 | Then, complete the following steps for Prod (`master` branch), Staging (`staging` branch), and Dev (your development branch you created). Note that you only need to setup Prod and Staging once per team. Each team member will need to configure their own development branch. 243 | 244 | 1. **Atlas** 245 | 1. Create a new [MongoDB Atlas](http://bit.ly/MDB_Atlas) project. 246 | 1. Create a cluster (a free cluster is fine if you are not using the app in production). 247 | 1. Create a database user for your tests. The user should have read and write privileges. 248 | 1. Add your current IP address to the IP Access List. 249 | 1. [Create a MongoDB Realm application](https://docs.mongodb.com/realm/procedures/create-realm-app/) in this Atlas project. Then... 250 | 1. Enable auto deploy. 251 | 1. Enable hosting. 252 | 1. [Load function dependencies](https://docs.mongodb.com/realm/functions/upload-external-dependencies/) for the comma-separated-values npm module. 253 | 1. Review and deploy changes to Realm app. 254 | 1. **Charts** 255 | 1. Activate Charts in your Atlas project. 256 | 1. Inside of Charts, add a Data Source for your Atlas cluster. 257 | 1. Create a dashboard. 258 | 1. Add the 4 charts as seen in https://charts.mongodb.com/charts-twitter-stats-vzwpx/public/dashboards/82195382-6cea-4994-9283-cf2fb899c6de 259 | 1. **Travis CI** 260 | 1. Run the [Travis CI to Atlas IP Access Lister](https://github.com/mongodb-developer/Travic-CI-to-Atlas-IP-Access-Lister) so Travis CI can access your database hosted on Atlas. 261 | 1. Add your repo to Travis CI (you only need to do this once for the entire repo). Hint: you may need to sign out and sign back in to see the repo in your list of repos. 262 | 1. Add the variables described in [Project Variables](#project-variables) to your build's Environment Variables. Each variable will need to be created for each branch, so make each variable visible only to the appropriate branch. For example, you will create a `CLUSTER_URI` variable that is only available to the `master` branch, a `CLUSTER_URI` variable that is only available to the `staging` branch, and a `CLUSTER_URI` variable that is only available to your development branch. 263 | 1. Disable `build on pushed pull requests` since Realm will not deploy on PRs. If you left this option enabled, your tests would be running against an old deployment. 264 | 265 | After you've completed the steps above for **each** stage (Prod, Stage, and Dev), configure the app: 266 | 1. **Configure the App** 267 | 1. Switch to your development branch. 268 | 1. In your code editor, open [/hosting/files/config.js](/hosting/files/config.js). 269 | 1. Update the file to reflect the config for your app. 270 | 1. Commit and push the changes. 271 | 1. Move the changes to Staging and then to Prod. 272 | 273 | -------------------------------------------------------------------------------- /auth_providers/anon-user.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "5e67ecb046727fcd5a3ad99f", 3 | "name": "anon-user", 4 | "type": "anon-user", 5 | "disabled": false 6 | } 7 | -------------------------------------------------------------------------------- /auth_providers/api-key.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "api-key", 3 | "type": "api-key", 4 | "disabled": true 5 | } 6 | -------------------------------------------------------------------------------- /functions/processCsv/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "processCsv", 3 | "private": false 4 | } -------------------------------------------------------------------------------- /functions/processCsv/source.js: -------------------------------------------------------------------------------- 1 | exports = function (encodedData) { 2 | 3 | // TODO: handle if the data isn't in the format we expect 4 | // Remove the front part of the data (for example: "data:text/csv;base64,SGkgTGF1cmVu") 5 | encodedData = encodedData.substring(21); 6 | 7 | const myBuffer = new Buffer(encodedData, 'base64') 8 | 9 | const decodedData = myBuffer.toString(); 10 | 11 | return context.functions.execute("storeCsvInDb", decodedData); 12 | }; 13 | 14 | if (typeof module === 'object') { 15 | module.exports = exports; 16 | } -------------------------------------------------------------------------------- /functions/removeBreakingCharacters/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "removeBreakingCharacters", 3 | "private": false 4 | } -------------------------------------------------------------------------------- /functions/removeBreakingCharacters/source.js: -------------------------------------------------------------------------------- 1 | exports = function (csvTweets) { 2 | 3 | // The CSV parser chokes on emoji, so we're pulling out all non-standard characters. 4 | // Note that this may remove non-English characters 5 | csvTweets = csvTweets.replace(/[^a-zA-Z0-9\, "\/\\\n\`~!@#$%^&*()\-_—+=[\]{}|:;\'"<>,.?/']/g, ''); 6 | 7 | return csvTweets; 8 | }; 9 | 10 | if (typeof module === 'object') { 11 | module.exports = exports; 12 | } 13 | -------------------------------------------------------------------------------- /functions/storeCsvInDb/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "storeCsvInDb", 3 | "private": false, 4 | "run_as_system": true 5 | } 6 | -------------------------------------------------------------------------------- /functions/storeCsvInDb/source.js: -------------------------------------------------------------------------------- 1 | exports = async function (csvTweets) { 2 | const CSV = require("comma-separated-values"); 3 | 4 | // The CSV parser chokes on emoji, so we're pulling out all non-standard characters. 5 | // Note that this may remove non-English characters 6 | csvTweets = context.functions.execute("removeBreakingCharacters", csvTweets); 7 | 8 | // Convert the CSV Tweets to JSON Tweets 9 | jsonTweets = new CSV(csvTweets, { header: true }).parse(); 10 | 11 | // Prepare the results object that we will return 12 | var results = { 13 | newTweets: [], 14 | updatedTweets: [], 15 | tweetsNotInsertedOrUpdated: [] 16 | } 17 | 18 | // Clean each Tweet and store it in the DB 19 | jsonTweets.forEach(async (tweet) => { 20 | 21 | // The Tweet ID from the CSV is being rounded, so we'll manually pull it out of the Tweet link instead 22 | delete tweet["Tweet id"]; 23 | 24 | // Pull the author and Tweet id out of the Tweet permalink 25 | // TODO: check that the author matches the user who is signed in to prevent sabotage 26 | const link = tweet["Tweet permalink"]; 27 | const pattern = /https?:\/\/twitter.com\/([^\/]+)\/status\/(.*)/i; 28 | const regexResults = pattern.exec(link); 29 | tweet.author = regexResults[1]; 30 | tweet._id = regexResults[2] 31 | 32 | // Generate a date from the time string 33 | tweet.date = new Date(tweet.time.substring(0, 10)); 34 | 35 | try { 36 | // Upsert the Tweet, so we can update stats for existing Tweets 37 | const result = await context.services.get("mongodb-atlas").db("TwitterStats").collection("stats").updateOne( 38 | { _id: tweet._id }, 39 | { $set: tweet }, 40 | { upsert: true }); 41 | 42 | if (result.upsertedId) { 43 | results.newTweets.push(tweet._id); 44 | } else if (result.modifiedCount > 0) { 45 | results.updatedTweets.push(tweet._id); 46 | } else { 47 | results.tweetsNotInsertedOrUpdated.push(tweet._id); 48 | } 49 | } catch (error) { 50 | console.error(error); 51 | results.tweetsNotInsertedOrUpdated.push(tweet._id); 52 | } 53 | 54 | }); 55 | 56 | return results; 57 | }; 58 | 59 | if (typeof module === 'object') { 60 | module.exports = exports; 61 | } 62 | -------------------------------------------------------------------------------- /hosting/files/config.js: -------------------------------------------------------------------------------- 1 | // Get the Realm App ID from the current URL 2 | // Note that this assumes you are NOT using a custom domain 3 | // If you are using a custom domain, you should create a 4 | // switch statement with a case for every environment 5 | let url = window.location.hostname; 6 | const REALM_APP_ID = url.split(".")[0]; 7 | 8 | // Get the URL for the Charts dashboard based on the 9 | // Realm App Id 10 | const CHARTS_URL = getChartsUrl(REALM_APP_ID); 11 | 12 | function getChartsUrl(REALM_APP_ID) { 13 | switch (REALM_APP_ID) { 14 | case "twitterstats-vpxim": 15 | return "https://charts.mongodb.com/charts-twitter-stats-vzwpx/public/dashboards/82195382-6cea-4994-9283-cf2fb899c6de"; 16 | case "socialstats-staging-pseyi": 17 | return "https://charts.mongodb.com/charts-socialstats-staging-vkoue/public/dashboards/852be5c1-3476-44b8-9d6f-74bcda18bd03"; 18 | case "socialstats-dev-lauren-ncrqz": 19 | return "https://charts.mongodb.com/charts-socialstats-dev-lauren-fmxwm/public/dashboards/2e3a452e-a77d-4849-bdc0-90b361026b05"; 20 | } 21 | throw new Error("Unable to find Charts URL for this app."); 22 | } 23 | -------------------------------------------------------------------------------- /hosting/files/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 |

DevRel Tweet Statistics

11 | 12 |

View Dashboard

13 | 14 |

Upload your Tweet statistics CSV:

15 | 16 | 17 | 18 | 19 |

20 | 21 | 22 | 23 | 24 | 25 | 26 | 89 | 90 | -------------------------------------------------------------------------------- /hosting/metadata.json: -------------------------------------------------------------------------------- 1 | [] -------------------------------------------------------------------------------- /images/architecture.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mongodb-developer/SocialStats/9f053671ccb71de26f680dc7e37241a7bc06e16d/images/architecture.png -------------------------------------------------------------------------------- /images/dashboard.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mongodb-developer/SocialStats/9f053671ccb71de26f680dc7e37241a7bc06e16d/images/dashboard.png -------------------------------------------------------------------------------- /images/upload.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mongodb-developer/SocialStats/9f053671ccb71de26f680dc7e37241a7bc06e16d/images/upload.png -------------------------------------------------------------------------------- /images/webinar.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mongodb-developer/SocialStats/9f053671ccb71de26f680dc7e37241a7bc06e16d/images/webinar.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "socialstats", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "env-cmd -f ./config/test.env jest --watch --runInBand" 8 | }, 9 | "repository": { 10 | "type": "git", 11 | "url": "git+https://github.com/mongodb-developer/SocialStats.git" 12 | }, 13 | "author": "", 14 | "license": "ISC", 15 | "bugs": { 16 | "url": "https://github.com/mongodb-developer/SocialStats/issues" 17 | }, 18 | "homepage": "https://github.com/mongodb-developer/SocialStats#readme", 19 | "dependencies": { 20 | "comma-separated-values": "^3.6.4", 21 | "parse-multipart": "^1.0.4", 22 | "realm-web": "^0.7.0", 23 | "selenium-webdriver": "^4.0.0-alpha.8" 24 | }, 25 | "devDependencies": { 26 | "chromedriver": "^2.26.1", 27 | "env-cmd": "^10.1.0", 28 | "jest": "^26.6.3", 29 | "jest-cli": "^26.6.3", 30 | "mongodb": "^3.6.3" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /services/mongodb-atlas/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "mongodb-atlas", 3 | "type": "mongodb-atlas", 4 | "config": { 5 | "readPreference": "primary", 6 | "wireProtocolEnabled": false 7 | }, 8 | "version": 1 9 | } 10 | -------------------------------------------------------------------------------- /services/uploadTweets/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "uploadTweets", 3 | "type": "http", 4 | "config": {}, 5 | "version": 1 6 | } -------------------------------------------------------------------------------- /services/uploadTweets/incoming_webhooks/webhook0/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "sendCsv", 3 | "run_as_authed_user": false, 4 | "run_as_user_id": "", 5 | "run_as_user_id_script_source": "", 6 | "options": { 7 | "httpMethod": "POST", 8 | "validationMethod": "NO_VALIDATION" 9 | }, 10 | "respond_result": true 11 | } -------------------------------------------------------------------------------- /services/uploadTweets/incoming_webhooks/webhook0/source.js: -------------------------------------------------------------------------------- 1 | // This function is the webhook's request handler. 2 | exports = function (payload, response) { 3 | 4 | // console.log("inside the webhook: " + JSON.stringify(payload)); 5 | // console.log(JSON.stringify(payload.body)); 6 | // console.log(JSON.stringify(payload.headers)); 7 | // console.log(JSON.stringify(payload.headers["Content-Type"])); 8 | // console.log(JSON.stringify(payload.query)); 9 | 10 | // Retrieve the csv tweet data from the request 11 | const multipart = require('parse-multipart'); 12 | 13 | const contentTypes = payload.headers["Content-Type"]; 14 | 15 | const body = new Buffer(payload.body.text(), 'utf-8'); 16 | 17 | var boundary = multipart.getBoundary(JSON.stringify(contentTypes)); 18 | // trim the extra junk off the end of the boundary 19 | boundary = boundary.substring(0, boundary.length - 2); 20 | 21 | const parts = multipart.Parse(body, boundary); 22 | 23 | //TODO: check the file is really a csv 24 | 25 | // TODO: limit this to one file 26 | // For each file that was passed, store the data in the database 27 | for (var i = 0; i < parts.length; i++) { 28 | var part = parts[i]; 29 | return context.functions.execute("storeCsvInDb", part.data.toString('utf-8')); 30 | } 31 | 32 | //TODO: Return invalid response if no file is uploaded 33 | return "No files were uploaded"; 34 | }; 35 | 36 | if (typeof module !== 'undefined') { 37 | module.exports = exports; 38 | } 39 | -------------------------------------------------------------------------------- /stitch.json: -------------------------------------------------------------------------------- 1 | { 2 | "config_version": 20180301, 3 | "security": {}, 4 | "custom_user_data_config": { 5 | "enabled": false 6 | }, 7 | "realm_config": { 8 | "development_mode_enabled": false 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /tests/constants.js: -------------------------------------------------------------------------------- 1 | const TwitterStatsDb = "TwitterStats" 2 | 3 | const statsCollection = "stats"; 4 | 5 | const header = `"Tweet id","Tweet permalink","Tweet text","time","impressions","engagements","engagement rate","retweets","replies","likes","user profile clicks","url clicks","hashtag clicks","detail expands","permalink clicks","app opens","app installs","follows","email tweet","dial phone","media views","media engagements","promoted impressions","promoted engagements","promoted engagement rate","promoted retweets","promoted replies","promoted likes","promoted user profile clicks","promoted url clicks","promoted hashtag clicks","promoted detail expands","promoted permalink clicks","promoted app opens","promoted app installs","promoted follows","promoted email tweet","promoted dial phone","promoted media views","promoted media engagements"`; 6 | 7 | const validTweetId = "1226928883355791360" 8 | 9 | const validTweetCsv = `"${validTweetId}","https://twitter.com/Lauren_Schaefer/status/${validTweetId}","Simple tweet","2020-02-10 18:00 +0000","1203.0","39.0","0.032418952618453865","4.0","0.0","7.0","2.0","22.0","0.0","4.0","0.0","0","0","0","0","0","0","0","-","-","-","-","-","-","-","-","-","-","-","-","-","-","-","-","-","-"`; 10 | 11 | const validTweetJson = { 12 | "Tweet permalink": "https://twitter.com/Lauren_Schaefer/status/" + validTweetId, 13 | "Tweet text": "Simple tweet", 14 | "_id": validTweetId, 15 | "app installs": 0, 16 | "app opens": 0, 17 | "author": "Lauren_Schaefer", 18 | "date": new Date("2020-02-10 00:00 +0000"), 19 | "detail expands": 4, 20 | "dial phone": 0, 21 | "email tweet": 0, 22 | "engagement rate": 0.032418952618453865, 23 | "engagements": 39, 24 | "follows": 0, 25 | "hashtag clicks": 0, 26 | "impressions": 1203, 27 | "likes": 7, 28 | "media engagements": 0, 29 | "media views": 0, 30 | "permalink clicks": 0, 31 | "promoted app installs": "-", 32 | "promoted app opens": "-", 33 | "promoted detail expands": "-", 34 | "promoted dial phone": "-", 35 | "promoted email tweet": "-", 36 | "promoted engagement rate": "-", 37 | "promoted engagements": "-", 38 | "promoted follows": "-", 39 | "promoted hashtag clicks": "-", 40 | "promoted impressions": "-", 41 | "promoted likes": "-", 42 | "promoted media engagements": "-", 43 | "promoted media views": "-", 44 | "promoted permalink clicks": "-", 45 | "promoted replies": "-", 46 | "promoted retweets": "-", 47 | "promoted url clicks": "-", 48 | "promoted user profile clicks": "-", 49 | "replies": 0, 50 | "retweets": 4, 51 | "time": "2020-02-10 18:00 +0000", 52 | "url clicks": 22, 53 | "user profile clicks": 2 54 | }; 55 | 56 | const validTweetUpdatedCsv = `"${validTweetId}","https://twitter.com/Lauren_Schaefer/status/${validTweetId}","Simple tweet","2020-02-10 18:00 +0000","1423.0","45.0","0.05134498689","6.0","1.0","10.0","3.0","26.0","0.0","0","3.0","0","0","3.0","0","0","0","0","-","-","-","-","-","-","-","-","-","-","-","-","-","-","-","-","-","-"`; 57 | 58 | const validTweetUpdatedJson = { 59 | "Tweet permalink": "https://twitter.com/Lauren_Schaefer/status/" + validTweetId, 60 | "Tweet text": "Simple tweet", 61 | "_id": validTweetId, 62 | "app installs": 0, 63 | "app opens": 0, 64 | "author": "Lauren_Schaefer", 65 | "date": new Date("2020-02-10 00:00 +0000"), 66 | "detail expands": 0, 67 | "dial phone": 0, 68 | "email tweet": 0, 69 | "engagement rate": 0.05134498689, 70 | "engagements": 45, 71 | "follows": 3, 72 | "hashtag clicks": 0, 73 | "impressions": 1423, 74 | "likes": 10, 75 | "media engagements": 0, 76 | "media views": 0, 77 | "permalink clicks": 3, 78 | "promoted app installs": "-", 79 | "promoted app opens": "-", 80 | "promoted detail expands": "-", 81 | "promoted dial phone": "-", 82 | "promoted email tweet": "-", 83 | "promoted engagement rate": "-", 84 | "promoted engagements": "-", 85 | "promoted follows": "-", 86 | "promoted hashtag clicks": "-", 87 | "promoted impressions": "-", 88 | "promoted likes": "-", 89 | "promoted media engagements": "-", 90 | "promoted media views": "-", 91 | "promoted permalink clicks": "-", 92 | "promoted replies": "-", 93 | "promoted retweets": "-", 94 | "promoted url clicks": "-", 95 | "promoted user profile clicks": "-", 96 | "replies": 1, 97 | "retweets": 6, 98 | "time": "2020-02-10 18:00 +0000", 99 | "url clicks": 26, 100 | "user profile clicks": 3 101 | }; 102 | 103 | const validTweet2Id = "1226929883355791365"; 104 | 105 | const validTweet2Csv = `"${validTweet2Id}","https://twitter.com/Lauren_Schaefer/status/${validTweet2Id}","Another tweet from me <3","2020-02-11 18:00 +0000","1203.0","39.0","0.032418952618453865","4.0","0.0","7.0","2.0","22.0","0.0","4.0","0.0","0","0","0","0","0","0","0","-","-","-","-","-","-","-","-","-","-","-","-","-","-","-","-","-","-"`; 106 | 107 | const validTweet2Json = { 108 | "Tweet permalink": "https://twitter.com/Lauren_Schaefer/status/" + validTweet2Id, 109 | "Tweet text": "Another tweet from me <3", 110 | "_id": validTweet2Id, 111 | "app installs": 0, 112 | "app opens": 0, 113 | "author": "Lauren_Schaefer", 114 | "date": new Date("2020-02-11 00:00 +0000"), 115 | "detail expands": 4, 116 | "dial phone": 0, 117 | "email tweet": 0, 118 | "engagement rate": 0.032418952618453865, 119 | "engagements": 39, 120 | "follows": 0, 121 | "hashtag clicks": 0, 122 | "impressions": 1203, 123 | "likes": 7, 124 | "media engagements": 0, 125 | "media views": 0, 126 | "permalink clicks": 0, 127 | "promoted app installs": "-", 128 | "promoted app opens": "-", 129 | "promoted detail expands": "-", 130 | "promoted dial phone": "-", 131 | "promoted email tweet": "-", 132 | "promoted engagement rate": "-", 133 | "promoted engagements": "-", 134 | "promoted follows": "-", 135 | "promoted hashtag clicks": "-", 136 | "promoted impressions": "-", 137 | "promoted likes": "-", 138 | "promoted media engagements": "-", 139 | "promoted media views": "-", 140 | "promoted permalink clicks": "-", 141 | "promoted replies": "-", 142 | "promoted retweets": "-", 143 | "promoted url clicks": "-", 144 | "promoted user profile clicks": "-", 145 | "replies": 0, 146 | "retweets": 4, 147 | "time": "2020-02-11 18:00 +0000", 148 | "url clicks": 22, 149 | "user profile clicks": 2 150 | } 151 | 152 | const emojiTweetId = "1226928883355791361"; 153 | 154 | const emojiTweetCsv = `"${emojiTweetId}","https://twitter.com/Lauren_Schaefer/status/${emojiTweetId}","Emoji tweet 😀💅👸","2020-02-11 18:00 +0000","1203.0","39.0","0.032418952618453865","4.0","0.0","7.0","2.0","22.0","0.0","4.0","0.0","0","0","0","0","0","0","0","-","-","-","-","-","-","-","-","-","-","-","-","-","-","-","-","-","-"`; 155 | 156 | const emojiTweetCsvClean = `"${emojiTweetId}","https://twitter.com/Lauren_Schaefer/status/${emojiTweetId}","Emoji tweet ","2020-02-11 18:00 +0000","1203.0","39.0","0.032418952618453865","4.0","0.0","7.0","2.0","22.0","0.0","4.0","0.0","0","0","0","0","0","0","0","-","-","-","-","-","-","-","-","-","-","-","-","-","-","-","-","-","-"`; 157 | 158 | const emojiTweetJson = { 159 | "Tweet permalink": "https://twitter.com/Lauren_Schaefer/status/" + emojiTweetId, 160 | "Tweet text": "Emoji tweet ", 161 | "_id": emojiTweetId, 162 | "app installs": 0, 163 | "app opens": 0, 164 | "author": "Lauren_Schaefer", 165 | "date": new Date("2020-02-11 00:00 +0000"), 166 | "detail expands": 4, 167 | "dial phone": 0, 168 | "email tweet": 0, 169 | "engagement rate": 0.032418952618453865, 170 | "engagements": 39, 171 | "follows": 0, 172 | "hashtag clicks": 0, 173 | "impressions": 1203, 174 | "likes": 7, 175 | "media engagements": 0, 176 | "media views": 0, 177 | "permalink clicks": 0, 178 | "promoted app installs": "-", 179 | "promoted app opens": "-", 180 | "promoted detail expands": "-", 181 | "promoted dial phone": "-", 182 | "promoted email tweet": "-", 183 | "promoted engagement rate": "-", 184 | "promoted engagements": "-", 185 | "promoted follows": "-", 186 | "promoted hashtag clicks": "-", 187 | "promoted impressions": "-", 188 | "promoted likes": "-", 189 | "promoted media engagements": "-", 190 | "promoted media views": "-", 191 | "promoted permalink clicks": "-", 192 | "promoted replies": "-", 193 | "promoted retweets": "-", 194 | "promoted url clicks": "-", 195 | "promoted user profile clicks": "-", 196 | "replies": 0, 197 | "retweets": 4, 198 | "time": "2020-02-11 18:00 +0000", 199 | "url clicks": 22, 200 | "user profile clicks": 2 201 | }; 202 | 203 | const validTweetKenId = "1226928883355791362" 204 | 205 | const validTweetKenCsv = `"${validTweetKenId}","https://twitter.com/kenwalger/status/${validTweetKenId}","I like to make bad dad jokes","2020-02-12 18:00 +0000","10005.0","62.0","0.041111111111111","5.0","3.0","33.0","5.0","62.0","0.0","11.0","3.0","0","0","3.0","0","0","0","0","-","-","-","-","-","-","-","-","-","-","-","-","-","-","-","-","-","-"`; 206 | 207 | const validTweetKenJson = { 208 | "Tweet permalink": "https://twitter.com/kenwalger/status/" + validTweetKenId, 209 | "Tweet text": "I like to make bad dad jokes", 210 | "_id": validTweetKenId, 211 | "app installs": 0, 212 | "app opens": 0, 213 | "author": "kenwalger", 214 | "date": new Date("2020-02-12 00:00 +0000"), 215 | "detail expands": 11, 216 | "dial phone": 0, 217 | "email tweet": 0, 218 | "engagement rate": 0.041111111111111, 219 | "engagements": 62, 220 | "follows": 3, 221 | "hashtag clicks": 0, 222 | "impressions": 10005, 223 | "likes": 33, 224 | "media engagements": 0, 225 | "media views": 0, 226 | "permalink clicks": 3, 227 | "promoted app installs": "-", 228 | "promoted app opens": "-", 229 | "promoted detail expands": "-", 230 | "promoted dial phone": "-", 231 | "promoted email tweet": "-", 232 | "promoted engagement rate": "-", 233 | "promoted engagements": "-", 234 | "promoted follows": "-", 235 | "promoted hashtag clicks": "-", 236 | "promoted impressions": "-", 237 | "promoted likes": "-", 238 | "promoted media engagements": "-", 239 | "promoted media views": "-", 240 | "promoted permalink clicks": "-", 241 | "promoted replies": "-", 242 | "promoted retweets": "-", 243 | "promoted url clicks": "-", 244 | "promoted user profile clicks": "-", 245 | "replies": 3, 246 | "retweets": 5, 247 | "time": "2020-02-12 18:00 +0000", 248 | "url clicks": 62, 249 | "user profile clicks": 5 250 | }; 251 | 252 | const specialCharactersTweetId = "1226921113355791366"; 253 | 254 | const specialCharactersTweetCsv = `"${specialCharactersTweetId}","https://twitter.com/Lauren_Schaefer/status/${specialCharactersTweetId}","Lots of special characters 0123456789 !@#$%^&*()-_=+[]{}\\|;:'",./<>? \`~","2020-02-12 18:00 +0000","1203.0","39.0","0.032418952618453865","4.0","0.0","7.0","2.0","22.0","0.0","4.0","0.0","0","0","0","0","0","0","0","-","-","-","-","-","-","-","-","-","-","-","-","-","-","-","-","-","-"`; 255 | 256 | module.exports = { 257 | TwitterStatsDb, 258 | statsCollection, 259 | header, 260 | validTweetId, 261 | validTweetCsv, 262 | validTweetJson, 263 | validTweet2Id, 264 | validTweet2Csv, 265 | validTweet2Json, 266 | validTweetUpdatedCsv, 267 | validTweetUpdatedJson, 268 | emojiTweetId, 269 | emojiTweetCsv, 270 | emojiTweetCsvClean, 271 | emojiTweetJson, 272 | validTweetKenId, 273 | validTweetKenCsv, 274 | validTweetKenJson, 275 | specialCharactersTweetId, 276 | specialCharactersTweetCsv 277 | }; -------------------------------------------------------------------------------- /tests/integration/processCsvToDb.test.js: -------------------------------------------------------------------------------- 1 | /** 2 | * These tests test the following integrations: 3 | * * storeCsvInDb function -> storeCsvInDb function -> underlying db 4 | */ 5 | 6 | const { MongoClient } = require('mongodb'); 7 | 8 | const { TwitterStatsDb, statsCollection, header, validTweetCsv, validTweetJson, validTweetId, validTweetUpdatedCsv, validTweetUpdatedJson, emojiTweetId, emojiTweetCsv, emojiTweetJson, validTweetKenId, validTweetKenCsv, validTweetKenJson } = require('../constants.js'); 9 | 10 | const RealmWeb = require('realm-web'); 11 | 12 | let collection; 13 | let mongoClient; 14 | let app; 15 | 16 | beforeAll(async () => { 17 | jest.setTimeout(10000); 18 | 19 | // Connect to the Realm app 20 | app = new RealmWeb.App({ id: `${process.env.REALM_APP_ID}` }); 21 | 22 | // Login to the Realm app with anonymous credentials 23 | await app.logIn(RealmWeb.Credentials.anonymous()); 24 | 25 | // Connect directly to the database 26 | const uri = `mongodb+srv://${process.env.DB_USERNAME}:${process.env.DB_PASSWORD}@${process.env.CLUSTER_URI}/test?retryWrites=true&w=majority`; 27 | mongoClient = new MongoClient(uri); 28 | await mongoClient.connect(); 29 | collection = mongoClient.db(TwitterStatsDb).collection(statsCollection); 30 | }); 31 | 32 | afterAll(async () => { 33 | await mongoClient.close(); 34 | }) 35 | 36 | beforeEach(async () => { 37 | await collection.deleteMany({}); 38 | }); 39 | 40 | 41 | test('Single tweet', async () => { 42 | const data = "data:text/csv;base64," + Buffer.from(header + "\n" + validTweetCsv).toString('base64'); 43 | 44 | expect(await app.functions.processCsv(data)).toStrictEqual({ 45 | newTweets: [validTweetId], 46 | tweetsNotInsertedOrUpdated: [], 47 | updatedTweets: [] 48 | }); 49 | 50 | const tweet = await collection.findOne({ _id: validTweetId }); 51 | expect(tweet).toStrictEqual(validTweetJson); 52 | }); 53 | 54 | test('Update single tweet', async () => { 55 | 56 | const data = "data:text/csv;base64," + Buffer.from(header + "\n" + validTweetCsv).toString('base64'); 57 | 58 | expect(await app.functions.processCsv(data)).toEqual({ 59 | newTweets: [validTweetId], 60 | tweetsNotInsertedOrUpdated: [], 61 | updatedTweets: [] 62 | }); 63 | 64 | let tweet = await collection.findOne({ _id: validTweetId }); 65 | expect(tweet).toStrictEqual(validTweetJson); 66 | 67 | const updatedData = "data:text/csv;base64," + Buffer.from(header + "\n" + validTweetUpdatedCsv).toString('base64'); 68 | 69 | expect(await app.functions.processCsv(updatedData)).toEqual({ 70 | newTweets: [], 71 | tweetsNotInsertedOrUpdated: [], 72 | updatedTweets: [validTweetId] 73 | }); 74 | 75 | tweet = await collection.findOne({ _id: validTweetId }); 76 | expect(tweet).toStrictEqual(validTweetUpdatedJson); 77 | 78 | }); 79 | 80 | test('Store new and updated tweets', async () => { 81 | 82 | const data = "data:text/csv;base64," + Buffer.from(header + "\n" + validTweetCsv + "\n" + emojiTweetCsv).toString('base64'); 83 | 84 | // Store validTweet and emojiTweet 85 | let results = await app.functions.processCsv(data); 86 | 87 | // Sort the results to avoid test failures due to the order of the Tweets in the array 88 | results.newTweets = results.newTweets.sort(); 89 | 90 | let expectedResults = { 91 | newTweets: [validTweetId, emojiTweetId].sort(), 92 | tweetsNotInsertedOrUpdated: [], 93 | updatedTweets: [] 94 | } 95 | 96 | expect(results).toEqual(expectedResults); 97 | 98 | let tweet = await collection.findOne({ _id: validTweetId }); 99 | expect(tweet).toStrictEqual(validTweetJson); 100 | 101 | tweet = await collection.findOne({ _id: emojiTweetId }); 102 | expect(tweet).toStrictEqual(emojiTweetJson); 103 | 104 | // Store validTweetKen and updatedValidTweet 105 | 106 | const updatedData = "data:text/csv;base64," + Buffer.from(header + "\n" + validTweetKenCsv + "\n" + validTweetUpdatedCsv).toString('base64'); 107 | 108 | expect(await app.functions.processCsv(updatedData)).toEqual({ 109 | newTweets: [validTweetKenId], 110 | tweetsNotInsertedOrUpdated: [], 111 | updatedTweets: [validTweetId] 112 | }); 113 | 114 | tweet = await collection.findOne({ _id: validTweetId }); 115 | expect(tweet).toStrictEqual(validTweetUpdatedJson); 116 | 117 | tweet = await collection.findOne({ _id: validTweetKenId }); 118 | expect(tweet).toStrictEqual(validTweetKenJson); 119 | 120 | }) 121 | -------------------------------------------------------------------------------- /tests/integration/storeCsvInDB.test.js: -------------------------------------------------------------------------------- 1 | /** 2 | * These tests test the following integrations: 3 | * * storeCsvInDb function -> removeBreakingCharacters function 4 | * * storeCsvInDb function -> underlying database 5 | */ 6 | 7 | const { MongoClient } = require('mongodb'); 8 | 9 | const { TwitterStatsDb, statsCollection, header, validTweetCsv, validTweetJson, validTweetId, validTweetUpdatedCsv, validTweetUpdatedJson, emojiTweetId, emojiTweetCsv, emojiTweetJson, validTweetKenId, validTweetKenCsv, validTweetKenJson } = require('../constants.js'); 10 | 11 | const RealmWeb = require('realm-web'); 12 | 13 | let collection; 14 | let mongoClient; 15 | let app; 16 | 17 | beforeAll(async () => { 18 | // Connect to the Realm app 19 | app = new RealmWeb.App({ id: `${process.env.REALM_APP_ID}` }); 20 | 21 | // Login to the Realm app with anonymous credentials 22 | await app.logIn(RealmWeb.Credentials.anonymous()); 23 | 24 | // Connect directly to the database 25 | const uri = `mongodb+srv://${process.env.DB_USERNAME}:${process.env.DB_PASSWORD}@${process.env.CLUSTER_URI}/test?retryWrites=true&w=majority`; 26 | mongoClient = new MongoClient(uri); 27 | await mongoClient.connect(); 28 | collection = mongoClient.db(TwitterStatsDb).collection(statsCollection); 29 | }); 30 | 31 | afterAll(async () => { 32 | await mongoClient.close(); 33 | }) 34 | 35 | beforeEach(async () => { 36 | await collection.deleteMany({}); 37 | }); 38 | 39 | test('Single tweet', async () => { 40 | 41 | expect(await app.functions.storeCsvInDb(header + "\n" + validTweetCsv)).toStrictEqual({ 42 | newTweets: [validTweetId], 43 | tweetsNotInsertedOrUpdated: [], 44 | updatedTweets: [] 45 | }); 46 | 47 | const tweet = await collection.findOne({ _id: validTweetId }); 48 | expect(tweet).toStrictEqual(validTweetJson); 49 | }); 50 | 51 | test('Emoji tweet', async () => { 52 | 53 | expect(await app.functions.storeCsvInDb(header + "\n" + emojiTweetCsv)).toStrictEqual({ 54 | newTweets: [emojiTweetId], 55 | tweetsNotInsertedOrUpdated: [], 56 | updatedTweets: [] 57 | }); 58 | 59 | const tweet = await collection.findOne({ _id: emojiTweetId }); 60 | expect(tweet).toStrictEqual(emojiTweetJson); 61 | }); 62 | 63 | test('Update single tweet', async () => { 64 | 65 | expect(await app.functions.storeCsvInDb(header + "\n" + validTweetCsv)).toStrictEqual({ 66 | newTweets: [validTweetId], 67 | tweetsNotInsertedOrUpdated: [], 68 | updatedTweets: [] 69 | }); 70 | 71 | let tweet = await collection.findOne({ _id: validTweetId }); 72 | expect(tweet).toStrictEqual(validTweetJson); 73 | 74 | expect(await app.functions.storeCsvInDb(header + "\n" + validTweetUpdatedCsv)).toStrictEqual({ 75 | newTweets: [], 76 | tweetsNotInsertedOrUpdated: [], 77 | updatedTweets: [validTweetId] 78 | }); 79 | 80 | tweet = await collection.findOne({ _id: validTweetId }); 81 | expect(tweet).toStrictEqual(validTweetUpdatedJson); 82 | 83 | }); 84 | 85 | test('Store new and updated tweets', async () => { 86 | 87 | // Store validTweet and emojiTweet 88 | let results = await app.functions.storeCsvInDb(header + "\n" + validTweetCsv + "\n" + emojiTweetCsv); 89 | 90 | // Sort the results to avoid test failures due to the order of the Tweets in the array 91 | results.newTweets = results.newTweets.sort(); 92 | 93 | let expectedResults = { 94 | newTweets: [validTweetId, emojiTweetId].sort(), 95 | tweetsNotInsertedOrUpdated: [], 96 | updatedTweets: [] 97 | } 98 | 99 | expect(results).toEqual(expectedResults); 100 | 101 | let tweet = await collection.findOne({ _id: validTweetId }); 102 | expect(tweet).toStrictEqual(validTweetJson); 103 | 104 | tweet = await collection.findOne({ _id: emojiTweetId }); 105 | expect(tweet).toStrictEqual(emojiTweetJson); 106 | 107 | // Store validTweetKen and updatedValidTweet 108 | expect(await app.functions.storeCsvInDb(header + "\n" + validTweetKenCsv + "\n" + validTweetUpdatedCsv)).toStrictEqual({ 109 | newTweets: [validTweetKenId], 110 | tweetsNotInsertedOrUpdated: [], 111 | updatedTweets: [validTweetId] 112 | }); 113 | 114 | tweet = await collection.findOne({ _id: validTweetId }); 115 | expect(tweet).toStrictEqual(validTweetUpdatedJson); 116 | 117 | tweet = await collection.findOne({ _id: validTweetKenId }); 118 | expect(tweet).toStrictEqual(validTweetKenJson); 119 | 120 | }) 121 | -------------------------------------------------------------------------------- /tests/ui/files/singletweet.csv: -------------------------------------------------------------------------------- 1 | Tweet id,Tweet permalink,Tweet text,time,impressions,engagements,engagement rate,retweets,replies,likes,user profile clicks,url clicks,hashtag clicks,detail expands,permalink clicks,app opens,app installs,follows,email tweet,dial phone,media views,media engagements,promoted impressions,promoted engagements,promoted engagement rate,promoted retweets,promoted replies,promoted likes,promoted user profile clicks,promoted url clicks,promoted hashtag clicks,promoted detail expands,promoted permalink clicks,promoted app opens,promoted app installs,promoted follows,promoted email tweet,promoted dial phone,promoted media views,promoted media engagements 2 | 1225479302868873216,https://twitter.com/Lauren_Schaefer/status/1225479302868873216,"For the fellow #parents out there... 3 | 4 | Tech conferences with childcare 5 | 6 | https://t.co/91SuX4nHIy 7 | 8 | #WomenInTech #WomenInSTEM #PeopleInTech",2020-02-06 18:00 +0000,260.0,4.0,0.015384615384615400,1.0,0.0,3.0,0.0,0.0,0.0,0.0,0.0,0,0,0,0,0,0,0,-,-,-,-,-,-,-,-,-,-,-,-,-,-,-,-,-,- -------------------------------------------------------------------------------- /tests/ui/files/threetweets_ken.csv: -------------------------------------------------------------------------------- 1 | Tweet id,Tweet permalink,Tweet text,time,impressions,engagements,engagement rate,retweets,replies,likes,user profile clicks,url clicks,hashtag clicks,detail expands,permalink clicks,app opens,app installs,follows,email tweet,dial phone,media views,media engagements,promoted impressions,promoted engagements,promoted engagement rate,promoted retweets,promoted replies,promoted likes,promoted user profile clicks,promoted url clicks,promoted hashtag clicks,promoted detail expands,promoted permalink clicks,promoted app opens,promoted app installs,promoted follows,promoted email tweet,promoted dial phone,promoted media views,promoted media engagements 2 | 1224774552019853312,https://twitter.com/kenwalger/status/1224774552019853312,"Check out this great article from Senior Developer Advocate @MongoDB @KukicAdo 3 | on Building Modern Applications with Next.js and MongoDB https://t.co/nCQrVCTjjM",2020-02-04 19:19 +0000,1376.0,39.0,0.028343023255814000,4.0,0.0,13.0,5.0,16.0,0.0,1.0,0.0,0,0,0,0,0,0,0,-,-,-,-,-,-,-,-,-,-,-,-,-,-,-,-,-,- 4 | 1218163006757990401,https://twitter.com/kenwalger/status/1218163006757990401,Wow! Honored to make the @WhiteSourceSoft Top 20 Developer Advocates to Follow in 2020 list https://t.co/MpzftlQbod,2020-01-17 13:27 +0000,1237.0,42.0,0.03395311236863380,2.0,1.0,16.0,3.0,18.0,0.0,2.0,0.0,0,0,0,0,0,0,0,-,-,-,-,-,-,-,-,-,-,-,-,-,-,-,-,-,- 5 | 1217921638844387328,https://twitter.com/kenwalger/status/1217921638844387328,Check out my latest post on the Decimal128 BSON data type in #MongoDB https://t.co/qs71fMlmqf,2020-01-16 21:28 +0000,867.0,15.0,0.01730103806228370,1.0,0.0,3.0,2.0,9.0,0.0,0.0,0.0,0,0,0,0,0,0,0,-,-,-,-,-,-,-,-,-,-,-,-,-,-,-,-,-,- -------------------------------------------------------------------------------- /tests/ui/files/twotweets.csv: -------------------------------------------------------------------------------- 1 | Tweet id,Tweet permalink,Tweet text,time,impressions,engagements,engagement rate,retweets,replies,likes,user profile clicks,url clicks,hashtag clicks,detail expands,permalink clicks,app opens,app installs,follows,email tweet,dial phone,media views,media engagements,promoted impressions,promoted engagements,promoted engagement rate,promoted retweets,promoted replies,promoted likes,promoted user profile clicks,promoted url clicks,promoted hashtag clicks,promoted detail expands,promoted permalink clicks,promoted app opens,promoted app installs,promoted follows,promoted email tweet,promoted dial phone,promoted media views,promoted media engagements 2 | 1225785799146430465,https://twitter.com/Lauren_Schaefer/status/1225785799146430465,"I've found that I notice less that I'm the only woman in the ""room"" when I work remotely vs working in the office. My minority status feels less isolating when I'm remote. 3 | 4 | I can't find any stats or articles to back this up. Anyone else feel the same? 5 | 6 | #RemoteWork #WorkFromHome",2020-02-07 14:18 +0000,206.0,6.0,0.02912621359223300,1.0,1.0,3.0,1.0,0.0,0.0,0.0,0.0,0,0,0,0,0,0,0,-,-,-,-,-,-,-,-,-,-,-,-,-,-,-,-,-,- 7 | 1225479302868873216,https://twitter.com/Lauren_Schaefer/status/1225479302868873216,"For the fellow #parents out there... 8 | 9 | Tech conferences with childcare 10 | 11 | https://t.co/91SuX4nHIy 12 | 13 | #WomenInTech #WomenInSTEM #PeopleInTech",2020-02-06 18:00 +0000,260.0,4.0,0.015384615384615400,1.0,0.0,3.0,0.0,0.0,0.0,0.0,0.0,0,0,0,0,0,0,0,-,-,-,-,-,-,-,-,-,-,-,-,-,-,-,-,-,- -------------------------------------------------------------------------------- /tests/ui/files/twotweets_updated.csv: -------------------------------------------------------------------------------- 1 | Tweet id,Tweet permalink,Tweet text,time,impressions,engagements,engagement rate,retweets,replies,likes,user profile clicks,url clicks,hashtag clicks,detail expands,permalink clicks,app opens,app installs,follows,email tweet,dial phone,media views,media engagements,promoted impressions,promoted engagements,promoted engagement rate,promoted retweets,promoted replies,promoted likes,promoted user profile clicks,promoted url clicks,promoted hashtag clicks,promoted detail expands,promoted permalink clicks,promoted app opens,promoted app installs,promoted follows,promoted email tweet,promoted dial phone,promoted media views,promoted media engagements 2 | 1225785799146430465,https://twitter.com/Lauren_Schaefer/status/1225785799146430465,"I've found that I notice less that I'm the only woman in the ""room"" when I work remotely vs working in the office. My minority status feels less isolating when I'm remote. 3 | 4 | I can't find any stats or articles to back this up. Anyone else feel the same? 5 | 6 | #RemoteWork #WorkFromHome",2020-02-07 14:18 +0000,332,10,0.02912621359223300,1.0,1.0,3.0,1.0,0.0,0.0,0.0,0.0,0,0,0,0,0,0,0,-,-,-,-,-,-,-,-,-,-,-,-,-,-,-,-,-,- 7 | 1225479302868873216,https://twitter.com/Lauren_Schaefer/status/1225479302868873216,"For the fellow #parents out there... 8 | 9 | Tech conferences with childcare 10 | 11 | https://t.co/91SuX4nHIy 12 | 13 | #WomenInTech #WomenInSTEM #PeopleInTech",2020-02-06 18:00 +0000,285,6,0.015384615384615400,1.0,0.0,3.0,0.0,0.0,0.0,0.0,0.0,0,0,0,0,0,0,0,-,-,-,-,-,-,-,-,-,-,-,-,-,-,-,-,-,- 14 | 1224392302615048192,https://twitter.com/Lauren_Schaefer/status/1224392302615048192,"What is #JavaScript made of by @dan_abramov? https://t.co/A69ugbmEdt 15 | 16 | A great reminder of the JavaScript basics",2020-02-03 18:00 +0000,175.0,2.0,0.011428571428571400,1.0,0.0,0.0,0.0,1.0,0.0,0.0,0.0,0,0,0,0,0,0,0,-,-,-,-,-,-,-,-,-,-,-,-,-,-,-,-,-,- 17 | 1224126591917264899,https://twitter.com/Lauren_Schaefer/status/1224126591917264899,"Is there enough space in space for women? 18 | 19 | #MakeSpaceForWomen https://t.co/uPGApiLveh",2020-02-03 00:24 +0000,295.0,3.0,0.010169491525423700,0.0,0.0,1.0,2.0,0.0,0.0,0.0,0.0,0,0,0,0,0,0,0,-,-,-,-,-,-,-,-,-,-,-,-,-,-,-,-,-,- 20 | 1222942690037379072,https://twitter.com/Lauren_Schaefer/status/1222942690037379072,"How to evaluate a remote job https://t.co/FngPIiHTx0 21 | 22 | #remotework #WorkFromHome",2020-01-30 18:00 +0000,236.0,2.0,0.00847457627118644,0.0,0.0,0.0,0.0,2.0,0.0,0.0,0.0,0,0,0,0,0,0,0,-,-,-,-,-,-,-,-,-,-,-,-,-,-,-,-,-,- -------------------------------------------------------------------------------- /tests/ui/selenium-server-standalone-3.141.59.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mongodb-developer/SocialStats/9f053671ccb71de26f680dc7e37241a7bc06e16d/tests/ui/selenium-server-standalone-3.141.59.jar -------------------------------------------------------------------------------- /tests/ui/uploadTweetStats.test.js: -------------------------------------------------------------------------------- 1 | const { MongoClient } = require('mongodb'); 2 | 3 | const { Builder, By, until, Capabilities } = require('selenium-webdriver'); 4 | 5 | const { TwitterStatsDb, statsCollection } = require('../constants.js'); 6 | 7 | let collection; 8 | let mongoClient; 9 | let driver; 10 | 11 | const totalEngagementsXpath = "//div[@data-test-id='chart']/div/h4[text()='Total Engagements']"; 12 | const totalImpressionsXpath = "//div[@data-test-id='chart']/div/h4[text()='Total Impressions']"; 13 | 14 | beforeAll(async () => { 15 | jest.setTimeout(30000); 16 | 17 | // Connect directly to the database 18 | const uri = `mongodb+srv://${process.env.DB_USERNAME}:${process.env.DB_PASSWORD}@${process.env.CLUSTER_URI}/test?retryWrites=true&w=majority`; 19 | mongoClient = new MongoClient(uri); 20 | await mongoClient.connect(); 21 | collection = mongoClient.db(TwitterStatsDb).collection(statsCollection); 22 | }); 23 | 24 | beforeEach(async () => { 25 | // Clear the database 26 | const result = await collection.deleteMany({}); 27 | 28 | // Create a new driver using headless Chrome 29 | let chromeCapabilities = Capabilities.chrome(); 30 | var chromeOptions = { 31 | 'args': ['--headless', 'window-size=1920,1080'] 32 | }; 33 | chromeCapabilities.set('chromeOptions', chromeOptions); 34 | driver = new Builder() 35 | .forBrowser('chrome') 36 | .usingServer('http://localhost:4444/wd/hub') 37 | .withCapabilities(chromeCapabilities) 38 | .build(); 39 | }); 40 | 41 | afterEach(async () => { 42 | driver.close(); 43 | }) 44 | 45 | afterAll(async () => { 46 | await mongoClient.close(); 47 | }) 48 | 49 | test('Single tweet', async () => { 50 | await driver.get(`${process.env.URL}`); 51 | const button = await driver.findElement(By.id('csvUpload')); 52 | await button.sendKeys(process.cwd() + "/tests/ui/files/singletweet.csv"); 53 | 54 | const results = await driver.findElement(By.id('results')); 55 | await driver.wait(until.elementTextIs(results, `Fabulous! 1 new Tweet(s) was/were saved.`), 10000); 56 | 57 | const dashboardLink = await driver.findElement(By.id('dashboard-link')); 58 | dashboardLink.click(); 59 | 60 | await refreshChartsDashboard(); 61 | 62 | await verifyChartText(totalEngagementsXpath, "4"); 63 | await verifyChartText(totalImpressionsXpath, "260"); 64 | }) 65 | 66 | test('New, updates, and multiple authors', async () => { 67 | await driver.get(`${process.env.URL}`); 68 | 69 | const button = await driver.findElement(By.id('csvUpload')); 70 | await button.sendKeys(process.cwd() + "/tests/ui/files/twotweets.csv"); 71 | 72 | const results = await driver.findElement(By.id('results')); 73 | await driver.wait(until.elementTextIs(results, `Fabulous! 2 new Tweet(s) was/were saved.`), 10000); 74 | 75 | await button.sendKeys(process.cwd() + "/tests/ui/files/twotweets_updated.csv"); 76 | await driver.wait(until.elementTextIs(results, `Fabulous! 3 new Tweet(s) was/were saved. 2 Tweet(s) was/were updated.`), 10000); 77 | 78 | await button.sendKeys(process.cwd() + "/tests/ui/files/threetweets_ken.csv"); 79 | await driver.wait(until.elementTextIs(results, `Fabulous! 3 new Tweet(s) was/were saved.`), 10000); 80 | 81 | const dashboardLink = await driver.findElement(By.id('dashboard-link')); 82 | dashboardLink.click(); 83 | 84 | await refreshChartsDashboard(); 85 | 86 | await verifyChartText(totalEngagementsXpath, "119"); 87 | 88 | await verifyChartText(totalImpressionsXpath, "4,803"); 89 | 90 | }) 91 | 92 | async function verifyChartText(elementXpath, chartText) { 93 | let i = 0; 94 | // Getting sporadic errors so will try 5 times before failing 95 | 96 | while (i < 5) { 97 | try { 98 | await moveToCanvasOfElement(elementXpath); 99 | await driver.wait(until.elementLocated(By.xpath("//*[@id='vg-tooltip-element']//*[text()='" + chartText + "']")), 5000); 100 | } catch (error) { 101 | if (i == 4) { 102 | throw error; 103 | } 104 | await refreshChartsDashboard(); 105 | } 106 | i++; 107 | } 108 | 109 | } 110 | 111 | async function moveToCanvasOfElement(elementXPath) { 112 | let i = 0; 113 | // Getting sporadic StaleElementReferenceErrors as the elements briefly disappear after loading 114 | // so we'll try 5 times in order to get around the sporadic failures 115 | while (i < 5) { 116 | try { 117 | 118 | // Hacking this a bit since we can't access the numbers in the chart itself. 119 | // Instead we'll hover over the chart and pull the values out of the tooltip. 120 | 121 | await driver.wait(until.elementLocated(By.xpath(elementXPath)), 10000); 122 | await driver.wait(until.elementLocated(By.xpath(elementXPath + "/../..//canvas")), 1000); 123 | const canvas = await driver.findElement(By.xpath(elementXPath + "/../..//canvas")); 124 | const actions = driver.actions(); 125 | await actions.move({ origin: canvas }).perform(); 126 | break; 127 | } catch (e) { 128 | if (i == 4) { 129 | throw e; 130 | } 131 | } 132 | i++; 133 | } 134 | } 135 | 136 | async function refreshChartsDashboard() { 137 | const refreshButton = await driver.wait(until.elementLocated(By.xpath("//button[@data-test-id='refresh-entity-btn']"))); 138 | refreshButton.click(); 139 | 140 | const forceRefreshButton = await driver.wait(until.elementLocated(By.xpath("//div[text()='Force Refresh All Charts']"))); 141 | forceRefreshButton.click(); 142 | } 143 | -------------------------------------------------------------------------------- /tests/unit/functions/processCsv.test.js: -------------------------------------------------------------------------------- 1 | const processCsv = require('../../../functions/processCsv/source.js'); 2 | 3 | beforeEach(() => { 4 | 5 | // Mock the storeCsvInDB function to return true 6 | global.context = { 7 | functions: { 8 | execute: jest.fn((functionName, anyParam) => { return true; }) 9 | } 10 | } 11 | }); 12 | 13 | 14 | test('Simple', () => { 15 | const csv = "data:text/csv;base64,SGkgTGF1cmVu"; 16 | expect(processCsv(csv)).toBe(true); 17 | expect(context.functions.execute).toHaveBeenCalledWith("storeCsvInDb", "Hi Lauren"); 18 | }) 19 | 20 | 21 | -------------------------------------------------------------------------------- /tests/unit/functions/removeBreakingCharacters.test.js: -------------------------------------------------------------------------------- 1 | const removeBreakingCharacters = require('../../../functions/removeBreakingCharacters/source.js'); 2 | 3 | const { header, validTweetCsv, emojiTweetCsv, emojiTweetCsvClean, specialCharactersTweetCsv } = require('../../constants.js'); 4 | 5 | test('SingleValidTweet', () => { 6 | const csv = header + "\n" + validTweetCsv; 7 | expect(removeBreakingCharacters(csv)).toBe(csv); 8 | }) 9 | 10 | test('EmojiTweet', () => { 11 | const csvBefore = header + "\n" + emojiTweetCsv; 12 | const csvAfter = header + "\n" + emojiTweetCsvClean; 13 | expect(removeBreakingCharacters(csvBefore)).toBe(csvAfter); 14 | }) 15 | 16 | test('ValidSpecialCharacters', () => { 17 | const csv = header + "\n" + specialCharactersTweetCsv; 18 | expect(removeBreakingCharacters(csv)).toBe(csv); 19 | }) 20 | 21 | test('LotsOfTweets', () => { 22 | const csvBefore = header + "\n" + validTweetCsv + "\n" + emojiTweetCsv + "\n" + specialCharactersTweetCsv 23 | const csvAfter = header + "\n" + validTweetCsv + "\n" + emojiTweetCsvClean + "\n" + specialCharactersTweetCsv 24 | expect(removeBreakingCharacters(csvBefore)).toBe(csvAfter); 25 | }) 26 | -------------------------------------------------------------------------------- /tests/unit/functions/storeCsvInDB.test.js: -------------------------------------------------------------------------------- 1 | const storeCsvInDb = require('../../../functions/storeCsvInDb/source.js'); 2 | 3 | const { header, validTweetCsv, validTweetJson, validTweetId, validTweet2Csv, validTweet2Id, validTweet2Json, validTweetKenId, validTweetKenCsv, validTweetKenJson } = require('../../constants.js'); 4 | 5 | let updateOne; 6 | 7 | beforeEach(() => { 8 | // Mock functions to support context.services.get().db().collection().updateOne() 9 | updateOne = jest.fn(() => { 10 | return result = { 11 | upsertedId: validTweetId 12 | } 13 | }); 14 | 15 | const collection = jest.fn().mockReturnValue({ updateOne }); 16 | const db = jest.fn().mockReturnValue({ collection }); 17 | const get = jest.fn().mockReturnValue({ db }); 18 | 19 | collection.updateOne = updateOne; 20 | db.collection = collection; 21 | get.db = db; 22 | 23 | // Mock the removeBreakingCharacters function to return whatever is passed to it 24 | // Setup global.context.services 25 | global.context = { 26 | functions: { 27 | execute: jest.fn((functionName, csvTweets) => { return csvTweets; }) 28 | }, 29 | services: { 30 | get 31 | } 32 | } 33 | }); 34 | 35 | test('Single tweet', async () => { 36 | 37 | const csvTweets = header + "\n" + validTweetCsv; 38 | 39 | expect(await storeCsvInDb(csvTweets)).toStrictEqual({ 40 | newTweets: [validTweetId], 41 | tweetsNotInsertedOrUpdated: [], 42 | updatedTweets: [] 43 | }); 44 | 45 | expect(context.functions.execute).toHaveBeenCalledWith("removeBreakingCharacters", csvTweets); 46 | expect(context.services.get.db.collection.updateOne).toHaveBeenCalledWith( 47 | { _id: validTweetId }, 48 | { 49 | $set: validTweetJson 50 | }, 51 | { upsert: true }); 52 | }) 53 | 54 | test('Multiple tweets', async () => { 55 | 56 | updateOne.mockReturnValueOnce(result = { 57 | modifiedCount: 1 58 | }) 59 | 60 | updateOne.mockReturnValueOnce(result = { 61 | upsertedId: validTweet2Id, 62 | modifiedCount: 0 63 | }) 64 | 65 | updateOne.mockReturnValueOnce(result = { 66 | upsertedCount: 0, 67 | modifiedCount: 0 68 | }) 69 | 70 | const csvTweets = header + "\n" + validTweetCsv + "\n" + validTweet2Csv + "\n" + validTweetKenCsv; 71 | 72 | expect(await storeCsvInDb(csvTweets)).toStrictEqual({ 73 | newTweets: [validTweet2Id], 74 | tweetsNotInsertedOrUpdated: [validTweetKenId], 75 | updatedTweets: [validTweetId] 76 | }); 77 | expect(context.functions.execute).toHaveBeenCalledWith("removeBreakingCharacters", csvTweets); 78 | expect(context.services.get.db.collection.updateOne).toHaveBeenNthCalledWith(1, 79 | { _id: validTweetId }, 80 | { 81 | $set: validTweetJson 82 | }, 83 | { upsert: true }); 84 | 85 | expect(context.services.get.db.collection.updateOne).toHaveBeenNthCalledWith(2, 86 | { _id: validTweet2Id }, 87 | { 88 | $set: validTweet2Json 89 | }, 90 | { upsert: true }); 91 | 92 | expect(context.services.get.db.collection.updateOne).toHaveBeenNthCalledWith(3, 93 | { _id: validTweetKenId }, 94 | { 95 | $set: validTweetKenJson 96 | }, 97 | { upsert: true }); 98 | }) 99 | 100 | test('Single tweet with exception', async () => { 101 | 102 | updateOne.mockImplementation(() => { 103 | throw new Error(); 104 | }); 105 | 106 | const csvTweets = header + "\n" + validTweetCsv; 107 | 108 | expect(await storeCsvInDb(csvTweets)).toStrictEqual({ 109 | newTweets: [], 110 | tweetsNotInsertedOrUpdated: [validTweetId], 111 | updatedTweets: [] 112 | }); 113 | 114 | expect(context.functions.execute).toHaveBeenCalledWith("removeBreakingCharacters", csvTweets); 115 | expect(context.services.get.db.collection.updateOne).toHaveBeenCalledWith( 116 | { _id: validTweetId }, 117 | { 118 | $set: validTweetJson 119 | }, 120 | { upsert: true }); 121 | }) 122 | 123 | -------------------------------------------------------------------------------- /tests/unit/incoming_webhooks/uploadTweets.test.js: -------------------------------------------------------------------------------- 1 | const uploadTweets = require('../../../services/uploadTweets/incoming_webhooks/webhook0/source.js'); 2 | 3 | const mockTweetResults = { 4 | newTweets: ["1226928883355791361"], 5 | tweetsNotInsertedOrUpdated: ["1226928883355791362"], 6 | updatedTweets: ["1226928883355791360"] 7 | } 8 | 9 | beforeEach(() => { 10 | 11 | // Mock the storeCsvInDb function 12 | global.context = { 13 | functions: { 14 | execute: jest.fn((functionName, data) => { 15 | return mockTweetResults; 16 | }) 17 | } 18 | } 19 | }); 20 | 21 | test('Single csv file with lots of tweets', () => { 22 | // Note that the results we're expecting don't actually match the tweets that are in 23 | // the body below, but it's fine as we're not testing that part of the functionality here 24 | 25 | expect(uploadTweets({ 26 | body: { 27 | text: () => { 28 | return `-----------------------------8165087126414182542096170685\r\nContent-Disposition: form-data; name=\"Tweetcsv\"; filename=\"tweet_activity_metrics_Lauren_Schaefer_20200114_20200211_en (1).csv\"\r\nContent-Type: text/csv\r\n\r\n\"Tweet id\",\"Tweet permalink\",\"Tweet text\",\"time\",\"impressions\",\"engagements\",\"engagement rate\",\"retweets\",\"replies\",\"likes\",\"user profile clicks\",\"url clicks\",\"hashtag clicks\",\"detail expands\",\"permalink clicks\",\"app opens\",\"app installs\",\"follows\",\"email tweet\",\"dial phone\",\"media views\",\"media engagements\",\"promoted impressions\",\"promoted engagements\",\"promoted engagement rate\",\"promoted retweets\",\"promoted replies\",\"promoted likes\",\"promoted user profile clicks\",\"promoted url clicks\",\"promoted hashtag clicks\",\"promoted detail expands\",\"promoted permalink clicks\",\"promoted app opens\",\"promoted app installs\",\"promoted follows\",\"promoted email tweet\",\"promoted dial phone\",\"promoted media views\",\"promoted media engagements\"\n\"1226928883355791360\",\"https://twitter.com/Lauren_Schaefer/status/1226928883355791360\",\"“Whether your company is based in a lower-paying area, or your employee is based in a lower-paying area does not matter...if you want to attract and retain above-average developers... #RemoteWork is a big bonus attraction to half of the tech workforce.”\n\nhttps://t.co/wso3RTFd1u\",\"2020-02-10 18:00 +0000\",\"1203.0\",\"39.0\",\"ssss\",\"4.0\",\"0.0\",\"7.0\",\"2.0\",\"22.0\",\"0.0\",\"4.0\",\"0.0\",\"0\",\"0\",\"0\",\"0\",\"0\",\"0\",\"0\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\"\n\"1226846945181917184\",\"https://twitter.com/Lauren_Schaefer/status/1226846945181917184\",\"@h_ingo Yes! It's so helpful. I'm working on using it more and more.\",\"2020-02-10 12:34 +0000\",\"32.0\",\"1.0\",\"0.03125\",\"0.0\",\"0.0\",\"1.0\",\"0.0\",\"0.0\",\"0.0\",\"0.0\",\"0.0\",\"0\",\"0\",\"0\",\"0\",\"0\",\"0\",\"0\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\"\n\"1226846634702757889\",\"https://twitter.com/Lauren_Schaefer/status/1226846634702757889\",\"@h_ingo https://t.co/n2tX8TNJFL\",\"2020-02-10 12:33 +0000\",\"35.0\",\"0.0\",\"0.0\",\"0.0\",\"0.0\",\"0.0\",\"0.0\",\"0.0\",\"0.0\",\"0.0\",\"0.0\",\"0\",\"0\",\"0\",\"0\",\"0\",\"0\",\"0\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\"\n\"1226846610593845250\",\"https://twitter.com/Lauren_Schaefer/status/1226846610593845250\",\"@h_ingo Definitely. On a related node - I read an article that suggested remote teams focus more on results than other factors like politics or likability, which can reduce unconscious bias. This is a win for everyone--especially those from traditionally underrepresented groups.\",\"2020-02-10 12:33 +0000\",\"134.0\",\"2.0\",\"0.014925373134328358\",\"0.0\",\"2.0\",\"0.0\",\"0.0\",\"0.0\",\"0.0\",\"0.0\",\"0.0\",\"0\",\"0\",\"0\",\"0\",\"0\",\"0\",\"0\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\"\n\"1225790691214811136\",\"https://twitter.com/Lauren_Schaefer/status/1225790691214811136\",\"@itsaydrian Perhaps. Although, I find it's true in larger groups as well.\",\"2020-02-07 14:37 +0000\",\"88.0\",\"6.0\",\"0.06818181818181818\",\"0.0\",\"1.0\",\"0.0\",\"1.0\",\"0.0\",\"0.0\",\"4.0\",\"0.0\",\"0\",\"0\",\"0\",\"0\",\"0\",\"0\",\"0\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\"\n\"1225785799146430465\",\"https://twitter.com/Lauren_Schaefer/status/1225785799146430465\",\"I've found that I notice less that I'm the only woman in the \"\"room\"\" when I work remotely vs working in the office. My minority status feels less isolating when I'm remote. \n\nI can't find any stats or articles to back this up. Anyone else feel the same?\n\n#RemoteWork #WorkFromHome\",\"2020-02-07 14:18 +0000\",\"836.0\",\"18.0\",\"0.0215311004784689\",\"1.0\",\"2.0\",\"6.0\",\"3.0\",\"0.0\",\"1.0\",\"5.0\",\"0.0\",\"0\",\"0\",\"0\",\"0\",\"0\",\"0\",\"0\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\"\n\"1225479302868873216\",\"https://twitter.com/Lauren_Schaefer/status/1225479302868873216\",\"For the fellow #parents out there...\n\nTech conferences with childcare\n\nhttps://t.co/91SuX4nHIy\n\n#WomenInTech #WomenInSTEM #PeopleInTech\",\"2020-02-06 18:00 +0000\",\"308.0\",\"4.0\",\"0.012987012987012988\",\"1.0\",\"0.0\",\"3.0\",\"0.0\",\"0.0\",\"0.0\",\"0.0\",\"0.0\",\"0\",\"0\",\"0\",\"0\",\"0\",\"0\",\"0\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\"\n\"1225103341283348481\",\"https://twitter.com/Lauren_Schaefer/status/1225103341283348481\",\"@itsaydrian @mlynn @MongoDB https://t.co/MjghgZfXaP\",\"2020-02-05 17:06 +0000\",\"75.0\",\"2.0\",\"0.02666666666666667\",\"0.0\",\"0.0\",\"2.0\",\"0.0\",\"0.0\",\"0.0\",\"0.0\",\"0.0\",\"0\",\"0\",\"0\",\"0\",\"0\",\"12\",\"0\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\"\n\"1225099723335503875\",\"https://twitter.com/Lauren_Schaefer/status/1225099723335503875\",\"@mlynn @MongoDB https://t.co/cB2i0Ho4B5\",\"2020-02-05 16:51 +0000\",\"131.0\",\"4.0\",\"0.030534351145038167\",\"0.0\",\"1.0\",\"1.0\",\"1.0\",\"0.0\",\"0.0\",\"0.0\",\"0.0\",\"0\",\"0\",\"0\",\"0\",\"0\",\"24\",\"1\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\"\n\"1224720773413249025\",\"https://twitter.com/Lauren_Schaefer/status/1224720773413249025\",\"@TheLeadDev Thank you!\",\"2020-02-04 15:46 +0000\",\"39.0\",\"0.0\",\"0.0\",\"0.0\",\"0.0\",\"0.0\",\"0.0\",\"0.0\",\"0.0\",\"0.0\",\"0.0\",\"0\",\"0\",\"0\",\"0\",\"0\",\"0\",\"0\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\"\n\"1224713763821162497\",\"https://twitter.com/Lauren_Schaefer/status/1224713763821162497\",\"@TheLeadDev How many attendees are you anticipating?\",\"2020-02-04 15:18 +0000\",\"145.0\",\"3.0\",\"0.020689655172413793\",\"0.0\",\"1.0\",\"0.0\",\"0.0\",\"0.0\",\"0.0\",\"2.0\",\"0.0\",\"0\",\"0\",\"0\",\"0\",\"0\",\"0\",\"0\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\"\n\"1224671846056058884\",\"https://twitter.com/Lauren_Schaefer/status/1224671846056058884\",\"@jessicaewest @LaunchDarkly So happy for you Jess! https://t.co/gh7dsvdmNu\",\"2020-02-04 12:31 +0000\",\"78.0\",\"2.0\",\"0.02564102564102564\",\"0.0\",\"1.0\",\"1.0\",\"0.0\",\"0.0\",\"0.0\",\"0.0\",\"0.0\",\"0\",\"0\",\"0\",\"0\",\"0\",\"23\",\"0\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\"\n\"1224392302615048192\",\"https://twitter.com/Lauren_Schaefer/status/1224392302615048192\",\"What is #JavaScript made of by @dan_abramov? https://t.co/A69ugbmEdt\n\nA great reminder of the JavaScript basics\",\"2020-02-03 18:00 +0000\",\"216.0\",\"2.0\",\"0.009259259259259259\",\"1.0\",\"0.0\",\"0.0\",\"0.0\",\"1.0\",\"0.0\",\"0.0\",\"0.0\",\"0\",\"0\",\"0\",\"0\",\"0\",\"0\",\"0\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\"\n\"1224126591917264899\",\"https://twitter.com/Lauren_Schaefer/status/1224126591917264899\",\"Is there enough space in space for women?\n\n#MakeSpaceForWomen https://t.co/uPGApiLveh\",\"2020-02-03 00:24 +0000\",\"339.0\",\"3.0\",\"0.008849557522123894\",\"0.0\",\"0.0\",\"1.0\",\"2.0\",\"0.0\",\"0.0\",\"0.0\",\"0.0\",\"0\",\"0\",\"0\",\"0\",\"0\",\"0\",\"0\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\"\n\"1222942690037379072\",\"https://twitter.com/Lauren_Schaefer/status/1222942690037379072\",\"How to evaluate a remote job https://t.co/FngPIiHTx0\n\n#remotework #WorkFromHome\",\"2020-01-30 18:00 +0000\",\"289.0\",\"2.0\",\"0.006920415224913495\",\"0.0\",\"0.0\",\"0.0\",\"0.0\",\"2.0\",\"0.0\",\"0.0\",\"0.0\",\"0\",\"0\",\"0\",\"0\",\"0\",\"0\",\"0\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\"\n\"1222583861579780096\",\"https://twitter.com/Lauren_Schaefer/status/1222583861579780096\",\"@nraboy @kenwalger Good call. https://t.co/SLbbsthzmF\",\"2020-01-29 18:14 +0000\",\"75.0\",\"1.0\",\"0.013333333333333334\",\"0.0\",\"0.0\",\"1.0\",\"0.0\",\"0.0\",\"0.0\",\"0.0\",\"0.0\",\"0\",\"0\",\"0\",\"0\",\"0\",\"10\",\"0\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\"\n\"1222583364055642114\",\"https://twitter.com/Lauren_Schaefer/status/1222583364055642114\",\"@nraboy @kenwalger Is that expensable?\",\"2020-01-29 18:12 +0000\",\"76.0\",\"3.0\",\"0.039473684210526314\",\"0.0\",\"1.0\",\"0.0\",\"2.0\",\"0.0\",\"0.0\",\"0.0\",\"0.0\",\"0\",\"0\",\"0\",\"0\",\"0\",\"0\",\"0\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\"\n\"1222581543702597634\",\"https://twitter.com/Lauren_Schaefer/status/1222581543702597634\",\"What does #RonSwanson have to do with #MongoDB?\n\nWatch the recording of the Back to Basics webinar that @kenwalger and I hosted to find out: https://t.co/WOgtUAcKrW https://t.co/1oCJ4Pe9dd\",\"2020-01-29 18:05 +0000\",\"639.0\",\"18.0\",\"0.028169014084507043\",\"0.0\",\"1.0\",\"4.0\",\"3.0\",\"5.0\",\"0.0\",\"2.0\",\"0.0\",\"0\",\"0\",\"0\",\"0\",\"0\",\"88\",\"3\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\"\n\"1222222732953341954\",\"https://twitter.com/Lauren_Schaefer/status/1222222732953341954\",\"@ChegeHarrison @kenwalger https://t.co/TyaripZjmV\",\"2020-01-28 18:19 +0000\",\"77.0\",\"2.0\",\"0.025974025974025976\",\"0.0\",\"0.0\",\"1.0\",\"1.0\",\"0.0\",\"0.0\",\"0.0\",\"0.0\",\"0\",\"0\",\"0\",\"0\",\"0\",\"15\",\"0\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\"\n\"1221855530232225794\",\"https://twitter.com/Lauren_Schaefer/status/1221855530232225794\",\"Fantastic 13 minute keynote from @kelseyhightower where he touches on #diversity, #inclusion, & running the race together. https://t.co/03X29YQr4j\",\"2020-01-27 18:00 +0000\",\"26818.0\",\"230.0\",\"0.008576329331046312\",\"4.0\",\"1.0\",\"12.0\",\"91.0\",\"58.0\",\"4.0\",\"16.0\",\"0.0\",\"1\",\"0\",\"0\",\"0\",\"0\",\"43\",\"43\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\"\n\"1220747798100021248\",\"https://twitter.com/Lauren_Schaefer/status/1220747798100021248\",\".@kenwalger and I are hosting a #MongoDB Back to Basics webinar Tuesday, January 28 at noon eastern. Hope to see you there!\n\nSlides: https://t.co/4t7jYcjKk6\nRegister: https://t.co/D8zKU3BKDc\",\"2020-01-24 16:38 +0000\",\"805.0\",\"126.0\",\"0.1565217391304348\",\"1.0\",\"0.0\",\"6.0\",\"10.0\",\"107.0\",\"0.0\",\"2.0\",\"0.0\",\"0\",\"0\",\"0\",\"0\",\"0\",\"0\",\"0\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\"\n\"1220405881155866626\",\"https://twitter.com/Lauren_Schaefer/status/1220405881155866626\",\"I was asked to sum up my remote work experience in one word. I chose \"\"quiet.\"\" This could explain why. When I'm working, I have guilt-free time to sit and think uninterrupted. And I really enjoy that.\n\nhttps://t.co/eHcgZPmQcz\",\"2020-01-23 18:00 +0000\",\"517.0\",\"5.0\",\"0.009671179883945842\",\"0.0\",\"0.0\",\"5.0\",\"0.0\",\"0.0\",\"0.0\",\"0.0\",\"0.0\",\"0\",\"0\",\"0\",\"0\",\"0\",\"0\",\"0\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\"\n\"1220326861772935170\",\"https://twitter.com/Lauren_Schaefer/status/1220326861772935170\",\"Craving some delicious #GirlScout cookies??? 🍪🍪🍪\n\nOrder from my friend who will use the proceeds so she can attend camp this summer. Help her #LeadLikeAGirl\n\nAble to resist those super yummy cookies yourself? Order a box for the troops. :-)\n\nhttps://t.co/UXZ340uOcQ\",\"2020-01-23 12:46 +0000\",\"502.0\",\"6.0\",\"0.01195219123505976\",\"0.0\",\"0.0\",\"2.0\",\"0.0\",\"2.0\",\"0.0\",\"2.0\",\"0.0\",\"0\",\"0\",\"0\",\"0\",\"0\",\"0\",\"0\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\"\n\"1219318848572350464\",\"https://twitter.com/Lauren_Schaefer/status/1219318848572350464\",\"The epistemology of software quality https://t.co/k8xTO2QK9P\n\nSleep matters. 😴 Stress matters. 😫 Perhaps your programming language doesn't matter...\",\"2020-01-20 18:00 +0000\",\"491.0\",\"4.0\",\"0.008146639511201629\",\"0.0\",\"0.0\",\"3.0\",\"0.0\",\"1.0\",\"0.0\",\"0.0\",\"0.0\",\"0\",\"0\",\"0\",\"0\",\"0\",\"0\",\"0\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\"\n\"1218147364361441280\",\"https://twitter.com/Lauren_Schaefer/status/1218147364361441280\",\"Hey...I know that guy! Congrats, @kenwalger! 🥳\n\nhttps://t.co/o17zCbwLKb\",\"2020-01-17 12:25 +0000\",\"655.0\",\"52.0\",\"0.07938931297709924\",\"0.0\",\"0.0\",\"8.0\",\"2.0\",\"31.0\",\"0.0\",\"11.0\",\"0.0\",\"0\",\"0\",\"0\",\"0\",\"0\",\"0\",\"0\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\"\n\"1217869301488803840\",\"https://twitter.com/Lauren_Schaefer/status/1217869301488803840\",\"23 lesser known #VSCode Shortcuts as GIF - DEV Community 👩‍💻👨‍💻 \n\nAlt + Z = Toggle World Wrap 😳\n\nhttps://t.co/RqglHtinbv https://t.co/eXGaWgW75k\",\"2020-01-16 18:00 +0000\",\"607.0\",\"12.0\",\"0.019769357495881382\",\"0.0\",\"0.0\",\"3.0\",\"2.0\",\"2.0\",\"0.0\",\"3.0\",\"0.0\",\"0\",\"0\",\"0\",\"0\",\"0\",\"58\",\"2\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\"\n\"1217787856976056321\",\"https://twitter.com/Lauren_Schaefer/status/1217787856976056321\",\"@enterjsconf Thank you for the info!\",\"2020-01-16 12:37 +0000\",\"48.0\",\"0.0\",\"0.0\",\"0.0\",\"0.0\",\"0.0\",\"0.0\",\"0.0\",\"0.0\",\"0.0\",\"0.0\",\"0\",\"0\",\"0\",\"0\",\"0\",\"0\",\"0\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\"\n\"1217457679205126146\",\"https://twitter.com/Lauren_Schaefer/status/1217457679205126146\",\"@enterjsconf How many attendees are you expecting this year?\",\"2020-01-15 14:45 +0000\",\"58.0\",\"4.0\",\"0.06896551724137931\",\"0.0\",\"1.0\",\"0.0\",\"0.0\",\"0.0\",\"0.0\",\"3.0\",\"0.0\",\"0\",\"0\",\"0\",\"0\",\"0\",\"0\",\"0\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\"\n\r\n-----------------------------8165087126414182542096170685--\r\n` 29 | } 30 | }, 31 | headers: { 32 | Host: "webhooks.mongodb-stitch.com", 33 | "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.13; rv:73.0) Gecko/20100101 Firefox/73.0", 34 | Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8", 35 | "Accept-Language": "en-US,en;q=0.5", 36 | "Accept-Encoding": "gzip, deflate, br", 37 | "Content-Type": "multipart/form-data; boundary=---------------------------8165087126414182542096170685\]", 38 | "Content-Length": "12661", 39 | Connection: "keep-alive", 40 | "Upgrade-Insecure-Requests": 1, 41 | Pragma: "no-cache", 42 | "Cache-Control": "no-cache", 43 | TE: "Trailers", 44 | } 45 | })).toStrictEqual(mockTweetResults); 46 | 47 | expect(context.functions.execute).toHaveBeenCalledWith("storeCsvInDb", `\"Tweet id\",\"Tweet permalink\",\"Tweet text\",\"time\",\"impressions\",\"engagements\",\"engagement rate\",\"retweets\",\"replies\",\"likes\",\"user profile clicks\",\"url clicks\",\"hashtag clicks\",\"detail expands\",\"permalink clicks\",\"app opens\",\"app installs\",\"follows\",\"email tweet\",\"dial phone\",\"media views\",\"media engagements\",\"promoted impressions\",\"promoted engagements\",\"promoted engagement rate\",\"promoted retweets\",\"promoted replies\",\"promoted likes\",\"promoted user profile clicks\",\"promoted url clicks\",\"promoted hashtag clicks\",\"promoted detail expands\",\"promoted permalink clicks\",\"promoted app opens\",\"promoted app installs\",\"promoted follows\",\"promoted email tweet\",\"promoted dial phone\",\"promoted media views\",\"promoted media engagements\"\n\"1226928883355791360\",\"https://twitter.com/Lauren_Schaefer/status/1226928883355791360\",\"“Whether your company is based in a lower-paying area, or your employee is based in a lower-paying area does not matter...if you want to attract and retain above-average developers... #RemoteWork is a big bonus attraction to half of the tech workforce.”\n\nhttps://t.co/wso3RTFd1u\",\"2020-02-10 18:00 +0000\",\"1203.0\",\"39.0\",\"ssss\",\"4.0\",\"0.0\",\"7.0\",\"2.0\",\"22.0\",\"0.0\",\"4.0\",\"0.0\",\"0\",\"0\",\"0\",\"0\",\"0\",\"0\",\"0\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\"\n\"1226846945181917184\",\"https://twitter.com/Lauren_Schaefer/status/1226846945181917184\",\"@h_ingo Yes! It's so helpful. I'm working on using it more and more.\",\"2020-02-10 12:34 +0000\",\"32.0\",\"1.0\",\"0.03125\",\"0.0\",\"0.0\",\"1.0\",\"0.0\",\"0.0\",\"0.0\",\"0.0\",\"0.0\",\"0\",\"0\",\"0\",\"0\",\"0\",\"0\",\"0\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\"\n\"1226846634702757889\",\"https://twitter.com/Lauren_Schaefer/status/1226846634702757889\",\"@h_ingo https://t.co/n2tX8TNJFL\",\"2020-02-10 12:33 +0000\",\"35.0\",\"0.0\",\"0.0\",\"0.0\",\"0.0\",\"0.0\",\"0.0\",\"0.0\",\"0.0\",\"0.0\",\"0.0\",\"0\",\"0\",\"0\",\"0\",\"0\",\"0\",\"0\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\"\n\"1226846610593845250\",\"https://twitter.com/Lauren_Schaefer/status/1226846610593845250\",\"@h_ingo Definitely. On a related node - I read an article that suggested remote teams focus more on results than other factors like politics or likability, which can reduce unconscious bias. This is a win for everyone--especially those from traditionally underrepresented groups.\",\"2020-02-10 12:33 +0000\",\"134.0\",\"2.0\",\"0.014925373134328358\",\"0.0\",\"2.0\",\"0.0\",\"0.0\",\"0.0\",\"0.0\",\"0.0\",\"0.0\",\"0\",\"0\",\"0\",\"0\",\"0\",\"0\",\"0\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\"\n\"1225790691214811136\",\"https://twitter.com/Lauren_Schaefer/status/1225790691214811136\",\"@itsaydrian Perhaps. Although, I find it's true in larger groups as well.\",\"2020-02-07 14:37 +0000\",\"88.0\",\"6.0\",\"0.06818181818181818\",\"0.0\",\"1.0\",\"0.0\",\"1.0\",\"0.0\",\"0.0\",\"4.0\",\"0.0\",\"0\",\"0\",\"0\",\"0\",\"0\",\"0\",\"0\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\"\n\"1225785799146430465\",\"https://twitter.com/Lauren_Schaefer/status/1225785799146430465\",\"I've found that I notice less that I'm the only woman in the \"\"room\"\" when I work remotely vs working in the office. My minority status feels less isolating when I'm remote. \n\nI can't find any stats or articles to back this up. Anyone else feel the same?\n\n#RemoteWork #WorkFromHome\",\"2020-02-07 14:18 +0000\",\"836.0\",\"18.0\",\"0.0215311004784689\",\"1.0\",\"2.0\",\"6.0\",\"3.0\",\"0.0\",\"1.0\",\"5.0\",\"0.0\",\"0\",\"0\",\"0\",\"0\",\"0\",\"0\",\"0\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\"\n\"1225479302868873216\",\"https://twitter.com/Lauren_Schaefer/status/1225479302868873216\",\"For the fellow #parents out there...\n\nTech conferences with childcare\n\nhttps://t.co/91SuX4nHIy\n\n#WomenInTech #WomenInSTEM #PeopleInTech\",\"2020-02-06 18:00 +0000\",\"308.0\",\"4.0\",\"0.012987012987012988\",\"1.0\",\"0.0\",\"3.0\",\"0.0\",\"0.0\",\"0.0\",\"0.0\",\"0.0\",\"0\",\"0\",\"0\",\"0\",\"0\",\"0\",\"0\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\"\n\"1225103341283348481\",\"https://twitter.com/Lauren_Schaefer/status/1225103341283348481\",\"@itsaydrian @mlynn @MongoDB https://t.co/MjghgZfXaP\",\"2020-02-05 17:06 +0000\",\"75.0\",\"2.0\",\"0.02666666666666667\",\"0.0\",\"0.0\",\"2.0\",\"0.0\",\"0.0\",\"0.0\",\"0.0\",\"0.0\",\"0\",\"0\",\"0\",\"0\",\"0\",\"12\",\"0\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\"\n\"1225099723335503875\",\"https://twitter.com/Lauren_Schaefer/status/1225099723335503875\",\"@mlynn @MongoDB https://t.co/cB2i0Ho4B5\",\"2020-02-05 16:51 +0000\",\"131.0\",\"4.0\",\"0.030534351145038167\",\"0.0\",\"1.0\",\"1.0\",\"1.0\",\"0.0\",\"0.0\",\"0.0\",\"0.0\",\"0\",\"0\",\"0\",\"0\",\"0\",\"24\",\"1\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\"\n\"1224720773413249025\",\"https://twitter.com/Lauren_Schaefer/status/1224720773413249025\",\"@TheLeadDev Thank you!\",\"2020-02-04 15:46 +0000\",\"39.0\",\"0.0\",\"0.0\",\"0.0\",\"0.0\",\"0.0\",\"0.0\",\"0.0\",\"0.0\",\"0.0\",\"0.0\",\"0\",\"0\",\"0\",\"0\",\"0\",\"0\",\"0\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\"\n\"1224713763821162497\",\"https://twitter.com/Lauren_Schaefer/status/1224713763821162497\",\"@TheLeadDev How many attendees are you anticipating?\",\"2020-02-04 15:18 +0000\",\"145.0\",\"3.0\",\"0.020689655172413793\",\"0.0\",\"1.0\",\"0.0\",\"0.0\",\"0.0\",\"0.0\",\"2.0\",\"0.0\",\"0\",\"0\",\"0\",\"0\",\"0\",\"0\",\"0\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\"\n\"1224671846056058884\",\"https://twitter.com/Lauren_Schaefer/status/1224671846056058884\",\"@jessicaewest @LaunchDarkly So happy for you Jess! https://t.co/gh7dsvdmNu\",\"2020-02-04 12:31 +0000\",\"78.0\",\"2.0\",\"0.02564102564102564\",\"0.0\",\"1.0\",\"1.0\",\"0.0\",\"0.0\",\"0.0\",\"0.0\",\"0.0\",\"0\",\"0\",\"0\",\"0\",\"0\",\"23\",\"0\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\"\n\"1224392302615048192\",\"https://twitter.com/Lauren_Schaefer/status/1224392302615048192\",\"What is #JavaScript made of by @dan_abramov? https://t.co/A69ugbmEdt\n\nA great reminder of the JavaScript basics\",\"2020-02-03 18:00 +0000\",\"216.0\",\"2.0\",\"0.009259259259259259\",\"1.0\",\"0.0\",\"0.0\",\"0.0\",\"1.0\",\"0.0\",\"0.0\",\"0.0\",\"0\",\"0\",\"0\",\"0\",\"0\",\"0\",\"0\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\"\n\"1224126591917264899\",\"https://twitter.com/Lauren_Schaefer/status/1224126591917264899\",\"Is there enough space in space for women?\n\n#MakeSpaceForWomen https://t.co/uPGApiLveh\",\"2020-02-03 00:24 +0000\",\"339.0\",\"3.0\",\"0.008849557522123894\",\"0.0\",\"0.0\",\"1.0\",\"2.0\",\"0.0\",\"0.0\",\"0.0\",\"0.0\",\"0\",\"0\",\"0\",\"0\",\"0\",\"0\",\"0\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\"\n\"1222942690037379072\",\"https://twitter.com/Lauren_Schaefer/status/1222942690037379072\",\"How to evaluate a remote job https://t.co/FngPIiHTx0\n\n#remotework #WorkFromHome\",\"2020-01-30 18:00 +0000\",\"289.0\",\"2.0\",\"0.006920415224913495\",\"0.0\",\"0.0\",\"0.0\",\"0.0\",\"2.0\",\"0.0\",\"0.0\",\"0.0\",\"0\",\"0\",\"0\",\"0\",\"0\",\"0\",\"0\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\"\n\"1222583861579780096\",\"https://twitter.com/Lauren_Schaefer/status/1222583861579780096\",\"@nraboy @kenwalger Good call. https://t.co/SLbbsthzmF\",\"2020-01-29 18:14 +0000\",\"75.0\",\"1.0\",\"0.013333333333333334\",\"0.0\",\"0.0\",\"1.0\",\"0.0\",\"0.0\",\"0.0\",\"0.0\",\"0.0\",\"0\",\"0\",\"0\",\"0\",\"0\",\"10\",\"0\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\"\n\"1222583364055642114\",\"https://twitter.com/Lauren_Schaefer/status/1222583364055642114\",\"@nraboy @kenwalger Is that expensable?\",\"2020-01-29 18:12 +0000\",\"76.0\",\"3.0\",\"0.039473684210526314\",\"0.0\",\"1.0\",\"0.0\",\"2.0\",\"0.0\",\"0.0\",\"0.0\",\"0.0\",\"0\",\"0\",\"0\",\"0\",\"0\",\"0\",\"0\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\"\n\"1222581543702597634\",\"https://twitter.com/Lauren_Schaefer/status/1222581543702597634\",\"What does #RonSwanson have to do with #MongoDB?\n\nWatch the recording of the Back to Basics webinar that @kenwalger and I hosted to find out: https://t.co/WOgtUAcKrW https://t.co/1oCJ4Pe9dd\",\"2020-01-29 18:05 +0000\",\"639.0\",\"18.0\",\"0.028169014084507043\",\"0.0\",\"1.0\",\"4.0\",\"3.0\",\"5.0\",\"0.0\",\"2.0\",\"0.0\",\"0\",\"0\",\"0\",\"0\",\"0\",\"88\",\"3\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\"\n\"1222222732953341954\",\"https://twitter.com/Lauren_Schaefer/status/1222222732953341954\",\"@ChegeHarrison @kenwalger https://t.co/TyaripZjmV\",\"2020-01-28 18:19 +0000\",\"77.0\",\"2.0\",\"0.025974025974025976\",\"0.0\",\"0.0\",\"1.0\",\"1.0\",\"0.0\",\"0.0\",\"0.0\",\"0.0\",\"0\",\"0\",\"0\",\"0\",\"0\",\"15\",\"0\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\"\n\"1221855530232225794\",\"https://twitter.com/Lauren_Schaefer/status/1221855530232225794\",\"Fantastic 13 minute keynote from @kelseyhightower where he touches on #diversity, #inclusion, & running the race together. https://t.co/03X29YQr4j\",\"2020-01-27 18:00 +0000\",\"26818.0\",\"230.0\",\"0.008576329331046312\",\"4.0\",\"1.0\",\"12.0\",\"91.0\",\"58.0\",\"4.0\",\"16.0\",\"0.0\",\"1\",\"0\",\"0\",\"0\",\"0\",\"43\",\"43\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\"\n\"1220747798100021248\",\"https://twitter.com/Lauren_Schaefer/status/1220747798100021248\",\".@kenwalger and I are hosting a #MongoDB Back to Basics webinar Tuesday, January 28 at noon eastern. Hope to see you there!\n\nSlides: https://t.co/4t7jYcjKk6\nRegister: https://t.co/D8zKU3BKDc\",\"2020-01-24 16:38 +0000\",\"805.0\",\"126.0\",\"0.1565217391304348\",\"1.0\",\"0.0\",\"6.0\",\"10.0\",\"107.0\",\"0.0\",\"2.0\",\"0.0\",\"0\",\"0\",\"0\",\"0\",\"0\",\"0\",\"0\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\"\n\"1220405881155866626\",\"https://twitter.com/Lauren_Schaefer/status/1220405881155866626\",\"I was asked to sum up my remote work experience in one word. I chose \"\"quiet.\"\" This could explain why. When I'm working, I have guilt-free time to sit and think uninterrupted. And I really enjoy that.\n\nhttps://t.co/eHcgZPmQcz\",\"2020-01-23 18:00 +0000\",\"517.0\",\"5.0\",\"0.009671179883945842\",\"0.0\",\"0.0\",\"5.0\",\"0.0\",\"0.0\",\"0.0\",\"0.0\",\"0.0\",\"0\",\"0\",\"0\",\"0\",\"0\",\"0\",\"0\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\"\n\"1220326861772935170\",\"https://twitter.com/Lauren_Schaefer/status/1220326861772935170\",\"Craving some delicious #GirlScout cookies??? 🍪🍪🍪\n\nOrder from my friend who will use the proceeds so she can attend camp this summer. Help her #LeadLikeAGirl\n\nAble to resist those super yummy cookies yourself? Order a box for the troops. :-)\n\nhttps://t.co/UXZ340uOcQ\",\"2020-01-23 12:46 +0000\",\"502.0\",\"6.0\",\"0.01195219123505976\",\"0.0\",\"0.0\",\"2.0\",\"0.0\",\"2.0\",\"0.0\",\"2.0\",\"0.0\",\"0\",\"0\",\"0\",\"0\",\"0\",\"0\",\"0\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\"\n\"1219318848572350464\",\"https://twitter.com/Lauren_Schaefer/status/1219318848572350464\",\"The epistemology of software quality https://t.co/k8xTO2QK9P\n\nSleep matters. 😴 Stress matters. 😫 Perhaps your programming language doesn't matter...\",\"2020-01-20 18:00 +0000\",\"491.0\",\"4.0\",\"0.008146639511201629\",\"0.0\",\"0.0\",\"3.0\",\"0.0\",\"1.0\",\"0.0\",\"0.0\",\"0.0\",\"0\",\"0\",\"0\",\"0\",\"0\",\"0\",\"0\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\"\n\"1218147364361441280\",\"https://twitter.com/Lauren_Schaefer/status/1218147364361441280\",\"Hey...I know that guy! Congrats, @kenwalger! 🥳\n\nhttps://t.co/o17zCbwLKb\",\"2020-01-17 12:25 +0000\",\"655.0\",\"52.0\",\"0.07938931297709924\",\"0.0\",\"0.0\",\"8.0\",\"2.0\",\"31.0\",\"0.0\",\"11.0\",\"0.0\",\"0\",\"0\",\"0\",\"0\",\"0\",\"0\",\"0\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\"\n\"1217869301488803840\",\"https://twitter.com/Lauren_Schaefer/status/1217869301488803840\",\"23 lesser known #VSCode Shortcuts as GIF - DEV Community 👩‍💻👨‍💻 \n\nAlt + Z = Toggle World Wrap 😳\n\nhttps://t.co/RqglHtinbv https://t.co/eXGaWgW75k\",\"2020-01-16 18:00 +0000\",\"607.0\",\"12.0\",\"0.019769357495881382\",\"0.0\",\"0.0\",\"3.0\",\"2.0\",\"2.0\",\"0.0\",\"3.0\",\"0.0\",\"0\",\"0\",\"0\",\"0\",\"0\",\"58\",\"2\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\"\n\"1217787856976056321\",\"https://twitter.com/Lauren_Schaefer/status/1217787856976056321\",\"@enterjsconf Thank you for the info!\",\"2020-01-16 12:37 +0000\",\"48.0\",\"0.0\",\"0.0\",\"0.0\",\"0.0\",\"0.0\",\"0.0\",\"0.0\",\"0.0\",\"0.0\",\"0.0\",\"0\",\"0\",\"0\",\"0\",\"0\",\"0\",\"0\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\"\n\"1217457679205126146\",\"https://twitter.com/Lauren_Schaefer/status/1217457679205126146\",\"@enterjsconf How many attendees are you expecting this year?\",\"2020-01-15 14:45 +0000\",\"58.0\",\"4.0\",\"0.06896551724137931\",\"0.0\",\"1.0\",\"0.0\",\"0.0\",\"0.0\",\"0.0\",\"3.0\",\"0.0\",\"0\",\"0\",\"0\",\"0\",\"0\",\"0\",\"0\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\",\"-\"\n` 48 | .toString('utf-8')); 49 | }) 50 | 51 | test('No file uploaded', () => { 52 | 53 | expect(uploadTweets({ 54 | body: { 55 | text: () => { 56 | return `-----------------------------8165087126414182542096170685\r\nContent-Disposition: form-data; name=\"Tweetcsv\"; filename=\"\"\r\nContent-Type: text/csv\r\n\r\n\r\n----------------------------8165087126414182542096170685--\r\n` 57 | } 58 | }, 59 | headers: { 60 | Host: "webhooks.mongodb-stitch.com", 61 | "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.13; rv:73.0) Gecko/20100101 Firefox/73.0", 62 | Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8", 63 | "Accept-Language": "en-US,en;q=0.5", 64 | "Accept-Encoding": "gzip, deflate, br", 65 | "Content-Type": "multipart/form-data; boundary=---------------------------8165087126414182542096170685\]", 66 | "Content-Length": "12661", 67 | Connection: "keep-alive", 68 | "Upgrade-Insecure-Requests": 1, 69 | Pragma: "no-cache", 70 | "Cache-Control": "no-cache", 71 | TE: "Trailers", 72 | } 73 | })).toBe("No files were uploaded"); 74 | 75 | expect(context.functions.execute).toHaveBeenCalledTimes(0); 76 | }) 77 | --------------------------------------------------------------------------------