├── .gitignore ├── .travis.yml ├── .eslintrc ├── .gitattributes ├── LICENSE ├── README.md ├── package.json ├── index.js ├── main.js └── main.test.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .idea 3 | .nyc_output 4 | data_for_testing 5 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "8" 4 | cache: 5 | directories: 6 | - "node_modules" -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "standard", 3 | "env": { 4 | "node": true, 5 | "mocha": true 6 | }, 7 | "rules": { 8 | "space-before-function-paren": 0, 9 | "no-trailing-spaces": "off", 10 | "semi": "off", 11 | "indent": "off" 12 | }, 13 | "parserOptions": { 14 | "ecmaVersion": 2017 15 | } 16 | } -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Set the default behavior, in case people don't have core.autocrlf set. 2 | * text=auto 3 | 4 | # Explicitly declare text files you want to always be normalized and converted 5 | # to native line endings on checkout. 6 | *.js text 7 | *.json text 8 | *.md 9 | .eslintrc 10 | .gitignore 11 | LICENSE 12 | 13 | # Declare files that will always have CRLF line endings on checkout. 14 | 15 | 16 | # Denote all files that are truly binary and should not be modified. 17 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2016 Lana Larsen 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # netflix-migrate [![Build Status](https://travis-ci.com/LBBO/netflix-migrate.svg?branch=master)](https://travis-ci.com/LBBO/netflix-migrate) 2 | 3 | A command line utility to export and import your ratings. 4 | 5 | ## ⚠️ This repository is no longer actively maintained due to a native implementation of its features 6 | Netflix has implemented a migration feature natively, rendering this project basically useless. While netflix-migrate is able to export slightly more detailed data than you can get from Netflix's native export, the maintanance effort is quite high as they regularly change their API, thereby just breaking the project over night. Hence I have decided to officially stop maintaining this project (at least until the need for it resurfaces). 7 | 8 | If you're looking to migrate your profile to another Netflix account, please refer to [the documentation of the official account transfer feature](https://help.netflix.com/en/node/124844). If you want to use this project despite the official feature, please refer to the [`cookie-login` branch](https://github.com/LBBO/netflix-migrate/tree/cookie-login) which contains the latest version which hadn't been merged yet. 9 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "netflix-migrate", 3 | "version": "1.0.0", 4 | "description": "A command-line tool to migrate data to and from Netflix profiles", 5 | "keywords": [ 6 | "netflix", 7 | "migrate" 8 | ], 9 | "license": "MIT", 10 | "author": "Michael Kuckuk ", 11 | "homepage": "https://github.com/LBBO/netflix-migrate", 12 | "repository": { 13 | "type": "git", 14 | "url": "https://github.com/LBBO/netflix-migrate.git" 15 | }, 16 | "main": "./index.js", 17 | "bin": { 18 | "netflix-migrate": "./index.js" 19 | }, 20 | "scripts": { 21 | "test": "nyc --reporter=text --check-coverage --lines 100 mocha main.test.js", 22 | "test-without-coverage": "mocha main.test.js" 23 | }, 24 | "dependencies": { 25 | "array.prototype.find": "^2.0.0", 26 | "async": "^2.0.0-rc.5", 27 | "commander": "^2.9.0", 28 | "netflix2": "^1.0.1", 29 | "prompt": "^1.0.0" 30 | }, 31 | "devDependencies": { 32 | "chai": "^4.2.0", 33 | "chai-as-promised": "^7.1.1", 34 | "eslint": "^2.10.2", 35 | "eslint-config-standard": "^5.3.1", 36 | "eslint-plugin-promise": "^1.1.0", 37 | "eslint-plugin-standard": "^1.3.2", 38 | "mocha": "^5.2.0", 39 | "nyc": "^13.0.1", 40 | "sinon": "^6.3.4", 41 | "sinon-chai": "^3.2.0" 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 'use strict'; 3 | 4 | const program = require('commander'); 5 | const prompt = require('prompt'); 6 | const main = require('./main'); 7 | 8 | // Specify supported arguments 9 | program 10 | .option('-e, --email ') 11 | .option('-p, --password ') 12 | .option('-r, --profile ') 13 | .option('-i, --import [file]') 14 | .option('-x, --export [file]') 15 | .option('-s, --spaces [spaces]') 16 | .parse(process.argv); 17 | 18 | if (program.import && program.export) { 19 | main.exitWithMessage('Options `import` and `export` cannot be used together.'); 20 | } 21 | 22 | const shouldExport = program.export !== undefined || program.import === undefined; 23 | 24 | // If arg "spaces" is set, use either it's value or a default value of 4 25 | if (program.spaces === true) { 26 | program.spaces = 4; 27 | } 28 | program.spaces = parseInt(program.spaces) || null; 29 | 30 | // Ensure the user is not prompted for values they already provided in the args 31 | prompt.override = program; 32 | prompt.message = ''; 33 | prompt.colors = false; 34 | 35 | // Specify values the user should be prompted for 36 | var prompts = [{ 37 | name: 'email', 38 | description: 'Email' 39 | }, { 40 | name: 'password', 41 | description: 'Password', 42 | hidden: true 43 | }, { 44 | name: 'profile', 45 | description: 'Profile' 46 | }]; 47 | 48 | // Prompt user for remaining values and pass them on to main 49 | prompt.get(prompts, function (error, args) { 50 | if (error) { 51 | if (error.message === 'canceled') { 52 | console.log(); // new line 53 | } else { 54 | process.statusCode = 1; 55 | console.error(error); 56 | } 57 | } else { 58 | main({ 59 | shouldExport, 60 | spaces: program.spaces, 61 | export: program.export, 62 | import: program.import, 63 | ...args 64 | }); 65 | } 66 | }); 67 | -------------------------------------------------------------------------------- /main.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 'use strict'; 3 | 4 | const fs = require('fs'); 5 | const Netflix = require('netflix2'); 6 | const util = require('util'); 7 | 8 | // Promisify the netflix2 API so that it doesn't follow the 9 | // (error, [...], callback) => void scheme but instead looks 10 | // like (...) => Promise 11 | const sleep = util.promisify(setTimeout); 12 | 13 | /** 14 | * Logs into specified Netflix account and profile and performs action 15 | * specified by program.export 16 | * @param {{email: String, password: String, profile: String, export: String | Boolean, import: String | Boolean, 17 | * shouldExport: Boolean, spaces: Number | Null}} args 18 | * @param netflix {Netflix} 19 | */ 20 | async function main(args, netflix = new Netflix()) { 21 | try { 22 | console.log('Logging into Netflix as ' + args.email); 23 | await netflix.login({ 24 | email: args.email, 25 | password: args.password 26 | }); 27 | 28 | console.log('Switching to profile ' + args.profile); 29 | const profileGuid = await main.getProfileGuid(netflix, args.profile); 30 | await main.switchProfile(netflix, profileGuid); 31 | 32 | if (args.shouldExport) { 33 | const filename = args.export === true ? undefined : args.export; 34 | const packageJSON = require('./package'); 35 | const version = packageJSON.version; 36 | 37 | const ratingHistory = await main.getRatingHistory(netflix); 38 | const viewingHistory = await main.getViewingHistory(netflix); 39 | 40 | const dataToBeSaved = { 41 | version: version, 42 | ratingHistory: ratingHistory, 43 | viewingHistory: viewingHistory 44 | }; 45 | 46 | main.writeToChosenOutput(dataToBeSaved, filename, args.spaces); 47 | } else { 48 | const filename = args.import === true ? undefined : args.import; 49 | const savedData = main.readDataFromChosenInput(filename); 50 | await main.setRatingHistory(netflix, savedData.ratingHistory); 51 | } 52 | 53 | console.log('Done'); 54 | } catch (e) { 55 | main.exitWithMessage(e); 56 | } 57 | } 58 | 59 | /** 60 | * Prints error message to console and exits the process 61 | * @param {String | Error} message 62 | */ 63 | main.exitWithMessage = function(message) { 64 | console.error(message); 65 | process.exit(1); 66 | }; 67 | 68 | /** 69 | * Executes an array of promises, one after another and returns a promise 70 | * that is resolved when the last promise resolves 71 | * @param {Promise[]} promises 72 | * @returns {Promise} 73 | */ 74 | main.waterfall = async function(promises) { 75 | return promises.reduce((promiseChain, currPromise) => promiseChain.then(currPromise), Promise.resolve()); 76 | }; 77 | 78 | /** 79 | * Gets profile guid from profile name 80 | * @param {Netflix} netflix 81 | * @param {String} profileName 82 | * @returns {Promise} Promise that is resolved with guid once fetched 83 | */ 84 | main.getProfileGuid = async function(netflix, profileName) { 85 | let profiles; 86 | 87 | try { 88 | profiles = await netflix.getProfiles(); 89 | } catch (e) { 90 | console.error(e); 91 | throw new Error('Profile GUID could not be determined. For more information, please see previous log' + 92 | 'statements.'); 93 | } 94 | 95 | const profileWithCorrectName = profiles.find(profile => profile.firstName === profileName); 96 | 97 | if (profileWithCorrectName === undefined) { 98 | throw new Error(`No profile with name "${profileName}"`); 99 | } else { 100 | return profileWithCorrectName.guid; 101 | } 102 | }; 103 | 104 | /** 105 | * Switches to profile specified by guid 106 | * @param {Netflix} netflix 107 | * @param {*} guid 108 | * @returns {Promise} Promise that is resolved once profile is switched 109 | */ 110 | main.switchProfile = async function(netflix, guid) { 111 | try { 112 | const result = await netflix.switchProfile(guid); 113 | console.log('Successfully switched profile!'); 114 | return result; 115 | } catch (e) { 116 | console.error(e); 117 | throw new Error('Could not switch profiles. For more information, please see previous log statements.'); 118 | } 119 | }; 120 | 121 | /** 122 | * Gets rating history from current profile 123 | * @param {Netflix} netflix 124 | * @returns {Promise} Promise that is resolved with rating history once it has been fetched 125 | */ 126 | main.getRatingHistory = async function(netflix) { 127 | let ratings; 128 | 129 | try { 130 | console.log('Getting rating history...'); 131 | ratings = await netflix.getRatingHistory(); 132 | console.log('Finished getting rating history'); 133 | return ratings; 134 | } catch (e) { 135 | console.error(e); 136 | throw new Error('Could not retrieve rating history. For more information, please see previous log statements.'); 137 | } 138 | }; 139 | 140 | /** 141 | * Gets viewing history from current profile 142 | * @param {Netflix} netflix 143 | * @returns {Promise} Promise that is resolved with viewing history once it has been fetched 144 | */ 145 | main.getViewingHistory = async function(netflix) { 146 | let viewingHistory; 147 | 148 | try { 149 | console.log('Getting viewing history...'); 150 | viewingHistory = await netflix.getViewingHistory(); 151 | console.log('Finished getting viewing history'); 152 | return viewingHistory; 153 | } catch (e) { 154 | console.error(e); 155 | throw new Error('Could not retrieve viewing history. For more information, please see previous log satements.') 156 | } 157 | }; 158 | 159 | /** 160 | * Writes a native Object's JSON representation either to a file, if the file name 161 | * is specified, or to process.stdout 162 | * @param {Object} data 163 | * @param {String} [fileName] 164 | * @param {Number | String} [numberOfSpaces] 165 | */ 166 | main.writeToChosenOutput = (data, fileName, numberOfSpaces) => { 167 | const dataJson = JSON.stringify(data, null, numberOfSpaces); 168 | 169 | if (fileName === undefined) { 170 | process.stdout.write(dataJson); 171 | } else { 172 | console.log('Writing results to ' + fileName); 173 | fs.writeFileSync(fileName, dataJson); 174 | } 175 | }; 176 | 177 | /** 178 | * Reads data from a file (if filename is specified) or from 179 | * stdout and parses rating history and viewing history 180 | * @param {String} [fileName] 181 | */ 182 | main.readDataFromChosenInput = (fileName) => { 183 | let dataJSON; 184 | 185 | if (fileName === undefined) { 186 | console.log('Please enter your data:'); 187 | dataJSON = process.stdin.read(); 188 | } else { 189 | console.log('Reading data from ' + fileName); 190 | dataJSON = fs.readFileSync(fileName); 191 | } 192 | 193 | let data = JSON.parse(dataJSON); 194 | let result = { 195 | version: null, 196 | ratingHistory: null, 197 | viewingHistory: null 198 | }; 199 | 200 | /* 201 | * Ensure downwards compatibility for versions < 0.3.0 202 | * In those versions, netflix-migrate used to only export 203 | * the rating history as an array. So, if data is an array, 204 | * we're only dealing with the ratingHistory 205 | */ 206 | if (Array.isArray(data)) { 207 | console.log('Found rating history'); 208 | result.ratingHistory = data; 209 | } else if (data && data instanceof Object) { 210 | /* 211 | * data is an object and should contain viewing history as well 212 | * as rating history. If either is not found, throw an error. 213 | */ 214 | 215 | if (Array.isArray(data.ratingHistory)) { 216 | result.ratingHistory = data.ratingHistory; 217 | } else { 218 | throw new Error('Expected data.ratingHistory to be an Array, instead found ' + JSON.stringify(data.ratingHistory)); 219 | } 220 | 221 | if (Array.isArray(data.viewingHistory)) { 222 | result.viewingHistory = data.viewingHistory; 223 | } else { 224 | throw new Error('Expected data.viewingHistory to be an Array, instead found ' + JSON.stringify(data.viewingHistory)); 225 | } 226 | 227 | if (typeof data.version === 'string' && data.version.match(/^\d+\.\d+\.\d+$/)) { 228 | result.version = data.version; 229 | } else { 230 | throw new Error('Expected data.version to be a string like 1.2.3, instead found ' + data.version); 231 | } 232 | 233 | console.log('Found rating history and viewing history'); 234 | } else { 235 | throw new Error('An unexpected Error occurred while reading the data to be imported.'); 236 | } 237 | 238 | return result; 239 | }; 240 | 241 | /** 242 | * Writes rating history into current netflix profile. A 100 millisecond 243 | * timeout is added after each written rating in order to not annoy Netflix, 244 | * so this may take a while. 245 | * @param {Netflix} netflix 246 | * @param {Array} [ratings] 247 | * @returns {Promise} Promise that is resolved after setting the last rating 248 | */ 249 | main.setRatingHistory = async function(netflix, ratings) { 250 | return main.waterfall(ratings.map(rating => async () => { 251 | try { 252 | if (rating.ratingType === 'thumb') { 253 | console.log('Setting rating for ' + rating.title + ': thumbs ' + (rating.yourRating === 2 ? 'up' : 'down')); 254 | await netflix.setThumbRating(rating.movieID, rating.yourRating); 255 | } else { 256 | console.log('Setting rating for ' + rating.title + ': ' + rating.yourRating + ' star' + 257 | (rating.yourRating === 1 ? '' : 's')); 258 | await netflix.setStarRating(rating.movieID, rating.yourRating); 259 | } 260 | } catch (e) { 261 | console.error(e); 262 | throw new Error('Could not set ' + rating.ratingType + ' rating for ' + rating.title + '. For more' + 263 | ' information, please see previous log statements.'); 264 | } 265 | await sleep(100); 266 | })); 267 | }; 268 | 269 | module.exports = main; 270 | -------------------------------------------------------------------------------- /main.test.js: -------------------------------------------------------------------------------- 1 | const chai = require('chai'); 2 | const {expect} = chai; 3 | const chaiAsPromised = require('chai-as-promised'); 4 | const sinon = require('sinon'); 5 | const sinonChai = require('sinon-chai'); 6 | chai.use(chaiAsPromised); 7 | chai.use(sinonChai); 8 | 9 | const main = require('./main'); 10 | const {waterfall, getProfileGuid, switchProfile, getRatingHistory, getViewingHistory, setRatingHistory, exitWithMessage, 11 | writeToChosenOutput, readDataFromChosenInput} = main; 12 | const Netflix = require('netflix2'); 13 | const fs = require('fs'); 14 | 15 | describe('exitWithMessage', () => { 16 | let processExit, consoleError; 17 | 18 | beforeEach(() => { 19 | processExit = sinon.stub(process, 'exit'); 20 | consoleError = sinon.stub(console, 'error'); 21 | }); 22 | 23 | afterEach(() => { 24 | processExit.restore(); 25 | consoleError.restore(); 26 | }); 27 | 28 | it('Should should print the provided error to the console via console.error', () => { 29 | const message = 'bla bla'; 30 | exitWithMessage(message); 31 | 32 | expect(consoleError).to.have.been.calledOnceWithExactly(message); 33 | }); 34 | 35 | it('Should should exit the process with exit code 1', () => { 36 | const message = 'bla bla'; 37 | exitWithMessage(message); 38 | 39 | expect(processExit).to.have.been.calledOnceWithExactly(1); 40 | }); 41 | 42 | it('Should should exit the process after printing the message to the console', () => { 43 | const message = 'bla bla'; 44 | exitWithMessage(message); 45 | 46 | expect(processExit).to.have.been.calledImmediatelyAfter(consoleError); 47 | }); 48 | }); 49 | 50 | describe('waterfall', () => { 51 | let promises = []; 52 | 53 | beforeEach(() => { 54 | promises = []; 55 | 56 | for (let i = 0; i < 10; i++) { 57 | promises.push( 58 | sinon 59 | .stub() 60 | .callsFake(() => new Promise((resolve) => setTimeout(() => { 61 | resolve(); 62 | }, 10))) 63 | ); 64 | } 65 | }); 66 | 67 | it('Should return a promise', () => { 68 | expect(waterfall([])).to.be.instanceOf(Promise); 69 | }); 70 | 71 | it('Should execute all promises', async () => { 72 | await waterfall(promises); 73 | for (const promise of promises) { 74 | expect(promise).to.have.been.calledOnce; 75 | } 76 | }); 77 | 78 | it('Should execute all promises in correct order', async () => { 79 | await waterfall(promises); 80 | for (let i = 1; i < promises.length; i++) { 81 | expect(promises[i - 1]).to.have.been.calledBefore(promises[i]); 82 | } 83 | }); 84 | }); 85 | 86 | describe('getProfileGuid', () => { 87 | let netflixGetProfiles, consoleError; 88 | let netflix; 89 | const profiles = [ 90 | {firstName: 'Michael', guid: '0'}, 91 | {firstName: 'Klaus', guid: '1'}, 92 | {firstName: 'Carsten', guid: '2'}, 93 | {firstName: 'Yannic', guid: '3'}, 94 | {firstName: 'Franziska', guid: '4'}, 95 | {firstName: 'Anna', guid: '5'}, 96 | {firstName: 'Hanna', guid: '6'}, 97 | {firstName: 'Marcel', guid: '7'}, 98 | {firstName: '1234567890', guid: '8'}, 99 | {firstName: 'What\'s wrong with you?', guid: '9'} 100 | ]; 101 | 102 | beforeEach(() => { 103 | netflix = new Netflix(); 104 | netflixGetProfiles = sinon.stub(netflix, 'getProfiles') 105 | .resolves([{firstName: ''}]); 106 | consoleError = sinon.stub(console, 'error'); 107 | }); 108 | 109 | afterEach(() => { 110 | netflixGetProfiles.restore(); 111 | consoleError.restore(); 112 | }); 113 | 114 | it('Should return a Promise', () => { 115 | expect(getProfileGuid(netflix, '')).to.be.instanceOf(Promise); 116 | }); 117 | 118 | it('Should resolve promise with correct profile guid', async () => { 119 | netflixGetProfiles.resolves(profiles); 120 | 121 | for (const profile of profiles) { 122 | const result = getProfileGuid(netflix, profile.firstName); 123 | 124 | await expect(result).to.eventually.be.fulfilled; 125 | await expect(result).to.eventually.become(profile.guid); 126 | } 127 | }); 128 | 129 | it('Should reject promise when no matching profile is found', async () => { 130 | netflixGetProfiles.resolves(profiles); 131 | 132 | const result = getProfileGuid(netflix, 'Non-existent name'); 133 | 134 | await expect(result).to.eventually.be.rejected; 135 | }); 136 | 137 | it('Should log any error thrown by netflix.getProfiles', async () => { 138 | const error = new Error('Error thrown by test'); 139 | netflixGetProfiles.rejects(error); 140 | 141 | try { 142 | await getProfileGuid(netflix, ''); 143 | } catch (e) { 144 | 145 | } finally { 146 | expect(consoleError).to.have.been.calledOnce; 147 | expect(consoleError).to.have.been.calledWithExactly(error); 148 | } 149 | }); 150 | 151 | it('Should throw an error when netflix.getProfiles throws an error', async () => { 152 | netflixGetProfiles.rejects(new Error()); 153 | 154 | await expect(getProfileGuid(netflix, '')).to.eventually.be.rejected; 155 | }); 156 | }); 157 | 158 | describe('switchProfile', () => { 159 | let netflix, netflixSwitchProfile, consoleError; 160 | const guid = {foo: 'bar'}; 161 | const result = {so: 'amazing'}; 162 | 163 | beforeEach(() => { 164 | netflix = new Netflix(); 165 | netflixSwitchProfile = sinon.stub(netflix, 'switchProfile') 166 | .resolves(result); 167 | consoleError = sinon.stub(console, 'error'); 168 | }); 169 | 170 | afterEach(() => { 171 | netflixSwitchProfile.restore(); 172 | consoleError.restore(); 173 | }); 174 | 175 | it('Should return a promise', () => { 176 | expect(switchProfile(netflix, guid)).to.be.instanceOf(Promise); 177 | }); 178 | 179 | it('Should call netflix.switchProfile and return it\'s value', async () => { 180 | const res = await switchProfile(netflix, guid); 181 | expect(netflixSwitchProfile).to.have.been.calledWithExactly(guid); 182 | expect(res).to.deep.equal(result); 183 | }); 184 | 185 | it('Should log any error thrown by netflix.switchProfile', async () => { 186 | const error = new Error('Error thrown by test'); 187 | netflixSwitchProfile.rejects(error); 188 | 189 | try { 190 | await switchProfile(netflix, guid); 191 | } catch (e) { 192 | 193 | } finally { 194 | expect(consoleError).to.have.been.calledOnce; 195 | expect(consoleError).to.have.been.calledWithExactly(error); 196 | } 197 | }); 198 | 199 | it('Should throw an error when netflix.switchProfile throws an error', async () => { 200 | netflixSwitchProfile.rejects(new Error()); 201 | 202 | await expect(switchProfile(netflix, guid)).to.eventually.be.rejected; 203 | }); 204 | }); 205 | 206 | describe('writeToChosenOutput', () => { 207 | let fsWriteFileSync, processStdoutWrite, jsonStringify; 208 | 209 | const data = [ 210 | { 211 | 'ratingType': 'star', 212 | 'title': 'Some movie', 213 | 'movieID': 12345678, 214 | 'yourRating': 5, 215 | 'intRating': 50, 216 | 'date': '01/02/2016', 217 | 'timestamp': 1234567890123, 218 | 'comparableDate': 1234567890 219 | }, 220 | { 221 | 'ratingType': 'thumb', 222 | 'title': 'Amazing Show', 223 | 'movieID': 87654321, 224 | 'yourRating': 2, 225 | 'date': '02/02/2018', 226 | 'timestamp': 2234567890123, 227 | 'comparableDate': 2234567890 228 | } 229 | ]; 230 | 231 | // JSON representations are hard coded into this test in order to notice when JSON.stringify changes 232 | const dataJSON = '[{"ratingType":"star","title":"Some movie","movieID":12345678,"yourRating":5,"intRating":50,"date":"01/02/2016","timestamp":1234567890123,"comparableDate":1234567890},{"ratingType":"thumb","title":"Amazing Show","movieID":87654321,"yourRating":2,"date":"02/02/2018","timestamp":2234567890123,"comparableDate":2234567890}]'; 233 | const dataJSONWith4Spaces = '[\n {\n "ratingType": "star",\n "title": "Some movie",\n "movieID": 12345678,\n "yourRating": 5,\n "intRating": 50,\n "date": "01/02/2016",\n "timestamp": 1234567890123,\n "comparableDate": 1234567890\n },\n {\n "ratingType": "thumb",\n "title": "Amazing Show",\n "movieID": 87654321,\n "yourRating": 2,\n "date": "02/02/2018",\n "timestamp": 2234567890123,\n "comparableDate": 2234567890\n }\n]'; 234 | const dataJSONWith3Spaces = '[\n {\n "ratingType": "star",\n "title": "Some movie",\n "movieID": 12345678,\n "yourRating": 5,\n "intRating": 50,\n "date": "01/02/2016",\n "timestamp": 1234567890123,\n "comparableDate": 1234567890\n },\n {\n "ratingType": "thumb",\n "title": "Amazing Show",\n "movieID": 87654321,\n "yourRating": 2,\n "date": "02/02/2018",\n "timestamp": 2234567890123,\n "comparableDate": 2234567890\n }\n]'; 235 | 236 | const filename = 'test.json'; 237 | const numberOfSpaces = 3; 238 | 239 | beforeEach(() => { 240 | processStdoutWrite = sinon.stub(process.stdout, 'write'); 241 | fsWriteFileSync = sinon.stub(fs, 'writeFileSync'); 242 | jsonStringify = sinon.stub(JSON, 'stringify').callThrough(); 243 | }); 244 | 245 | afterEach(() => { 246 | processStdoutWrite.restore(); 247 | fsWriteFileSync.restore(); 248 | jsonStringify.restore(); 249 | }); 250 | 251 | it('Should call JSON.stringify to convert data to JSON', () => { 252 | writeToChosenOutput(data); 253 | expect(jsonStringify).to.have.been.calledOnce; 254 | expect(jsonStringify).to.have.been.calledWithExactly(data, null, undefined); 255 | }); 256 | 257 | it('Should pass spaces along, if specified', () => { 258 | writeToChosenOutput(data, undefined, numberOfSpaces); 259 | expect(jsonStringify).to.have.been.calledWithExactly(data, null, numberOfSpaces); 260 | }); 261 | 262 | it('Should only print to process.stdout when filename is not specified', () => { 263 | writeToChosenOutput(data, undefined); 264 | expect(processStdoutWrite).to.have.been.calledOnce; 265 | expect(fsWriteFileSync).to.not.have.been.called; 266 | }); 267 | 268 | it('Should only print to file when filename is specified', async () => { 269 | writeToChosenOutput(data, filename); 270 | expect(fsWriteFileSync).to.have.been.calledOnce; 271 | // Expect a log statement to the console but not the complete data 272 | expect(processStdoutWrite).to.have.been.calledOnce; 273 | expect(processStdoutWrite).to.have.been.calledWith('Writing results to ' + filename + '\n'); 274 | }); 275 | 276 | it('Should print correct JSON to process.stdout', async () => { 277 | writeToChosenOutput(data, undefined, null); 278 | expect(processStdoutWrite).to.have.been.calledWithExactly(dataJSON); 279 | }); 280 | 281 | it('Should print correct JSON to file', async () => { 282 | writeToChosenOutput(data, filename, null); 283 | expect(fsWriteFileSync).to.have.been.calledOnceWith(filename, dataJSON); 284 | }); 285 | 286 | it('Should print correct JSON when spaces is set to 4', async () => { 287 | writeToChosenOutput(data, filename, 4); 288 | expect(fsWriteFileSync).to.have.been.calledOnceWith(filename, dataJSONWith4Spaces); 289 | }); 290 | 291 | it('Should print correct JSON when spaces is set to 3', async () => { 292 | writeToChosenOutput(data, filename, 3); 293 | expect(fsWriteFileSync).to.have.been.calledOnceWith(filename, dataJSONWith3Spaces); 294 | }); 295 | }); 296 | 297 | describe('getRatingHistory', () => { 298 | let netflix, netflixGetRatingHistory, consoleError; 299 | const ratings = [ 300 | { 301 | 'ratingType': 'star', 302 | 'title': 'Some movie', 303 | 'movieID': 12345678, 304 | 'yourRating': 5, 305 | 'intRating': 50, 306 | 'date': '01/02/2016', 307 | 'timestamp': 1234567890123, 308 | 'comparableDate': 1234567890 309 | }, 310 | { 311 | 'ratingType': 'thumb', 312 | 'title': 'Amazing Show', 313 | 'movieID': 87654321, 314 | 'yourRating': 2, 315 | 'date': '02/02/2018', 316 | 'timestamp': 2234567890123, 317 | 'comparableDate': 2234567890 318 | } 319 | ]; 320 | 321 | beforeEach(() => { 322 | netflix = new Netflix(); 323 | netflixGetRatingHistory = sinon.stub(netflix, 'getRatingHistory') 324 | .resolves(ratings); 325 | consoleError = sinon.stub(console, 'error'); 326 | }); 327 | 328 | afterEach(() => { 329 | netflixGetRatingHistory.restore(); 330 | consoleError.restore(); 331 | }); 332 | 333 | it('Should return a promise', () => { 334 | expect(getRatingHistory(netflix)).to.be.instanceOf(Promise); 335 | }); 336 | 337 | it('Should call netflix.getRatingsHistory()', async () => { 338 | await getRatingHistory(netflix); 339 | 340 | expect(netflixGetRatingHistory).to.have.been.calledOnce; 341 | expect(netflixGetRatingHistory).to.have.been.calledWithExactly(); 342 | }); 343 | 344 | it('Should resolve with the result of netflix.getRatingHistory', async () => { 345 | await expect(getRatingHistory(netflix)).to.eventually.deep.equal(ratings); 346 | }); 347 | 348 | it('Should log any error thrown by netflix.getRatingHistory', async () => { 349 | const error = new Error('Error thrown by test'); 350 | netflixGetRatingHistory.rejects(error); 351 | 352 | try { 353 | await getRatingHistory(netflix); 354 | } catch (e) { 355 | 356 | } finally { 357 | expect(consoleError).to.have.been.calledOnce; 358 | expect(consoleError).to.have.been.calledWithExactly(error); 359 | } 360 | }); 361 | 362 | it('Should throw an error when netflix.getRatingHistory throws an error', async () => { 363 | netflixGetRatingHistory.rejects(new Error()); 364 | 365 | await expect(getRatingHistory(netflix)).to.eventually.be.rejected; 366 | }); 367 | }); 368 | 369 | describe('getViewingHistory', () => { 370 | let netflix, netflixGetViewingHistory, consoleError; 371 | const viewingHistory = [ 372 | { 373 | 'ratingType': 'star', 374 | 'title': 'Some movie', 375 | 'movieID': 12345678, 376 | 'yourRating': 5, 377 | 'intRating': 50, 378 | 'date': '01/02/2016', 379 | 'timestamp': 1234567890123, 380 | 'comparableDate': 1234567890 381 | }, 382 | { 383 | 'ratingType': 'thumb', 384 | 'title': 'Amazing Show', 385 | 'movieID': 87654321, 386 | 'yourRating': 2, 387 | 'date': '02/02/2018', 388 | 'timestamp': 2234567890123, 389 | 'comparableDate': 2234567890 390 | } 391 | ]; 392 | 393 | beforeEach(() => { 394 | netflix = new Netflix(); 395 | netflixGetViewingHistory = sinon.stub(netflix, 'getViewingHistory') 396 | .resolves(viewingHistory); 397 | consoleError = sinon.stub(console, 'error'); 398 | }); 399 | 400 | afterEach(() => { 401 | netflixGetViewingHistory.restore(); 402 | consoleError.restore(); 403 | }); 404 | 405 | it('Should return a promise', () => { 406 | expect(getViewingHistory(netflix)).to.be.instanceOf(Promise); 407 | }); 408 | 409 | it('Should call netflix.getRatingsHistory()', async () => { 410 | await getViewingHistory(netflix); 411 | 412 | expect(netflixGetViewingHistory).to.have.been.calledOnce; 413 | expect(netflixGetViewingHistory).to.have.been.calledWithExactly(); 414 | }); 415 | 416 | it('Should resolve with the result of netflix.getViewingHistory', async () => { 417 | await expect(getViewingHistory(netflix)).to.eventually.deep.equal(viewingHistory); 418 | }); 419 | 420 | it('Should log any error thrown by netflix.getViewingHistory', async () => { 421 | const error = new Error('Error thrown by test'); 422 | netflixGetViewingHistory.rejects(error); 423 | 424 | try { 425 | await getViewingHistory(netflix); 426 | } catch (e) { 427 | 428 | } finally { 429 | expect(consoleError).to.have.been.calledOnce; 430 | expect(consoleError).to.have.been.calledWithExactly(error); 431 | } 432 | }); 433 | 434 | it('Should throw an error when netflix.getViewingHistory throws an error', async () => { 435 | netflixGetViewingHistory.rejects(new Error()); 436 | 437 | await expect(getViewingHistory(netflix)).to.eventually.be.rejected; 438 | }); 439 | }); 440 | 441 | describe('readDataFromChosenInput', () => { 442 | let fsReadFileSync, processStdinRead, jsonParse; 443 | 444 | const ratings = [ 445 | { 446 | 'ratingType': 'star', 447 | 'title': 'Some movie', 448 | 'movieID': 12345678, 449 | 'yourRating': 5, 450 | 'intRating': 50, 451 | 'date': '01/02/2016', 452 | 'timestamp': 1234567890123, 453 | 'comparableDate': 1234567890 454 | }, 455 | { 456 | 'ratingType': 'thumb', 457 | 'title': 'Amazing Show', 458 | 'movieID': 87654321, 459 | 'yourRating': 2, 460 | 'date': '02/02/2018', 461 | 'timestamp': 2234567890123, 462 | 'comparableDate': 2234567890 463 | } 464 | ]; 465 | const views = ratings; 466 | const versions = { 467 | beforeViewingHistory: '0.2.0', 468 | afterViewingHistory: '0.3.0' 469 | }; 470 | const totalHistory = { 471 | version: versions.afterViewingHistory, 472 | ratingHistory: ratings, 473 | viewingHistory: views 474 | }; 475 | 476 | // JSON representations are hard coded into this test in order to notice when JSON.stringify changes 477 | const ratingsJSON = '[{"ratingType":"star","title":"Some movie","movieID":12345678,"yourRating":5,"intRating":50,"date":"01/02/2016","timestamp":1234567890123,"comparableDate":1234567890},{"ratingType":"thumb","title":"Amazing Show","movieID":87654321,"yourRating":2,"date":"02/02/2018","timestamp":2234567890123,"comparableDate":2234567890}]'; 478 | const viewsJson = '[{"ratingType":"star","title":"Some movie","movieID":12345678,"yourRating":5,"intRating":50,"date":"01/02/2016","timestamp":1234567890123,"comparableDate":1234567890},{"ratingType":"thumb","title":"Amazing Show","movieID":87654321,"yourRating":2,"date":"02/02/2018","timestamp":2234567890123,"comparableDate":2234567890}]'; 479 | const totalHistoryJSON = '{"ratingHistory":' + ratingsJSON + ', "version":"' + versions.afterViewingHistory + '", "viewingHistory":' + viewsJson + '}'; 480 | const filename = 'test.json'; 481 | 482 | beforeEach(() => { 483 | processStdinRead = sinon.stub(process.stdin, 'read').returns(totalHistoryJSON); 484 | fsReadFileSync = sinon.stub(fs, 'readFileSync').returns(totalHistoryJSON); 485 | jsonParse = sinon.stub(JSON, 'parse').callThrough(); 486 | }); 487 | 488 | afterEach(() => { 489 | processStdinRead.restore(); 490 | fsReadFileSync.restore(); 491 | jsonParse.restore(); 492 | }); 493 | 494 | it('Should read its data from stdin when no filename is specified', () => { 495 | readDataFromChosenInput(); 496 | expect(processStdinRead).to.have.been.calledOnce; 497 | expect(fsReadFileSync).to.not.have.been.called; 498 | }); 499 | 500 | it('Should read its data from a file when a filename is specified', () => { 501 | readDataFromChosenInput(filename); 502 | expect(fsReadFileSync).to.have.been.calledOnce; 503 | expect(fsReadFileSync).to.have.been.calledWith(filename); 504 | expect(processStdinRead).to.not.have.been.called; 505 | }); 506 | 507 | it('Should call JSON.parse to convert JSON from stdin to an object', () => { 508 | readDataFromChosenInput(); 509 | expect(jsonParse).to.have.been.calledOnce; 510 | expect(jsonParse).to.have.been.calledWithExactly(totalHistoryJSON); 511 | }); 512 | 513 | it('Should call JSON.parse to convert JSON from file to an object', () => { 514 | readDataFromChosenInput(filename); 515 | expect(jsonParse).to.have.been.calledOnce; 516 | expect(jsonParse).to.have.been.calledWithExactly(totalHistoryJSON); 517 | }); 518 | 519 | it('Should always return an object with the properties ratingHistory and viewingHistory', () => { 520 | const calledWithFilename = readDataFromChosenInput(filename); 521 | const calledWithoutFilename = readDataFromChosenInput(); 522 | 523 | for (let call of [calledWithFilename, calledWithoutFilename]) { 524 | expect(call).to.be.instanceOf(Object); 525 | expect(call).to.have.ownProperty('ratingHistory'); 526 | expect(call).to.have.ownProperty('viewingHistory'); 527 | } 528 | }); 529 | 530 | describe('When finding an array (data from v0.2.0 or lower)', () => { 531 | beforeEach(() => { 532 | processStdinRead.returns(ratingsJSON); 533 | fsReadFileSync.returns(ratingsJSON); 534 | }); 535 | 536 | it('Should return a ratingHitory of data and a viewingHistory & version of null', () => { 537 | const result = readDataFromChosenInput(); 538 | expect(result.ratingHistory).to.deep.equal(ratings); 539 | expect(result.viewingHistory).to.be.null; 540 | expect(result.version).to.be.null; 541 | }); 542 | }); 543 | 544 | describe('When finding an object (data from v0.3.0 or higher)', () => { 545 | it('Should return the correct version number, rating and viewing histories', () => { 546 | const result = readDataFromChosenInput(); 547 | expect(result.version).to.deep.equal(totalHistory.version); 548 | expect(result.ratingHistory).to.deep.equal(ratings); 549 | expect(result.viewingHistory).to.deep.equal(views); 550 | }); 551 | 552 | const unacceptableScenarios = [ 553 | { 554 | description: 'there is no rating history', 555 | JSON: '{"version":"' + versions.afterViewingHistory + '", "viewingHistory":' + viewsJson + '}' 556 | }, 557 | { 558 | description: 'the rating history is not an array', 559 | JSON: '{"ratingHistory":1, "version":"' + versions.afterViewingHistory + '", "viewingHistory":' + viewsJson + '}' 560 | }, 561 | { 562 | description: 'there is no viewing history', 563 | JSON: '{"ratingHistory":' + ratingsJSON + ', "version":"' + versions.afterViewingHistory + '"}' 564 | }, 565 | { 566 | description: 'the viewing history is not an array', 567 | JSON: '{"ratingHistory":' + ratingsJSON + ', "version":"' + versions.afterViewingHistory + '",' + 568 | ' "viewingHistory":1}' 569 | }, 570 | { 571 | description: 'there is no version', 572 | JSON: '{"ratingHistory":' + ratingsJSON + ', "viewingHistory":' + viewsJson + '}' 573 | }, 574 | { 575 | description: 'the version is not a correct version number', 576 | JSON: '{"ratingHistory":' + ratingsJSON + ', "version":"1.2.3.4",' + 577 | ' "viewingHistory":' + viewsJson + '}' 578 | }, 579 | { 580 | description: 'the data is neither an array, nor an object', 581 | JSON: '1' 582 | } 583 | ]; 584 | 585 | for (const scenario of unacceptableScenarios) { 586 | it('Should throw an error if ' + scenario.description, () => { 587 | processStdinRead.returns(scenario.JSON); 588 | fsReadFileSync.returns(scenario.JSON); 589 | 590 | expect(() => readDataFromChosenInput()).to.throw(); 591 | }); 592 | } 593 | }); 594 | }); 595 | 596 | describe('setRatingHistory', () => { 597 | let netflix, netflixSetStarRating, netflixSetThumbRating, consoleError; 598 | const ratings = [ 599 | { 600 | 'ratingType': 'star', 601 | 'title': 'Some movie', 602 | 'movieID': 12345678, 603 | 'yourRating': 5, 604 | 'intRating': 50, 605 | 'date': '01/02/2016', 606 | 'timestamp': 1234567890123, 607 | 'comparableDate': 1234567890 608 | }, 609 | { 610 | 'ratingType': 'thumb', 611 | 'title': 'Amazing Show', 612 | 'movieID': 87654321, 613 | 'yourRating': 2, 614 | 'date': '02/02/2018', 615 | 'timestamp': 2234567890123, 616 | 'comparableDate': 2234567890 617 | } 618 | ]; 619 | const starRatings = ratings.filter(rating => rating.ratingType === 'star'); 620 | const thumbRatings = ratings.filter(rating => rating.ratingType === 'thumb'); 621 | 622 | beforeEach(() => { 623 | netflix = new Netflix(); 624 | netflixSetStarRating = sinon.stub(netflix, 'setStarRating') 625 | .resolves(); 626 | netflixSetThumbRating = sinon.stub(netflix, 'setThumbRating') 627 | .resolves(); 628 | consoleError = sinon.stub(console, 'error'); 629 | }); 630 | 631 | afterEach(() => { 632 | netflixSetStarRating.restore(); 633 | netflixSetThumbRating.restore(); 634 | consoleError.restore(); 635 | }); 636 | 637 | it('Should return a promise', () => { 638 | expect(setRatingHistory(netflix)).to.be.instanceOf(Promise); 639 | }); 640 | 641 | it('Should take about 100ms per rating due to timeout between requests', async () => { 642 | const beginning = Date.now().valueOf(); 643 | await setRatingHistory(netflix, ratings); 644 | const end = Date.now().valueOf(); 645 | expect(end - beginning).to.not.be.lessThan(ratings.length * 100); 646 | }); 647 | 648 | it('Should call netflix.setStarRating once per star rating', async () => { 649 | await setRatingHistory(netflix, ratings); 650 | expect(netflixSetStarRating).to.have.callCount(starRatings.length); 651 | 652 | for (const rating of starRatings) { 653 | expect(netflixSetStarRating).to.have.been.calledWithExactly(rating.movieID, rating.yourRating); 654 | } 655 | }); 656 | 657 | it('Should call netflix.setThumbRating once per thumb rating', async () => { 658 | await setRatingHistory(netflix, ratings); 659 | expect(netflixSetThumbRating).to.have.callCount(thumbRatings.length); 660 | 661 | for (const rating of thumbRatings) { 662 | expect(netflixSetThumbRating).to.have.been.calledWithExactly(rating.movieID, rating.yourRating); 663 | } 664 | }); 665 | 666 | it('Should call main.waterfall once with an array of functions that return promises', async () => { 667 | const waterfallStub = sinon.stub(main, 'waterfall').resolves(); 668 | 669 | await setRatingHistory(netflix, ratings); 670 | 671 | expect(waterfallStub).to.have.been.calledOnce; 672 | const functions = waterfallStub.args[0][0]; 673 | expect(functions).to.have.lengthOf(ratings.length); 674 | 675 | for (const func of functions) { 676 | expect(func).to.be.instanceOf(Function); 677 | expect(func()).to.be.instanceOf(Promise); 678 | } 679 | 680 | waterfallStub.restore(); 681 | }); 682 | 683 | it('Should call main.waterfall once with an array of functions', async () => { 684 | const waterfallStub = sinon.stub(main, 'waterfall').resolves(); 685 | 686 | await setRatingHistory(netflix, ratings); 687 | 688 | expect(waterfallStub).to.have.been.calledOnce; 689 | const functions = waterfallStub.args[0][0]; 690 | 691 | for (const func of functions) { 692 | netflixSetStarRating.resolves(); 693 | netflixSetThumbRating.resolves(); 694 | await expect(func()).to.eventually.be.fulfilled; 695 | 696 | netflixSetStarRating.rejects(new Error()); 697 | netflixSetThumbRating.rejects(new Error()); 698 | await expect(func()).to.eventually.be.rejected; 699 | } 700 | 701 | waterfallStub.restore(); 702 | }); 703 | 704 | it('Should call netflix.setStarRating in correct order', async () => { 705 | await setRatingHistory(netflix, ratings); 706 | for (let i = 0; i < starRatings.length; i++) { 707 | expect(netflixSetStarRating.getCall(i)) 708 | .to.have.been.calledWithExactly( 709 | starRatings[i].movieID, starRatings[i].yourRating 710 | ); 711 | } 712 | }); 713 | 714 | it('Should call netflix.setThumbRating in correct order', async () => { 715 | await setRatingHistory(netflix, ratings); 716 | for (let i = 0; i < thumbRatings.length; i++) { 717 | expect(netflixSetThumbRating.getCall(i)) 718 | .to.have.been.calledWithExactly( 719 | thumbRatings[i].movieID, thumbRatings[i].yourRating 720 | ); 721 | } 722 | }); 723 | }); 724 | 725 | describe('main', () => { 726 | let netflix, netflixLogin, mainGetProfileGuid, mainSwitchProfile, mainGetRatingHistory, mainGetViewingHistory, 727 | mainSetRatingHistory, mainExitWithMessage, mainWriteToChosenOutput, mainReadDataFromChosenInput, stubs, args; 728 | const profile = {guid: 1234567890, firstName: 'Foo'}; 729 | const ratings = [ 730 | { 731 | 'ratingType': 'star', 732 | 'title': 'Some movie', 733 | 'movieID': 12345678, 734 | 'yourRating': 5, 735 | 'intRating': 50, 736 | 'date': '01/02/2016', 737 | 'timestamp': 1234567890123, 738 | 'comparableDate': 1234567890 739 | }, 740 | { 741 | 'ratingType': 'thumb', 742 | 'title': 'Amazing Show', 743 | 'movieID': 87654321, 744 | 'yourRating': 2, 745 | 'date': '02/02/2018', 746 | 'timestamp': 2234567890123, 747 | 'comparableDate': 2234567890 748 | } 749 | ]; 750 | const views = ratings; 751 | const data = { 752 | version: require('./package').version, 753 | ratingHistory: ratings, 754 | viewingHistory: views 755 | }; 756 | 757 | beforeEach(() => { 758 | netflix = new Netflix(); 759 | args = {}; 760 | 761 | netflixLogin = sinon.stub(netflix, 'login') 762 | .resolves(); 763 | 764 | mainExitWithMessage = sinon.stub(main, 'exitWithMessage'); 765 | 766 | mainGetProfileGuid = sinon.stub(main, 'getProfileGuid') 767 | .resolves(profile); 768 | 769 | mainSwitchProfile = sinon.stub(main, 'switchProfile') 770 | .resolves(); 771 | 772 | mainGetRatingHistory = sinon.stub(main, 'getRatingHistory') 773 | .resolves(ratings); 774 | 775 | mainGetViewingHistory = sinon.stub(main, 'getViewingHistory') 776 | .resolves(views); 777 | 778 | mainSetRatingHistory = sinon.stub(main, 'setRatingHistory') 779 | .resolves(); 780 | 781 | mainWriteToChosenOutput = sinon.stub(main, 'writeToChosenOutput'); 782 | 783 | mainReadDataFromChosenInput = sinon.stub(main, 'readDataFromChosenInput') 784 | .returns(data); 785 | 786 | stubs = [ 787 | netflixLogin, mainExitWithMessage, mainGetProfileGuid, mainSwitchProfile, 788 | mainGetRatingHistory, mainGetViewingHistory, mainSetRatingHistory, mainWriteToChosenOutput, 789 | mainReadDataFromChosenInput 790 | ]; 791 | }); 792 | 793 | afterEach(() => { 794 | stubs.forEach(stub => stub.restore()); 795 | }); 796 | 797 | it('Should return a promise', () => { 798 | expect(main(args, netflix)).to.be.instanceOf(Promise); 799 | }); 800 | 801 | it('Should call netflix.login with args.email and args.password', async () => { 802 | args.email = {foo: 'bar'}; 803 | args.password = {bar: 'foo'}; 804 | 805 | await main(args, netflix); 806 | expect(netflixLogin).to.have.been.calledOnceWithExactly({ 807 | email: args.email, 808 | password: args.password 809 | }) 810 | }); 811 | 812 | it('Should call main.getProfileGuid after netflix.login', async () => { 813 | await main(args, netflix); 814 | expect(mainGetProfileGuid).to.have.been.calledAfter(netflixLogin); 815 | }); 816 | 817 | it('Should call main.getProfileGuid with args.profile', async () => { 818 | args.profile = {foo: 'bar'}; 819 | 820 | await main(args, netflix); 821 | expect(mainGetProfileGuid).to.have.been.calledOnceWithExactly(netflix, args.profile); 822 | }); 823 | 824 | it('Should call main.switchProfile after main.getProfileGuid', async () => { 825 | await main(args, netflix); 826 | expect(mainSwitchProfile).to.have.been.calledAfter(mainGetProfileGuid); 827 | }); 828 | 829 | it('Should call main.switchProfile with the result of main.getProfileGuid', async () => { 830 | await main(args, netflix); 831 | expect(mainSwitchProfile).to.have.been.calledOnceWithExactly(netflix, profile); 832 | }); 833 | 834 | describe('Should call main.getRatingHistory', () => { 835 | beforeEach(() => { 836 | args.shouldExport = true; 837 | }); 838 | 839 | it('if args.shouldExport is true', async () => { 840 | await main(args, netflix); 841 | expect(mainGetRatingHistory).to.have.been.calledOnce; 842 | expect(mainSetRatingHistory).to.not.have.been.called; 843 | }); 844 | 845 | it('after main.switchProfile', async () => { 846 | await main(args, netflix); 847 | expect(mainGetRatingHistory).to.have.been.calledAfter(mainSwitchProfile); 848 | }); 849 | }); 850 | 851 | describe('Should call main.getViewingHistory', () => { 852 | beforeEach(() => { 853 | args.shouldExport = true; 854 | }); 855 | 856 | it('if args.shouldExport is true', async () => { 857 | await main(args, netflix); 858 | expect(mainGetViewingHistory).to.have.been.calledOnce; 859 | expect(mainSetRatingHistory).to.not.have.been.called; 860 | }); 861 | 862 | it('after main.getRatingHistory', async () => { 863 | await main(args, netflix); 864 | expect(mainGetViewingHistory).to.have.been.calledAfter(mainGetRatingHistory); 865 | }); 866 | }); 867 | 868 | describe('Should call main.writeToChosenOutput', () => { 869 | beforeEach(() => { 870 | args.shouldExport = true; 871 | }); 872 | 873 | it('with an undefined filename if args.export is true', async () => { 874 | args.export = true; 875 | await main(args, netflix); 876 | expect(mainWriteToChosenOutput.getCall(0).args[1]).to.be.undefined; 877 | }); 878 | 879 | it('with filename provided in args.export', async () => { 880 | args.export = {foo: 'bar'}; 881 | await main(args, netflix); 882 | expect(mainWriteToChosenOutput.getCall(0).args[1]).to.equal(args.export); 883 | }); 884 | 885 | it('with args.spaces', async () => { 886 | args.export = true; 887 | args.spaces = {foo: 'bar'}; 888 | await main(args, netflix); 889 | expect(mainWriteToChosenOutput.getCall(0).args[2]).to.equal(args.spaces); 890 | }); 891 | 892 | it('after main.getViewingHistory', async () => { 893 | await main(args, netflix); 894 | expect(mainWriteToChosenOutput).to.have.been.calledAfter(mainGetViewingHistory); 895 | }); 896 | 897 | it('with an object containing a viewingHistory and a ratingHistory', async () => { 898 | await main(args, netflix); 899 | const firstCallArg = mainWriteToChosenOutput.getCall(0).args[0]; 900 | expect(firstCallArg).to.be.instanceOf(Object); 901 | expect(firstCallArg).to.haveOwnProperty('viewingHistory'); 902 | expect(firstCallArg).to.haveOwnProperty('ratingHistory'); 903 | }); 904 | 905 | it('with an object containing the results of main.getRatingHistory and main.getViewingHistory', async () => { 906 | await main(args, netflix); 907 | const firstCallArg = mainWriteToChosenOutput.getCall(0).args[0]; 908 | expect(firstCallArg).to.be.instanceOf(Object); 909 | expect(firstCallArg.ratingHistory).to.deep.equal(ratings); 910 | expect(firstCallArg.viewingHistory).to.deep.equal(views); 911 | }); 912 | }); 913 | 914 | describe('Should call main.readDataFromChosenInput', () => { 915 | beforeEach(() => { 916 | args.shouldExport = false; 917 | }); 918 | 919 | it('if args.shouldExport is false', async () => { 920 | await main(args, netflix); 921 | expect(mainReadDataFromChosenInput).to.have.been.calledOnce; 922 | }); 923 | 924 | it('with an undefined filename if args.import is true', async () => { 925 | args.import = true; 926 | await main(args, netflix); 927 | expect(mainReadDataFromChosenInput).to.have.been.calledOnceWithExactly(undefined); 928 | }); 929 | 930 | it('with filename provided in args.import', async () => { 931 | args.import = {foo: 'bar'}; 932 | await main(args, netflix); 933 | expect(mainReadDataFromChosenInput).to.have.been.calledOnceWithExactly(args.import); 934 | }); 935 | 936 | it('after main.switchProfile', async () => { 937 | await main(args, netflix); 938 | expect(mainReadDataFromChosenInput).to.have.been.calledAfter(mainSwitchProfile); 939 | }); 940 | }); 941 | 942 | describe('Should call main.setRatingHistory', () => { 943 | beforeEach(() => { 944 | args.shouldExport = false; 945 | }); 946 | 947 | it('if args.shouldExport is false', async () => { 948 | await main(args, netflix); 949 | expect(mainSetRatingHistory).to.have.been.calledOnce; 950 | expect(mainGetRatingHistory).to.not.have.been.called; 951 | }); 952 | 953 | it('with the rating history of the object returned by main.readDataFromChosenInput', async () => { 954 | const obj = {version: 'bar', viewingHistory: 'cool movies', ratingHistory: 1234567890}; 955 | mainReadDataFromChosenInput.returns(obj); 956 | await main(args, netflix); 957 | expect(mainSetRatingHistory).to.have.been.calledOnceWithExactly(netflix, obj.ratingHistory); 958 | }); 959 | 960 | it('after main.readDataFromChosenInput', async () => { 961 | await main(args, netflix); 962 | expect(mainSetRatingHistory).to.have.been.calledAfter(mainReadDataFromChosenInput); 963 | }); 964 | }); 965 | 966 | describe('Should call main.exitWithMessage immediately when an error is thrown', () => { 967 | it('by netflix.login', async () => { 968 | const err = new Error(); 969 | netflix.login.rejects(err); 970 | await main(args, netflix); 971 | expect(mainExitWithMessage).to.have.been.calledOnceWithExactly(err); 972 | }); 973 | 974 | const functionsToTest = [ 975 | // @todo make this work with netflix 976 | // {name: 'netflix.login', parent: netflix, args: {}}, 977 | {name: 'main.getProfileGuid', parent: main, args: {}, type: 'promise'}, 978 | {name: 'main.switchProfile', parent: main, args: {}, type: 'promise'}, 979 | {name: 'main.getRatingHistory', parent: main, args: {shouldExport: true}, type: 'promise'}, 980 | {name: 'main.getViewingHistory', parent: main, args: {shouldExport: true}, type: 'promise'}, 981 | {name: 'main.writeToChosenOutput', parent: main, args: {shouldExport: true}, type: 'function'}, 982 | {name: 'main.readDataFromChosenInput', parent: main, args: {shouldExport: false}, type: 'function'}, 983 | {name: 'main.setRatingHistory', parent: main, args: {shouldExport: false}, type: 'promise'} 984 | ]; 985 | 986 | for (let i = 0; i < functionsToTest.length; i++) { 987 | let func = functionsToTest[i]; 988 | 989 | it(`by ${func.name}`, async () => { 990 | const err = new Error(); 991 | 992 | const nameOfFunction = func.name.split('.')[1]; 993 | if (func.type === 'promise') { 994 | func.parent[nameOfFunction].rejects(err); 995 | } else { 996 | func.parent[nameOfFunction].throws(err); 997 | } 998 | 999 | await main(func.args, netflix); 1000 | 1001 | expect(mainExitWithMessage).to.have.been.calledOnce; 1002 | expect(mainExitWithMessage).to.have.been.calledOnceWithExactly(err); 1003 | }); 1004 | } 1005 | }); 1006 | }); 1007 | --------------------------------------------------------------------------------