Home Reference Source Test Repository

unit/controller/listening.js

import chai from 'chai';
import chaiAsPromised from 'chai-as-promised';
import sinon from 'sinon';
import { testConfig } from '../../test';
import mockery from 'mockery';
import fetchMock from 'fetch-mock';
import { LISTENING_V2_RECOMMENDATIONS_RESPONSE, ACCESS_TOKEN_RESPONSE } from './../../test-data';
import Listening from './../../../src/controller/listening';
import NprOne from './../../../src/index';

const should = chai.should();
chai.use(chaiAsPromised);

/** @test {Listening} */
describe('Listening', () => {
    const recommendUrl = `^${NprOne.getServiceUrl('listening')}/recommendations`;
    // This hostname is set in TEST_DATA output
    const ratingUrl = `^${NprOne.getServiceUrl('listening')}/ratings`;
    const adsWizzUrl = '^https://demo.adswizz.com';
    const adsWizzWwwUrl = 'https://adswizz.com';
    const adsWizzCdnUrl = '^https://delivery-s3.adswizz.com';
    const doubleClickUrl = '^https://ad.doubleclick.net';
    const historyUrl = `^${NprOne.getServiceUrl('listening')}/history`;

    let testDataClone = {};
    let listening;
    let refreshTokenUrl;

    const advance = (recommendation, rating) => {
        testDataClone.items.shift();
        recommendation.recordAction(NprOne.Action.START, 0);

        return new Promise((resolve) => {
            setTimeout(() => {
                recommendation.recordAction(rating, recommendation.attributes.duration);
                resolve(listening.getRecommendation());
            }, 5);
        });
    };

    const skip = recommendation => {
        return advance(recommendation, NprOne.Action.SKIP);
    };

    const complete = recommendation => {
        return advance(recommendation, NprOne.Action.COMPLETED);
    };

    const tapthru = recommendation => {
        return advance(recommendation, NprOne.Action.TAPTHRU);
    };

    beforeEach(() => {
        testDataClone = JSON.parse(JSON.stringify(LISTENING_V2_RECOMMENDATIONS_RESPONSE));

        mockery.registerMock('fetch', fetchMock
            .mock(recommendUrl, 'GET', testDataClone)
            .mock(ratingUrl, 'POST', testDataClone)
            .mock(adsWizzUrl, 'GET')
            .mock(adsWizzWwwUrl, 'GET', 200)
            .mock(adsWizzCdnUrl, 'GET', 200)
            .mock(doubleClickUrl, 'GET')
            .mock(historyUrl, 'GET', LISTENING_V2_RECOMMENDATIONS_RESPONSE)
            .getMock());

        listening = new Listening();
        NprOne.config = testConfig;

        refreshTokenUrl = `^${testConfig.authProxyBaseUrl}${NprOne.config.refreshTokenPath}`;
    });

    afterEach(() => {
        fetchMock.restore();
        mockery.deregisterMock('fetch');
    });

    /** @test {Listening#getRecommendation} */
    describe('getRecommendation', () => {
        let firstRecommendation = null;

        const sendSingleAction = (action) => {
            return listening.getRecommendation()
                .then(recommendation => {
                    firstRecommendation = recommendation;
                    testDataClone.items.shift();
                    firstRecommendation.recordAction(action, 0);
                    return listening.getRecommendation();
                });
        };


        beforeEach(() => {
            firstRecommendation = null;
        });


        it('should throw an exception if access token is not supplied', () => {
            NprOne.config = { accessToken: '' };

            chai.expect(() => {
                listening.getRecommendation();
            }).to.throw('An Access Token must set before making API requests.');
        });

        it('should make a request to /recommendations with channel npr if a bad channel is passed in', done => {
            listening.getRecommendation('', { 'bad': 'channel' })
                .then(() => {
                    fetchMock.called(recommendUrl).should.be.true;
                    fetchMock.calls().unmatched.length.should.equal(0);
                    done();
                })
                .catch(done);
        });

        it('should make a request to /recommendations when no queued ratings exist', done => {
            listening.getRecommendation()
                .then(() => {
                    fetchMock.called(recommendUrl).should.be.true;
                    fetchMock.calls().unmatched.length.should.equal(0);
                    done();
                })
                .catch(done);
        });

        it('should return the currently executing promise if called again before first promise has resolved', done => {
            const p1 = listening.getRecommendation();
            const p2 = listening.getRecommendation();

            Promise.all([p1, p2])
                .then(() => {
                    fetchMock.called(recommendUrl).should.be.true;
                    fetchMock.calls(recommendUrl).length.should.equal(1); // most important check
                    fetchMock.calls().unmatched.length.should.equal(0);
                    done();
                })
                .catch(done);
        });

        it('should make a request to /recommendations with a custom channel when no queued ratings exist and a channel is specified', done => {
            listening.getRecommendation(null, 'my_channel')
                .then(() => {
                    fetchMock.called(recommendUrl).should.be.true;
                    /channel=my_channel/.test(fetchMock.lastUrl()).should.be.true;
                    fetchMock.calls().unmatched.length.should.equal(0);
                    done();
                })
                .catch(done);
        });

        it('should include x- headers if supplied in config when making API requests', done => {
            NprOne.config = Object.assign({}, testConfig, {
                advertisingId: 'test_ad_id',
                advertisingTarget: 'test_ad_target',
            });

            listening.getRecommendation()
                .then(() => {
                    fetchMock.called(recommendUrl).should.be.true;
                    fetchMock.calls().unmatched.length.should.equal(0);
                    const lastCall = fetchMock.lastCall(recommendUrl);
                    lastCall[1].headers._headers['x-advertising-id'][0].should.equal('test_ad_id');
                    lastCall[1].headers._headers['x-advertising-target'][0].should.equal('test_ad_target');
                    done();
                })
                .catch(done);
        });

        it('should make /recommendations request with sharedMediaId when a UID is given', done => {
            fetchMock.restore();
            mockery.deregisterMock('fetch');

            const url = `^${NprOne.getServiceUrl('listening')}/recommendations?channel=npr&sharedMediaId=123`;

            mockery.registerMock('fetch', fetchMock
                .mock(url, 'GET', LISTENING_V2_RECOMMENDATIONS_RESPONSE)
                .mock(adsWizzWwwUrl, 'GET', 200)
                .mock(adsWizzCdnUrl, 'GET', 200)
                .getMock());

            listening.getRecommendation('123')
                .then(() => {
                    fetchMock.called(url).should.be.true;
                    fetchMock.calls().unmatched.length.should.equal(0);
                    done();
                })
                .catch(done);
        });

        it('should make a request to /ratings when a rating is queued', done => {
            listening.getRecommendation()
                .then(complete)
                .then(() => {
                    fetchMock.called(ratingUrl).should.be.true;
                    fetchMock.called(recommendUrl).should.be.true;
                    fetchMock.calls().unmatched.length.should.equal(0);
                    done();
                })
                .catch(done);
        });

        it('should make a request to /ratings and not send duplicate ratings if an action was record twice', done => {
            listening.getRecommendation()
                .then(recommendation => {
                    // COMPLETED before START is not correct, but needed for this test
                    recommendation.recordAction(NprOne.Action.COMPLETED, recommendation.attributes.duration);
                    recommendation.recordAction(NprOne.Action.COMPLETED, recommendation.attributes.duration);
                    recommendation.recordAction(NprOne.Action.START, 0);
                })
                .then(() => {
                    fetchMock.called(ratingUrl).should.be.true;
                    fetchMock.called(recommendUrl).should.be.true;
                    JSON.parse(fetchMock.lastOptions(ratingUrl).body).length.should.be.equal(2); // 2 ratings, not 3!
                    fetchMock.calls().unmatched.length.should.equal(0);
                    done();
                })
                .catch(done);
        });

        it('should return the same recommendation when no START rating has been received', done => {
            listening.getRecommendation()
                .then(recommendation => {
                    firstRecommendation = recommendation;
                    return listening.getRecommendation();
                })
                .then((nextRecommendation) => {
                    firstRecommendation.should.deep.equal(nextRecommendation);
                    done();
                })
                .catch(done);
        });

        it('should return a different recommendation when a START rating has been received', done => {
            sendSingleAction(NprOne.Action.START)
                .then(nextRecommendation => {
                    firstRecommendation.should.not.equal(nextRecommendation);
                    done();
                })
                .catch(done);
        });

        it('should return a different recommendation when a TIMEOUT rating has been received', done => {
            sendSingleAction(NprOne.Action.TIMEOUT)
                .then(nextRecommendation => {
                    firstRecommendation.should.not.equal(nextRecommendation);
                    done();
                })
                .catch(done);
        });

        it('should return a different recommendation when a TAPTHRU rating has been received', done => {
            sendSingleAction(NprOne.Action.TAPTHRU)
                .then(nextRecommendation => {
                    firstRecommendation.should.not.equal(nextRecommendation);
                    done();
                })
                .catch(done);
        });

        it('should fire impression url GET requests for sponsorship when a START happens', done => {
            testDataClone.items.splice(0, 3); // remove stationId, newscast, story

            listening.getRecommendation()
                .then(recommendation => { // start ad
                    recommendation.attributes.uid.should.equal('299999:AdswizzAd13460|2016-02-17-12-42');
                    return complete(recommendation);
                })
                .then(recommendation => { // start promo
                    recommendation.attributes.uid.should.equal('458726244:458726246-P');
                    fetchMock.called(adsWizzUrl).should.be.true;
                    fetchMock.called(doubleClickUrl).should.be.true;
                    fetchMock.calls().unmatched.length.should.equal(0);
                    done();
                })
                .catch(done);
        });

        it('should disable ads when an adblocker is present', done => {
            fetchMock.restore();
            mockery.deregisterMock('fetch');

            mockery.registerMock('fetch', fetchMock
                .mock(recommendUrl, 'GET', testDataClone)
                .mock(ratingUrl, 'POST', testDataClone)
                .mock(adsWizzWwwUrl, 'GET', { 'throws': new Error('TypeError: Failed to fetch') })
                .mock(adsWizzCdnUrl, 'GET', { 'throws': new Error('TypeError: Failed to fetch') })
                .getMock());

            testDataClone.items.splice(0, 2); // remove stationId, newscast,

            NprOne.config = testConfig;
            listening = new NprOne();


            listening.getRecommendation()
                .then(recommendation => {
                    recommendation.attributes.uid.should.equal('466898631:466898632');
                    return complete(recommendation);
                })
                .then(recommendation => {
                    // normally sponsorship would be next
                    recommendation.attributes.uid.should.equal('458726244:458726246-P');
                    done();
                })
                .catch(done);
        });

        it('should progress through the flow normally', done => {
            listening.getRecommendation()
                .then(recommendation => {
                    recommendation.attributes.uid.should.equal('300305:idWAMU2016-02-16-09-37');
                    return complete(recommendation);
                })
                .then(recommendation => {
                    recommendation.attributes.uid.should.equal('999900001:2016-02-16T09:00:00-0500|short');
                    return skip(recommendation);
                })
                .then(recommendation => {
                    recommendation.attributes.uid.should.equal('466898631:466898632');
                    return complete(recommendation);
                })
                .then(recommendation => {
                    recommendation.attributes.uid.should.equal('299999:AdswizzAd13460|2016-02-17-12-42');
                    return complete(recommendation);
                })
                .then(recommendation => {
                    recommendation.attributes.uid.should.equal('458726244:458726246-P');
                    done();
                })
                .catch(done);
        });

        it('should go to sponsored channel on TAPTHRU to sponsorship [tests related]', done => {
            let actionUrl = '';

            testDataClone.items.splice(0, 3); // remove stationId, newscast, story

            listening.getRecommendation()
                .then(recommendation => {
                    recommendation.attributes.uid.should.equal('299999:AdswizzAd13460|2016-02-17-12-42');
                    actionUrl = recommendation.getActionRecommendationUrl();
                    actionUrl.should.not.be.empty;
                    return tapthru(recommendation);
                })
                .then(() => {
                    /*
                     * All bets are off here as to what the recommendation would be - I'm not going to mock
                     * the sponsored channel API behavior. It's sufficient to see that we made a ratings call to
                     * the sponsored channel URL.
                     */
                    fetchMock.lastUrl().should.equal(actionUrl);
                    done();
                })
                .catch(done);
        });

        it('should go to action URL on TAPTHRU of promo [tests action, or callsToAction]', done => {
            let actionUrl = '';

            testDataClone.items.splice(0, 4); // remove stationId, newscast, story, ad

            listening.getRecommendation()
                .then(recommendation => {
                    recommendation.attributes.uid.should.equal('458726244:458726246-P');
                    actionUrl = recommendation.getActionRecommendationUrl();
                    actionUrl.should.not.be.empty;
                    return tapthru(recommendation);
                })
                .then(() => {
                    /*
                     * All bets are off here as to what the recommendation would be - I'm not going to mock
                     * the promo behavior. It's sufficient to see that we made a ratings call to
                     * the promo channel URL.
                     */
                    fetchMock.lastUrl().should.equal(actionUrl);
                    done();
                })
                .catch(done);
        });

        it('should throw when no API recommendations are returned and no previous exist', done => {
            fetchMock.restore();
            mockery.deregisterMock('fetch');

            const url = `^${NprOne.getServiceUrl('listening')}/recommendations`;

            testDataClone.items = [];

            mockery.registerMock('fetch', fetchMock
                .mock(url, 'GET', testDataClone)
                .mock(adsWizzWwwUrl, 'GET', 200)
                .mock(adsWizzCdnUrl, 'GET', 200)
                .getMock());

            listening.getRecommendation().should.be.rejectedWith('Error: All recommendations exhausted!').notify(done);
        });

        it('should call the refresh token endpoint in the auth proxy if the getRecommendations call returns a 401, and then retry the request', done => {
            let numTries = 0;

            const responses = () => {
                numTries += 1;

                if (numTries === 1) {
                    return 401;
                } else {
                    return testDataClone;
                }
            };

            fetchMock.restore();
            mockery.deregisterMock('fetch');
            mockery.registerMock('fetch', fetchMock
                .mock(recommendUrl, 'GET', responses)
                .mock(refreshTokenUrl, 'POST', ACCESS_TOKEN_RESPONSE)
                .getMock());

            const originalAccessToken = NprOne.accessToken;

            listening.getRecommendation()
                .then(() => {
                    fetchMock.called(recommendUrl).should.be.true;
                    fetchMock.called(refreshTokenUrl).should.be.true;
                    fetchMock.calls().unmatched.length.should.equal(0);
                    NprOne.accessToken.should.equal(ACCESS_TOKEN_RESPONSE.access_token);
                    NprOne.accessToken.should.not.equal(originalAccessToken);
                    done();
                })
                .catch(done);
        });

        describe('if queueRecommendationFromChannel() was previously called and the recommendation with the given UID was already added to flowRecommendations', () => {
            const badUid = '1234:5678';
            const goodUid = '466898631:466898632';

            beforeEach(() => {
                listening._flowRecommendations = testDataClone.items;
            });

            it('should NOT make a new API call if the UID is present in the cached list, and return that item', done => {
                listening.getRecommendation(goodUid)
                    .then((recommendation) => {
                        fetchMock.called(recommendUrl).should.be.false;
                        fetchMock.called(ratingUrl).should.be.false;
                        fetchMock.calls().unmatched.length.should.equal(0);
                        recommendation.attributes.uid.should.equal(goodUid);
                        done();
                    })
                    .catch(done);
            });

            it('should make a new API call if the UID is not found in the list', done => {
                listening.getRecommendation(badUid)
                    .then(() => {
                        fetchMock.called(recommendUrl).should.be.true;
                        fetchMock.calls().unmatched.length.should.equal(0);
                        done();
                    })
                    .catch(done);
            });
        });
    });

    /** @test {Listening#getUpcomingFlowRecommendations} */
    describe('getUpcomingFlowRecommendations', () => {
        it('should make a GET request when no flow recommendations are present', done => {
            listening.getUpcomingFlowRecommendations()
                .then(() => {
                    fetchMock.called(recommendUrl).should.be.true;
                    fetchMock.calls().unmatched.length.should.equal(0);
                    /channel=npr/.test(fetchMock.lastUrl()).should.be.true;
                    done();
                })
                .catch(done);
        });

        it('should NOT make a GET request when flow recommendations are present', done => {
            listening._flowRecommendations = [1, 2, 3, 4]; // technically doesn't even matter what's in the array...

            listening.getUpcomingFlowRecommendations()
                .then(() => {
                    fetchMock.called(recommendUrl).should.be.false;
                    fetchMock.calls().unmatched.length.should.equal(0);
                    done();
                })
                .catch(done);
        });

        it('should make a GET request with a custom channel when no flow recommendations are present and a channel is specified', done => {
            listening.getUpcomingFlowRecommendations('newscasts')
                .then(() => {
                    fetchMock.called(recommendUrl).should.be.true;
                    /channel=newscasts/.test(fetchMock.lastUrl()).should.be.true;
                    fetchMock.calls().unmatched.length.should.equal(0);
                    done();
                })
                .catch(done);
        });
    });

    /** @test {Listening#getRecommendationsFromChannel} */
    describe('getRecommendationsFromChannel', () => {
        it('should make a GET request to the default channel ("recommended") if no channel is specified', done => {
            listening.getRecommendationsFromChannel()
                .then(() => {
                    fetchMock.called(recommendUrl).should.be.true;
                    /channel=recommended$/.test(fetchMock.lastUrl()).should.be.true;
                    fetchMock.calls().unmatched.length.should.equal(0);
                    done();
                })
                .catch(done);
        });

        it('should make a GET request to the specified channel if a channel is specified', done => {
            listening.getRecommendationsFromChannel('notrecommended')
                .then(() => {
                    fetchMock.called(recommendUrl).should.be.true;
                    /channel=notrecommended$/.test(fetchMock.lastUrl()).should.be.true;
                    fetchMock.calls().unmatched.length.should.equal(0);
                    done();
                })
                .catch(done);
        });

        it('should make a GET request to the default channel ("recommended") if an invalid channel format is supplied', done => {
            listening.getRecommendationsFromChannel(null)
                .then(() => {
                    fetchMock.called(recommendUrl).should.be.true;
                    /channel=recommended$/.test(fetchMock.lastUrl()).should.be.true;
                    fetchMock.calls().unmatched.length.should.equal(0);
                    done();
                })
                .catch(done);
        });

        it('should flush any pending ratings before returning channel recommendations', done => {
            const sendRatingsSpy = sinon.spy(listening, '_sendRatings');
            listening.getRecommendation()
                .then(recommendation => {
                    recommendation.recordAction(NprOne.Action.COMPLETED, recommendation.attributes.duration);
                })
                .then(() => {
                    listening.getRecommendationsFromChannel()
                        .then(() => {
                            fetchMock.called(ratingUrl).should.be.true;
                            fetchMock.called(recommendUrl).should.be.true;
                            /channel=recommended$/.test(fetchMock.lastUrl()).should.be.true;
                            fetchMock.calls().unmatched.length.should.equal(0);
                            sinon.assert.calledOnce(sendRatingsSpy);
                            done();
                        })
                        .catch(done);
                })
                .catch(done);
        });
    });

    /** @test {Listening#queueRecommendationFromChannel} */
    describe('queueRecommendationFromChannel', () => {
        const channel = 'my_channel';
        const badUid = '1234:5678';
        const goodUid = '466898631:466898632';

        it('should throw a TypeError if no channel is specified', () => {
            chai.expect(() => {
                listening.queueRecommendationFromChannel();
            }).to.throw('Must pass in a valid channel to queueRecommendationFromChannel()');
        });

        it('should throw a TypeError if a channel is specified but is not a valid string', () => {
            chai.expect(() => {
                listening.queueRecommendationFromChannel(1234);
            }).to.throw('Must pass in a valid channel to queueRecommendationFromChannel()');
        });

        it('should throw a TypeError if no UID is specified', () => {
            chai.expect(() => {
                listening.queueRecommendationFromChannel(channel);
            }).to.throw('Must pass in a valid uid to queueRecommendationFromChannel()');
        });

        it('should throw a TypeError if a channel is specified but is not a valid string', () => {
            chai.expect(() => {
                listening.queueRecommendationFromChannel(channel, 1234);
            }).to.throw('Must pass in a valid uid to queueRecommendationFromChannel()');
        });

        it('should throw an Error if getRecommendationsFromChannel() was not previously called', () => {
            chai.expect(() => {
                listening.queueRecommendationFromChannel(channel, badUid);
            }).to.throw('Results from channel "my_channel" are not cached. You must call getRecommendationsFromChannel() first.');
        });


        describe('if getRecommendationsFromChannel() was previously called but the cached list of recommendations is empty', () => {
            beforeEach(() => {
                listening._channelRecommendations[channel] = [];
            });

            it('should throw an Error', () => {
                chai.expect(() => {
                    listening.queueRecommendationFromChannel(channel, badUid);
                }).to.throw('Results from channel "my_channel" are not cached. You must call getRecommendationsFromChannel() first.');
            });
        });


        describe('if getRecommendationsFromChannel() was previously called and the cached list of recommendations is not empty', () => {
            beforeEach(() => {
                listening._channelRecommendations[channel] = testDataClone.items;
            });

            it('should throw an Error if the uid is not found in the list', () => {
                chai.expect(() => {
                    listening.queueRecommendationFromChannel(channel, badUid);
                }).to.throw('Unable to find story with uid ' + badUid + ' in cached list of recommendations from channel "' + channel + '".');
            });

            it('should return the found recommendation if the UID is present in the cached list', () => {
                const result = listening.queueRecommendationFromChannel(channel, goodUid);

                result.attributes.uid.should.equal(goodUid);
            });

            it('should put the found recommendation at the top of the flow recommendations if the UID is present in the cached list', () => {
                listening.queueRecommendationFromChannel(channel, goodUid);

                listening._flowRecommendations[0].attributes.uid.should.equal(goodUid);
            });
        });
    });

    /** @test {Listening#getHistory} */
    describe('getHistory', () => {
        it('should make a request to /history when getHistory is called', done => {
            listening.getHistory()
                .then(recommendations => {
                    fetchMock.called(historyUrl).should.be.true;
                    fetchMock.calls().unmatched.length.should.equal(0);
                    recommendations.should.not.be.undefined;
                    recommendations.length.should.be.equal(6);
                    done();
                })
                .catch(done);
        });
    });

    /** @test {Listening#resetFlow} */
    describe('resetFlow', () => {
        describe('no recommendations have been requested yet', () => {
            it('should return a fulfilled promise', done => {
                listening.resetFlow().should.eventually.be.ok.notify(done);
            });
        });
        describe('if recommendations have already been requested', () => {
            describe('and no queued ratings are present', () => {
                it('should reset internal variables to allow the flow to reset', done => {
                    listening.getRecommendation()
                        .then(() => {
                            listening.resetFlow()
                                .then(() => {
                                    listening._flowRecommendations.length.should.equal(0);
                                    chai.expect(listening._flowPromise).to.be.null;
                                    done();
                                })
                                .catch(done);
                        })
                        .catch(done);
                });
            });
            describe('and queued ratings are present', () => {
                it('should send the queued ratings first, then reset', done => {
                    listening.getRecommendation()
                        .then(recommendation => {
                            recommendation.recordAction(NprOne.Action.COMPLETED, recommendation.attributes.duration);
                            return listening.resetFlow();
                        })
                        .then(() => {
                            fetchMock.called(ratingUrl).should.be.true;
                            fetchMock.calls().unmatched.length.should.equal(0);
                            listening._flowRecommendations.length.should.equal(0);
                            done();
                        })
                        .catch(done);
                });
            });
        });
    });

    /** @test {Listening#resumeFlowFromRecommendation} */
    describe('resumeFlowFromRecommendation', () => {
        describe('no recommendations have been requested yet', () => {
            it('should set the recommendations to the active recommendation ' +
               'and the flow should advance as normal when actions are received', done => {
                testDataClone.items.splice(0, 1); // remove stationId, should be newscast

                const rec = listening.resumeFlowFromRecommendation(testDataClone);
                rec.recordAction(NprOne.Action.START, 0);

                listening.getRecommendation()
                    .then(recommendation => {
                        fetchMock.called(ratingUrl).should.be.true;
                        fetchMock.calls().unmatched.length.should.equal(0);
                        listening._flowRecommendations.length.should.equal(5);  // testDataClone
                        recommendation.attributes.uid !== testDataClone.items[0].attributes.uid;
                        done();
                    })
                    .catch(done);
            });
        });
    });
});