Search code examples
javascriptnode.jstestingchaisinon

How to stub https.request response.pipe with sinon.js and promise and possible paths?


I am currently doing something similar as described in How to stub https.request response.pipe with sinon.js?

Actually I am doing the same but my code is a bit more complex since I have multiple path ways (conditions) regarding the request code handling and use a promise instead of callback. Furthermore, I use the https instead of the request module.

I have currently the following code which I can't get to work:

//utils.js
/**
 * Fetches the repository archive
 *
 * @param url The archive download url
 * @param dest The temp directory path
 * @param accessToken The access token for the repository (only available if private repository)
 */
exports.fetchArchive = function(url, dest, accessToken) {
    let options = {
        headers: {}
    }
    if (accessToken) {
        options.headers = {'PRIVATE-TOKEN': accessToken}
    }
    return new Promise((resolve, reject) => {
        https
            .get(url, options,(response) => {
                const code = response.statusCode;
                if (code >= 400) {
                    reject({ code, message: response.statusMessage });
                } else if (code >= 300) {
                    this.fetchArchive(response.headers.location, dest).then(resolve, reject);
                } else {
                    response
                        .pipe(fs.createWriteStream(dest))
                        .on('end', () => resolve(null))
                        .on('error', () => reject({ code, message: response.statusMessage }));
                }
            })
    });
}

_

//utils.test.js
    describe('public fetchArchive', () => {
        it(`should have a redirect status code (>= 300) and redirect and thus be called at twice`, () => {
            let options = {
                headers: {}
            }
            options.headers = {'PRIVATE-TOKEN': repoPropsPrivate.accessToken}

            const mockResponse = `{"data": 123}`;
            // //Using a built-in PassThrough stream to emit needed data.
            const mockStream = new PassThrough();
            mockStream.push(mockResponse);
            mockStream.end(); //Mark that we pushed all the data.

            sinon
                .stub(https, 'get')
                .callsFake(function (privateUrl, options, callback) {
                    callback(mockStream);
                    return Promise.resolve(null); //Stub end method btw
                });


            //Finally keep track of how 'pipe' is going to be called
            sinon.spy(mockStream, 'pipe');

            return utils.fetchArchive(privateArchiveUrl, privateArchiveDest, repoPropsPrivate.accessToken)
                .then((res) => {
                    sinon.assert.calledOnce(mockStream.pipe);
                    //We can get the stream that we piped to.
                    let writable = mockStream.pipe.getCall(0).args[0];
                    assert.equal(writable.path, './output.json');
                })
        });
    });

I am not sure how to adapt the code from the other post to fit my requirements.

I don't know how to send a response including the request code followed by the stream, and furthermore how to process the promise then in the test.

I would really appreciate your help.


Solution

  • You could create a custom writable stream in order to have more control over your mocked data, like adding statusCode or headers properties on the stream object. I would create something like this (this simulates an actual stream where _read() will emit 4 bytes of data from the given responseBody until there's nothing left to read:

    const {Readable} = require('stream');
    
    class ResponseStreamMock extends Readable {
        constructor(statusCode, responseBody) {
            super();
            this.statusCode = statusCode;
            this.headers = {location: 'someLocation'};
            this.responseData = responseBody !== undefined ? Buffer.from(JSON.stringify(responseBody), "utf8") : Buffer.from([]);
            this.bytesRead = 0;
            this.offset = 4;
        }
    
        _read() {
            if (this.bytesRead >= this.responseData.byteLength) {
                this.push(null);
            } else {
                setTimeout(() => {
                    const buff = this.responseData.toString('utf8', this.bytesRead, this.bytesRead + this.offset);
                    this.push(Buffer.from(buff));
                    this.bytesRead += this.offset;
                }, Math.random() * 200)
            }
        }
    }
    

    You can then use it like this in your unit-test:

    describe('public fetchArchive', () => {
        it(`should redirect for status code >= 300, then pipe response into fs-stream`, async function () {
            this.timeout(5000)
            const httpGetStub = sinon.stub(https, 'get');
            let redirectStream = new ResponseStreamMock(300);
            let responseStream = new ResponseStreamMock(200, {"someData": {"test": 123}});
            httpGetStub.onCall(0).callsFake(function (privateUrl, options, callback) {
                redirectStream.resume(); // immediately flush the stream as this one does not get piped
                callback(redirectStream);
            });
            httpGetStub.onCall(1).callsFake(function (privateUrl, options, callback) {
                callback(responseStream);
            });
    
            sinon.spy(redirectStream, 'pipe');
            sinon.spy(responseStream, 'pipe');
            const fsWriteStreamStub = sinon
                .stub(fs, 'createWriteStream').callsFake(() => process.stdout); // you can replace process.stdout with new PassThrough() if you don't want any output
    
            const result = await fetchArchive("someURL", "someDestination", "")
    
            sinon.assert.calledOnce(fsWriteStreamStub);
            sinon.assert.calledOnce(responseStream.pipe);
            sinon.assert.notCalled(redirectStream.pipe);
            assert.equal(result, null);
        });
    });
    

    Note that you need to change the implementation of fetchArchive a bit to get this to work:

    exports.fetchArchive = function fetchArchive(url, dest, accessToken) {
        let options = {
            headers: {}
        }
        if (accessToken) {
            options.headers = {'PRIVATE-TOKEN': accessToken}
        }
        return new Promise((resolve, reject) => {
            https
                .get(url, options, (response) => {
                    const code = response.statusCode;
                    if (code >= 400) {
                        reject({code, message: response.statusMessage});
                    } else if (code >= 300) {
                        fetchArchive(response.headers.location, dest).then(resolve, reject);
                    } else {
                        response.on('end', () => resolve(null))
                        response.on('error', () => reject({code, message: response.statusMessage}));
                        response.pipe(fs.createWriteStream(dest))
                    }
                })
        });
    }