I have written a NodeJS module and its tests to generate a URL, shorten it with Bitly, insert the short link in a tex file, compile these tex files to PDF and then merge them into one.
The code is working but it looks confusing and the tests are too hard to follow in my opinion.
I'm not sure if I should mock fs
to avoid writing to disk or just let the module write during the tests and cleanup after all the tests.
I'm wondering if I shouldn't have dummy tex files in my fixtures and use these instead of external tex files that aren't included in the repo.
Here is the code:
lib/Package.js
// A package represents what is sent in the mail. It contains a letter and a resume, and provides its name, short_url
// and long_url.
var crypto = require('crypto');
var fs = require('fs');
var async = require('async');
var helpers = require('./helpers');
var Bitly = require('bitly');
var path = require('path');
var exec = require('child_process').exec;
var changeCase = require('change-case');
module.exports = Package;
function Package(config) {
var self = this;
var defaults = {
config_files: {
config: 'config.json',
secrets: 'secrets.json'
}
};
self.recipient = config.recipient;
self.files = config.files;
self.config_files = config.config_files || defaults.config_files;
}
// The init method should be called just after Package is intantiated. It runs the asynchronous code needed to
// populate various Package's properties.
// This avoids putting the async code in the constructor, saving us from a messy instantiation
Package.prototype.init = function(done) {
var self = this;
async.auto({
populateName: function(next) {
self._initName(next);
},
populateConfig: function(next) {
self._initConfig(next);
},
populateLongUrl: ['populateConfig', function(next) {
self._initLongUrl();
next();
}],
populateShortUrl: ['populateLongUrl', function(next) {
self._initShortUrl(next);
}],
writeShortUrl: ['populateShortUrl', function(next) {
self._writeShortUrl(next);
}]
}, function(err) {
if (err) return done(err);
return done(null, self);
});
};
Package.prototype.make = function(done) {
var self = this;
async.series([
function compileTex(next) {
self._compileTexToPdf(next)
},
function mergePdf(next) {
self._mergePdf(next);
}
], done);
};
// ***********************************
// Private functions
//
// ***********************************
Package.prototype._initName = function(done) {
var self = this;
if (typeof(self.name) === 'string') return done(null, self.name);
self._generateName(function(err, name) {
if (err) return done(err);
self.name = name;
return done(null, self.name);
});
};
Package.prototype._initConfig = function(done) {
var self = this;
async.forEachOf(self.config_files, function(file_path, file_type, done) {
helpers.parseJson(file_path, function(err, json_object) {
if (err) return done(err);
self[file_type] = json_object;
done(null);
});
}, function(err) {
if (err) return done(err);
done(null, self);
});
};
Package.prototype._initLongUrl = function() {
var self = this;
// Handle multiple init calls
if(typeof(self.long_url) === 'string') return self.long_url;
self.long_url = self._generateLongUrl();
return self.long_url;
};
Package.prototype._initShortUrl = function(done) {
var self = this;
// Handle multiple init calls
if(typeof(self.short_url) === 'string') return done(null, self.short_url);
self._generateShortUrl(function(err, short_url) {
if (err) return done(err);
self.short_url = short_url;
done(null, self.short_url);
});
};
Package.prototype._generateHash = function(files, done) {
var shasum = crypto.createHash('sha1');
// objects don't guarantee enumeration order, the order in which files are sent to the hash stream can change the
// resulting hash so we will use alphabetical order
var sorted_file_names = Object.keys(files).sort();
async.each(sorted_file_names, function(file_name, next) {
var stream = fs.createReadStream(files[file_name]);
stream.on('data', function(data) {
shasum.update(data);
});
stream.on('end', function() {
next();
});
}, function returnHash(err) {
if (err) return done(err);
return done(null, shasum.digest('hex'));
});
};
Package.prototype._generateName = function(done) {
var self = this;
self._generateHash(self.files, function(err, hash) {
if (err) return done(new Error('Package: Error when generating hash: ' + err));
var package_name = self.recipient.toLowerCase() + '_' + hash.substr(0, 10);
return done(null, package_name);
});
};
Package.prototype._generateLongUrl = function() {
var self = this;
var protocol = 'https://';
var aws_fqdn = '.s3.amazonaws.com/';
var package_name = self.name;
var archive_ext = '.tar.gz';
return protocol + self.config.s3_bucket + aws_fqdn + package_name + archive_ext;
};
Package.prototype._generateShortUrl = function(done) {
var self = this;
var bitly = new Bitly(self.secrets.bitly_access_token);
bitly.shorten(self.long_url, function tagLink(err, response) {
if (err) return done(err);
var short_url = response.data.url;
var link_title = self.recipient + "'s package";
var link_note = new Date().toISOString();
// Helps tracking which link was sent where and when
bitly.linkEdit(['title', 'note'], short_url, [link_title, link_note], function(err) {
if (err) return done(err);
done(null, short_url);
});
});
};
Package.prototype._writeShortUrl = function(done) {
var self = this;
var letter_path = path.parse(self.files.letter);
var url_file = letter_path.dir + path.sep + 'url.tex';
self._writeUrlToTex(url_file, self.short_url, done);
};
Package.prototype._writeUrlToTex = function(url_file, url, done) {
var file_contents = '\\url{' + url + '}';
fs.writeFile(url_file, file_contents, function(err) {
if (err) return done(err);
done(null, url_file);
});
};
// Checks if files.code is more recent than files.artifact (using mtime). If so, return false. If artifact doesn't
// exist, returns false. Returns true if files.artifact is newer than files.code
Package.prototype._isArtifactFresh = function(files, done) {
if (!files.hasOwnProperty('code') || !files.hasOwnProperty('artifact')) throw new TypeError('Package: missing argument, expected files.code and files.artifact, got: ' + JSON.stringify(files, null, 4));
fs.stat(files.code, function(err, code_stats) {
// missing files.code shouldn't happen
if (err) return done(err);
fs.stat(files.artifact, function(err, artifact_stats) {
// missing artifact is ok, it means it has to be created from files.code but it's not an exception
if (err) return done(null, false);
var code_mtime = code_stats.mtime.getTime(); // in ms
var artifact_mtime = artifact_stats.mtime.getTime(); // in ms
// if code was modified before artifact
if (code_mtime < artifact_mtime) return done(null, true);
done(null, false);
});
});
};
Package.prototype._compileTexToPdf = function(done) {
var self = this;
async.forEachOf(self.files, function compile(tex_path, filename, next) {
var parsed_tex_path = path.parse(tex_path);
var pdf_output_path = parsed_tex_path.dir + path.sep + parsed_tex_path.name + '.pdf';
var files = {
code: tex_path,
artifact: pdf_output_path
};
// avoids unnecessary compilation if pdf files exist and latex code wasn't changed
self._isArtifactFresh(files, function(err, isFresh) {
if (err) return next(err);
if (typeof(self.compiled_files) === 'undefined' && !self.compiled_files) self.compiled_files = {};
self.compiled_files[filename] = pdf_output_path;
if (!isFresh) {
// setting the cwd avoids pdflatex erroring when using custom latex documentclass files and puts the compiled pdf
// file next to the tex file.
var options = { cwd: parsed_tex_path.dir };
var command = 'pdflatex ' + tex_path;
exec(command, options, function(err) {
if (err) return next(err);
return next(null);
});
} else {
return next(null);
}
});
}, function(err) {
if (err) return done(err);
done(null);
});
};
Package.prototype._mergePdf = function(done) {
var self = this;
// use path.resolve to get absolute paths to pass to `gs`
var parsed_letter_path = path.parse(self.compiled_files.letter);
var parsed_resume_path = path.parse(self.compiled_files.resume);
var output_filename = parsed_letter_path.dir + path.sep + changeCase.snakeCase(self.recipient) + '.pdf';
var letter_path = path.resolve(parsed_letter_path.dir + path.sep + parsed_letter_path.base);
var resume_path = path.resolve(parsed_resume_path.dir + path.sep + parsed_resume_path.base);
// Use path.resolve so gs can find the file
var command = 'gs -dBATCH -dNOPAUSE -sDEVICE=pdfwrite -sOutputFile=' + path.resolve(output_filename) + ' ' + letter_path + ' ' + resume_path;
exec(command, function(err) {
if (err) return done(err);
self.compiled_files.package = output_filename;
done(null);
});
};
test/package_spec.js
/*eslint-env mocha */
var expect = require('chai').expect;
var fs = require('fs');
var rewire = require('rewire');
var Package = rewire('../lib/package');
var async = require('async');
var _ = require('lodash');
var path = require('path');
var nock = require('nock');
describe('Package', function() {
var valid_config = {
recipient: 'Test',
files: {
letter: 'test/fixtures/fileA.test',
resume: 'test/fixtures/fileB.test'
},
config_files: {
config: 'test/fixtures/config.json',
secrets: 'test/fixtures/secrets.json'
}
};
var revert = {};
// Don't clutter the disk during tests
var fsMock = {
writeFile: function(file_name, contents, cb) {
return cb(null);
}
};
var SHORT_URL = 'http://bit.ly/1V5mTM2';
var PACKAGE_NAME = 'test_0790feebb1'
var bitly_host = 'https://api-ssl.bitly.com:443';
var bitly_endpoints = {
shorten: '/v3/shorten',
link_edit: '/v3/user/link_edit'
};
var bitly_fixtures = {
shorten: 'test/fixtures/bitly_com_shorten.json',
link_edit: 'test/fixtures/bitly_com_link_edit.json'
};
nock(bitly_host)
.persist()
.get(bitly_endpoints.shorten)
.query(true)
.replyWithFile(200, bitly_fixtures.shorten)
.get(bitly_endpoints.link_edit)
.query(true)
.replyWithFile(200, bitly_fixtures.link_edit)
beforeEach(function() {
revert.fswriteFile = Package.__set__('fs.writeFile', fsMock.writeFile);
});
afterEach(function(done) {
// Reverts every mock so they don't have to be manually reverted every time
async.forEachOf(revert, function(revert_mock, mock_name, next) {
revert_mock();
delete revert[mock_name];
next();
}, done);
});
describe('#init', function() {
var test_package;
var revert;
var fixture = {
config_file_path: valid_config.config_files.config
};
beforeEach(function(done) {
// Prevent writing url.tex during tests to avoid cluttering
var fsMock = {
writeFile: function(file_name, contents, done) {
return done(null);
}
};
revert = Package.__set__('fs.writeFile', fsMock.writeFile);
async.parallel({
loadFixture: function(next) {
fs.readFile(fixture.config_file_path, function(err, data) {
if (err) return done(err);
fixture.config_data = JSON.parse(data);
next();
});
},
instantiatePkg: function(next) {
test_package = new Package(valid_config);
next();
},
initPkg: function(next) {
test_package.init(function(err) {
if (err) return done(err);
next();
});
}
}, function(err) {
if (err) return done(err);
done();
});
});
afterEach(function() {
// Remove fs.writeFile mock
revert();
});
it('handles being initialized more than once', function(done) {
test_package.init(function(err, pkg) {
if (err) return done(err);
expect(pkg.config).to.deep.equal(fixture.config_data);
done();
});
});
it('populates self.config', function() {
expect(test_package.config).to.deep.equal(fixture.config_data);
});
it('populates self.long_url', function() {
var long_url = 'https://test-bucket.s3.amazonaws.com/' + PACKAGE_NAME + '.tar.gz';
expect(test_package.long_url).to.eq(long_url);
});
it('populates self.name', function() {
expect(test_package.name).to.eq(PACKAGE_NAME);
});
it('populates self.short_url', function() {
expect(test_package.short_url).to.eq(SHORT_URL);
});
it('writes the short URL to a LaTeX file', function(done) {
var fsMock = {
writeFile: function(file_name, contents, done) {
var latex_string = '\\url{' + SHORT_URL + '}';
expect(contents).to.eq(latex_string);
done(null);
}
};
var revert = Package.__set__('fs.writeFile', fsMock.writeFile);
test_package.init(function(err) {
revert();
if (err) return done(err);
done(null);
});
});
});
describe('self.name', function() {
var subject;
beforeEach(function(done) {
subject = new Package(valid_config);
subject.init(done);
});
it('is a string', function() {
expect(typeof(subject.name)).to.eq('string');
});
it("contains the recipient's lowercase name", function() {
expect(subject.name).to.match(/test/);
});
it('contains a 6 chars hash', function() {
var hash_regex = /[a-f0-9]{6}/;
expect(subject.name).to.match(hash_regex);
});
});
describe('#make', function() {
context('with existing tex files', function() {
var subject;
beforeEach(function(done) {
subject = new Package(valid_config);
subject.init(done);
});
it('populates self.compiled_files', function(done) {
var execMock = function(cmd, opt, cb) {
// the options to exec are optional
if (!cb) {
cb = opt;
opt = {};
}
return cb(null);
};
revert.exec = Package.__set__('exec', execMock);
subject.make(function(err) {
if (err) return done(err);
expect(subject.compiled_files).to.deep.equal({
letter: 'test/fixtures/fileA.pdf',
resume: 'test/fixtures/fileB.pdf',
package: 'test/fixtures/test.pdf'
});
done(null);
});
});
context('when artifacts are newer', function() {
var subject;
beforeEach(function(done) {
var a_while_ago = new Date(1986, 7, 25, 0, 30);
var now = new Date(Date.now());
fsMock.stat = function(file, cb) {
// ensures that the artifact is always newer than the code
if (file.match(/.*\.pdf/)) return cb(null, { mtime: now });
return cb(null, { mtime: a_while_ago });
};
revert.fsWriteFile = Package.__set__('fs.writeFile', fsMock.writeFile);
revert.fsStat = Package.__set__('fs.stat', fsMock.stat);
subject = new Package(valid_config);
subject.init(done);
});
it('does not re-generate them', function(done) {
var exec_not_called = true; // will fail test if the mock isn't run
var execMock = function(cmd, opt, cb) {
// the options to exec are optional
if (!cb) {
cb = opt;
opt = {};
}
if (cmd.match(/^pdflatex/)) exec_not_called = false;
return cb(null);
};
revert.exec = Package.__set__('exec', execMock);
subject.make(function(err) {
if (err) return done(err);
expect(exec_not_called).to.eq(true);
done();
});
});
});
context('when artifacts are older', function() {
beforeEach(function(done) {
var a_while_ago = new Date(1986, 7, 25, 0, 30);
var now = new Date(Date.now());
fsMock.stat = function(file, cb) {
// ensures the code is always newer than the artifacact
if (file.match(/.*\.pdf/)) return cb(null, { mtime: a_while_ago });
return cb(null, { mtime: now });
};
revert.fsStat = Package.__set__('fs.stat', fsMock.stat);
subject = new Package(valid_config);
subject.init(done);
});
it('regenerates them', function(done) {
var exec_called = false;
var execMock = function(cmd, opt, cb) {
// the options to exec are optional
if (!cb) {
cb = opt;
opt = {};
}
if (cmd.match(/^pdflatex/)) exec_called = true;
return cb(null);
};
revert.exec = Package.__set__('exec', execMock);
subject.make(function(err) {
if (err) return done(err);
expect(exec_called).to.eq(true);
done();
});
});
});
it('merges the letter and resume into one file', function(done) {
var gs_cmd_string;
var execMock = function(cmd, opt, cb) {
if (!cb) {
cb = opt;
opt = {};
}
// ignore when exec runs for pdflatex
if (cmd.match(/^gs/)) {
gs_cmd_string = cmd;
}
return cb(null);
};
revert.execMock = Package.__set__('exec', execMock);
var output_file_path = path.resolve('test/fixtures/test.pdf');
var letter_file_path = path.resolve('test/fixtures/fileA.pdf');
var resume_file_path = path.resolve('test/fixtures/fileB.pdf');
var expected_gs_cmd_string = 'gs -dBATCH -dNOPAUSE -sDEVICE=pdfwrite -sOutputFile=' + output_file_path + ' ' + letter_file_path + ' ' + resume_file_path;
subject.make(function(err) {
if (err) return done(err);
expect(gs_cmd_string).to.eq(expected_gs_cmd_string);
done();
});
});
});
});
});