13
\$\begingroup\$

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();
        });
      });
    });
  });
});
\$\endgroup\$

1 Answer 1

4
\$\begingroup\$

I spent a good time reading your code, only found minor stuff:

  • You have code similar to this a few times:

    exec(command, options, function(err) {
      if (err) return next(err);
      return next(null);
    });
    

    since you never check for the actual 'nullness' of err, you could just write

    return exec(command, options, next);
    
  • I understand the following code is self documenting, however I still feel it's a bit overkill:

    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;
    };
    

    compared to

    Package.prototype._generateLongUrl = function() {
      return `https://${self.config.s3_bucket}.s3.amazonaws.com/${self.name}.tar.gz`;
    }
    
  • This might be a lack imagination on my part, but if (typeof(self.compiled_files) === 'undefined' && !self.compiled_files) if you check for undefined, then the second clause can be dropped? There is no way that self.compile_files can be true ? Peronally I would go for self.compiled_files = self.compiled_files || {};

  • I initially resolved not to mention it, but I cracked. Please use consistently lowerCamelCase everywhere. compiled_files -> compiledFiles

  • In _mergePdf, the following

    var parsed_resume_path = path.parse(self.compiled_files.resume);
    var resume_path = path.resolve(parsed_resume_path.dir + path.sep + parsed_resume_path.base);
    

    could be

    var resumePath = path.resolve(self.compiled_files.resume);
    

All in all, I think your variable are wordy, but then again your code is pretty easy to follow. I think your code will be even easier to follow with the first observation and in some cases I think you are trying too hard like in my last point.

\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge that you have read and understand our privacy policy and code of conduct.

Not the answer you're looking for? Browse other questions tagged or ask your own question.