diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 0000000..3dddf3f --- /dev/null +++ b/.eslintignore @@ -0,0 +1,2 @@ +dist/* +node_modules/* diff --git a/.eslintrc b/.eslintrc new file mode 100644 index 0000000..a47a248 --- /dev/null +++ b/.eslintrc @@ -0,0 +1,217 @@ +--- + env: + browser: true + node: false + + globals: + global: false + module: false + require: false + define: false + exports: false + console: false + debugger: false + # 0 disabled, 1 warning, 2 error + rules: + # possible errors + no-comma-dangle: 2 + no-cond-assign: + - 2 + - "always" + no-console: 1 + no-constant-condition: 2 + no-control-regex: 2 + no-debugger: 1 + no-dupe-keys: 2 + no-empty: 2 + no-empty-class: 2 + no-ex-assign: 2 + no-extra-boolean-cast: 2 + no-extra-semi: 2 + no-func-assign: 2 + no-inner-declarations: 2 + no-invalid-regexp: 2 + no-irregular-whitespace: 2 + no-negated-in-lhs: 2 + no-obj-calls: 2 + no-regex-spaces: 2 + no-reserved-keys: 2 + no-sparse-arrays: 2 + no-unreachable: 2 + use-isnan: 2 + valid-jsdoc: 2 + valid-typeof: 2 + + # best practices + # block-scoped-var: 2 + complexity: 0 + consistent-return: 2 + curly: + - 2 + - "all" + default-case: 2 + dot-notation: 2 + eqeqeq: + - 2 + - "allow-null" + guard-for-in: 2 + no-alert: 1 + no-caller: 2 + no-div-regex: 0 + no-else-return: 2 + no-empty-label: 2 + no-eq-null: 0 + no-eval: 2 + no-native-reassign: 2 + no-extra-bind: 2 + no-fallthrough: 2 + no-floating-decimal: 2 + no-implied-eval: 2 + no-iterator: 2 + no-labels: 2 + no-lone-blocks: 2 + no-loop-func: 2 + no-multi-spaces: 2 + no-multi-str: 2 + no-native-reassign: 2 + no-new: 2 + no-new-func: 2 + no-new-wrappers: 2 + no-octal: 2 + no-octal-escape: 2 + no-process-env: 0 + no-proto: 2 + no-redeclare: 2 + no-return-assign: 2 + no-script-url: 2 + no-self-compare: 2 + no-sequences: 2 + no-throw-literal: 2 + no-unused-expressions: 2 + no-void: 2 + no-warning-comments: + - 1 + - terms: + - "todo" + - "fixme" + location: "anywhere" + no-with: 2 + radix: 2 + vars-on-top: 2 + wrap-iife: + - 2 + - "inside" + yoda: + - 2 + - "never" + + # strict mode + global-strict: 0 + no-extra-strict: 0 + strict: 0 + + # variables + no-catch-shadow: 2 + no-delete-var: 2 + no-label-var: 2 + no-shadow: 2 + no-shadow-restricted-names: 2 + no-undef: 2 + no-undef-init: 2 + no-undefined: 2 + no-unused-vars: 2 + no-use-before-define: + - 2 + - "nofunc" + + # node.js + + # stylistic stuff + indent: + - 2 + - 2 + brace-style: + - 2 + - "1tbs" + - allowSingleLine: true + camelcase: 2 + comma-spacing: + - 2 + - before: false + after: true + comma-style: + - 2 + - "last" + consistent-this: + - 2 + - "self" + eol-last: 2 + func-names: 0 + func-style: + - 2 + - "declaration" + key-spacing: + - 2 + - beforeColon: false + afterColon: true + max-nested-callbacks: 0 + new-cap: + - 2 + - newIsCap: true + capIsNew: true + new-parens: 2 + no-array-constructor: 2 + no-inline-comments: 0 + no-lonely-if: 2 + no-mixed-spaces-and-tabs: 2 + no-multiple-empty-lines: + - 2 + - max: 1 + no-nested-ternary: 2 + no-new-object: 2 + no-space-before-semi: 2 + no-spaced-func: 2 + no-ternary: 0 + no-trailing-spaces: 2 + no-underscore-dangle: 0 + no-wrap-func: 2 + one-var: 0 + operator-assignment: + - 2 + - "always" + padded-blocks: + - 2 + - "never" + quote-props: + - 2 + - "as-needed" + quotes: + - 2 + - "single" + - "avoid-escape" + semi: + - 2 + - "always" + sort-vars: 0 + space-after-keywords: + - 2 + - "always" + - checkFunctionKeyword: true + space-before-blocks: + - 2 + - "always" + space-in-brackets: 0 + space-in-parens: + - 2 + - "never" + space-infix-ops: 2 + space-return-throw-case: 2 + space-unary-ops: + - 2 + - words: true + nonwords: false + spaced-line-comment: + - 2 + - "always" + wrap-regex: 0 + diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f6cb919 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +.DS_STORE +dist +npm-debug.log diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..b349eb6 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +v0.10.36 diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..cd86071 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,9 @@ +# 0.2.0 + +- `expirationYear` + - Year maxes out at 19 years in the future + +# 0.1.0 + +- `expirationDate` + - Add `month:` and `year:` to return object. Strings if both valid, `null` otherwise. diff --git a/README.md b/README.md new file mode 100644 index 0000000..e4bf2e3 --- /dev/null +++ b/README.md @@ -0,0 +1,251 @@ +# Credit Card Validator + +Credit Card Validator provides validation utilities for credit card data inputs. It is designed as a CommonJS module for use in Node, io.js, or the [browser](http://browserify.org/). It includes first class support for 'potential' validity so you can use it to present appropriate UI to your user as they type. + +A typical use case in a credit card form is to notify the user if the data they are entering is invalid. In a credit card field, entering “411” is not necessarily valid for submission, but it is still potentially valid. Conversely, if a user enters “41x” that value can no longer pass strict validation and you can provide a response immediately. + +Credit Card Validator will also provide a determined card type (using [credit-card-type](https://github.com/braintree/credit-card-type)). This is useful for scenarios in which you wish to render an accompanying payment method icon (Visa, MasterCard, etc.). Additionally, by having access to the current card type, you can better manage the state of your credit card form as a whole. For example, if you detect a user is entering (or has entered) an American Express card number, you can update the `maxlength` attribute of your `CVV` input element from 3 to 4 and even update the corresponding `label` from 'CVV' to 'CID'. + +## Example + +```javascript +var valid = require('card-validator'); + +var numberValidation = valid.number('4111'); + +if (!numberValidation.isPotentiallyValid) { + renderInvalidCardNumber(); +} + +if (numberValidation.card) { + console.log(numberValidation.card.type); // 'visa' +} +``` + +## API + +### `var valid = require('card-validator');` + +- - - + +#### `valid.number(value: string): object` + +```javascript +{ + card: { + niceType: 'American Express', + type: 'american-express', + pattern: '^3[47][\\s\\d]*$', + isAmex: true, + gaps: [4, 10], + length: 15, + code: {name: 'CID', size: 4} + }, + isPotentiallyValid: true, // if false, indicates there is no way the card could be valid + isValid: true // if true, number is valid for submission +} +``` + +If a valid card type cannot be determined, `number()` will return `null`; + +A fake session where a user is entering a card number may look like: + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
InputOutputSuggested Handling
Valuecard.typeisPotentiallyValidisValidRender Invalid UIAllow Submit
''nulltruefalsenono
'6'nulltruefalsenono
'60'nulltruefalsenono
'601'nulltruefalsenono
'6011''discover'truefalsenono
'601'nulltruefalsenono
'60'nulltruefalsenono
'6'nulltruefalsenono
''nulltruefalsenono
'x'nullfalsefalseyesno
''nulltruefalsenono
'4'nulltruefalsenono
'41''visa'truefalsenono
'411''visa'truefalsenono
'4111111111111111'visatruetruenoyes
'411x'nullfalsefalseyesno
+ +- - - + +#### `valid.expirationDate(value: string): object` + +```javascript +{ + isPotentiallyValid: true, // if false, indicates there is no way this could be valid in the future + isValid: true, + month: '10', // a string with the parsed month if valid, null if either month or year are invalid + year: '2016' // a string with the parsed year if valid, null if either month or year are invalid +} +``` + +`expirationDate` will parse strings in a variety of formats: + +| Input | Output | +| ----- | ------ | +| `'10/19'`
`'10 / 19'`
`'1019'`
`'10 19'` | `{month: '10', year: '19'}` | +| `'10/2019'`
`'10 / 2019'`
`'102019'`
`'10 2019'`
`'10 19'` | `{month: '10', year: '2019'}` | + +- - - + +#### `valid.expirationMonth(value: string): boolean` + +`expirationMonth` accepts 1 or 2 digit months. `1`, `01`, `10` are all valid entries. + +- - - + +#### `valid.expirationYear(value: string): boolean` + +`expirationYear` accepts 2 or 4 digit years. `16` and `2016` are both valid entries. + +- - - + +#### `valid.cvv(value: string, maxLength: integer): boolean` + +The `cvv` validation by default tests for a numeric string of 3 characters in length. The `maxLength` can be overridden by passing in an `integer` as a second argument. You would typically switch this length from 3 to 4 in the case of an American Express card which expects a 4 digit CID. + +- - - + +#### `valid.postalCode(value: string): boolean` + +The `postalCode` validation essentially tests for a valid string greater than 3 characters in length. + +## Design decisions + +- The maximum expiration year is 19 years from now. ([source](src/expiration-year.js)) +- `valid.expirationDate` will only return `month:` and `year:` as strings if the two are valid, otherwise they will be `null`. + diff --git a/bower.json b/bower.json new file mode 100644 index 0000000..57b4133 --- /dev/null +++ b/bower.json @@ -0,0 +1,26 @@ +{ + "name": "card-validator", + "main": "index.js", + "version": "1.0.0", + "homepage": "https://github.com/braintree/credit-card-validator", + "authors": [ + "braintree " + ], + "description": "Credit Card Validator provides validation utilities for credit card data inputs.", + "moduleType": [ + "node" + ], + "keywords": [ + "credit", + "card", + "validator" + ], + "license": "MIT", + "ignore": [ + "**/.*", + "node_modules", + "bower_components", + "test", + "tests" + ] +} diff --git a/config/karma.js b/config/karma.js new file mode 100644 index 0000000..5e635c8 --- /dev/null +++ b/config/karma.js @@ -0,0 +1,33 @@ +module.exports = function (config) { + config.set({ + basePath: '../', + frameworks: ['browserify', 'mocha', 'es5-shim', 'chai-sinon'], + autoWatch: true, + browsers: ['PhantomJS'], + + plugins: [ + 'karma-mocha', + 'karma-chai-sinon', + 'karma-browserify', + 'karma-es5-shim', + 'karma-phantomjs-launcher' + ], + + port: 7357, + reporters: ['dots'], + preprocessors: { + 'test/unit/**/*.js': ['browserify'] + }, + browserify: { + extensions: ['.js', '.json'], + ignore: [], + watch: true, + debug: true, + noParse: [] + }, + files: [ + 'test/unit/**/*.js' + ], + exclude: ['**/*.swp'] + }); +}; diff --git a/gulpfile.js b/gulpfile.js new file mode 100644 index 0000000..fcc4321 --- /dev/null +++ b/gulpfile.js @@ -0,0 +1,54 @@ +'use strict'; + +var gulp = require('gulp'); +var browserify = require('browserify'); +var source = require('vinyl-source-stream'); +var streamify = require('gulp-streamify'); +var size = require('gulp-size'); +var uglify = require('gulp-uglify'); +var rename = require('gulp-rename'); +var watch = require('gulp-watch'); +var eslint = require('gulp-eslint'); +var del = require('del'); + +var config = { + namespace: 'braintree', + src: { + js: { + all: './src/**/*.js', + main: './index.js', + watch: './public/js/**/*.js', + output: 'app.built.js', + min: 'app.built.min.js' + } + }, + dist: { js: 'dist/js' } +}; + +gulp.task('lint', function () { + gulp.src([config.src.js.main]) + .pipe(eslint()) + .pipe(eslint.format()); +}); + +gulp.task('js', ['lint'], function () { + return browserify(config.src.js.main) + .bundle() + .pipe(source(config.src.js.output)) + .pipe(streamify(size())) + .pipe(gulp.dest(config.dist.js)) + .pipe(streamify(uglify())) + .pipe(streamify(size())) + .pipe(rename(config.src.js.min)) + .pipe(gulp.dest(config.dist.js)); +}); + +gulp.task('watch', ['js'], function () { + gulp.watch(config.src.js.watch, ['js']); +}); + +gulp.task('clean', function (done) { + del([ config.dist.js ], done); +}); + +gulp.task('build', ['clean', 'js']); diff --git a/index.js b/index.js new file mode 100644 index 0000000..77fad4b --- /dev/null +++ b/index.js @@ -0,0 +1,8 @@ +module.exports = { + number: require('./src/card-number'), + expirationDate: require('./src/expiration-date'), + expirationMonth: require('./src/expiration-month'), + expirationYear: require('./src/expiration-year'), + cvv: require('./src/cvv'), + postalCode: require('./src/postal-code') +}; diff --git a/package.json b/package.json new file mode 100644 index 0000000..fe38189 --- /dev/null +++ b/package.json @@ -0,0 +1,41 @@ +{ + "name": "card-validator", + "version": "1.0.0", + "description": "A library for validating credit card fields", + "main": "index.js", + "scripts": { + "test": "gulp lint && mocha test/**/*.js", + "build": "gulp build" + }, + "author": "", + "license": "MIT", + "devDependencies": { + "browserify": "^6.1.0", + "chai": "^2.1.2", + "del": "^1.1.0", + "gulp": "^3.8.8", + "gulp-eslint": "^0.6.0", + "gulp-rename": "^1.2.0", + "gulp-size": "^1.1.0", + "gulp-streamify": "0.0.5", + "gulp-uglify": "^1.0.2", + "gulp-watch": "^0.6.8", + "karma": "^0.12.31", + "karma-browserify": "^4.0.0", + "karma-chai-sinon": "^0.1.4", + "karma-es5-shim": "0.0.4", + "karma-mocha": "^0.1.10", + "karma-phantomjs-launcher": "^0.1.4", + "mocha": "^2.2.1", + "phantomjs": "^1.9.16", + "sinon": "^1.14.1", + "sinon-chai": "^2.7.0", + "vinyl-source-stream": "^1.0.0" + }, + "dependencies": { + "lodash.isnumber": "^3.0.1", + "credit-card-type": "^1.0.0", + "lodash.assign": "^3.0.0", + "lodash.isstring": "^3.0.1" + } +} diff --git a/src/card-number.js b/src/card-number.js new file mode 100644 index 0000000..8d569d2 --- /dev/null +++ b/src/card-number.js @@ -0,0 +1,64 @@ +var isString = require('lodash.isstring'); +var extend = require('lodash.assign'); +var luhn10 = require('./luhn-10'); +var getCardType = require('credit-card-type'); +var isNumber = require('lodash.isnumber'); + +function verification(card, isPotentiallyValid, isValid) { + return extend({}, {card: card, isPotentiallyValid: isPotentiallyValid, isValid: isValid}); +} + +function isEmptyObject(object) { + var key; + + for (key in object) { + if (object.hasOwnProperty(key)) { return false; } + } + + return true; +} + +function cardNumber(value) { + var cardType; + + if (isNumber(value)) { + value = String(value); + } + + if (!isString(value)) { return verification(null, false, false); } + + if (value === '') { return verification(null, true, false); } + + value = value.replace(/\-|\s/g, ''); + + if (!/^\d*$/.test(value)) { return verification(null, false, false); } + + // TODO: Just letting these go here as validPartials + // The optimal solution would be to have a separate + // set of regexes for partial validations + + // Discover cannot be determined until we have 4 digits + if (value.length <= 3 && value[0] === '6') { + return verification(null, true, false); + } + + // Non-discover cards + if (value.length <= 1 && /^(5|4|3)/.test(value)) { return verification(null, true, false); } + + cardType = getCardType(value); + if (isEmptyObject(cardType)) { return verification(null, false, false); } + + // Recognized as card but not long enough yet: validPartial + if (value.length < cardType.length) { + return verification(cardType, true, false); + } + + if (value.length > cardType.length) { + return verification(cardType, false, false); + } + + var valid = luhn10(value); + return verification(cardType, valid, valid); +} + +module.exports = cardNumber; diff --git a/src/cvv.js b/src/cvv.js new file mode 100644 index 0000000..0dd22b0 --- /dev/null +++ b/src/cvv.js @@ -0,0 +1,20 @@ +var isString = require('lodash.isstring'); +var DEFAULT_LENGTH = 3; + +function verification(isValid, isPotentiallyValid) { + return {isValid: isValid, isPotentiallyValid: isPotentiallyValid}; +} + +function cvv(value, maxLength) { + maxLength = maxLength || DEFAULT_LENGTH; + + if (!isString(value)) { return verification(false, false); } + if (!/^\d*$/.test(value)) { return verification(false, false); } + if (value.length === maxLength) { return verification(true, true); } + if (value.length < maxLength) { return verification(false, true); } + if (value.length > maxLength) { return verification(false, false); } + + return verification(true, true); +} + +module.exports = cvv; diff --git a/src/expiration-date.js b/src/expiration-date.js new file mode 100644 index 0000000..983ca48 --- /dev/null +++ b/src/expiration-date.js @@ -0,0 +1,38 @@ +var parseDate = require('./parse-date'); +var expirationMonth = require('./expiration-month'); +var expirationYear = require('./expiration-year'); +var isString = require('lodash.isstring'); + +function verification(isValid, isPotentiallyValid, month, year) { + return { + isValid: isValid, + isPotentiallyValid: isPotentiallyValid, + month: month, + year: year + }; +} + +function expirationDate(value) { + var date, monthValid, yearValid; + + if (!isString(value)) { + return verification(false, false, null, null); + } + + value = value.replace(/^(\d\d) (\d\d(\d\d)?)$/, '$1/$2'); + date = parseDate(value); + monthValid = expirationMonth(date.month); + yearValid = expirationYear(date.year); + + if (monthValid.isValid && yearValid.isValid) { + return verification(true, true, date.month, date.year); + } + + if (monthValid.isPotentiallyValid && yearValid.isPotentiallyValid) { + return verification(false, true, null, null); + } + + return verification(false, false, null, null); +} + +module.exports = expirationDate; diff --git a/src/expiration-month.js b/src/expiration-month.js new file mode 100644 index 0000000..af485a7 --- /dev/null +++ b/src/expiration-month.js @@ -0,0 +1,31 @@ +var isString = require('lodash.isstring'); + +function verification(isValid, isPotentiallyValid) { + return {isValid: isValid, isPotentiallyValid: isPotentiallyValid}; +} + +function expirationMonth(value) { + var month, result; + + if (!isString(value)) { + return verification(false, false); + } + if ((value.replace(/\s/g, '') === '') || (value === '0')) { + return verification(false, true); + } + if (!/^\d*$/.test(value)) { + return verification(false, false); + } + + month = parseInt(value, 10); + + if (isNaN(value)) { + return verification(false, false); + } + + result = month > 0 && month < 13; + + return verification(result, result); +} + +module.exports = expirationMonth; diff --git a/src/expiration-year.js b/src/expiration-year.js new file mode 100644 index 0000000..c098585 --- /dev/null +++ b/src/expiration-year.js @@ -0,0 +1,52 @@ +var isString = require('lodash.isstring'); +var maxYear = 19; + +function verification(isValid, isPotentiallyValid) { + return {isValid: isValid, isPotentiallyValid: isPotentiallyValid}; +} + +function expirationYear(value) { + var currentFirstTwo, currentYear, firstTwo, len, twoDigitYear, valid; + + if (!isString(value)) { + return verification(false, false); + } + if (value.replace(/\s/g, '') === '') { + return verification(false, true); + } + if (!/^\d*$/.test(value)) { + return verification(false, false); + } + + len = value.length; + + if (len < 2) { + return verification(false, true); + } + + currentYear = new Date().getFullYear(); + + if (len === 3) { + // 20x === 20x + firstTwo = value.slice(0, 2); + currentFirstTwo = String(currentYear).slice(0, 2); + return verification(false, firstTwo === currentFirstTwo); + } + + if (len > 4) { + return verification(false, false); + } + + value = parseInt(value, 10); + twoDigitYear = Number(String(currentYear).substr(2, 2)); + + if (len === 2) { + valid = value >= twoDigitYear && value <= twoDigitYear + maxYear; + } else if (len === 4) { + valid = value >= currentYear && value <= currentYear + maxYear; + } + + return verification(valid, valid); +} + +module.exports = expirationYear; diff --git a/src/luhn-10.js b/src/luhn-10.js new file mode 100644 index 0000000..d331b79 --- /dev/null +++ b/src/luhn-10.js @@ -0,0 +1,5 @@ +module.exports = function luhn10(a,b,c,d,e) { + for(d = +a[b = a.length-1], e=0; b--;) + c = +a[b], d += ++e % 2 ? 2 * c % 10 + (c > 4) : c; + return !(d%10) +}; diff --git a/src/parse-date.js b/src/parse-date.js new file mode 100644 index 0000000..009334e --- /dev/null +++ b/src/parse-date.js @@ -0,0 +1,22 @@ +function parseDate(value) { + var month, len; + + if (value.match('/')) { + value = value.split(/\s*\/\s*/g); + + return { + month: value[0], + year: value.slice(1).join() + }; + } else { + len = value[0] === '0' || value.length > 5 || value.length === 4 || value.length === 3 ? 2 : 1; + month = value.substr(0, len); + + return { + month: month, + year: value.substr(month.length, 4) + }; + } +} + +module.exports = parseDate; diff --git a/src/postal-code.js b/src/postal-code.js new file mode 100644 index 0000000..98141ca --- /dev/null +++ b/src/postal-code.js @@ -0,0 +1,17 @@ +var isString = require('lodash.isstring'); + +function verification(isValid, isPotentiallyValid) { + return {isValid: isValid, isPotentiallyValid: isPotentiallyValid}; +} + +function postalCode(value) { + if (!isString(value)) { + return verification(false, false); + } else if (value.length < 4) { + return verification(false, true); + } + + return verification(true, true); +} + +module.exports = postalCode; diff --git a/test/unit/card-number.js b/test/unit/card-number.js new file mode 100644 index 0000000..8741011 --- /dev/null +++ b/test/unit/card-number.js @@ -0,0 +1,146 @@ +var expect = require('chai').expect; +var cardNumber = require('../../src/card-number'); + +describe('number validates', function () { + + describe('partial validation sequences', function () { + table([ + ['', + {card: null, isPotentiallyValid: true, isValid: false}], + ['6', + {card: null, isPotentiallyValid: true, isValid: false}], + ['60', + {card: null, isPotentiallyValid: true, isValid: false}], + ['601', + {card: null, isPotentiallyValid: true, isValid: false}], + ['6011', + {card: 'discover', isPotentiallyValid: true, isValid: false}], + ['4', + {card: null, isPotentiallyValid: true, isValid: false}], + ['41', + {card: 'visa', isPotentiallyValid: true, isValid: false}], + ['411', + {card: 'visa', isPotentiallyValid: true, isValid: false}], + ['', + {card: null, isPotentiallyValid: true, isValid: false}], + ['x', + {card: null, isPotentiallyValid: false, isValid: false}] + ]); + }); + + describe('normal cases', function () { + table([ + ['123', + {card: null, isPotentiallyValid: false, isValid: false}], + ['4012888888881881', // Valid Visa + {card: 'visa', isPotentiallyValid: true, isValid: true}], + ['4111111111111111', // Valid Visa + {card: 'visa', isPotentiallyValid: true, isValid: true}], + ['6011000990139424', // Valid Discover + {card: 'discover', isPotentiallyValid: true, isValid: true}], + ['411111y', + {card: null, isPotentiallyValid: false, isValid: false}], + ['41111111111111111111', // Too long + {card: 'visa', isPotentiallyValid: false, isValid: false}], + ['1111111111111111', // Right length but not luhn + {card: null, isPotentiallyValid: false, isValid: false}], + ['4111111111111112', // Visa but no luhn + {card: 'visa', isPotentiallyValid: false, isValid: false}], + ]); + }); + + describe('weird formatting', function () { + table([ + ['4111-1111-1111-1111', + {card: 'visa', isPotentiallyValid: true, isValid: true}], + ['4111 1111 1111 1111', + {card: 'visa', isPotentiallyValid: true, isValid: true}], + ['601 1 1 1 1 1 1 1 1 1 1 1 1 7', + {card: 'discover', isPotentiallyValid: true, isValid: true}], + ]); + }); + + describe('Discover', function () { + table([ + ['6011111', + {card: 'discover', isPotentiallyValid: true, isValid: false}], + ['6011111111111117', + {card: 'discover', isPotentiallyValid: true, isValid: true}], + ]); + }); + + describe('Mastercard', function () { + table([ + ['55555555', + {card: 'master-card', isPotentiallyValid: true, isValid: false}], + ['5555555555554444', + {card: 'master-card', isPotentiallyValid: true, isValid: true}], + ['5555555555554446', + {card: 'master-card', isPotentiallyValid: false, isValid: false}], + ]); + }); + + describe('Amex', function () { + table([ + ['3782', + {card: 'american-express', isPotentiallyValid: true, isValid: false}], + ['378282246310005', + {card: 'american-express', isPotentiallyValid: true, isValid: true}], + ]); + }); + + describe('JCB', function () { + table([ + ['3530111', + {card: 'jcb', isPotentiallyValid: true, isValid: false}], + ['3530111333300000', + {card: 'jcb', isPotentiallyValid: true, isValid: true}], + ]); + }); + + describe('edge cases', function () { + table([ + ['', + {card: null, isPotentiallyValid: true, isValid: false}], + ['1', + {card: null, isPotentiallyValid: false, isValid: false}], + ['foo', + {card: null, isPotentiallyValid: false, isValid: false}], + [{}, + {card: null, isPotentiallyValid: false, isValid: false}], + [[], + {card: null, isPotentiallyValid: false, isValid: false}], + [32908234, + {card: null, isPotentiallyValid: false, isValid: false}], + [4111111111111111, + {card: 'visa', isPotentiallyValid: true, isValid: true}], + [true, + {card: null, isPotentiallyValid: false, isValid: false}], + [false, + {card: null, isPotentiallyValid: false, isValid: false}], + ]); + }); + +}); + +function table(tests) { + tests.forEach(function (test) { + var number = test[0]; + var expected = test[1]; + var actual = cardNumber(number); + + it('card: is ' + expected.card + ' for ' + number, function () { + if (expected.card) { + expect(actual.card.type).to.equal(expected.card); + } else { + expect(actual.card).to.equal(null); + } + }); + it('isPotentiallyValid: is ' + expected.isPotentiallyValid + ' for ' + number, function () { + expect(actual.isPotentiallyValid).to.equal(expected.isPotentiallyValid); + }); + it('valid: is ' + expected.valid + ' for ' + number, function () { + expect(actual.valid).to.equal(expected.valid); + }); + }); +} diff --git a/test/unit/cvv.js b/test/unit/cvv.js new file mode 100644 index 0000000..ecfe5a7 --- /dev/null +++ b/test/unit/cvv.js @@ -0,0 +1,74 @@ +var expect = require('chai').expect; +var cvv = require('../../src/cvv'); + +describe('cvv', function () { + describe('values', function () { + var describes = { + 'potentiallyValid': [ + ['', {isValid: false, isPotentiallyValid: true}], + ['1', {isValid: false, isPotentiallyValid: true}], + ['12', {isValid: false, isPotentiallyValid: true}] + ], + + 'returns true for valid strings': [ + ['000', {isValid: true, isPotentiallyValid: true}], + ['0000', {isValid: true, isPotentiallyValid: true}, 4], + ['123', {isValid: true, isPotentiallyValid: true}], + ['1234', {isValid: true, isPotentiallyValid: true}, 4] + ], + + 'returns false for invalid strings': [ + ['12345', {isValid: false, isPotentiallyValid: false}], + ['foo', {isValid: false, isPotentiallyValid: false}], + ['-123', {isValid: false, isPotentiallyValid: false}], + ['12 34', {isValid: false, isPotentiallyValid: false}], + ['12/34', {isValid: false, isPotentiallyValid: false}], + ['Infinity', {isValid: false, isPotentiallyValid: false}] + ], + + 'returns false for non-string types': [ + [0, {isValid: false, isPotentiallyValid: false}], + [123, {isValid: false, isPotentiallyValid: false}], + [1234, {isValid: false, isPotentiallyValid: false}], + [-1234, {isValid: false, isPotentiallyValid: false}], + [-10, {isValid: false, isPotentiallyValid: false}], + [0 / 0, {isValid: false, isPotentiallyValid: false}], + [Infinity, {isValid: false, isPotentiallyValid: false}], + [null, {isValid: false, isPotentiallyValid: false}], + [[], {isValid: false, isPotentiallyValid: false}], + [{}, {isValid: false, isPotentiallyValid: false}] + ] + }; + + Object.keys(describes).forEach(function (key) { + var tests = describes[key]; + describe(key, function () { + tests.forEach(function (test) { + var arg = test[0]; + var maxLength = test[2] || 3; + var output = test[1]; + + it('returns ' + JSON.stringify(output) + ' for "' + arg + '"', function () { + expect(cvv(arg, maxLength)).to.deep.equal(output); + }) + }); + }); + }); + }); + + describe('maxLength', function () { + it('defaults maxLength to 3', function () { + expect(cvv('1234')).to.deep.equal({isValid: false, isPotentiallyValid: false}); + expect(cvv('123')).to.deep.equal({isValid: true, isPotentiallyValid: true}); + }); + + it('accepts maxLength', function () { + expect(cvv('1234', 4)).to.deep.equal({isValid: true, isPotentiallyValid: true}); + }); + + it('returns invalid if beyond maxLength', function () { + expect(cvv('1234')).to.deep.equal({isValid: false, isPotentiallyValid: false}); + expect(cvv('12345', 4)).to.deep.equal({isValid: false, isPotentiallyValid: false}); + }); + }); +}); diff --git a/test/unit/expiration-date.js b/test/unit/expiration-date.js new file mode 100644 index 0000000..241d61d --- /dev/null +++ b/test/unit/expiration-date.js @@ -0,0 +1,121 @@ +var expect = require('chai').expect; +var expirationDate = require('../../src/expiration-date'); + +describe('expirationDate validates', function () { + var describes = { + 'valid expiration dates with slashes': [ + ['10 / 2016', {isValid: true, isPotentiallyValid: true, month: '10', year: '2016'}], + ['10/2016', {isValid: true, isPotentiallyValid: true, month: '10', year: '2016'}], + ['12 / 2016', {isValid: true, isPotentiallyValid: true, month: '12', year: '2016'}], + ['01 / 2016', {isValid: true, isPotentiallyValid: true, month: '01', year: '2016'}], + ['09 / 2016', {isValid: true, isPotentiallyValid: true, month: '09', year: '2016'}], + ['01 / 16', {isValid: true, isPotentiallyValid: true, month: '01', year: '16'}], + ['01 / 2016', {isValid: true, isPotentiallyValid: true, month: '01', year: '2016'}], + ['01 / 2016', {isValid: true, isPotentiallyValid: true, month: '01', year: '2016'}] + ], + + 'invalid expiration dates with slashes': [ + ['11 / 11', {isValid: false, isPotentiallyValid: false, month: null, year: null}], + ['00 / 2016', {isValid: false, isPotentiallyValid: false, month: null, year: null}], + ['13 / 2016', {isValid: false, isPotentiallyValid: false, month: null, year: null}], + ['01 / 1999', {isValid: false, isPotentiallyValid: false, month: null, year: null}], + ['01/1999', {isValid: false, isPotentiallyValid: false, month: null, year: null}], + ['01 / 2100', {isValid: false, isPotentiallyValid: false, month: null, year: null}], + ['10/123', {isValid: false, isPotentiallyValid: false, month: null, year: null}] + ], + + 'ambiguous expiration dates with slashes': [ + ['11 / 1', {isValid: false, isPotentiallyValid: true, month: null, year: null}], + ['01 / 201', {isValid: false, isPotentiallyValid: true, month: null, year: null}], + ['01 / 211', {isValid: false, isPotentiallyValid: false, month: null, year: null}], + ['01 / 199', {isValid: false, isPotentiallyValid: false, month: null, year: null}], + ['01/199', {isValid: false, isPotentiallyValid: false, month: null, year: null}] + ], + + 'valid expiration dates with no slashes': [ + ['102016', {isValid: true, isPotentiallyValid: true, month: '10', year: '2016'}], + ['102016', {isValid: true, isPotentiallyValid: true, month: '10', year: '2016'}], + ['122016', {isValid: true, isPotentiallyValid: true, month: '12', year: '2016'}], + ['012016', {isValid: true, isPotentiallyValid: true, month: '01', year: '2016'}], + ['092016', {isValid: true, isPotentiallyValid: true, month: '09', year: '2016'}], + ['1219', {isValid: true, isPotentiallyValid: true, month: '12', year: '19'}], + ['0116', {isValid: true, isPotentiallyValid: true, month: '01', year: '16'}] + ], + + 'valid space separated month and year': [ + ['01 2019', {isValid: true, isPotentiallyValid: true, month: '01', year: '2019'}], + ['01 2020', {isValid: true, isPotentiallyValid: true, month: '01', year: '2020'}], + ['01 19', {isValid: true, isPotentiallyValid: true, month: '01', year: '19'}], + ['01 21', {isValid: true, isPotentiallyValid: true, month: '01', year: '21'}] + ], + + 'invalid expiration dates with no slashes': [ + [' ', {isValid: false, isPotentiallyValid: true, month: null, year: null}], + [' ', {isValid: false, isPotentiallyValid: true, month: null, year: null}], + ['1111', {isValid: false, isPotentiallyValid: false, month: null, year: null}], + ['002016', {isValid: false, isPotentiallyValid: false, month: null, year: null}], + ['132016', {isValid: false, isPotentiallyValid: false, month: null, year: null}], + ['011999', {isValid: false, isPotentiallyValid: false, month: null, year: null}], + ['011999', {isValid: false, isPotentiallyValid: false, month: null, year: null}], + ['012100', {isValid: false, isPotentiallyValid: false, month: null, year: null}] + ], + + 'ambiguous expiration dates with no slashes': [ + ['011', {isValid: false, isPotentiallyValid: true, month: null, year: null}], + ['111', {isValid: false, isPotentiallyValid: true, month: null, year: null}], + ['01201', {isValid: false, isPotentiallyValid: true, month: null, year: null}], + ['01211', {isValid: false, isPotentiallyValid: false, month: null, year: null}], + ['01199', {isValid: false, isPotentiallyValid: false, month: null, year: null}], + ['01199', {isValid: false, isPotentiallyValid: false, month: null, year: null}] + ], + + 'empty strings': [ + ['', {isValid: false, isPotentiallyValid: true, month: null, year: null}], + ['/', {isValid: false, isPotentiallyValid: true, month: null, year: null}], + [' / ', {isValid: false, isPotentiallyValid: true, month: null, year: null}] + ], + + 'non-strings': [ + [[], {isValid: false, isPotentiallyValid: false, month: null, year: null}], + [{}, {isValid: false, isPotentiallyValid: false, month: null, year: null}], + [null, {isValid: false, isPotentiallyValid: false, month: null, year: null}], + [undefined, {isValid: false, isPotentiallyValid: false, month: null, year: null}], + [Infinity, {isValid: false, isPotentiallyValid: false, month: null, year: null}], + [0 / 0, {isValid: false, isPotentiallyValid: false, month: null, year: null}], + [0, {isValid: false, isPotentiallyValid: false, month: null, year: null}], + [1, {isValid: false, isPotentiallyValid: false, month: null, year: null}], + [2, {isValid: false, isPotentiallyValid: false, month: null, year: null}], + [12, {isValid: false, isPotentiallyValid: false, month: null, year: null}], + [-1, {isValid: false, isPotentiallyValid: false, month: null, year: null}], + [-12, {isValid: false, isPotentiallyValid: false, month: null, year: null}], + [2015, {isValid: false, isPotentiallyValid: false, month: null, year: null}] + ], + + 'malformed strings': [ + ['foo', {isValid: false, isPotentiallyValid: false, month: null, year: null}], + ['1.2', {isValid: false, isPotentiallyValid: false, month: null, year: null}], + ['1 2', {isValid: false, isPotentiallyValid: false, month: null, year: null}], + ['1 ', {isValid: false, isPotentiallyValid: true, month: null, year: null}], + [' 1', {isValid: false, isPotentiallyValid: true, month: null, year: null}], + ['01 / 20015', {isValid: false, isPotentiallyValid: false, month: null, year: null}], + ['15 / 2016', {isValid: false, isPotentiallyValid: false, month: null, year: null}], + ['01 / 2016 / 2016', {isValid: false, isPotentiallyValid: false, month: null, year: null}], + ['01 / 16/2016', {isValid: false, isPotentiallyValid: false, month: null, year: null}], + ['01 / 2016 / 01 / 2016', {isValid: false, isPotentiallyValid: false, month: null, year: null}] + ] + }; + +Object.keys(describes).forEach(function (key) { + var tests = describes[key]; + describe(key, function () { + tests.forEach(function (test) { + var arg = test[0]; + var output = test[1]; + + it('and returns ' + JSON.stringify(output) + ' for "' + arg + '"', function () { + expect(expirationDate(arg)).to.deep.equal(output); + }) + }); + }); +}); +}); diff --git a/test/unit/expiration-month.js b/test/unit/expiration-month.js new file mode 100644 index 0000000..a14c7e1 --- /dev/null +++ b/test/unit/expiration-month.js @@ -0,0 +1,67 @@ +var expect = require('chai').expect; +var expirationMonth = require('../../src/expiration-month'); + +describe('expirationMonth', function () { + var describes = { + 'returns false if not a string': [ + [[], {isValid: false, isPotentiallyValid: false}], + [{}, {isValid: false, isPotentiallyValid: false}], + [null, {isValid: false, isPotentiallyValid: false}], + [undefined, {isValid: false, isPotentiallyValid: false}], + [Infinity, {isValid: false, isPotentiallyValid: false}], + [0 / 0, {isValid: false, isPotentiallyValid: false}], + [0, {isValid: false, isPotentiallyValid: false}], + [1, {isValid: false, isPotentiallyValid: false}], + [2, {isValid: false, isPotentiallyValid: false}], + [12, {isValid: false, isPotentiallyValid: false}], + [13, {isValid: false, isPotentiallyValid: false}], + [-1, {isValid: false, isPotentiallyValid: false}], + [-12, {isValid: false, isPotentiallyValid: false}] + ], + + 'returns false for malformed strings': [ + ['foo', {isValid: false, isPotentiallyValid: false}], + ['1.2', {isValid: false, isPotentiallyValid: false}], + ['1/20', {isValid: false, isPotentiallyValid: false}], + ['1 2', {isValid: false, isPotentiallyValid: false}], + ['1 ', {isValid: false, isPotentiallyValid: false}], + [' 1', {isValid: false, isPotentiallyValid: false}] + ], + + 'returns null for incomplete input': [ + ['', {isValid: false, isPotentiallyValid: true}], + ['0', {isValid: false, isPotentiallyValid: true}] + ], + + 'valid month': [ + ['1', {isValid: true, isPotentiallyValid: true}], + ['2', {isValid: true, isPotentiallyValid: true}], + ['5', {isValid: true, isPotentiallyValid: true}], + ['02', {isValid: true, isPotentiallyValid: true}], + ['12', {isValid: true, isPotentiallyValid: true}] + ], + + 'invalid month': [ + ['14', {isValid: false, isPotentiallyValid: false}], + ['30', {isValid: false, isPotentiallyValid: false}], + ['-6', {isValid: false, isPotentiallyValid: false}], + ['20', {isValid: false, isPotentiallyValid: false}], + ['-1', {isValid: false, isPotentiallyValid: false}], + ['13', {isValid: false, isPotentiallyValid: false}] + ] + }; + + Object.keys(describes).forEach(function (key) { + var tests = describes[key]; + describe(key, function () { + tests.forEach(function (test) { + var arg = test[0]; + var output = test[1]; + + it('returns ' + JSON.stringify(output) + ' for "' + arg + '"', function () { + expect(expirationMonth(arg)).to.deep.equal(output); + }) + }); + }); + }); +}); diff --git a/test/unit/expiration-year.js b/test/unit/expiration-year.js new file mode 100644 index 0000000..42d37dc --- /dev/null +++ b/test/unit/expiration-year.js @@ -0,0 +1,100 @@ +var expect = require('chai').expect; +var expirationYear = require('../../src/expiration-year'); +var currentYear = new Date().getFullYear(); + +function yearsFromNow(fromNow, digits) { + var result = String(currentYear + fromNow); + if (digits === 2) { + result = result.substr(2, 2); + } + return result; +} + +describe('expirationYear', function () { + var describes = { + 'returns false if not a string': [ + [[], {isValid: false, isPotentiallyValid: false}], + [{}, {isValid: false, isPotentiallyValid: false}], + [null, {isValid: false, isPotentiallyValid: false}], + [undefined, {isValid: false, isPotentiallyValid: false}], + [Infinity, {isValid: false, isPotentiallyValid: false}], + [0 / 0, {isValid: false, isPotentiallyValid: false}], + [0, {isValid: false, isPotentiallyValid: false}], + [1, {isValid: false, isPotentiallyValid: false}], + [2, {isValid: false, isPotentiallyValid: false}], + [12, {isValid: false, isPotentiallyValid: false}], + [-1, {isValid: false, isPotentiallyValid: false}], + [-12, {isValid: false, isPotentiallyValid: false}] + ], + + 'returns false for malformed strings': [ + ['foo', {isValid: false, isPotentiallyValid: false}], + ['1.2', {isValid: false, isPotentiallyValid: false}], + ['1/20', {isValid: false, isPotentiallyValid: false}], + ['1 2', {isValid: false, isPotentiallyValid: false}], + ['1 ', {isValid: false, isPotentiallyValid: false}], + [' 1', {isValid: false, isPotentiallyValid: false}], + ['20015', {isValid: false, isPotentiallyValid: false}] + ], + + 'returns null for incomplete strings': [ + ['', {isValid: false, isPotentiallyValid: true}], + ['2', {isValid: false, isPotentiallyValid: true}], + ['9', {isValid: false, isPotentiallyValid: true}], + ['200', {isValid: false, isPotentiallyValid: true}], + ['123', {isValid: false, isPotentiallyValid: false}] + ], + + 'accepts four-digit years': [ + [yearsFromNow(0), {isValid: true, isPotentiallyValid: true}], + [yearsFromNow(-5), {isValid: false, isPotentiallyValid: false}], + [yearsFromNow(5), {isValid: true, isPotentiallyValid: true}], + [yearsFromNow(10), {isValid: true, isPotentiallyValid: true}], + [yearsFromNow(11), {isValid: true, isPotentiallyValid: true}], + [yearsFromNow(12), {isValid: true, isPotentiallyValid: true}], + [yearsFromNow(19), {isValid: true, isPotentiallyValid: true}], + [yearsFromNow(20), {isValid: false, isPotentiallyValid: false}], + [yearsFromNow(25), {isValid: false, isPotentiallyValid: false}], + [yearsFromNow(33), {isValid: false, isPotentiallyValid: false}] + ], + + 'accepts two-digit years': [ + [yearsFromNow(0, 2), {isValid: true, isPotentiallyValid: true}], + [yearsFromNow(-5, 2), {isValid: false, isPotentiallyValid: false}], + [yearsFromNow(5, 2), {isValid: true, isPotentiallyValid: true}], + [yearsFromNow(10, 2), {isValid: true, isPotentiallyValid: true}], + [yearsFromNow(11, 2), {isValid: true, isPotentiallyValid: true}], + [yearsFromNow(12, 2), {isValid: true, isPotentiallyValid: true}], + [yearsFromNow(19, 2), {isValid: true, isPotentiallyValid: true}], + [yearsFromNow(20, 2), {isValid: false, isPotentiallyValid: false}], + [yearsFromNow(25, 2), {isValid: false, isPotentiallyValid: false}], + [yearsFromNow(33, 2), {isValid: false, isPotentiallyValid: false}] + ], + + // This doesn't take 20xx -> 21xx into account, but probably YAGNI + 'accepts three-digit years': [ + [yearsFromNow(-3).slice(0, 3), {isValid: false, isPotentiallyValid: true}], + [yearsFromNow(-1).slice(0, 3), {isValid: false, isPotentiallyValid: true}], + [yearsFromNow(0).slice(0, 3), {isValid: false, isPotentiallyValid: true}], + [yearsFromNow(1).slice(0, 3), {isValid: false, isPotentiallyValid: true}], + [yearsFromNow(5).slice(0, 3), {isValid: false, isPotentiallyValid: true}], + [yearsFromNow(11).slice(0, 3), {isValid: false, isPotentiallyValid: true}], + [yearsFromNow(17).slice(0, 3), {isValid: false, isPotentiallyValid: true}], + [yearsFromNow(23).slice(0, 3), {isValid: false, isPotentiallyValid: true}] + ] + }; + + Object.keys(describes).forEach(function (key) { + var tests = describes[key]; + describe(key, function () { + tests.forEach(function (test) { + var arg = test[0]; + var output = test[1]; + + it('returns ' + JSON.stringify(output) + ' for "' + arg + '"', function () { + expect(expirationYear(arg)).to.deep.equal(output); + }) + }); + }); + }); +}); diff --git a/test/unit/postal-code.js b/test/unit/postal-code.js new file mode 100644 index 0000000..e093925 --- /dev/null +++ b/test/unit/postal-code.js @@ -0,0 +1,58 @@ +var expect = require('chai').expect; +var postalCode = require('../../src/postal-code'); + +describe('postalCode', function () { + var describes = { + 'returns false for non-string types': [ + [0, {isValid: false, isPotentiallyValid: false}], + [0, {isValid: false, isPotentiallyValid: false}], + [123, {isValid: false, isPotentiallyValid: false}], + [1234, {isValid: false, isPotentiallyValid: false}], + [12345, {isValid: false, isPotentiallyValid: false}], + [557016, {isValid: false, isPotentiallyValid: false}], + [-1234, {isValid: false, isPotentiallyValid: false}], + [-10, {isValid: false, isPotentiallyValid: false}], + [0 / 0, {isValid: false, isPotentiallyValid: false}], + [Infinity, {isValid: false, isPotentiallyValid: false}], + [null, {isValid: false, isPotentiallyValid: false}], + [[], {isValid: false, isPotentiallyValid: false}], + [{}, {isValid: false, isPotentiallyValid: false}] + ], + + 'accepts valid postal codes': [ + ['1234', {isValid: true, isPotentiallyValid: true}], + ['12345', {isValid: true, isPotentiallyValid: true}], + ['12345', {isValid: true, isPotentiallyValid: true}], + ['557016', {isValid: true, isPotentiallyValid: true}], // Romania + ['110001', {isValid: true, isPotentiallyValid: true}], // India + ['SE1 2LN', {isValid: true, isPotentiallyValid: true}], // UK + ['01234567890123456789', {isValid: true, isPotentiallyValid: true}] // some hypothetical country + ], + + 'doesn\'t reject non-numeric strings': [ + ['hello world', {isValid: true, isPotentiallyValid: true}] + ], + + 'returns isPotentiallyValid for shorter-than-4 strings': [ + ['', {isValid: false, isPotentiallyValid: true}], + ['1', {isValid: false, isPotentiallyValid: true}], + ['12', {isValid: false, isPotentiallyValid: true}], + ['123', {isValid: false, isPotentiallyValid: true}] + ] + }; + + Object.keys(describes).forEach(function (key) { + var tests = describes[key]; + describe(key, function () { + tests.forEach(function (test) { + var arg = test[0]; + var output = test[1]; + + it('returns ' + JSON.stringify(output) + ' for "' + arg + '"', function () { + expect(postalCode(arg)).to.deep.equal(output); + }) + }); + }); + }); + +}); diff --git a/test/unit/validator.js b/test/unit/validator.js new file mode 100644 index 0000000..8d01158 --- /dev/null +++ b/test/unit/validator.js @@ -0,0 +1,13 @@ +var expect = require('chai').expect; +var validator = require('../../index'); + +describe('validator', function () { + it('exports all necessary functions', function () { + expect(validator.number).to.be.a('function'); + expect(validator.expirationDate).to.be.a('function'); + expect(validator.expirationMonth).to.be.a('function'); + expect(validator.expirationYear).to.be.a('function'); + expect(validator.cvv).to.be.a('function'); + expect(validator.postalCode).to.be.a('function'); + }); +});