diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 560948df6..994954ab8 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -140,8 +140,6 @@ jobs: - check - unit_test - unit_test_remote - - e2e_test - - integration_test steps: - uses: actions/checkout@v3 - uses: browser-actions/setup-chrome@v1 diff --git a/lib/recurly.js b/lib/recurly.js index 19181a9c0..b8c56aff8 100644 --- a/lib/recurly.js +++ b/lib/recurly.js @@ -5,6 +5,7 @@ import deepAssign from './util/deep-assign'; import deepFilter from 'deep-filter'; import Emitter from 'component-emitter'; import pick from 'lodash.pick'; +import uniq from 'array-unique'; import uid from './util/uid'; import errors from './recurly/errors'; import { bankAccount } from './recurly/bank-account'; @@ -79,6 +80,7 @@ const DEFAULTS = { } }, api: DEFAULT_API_URL, + required: ['number', 'month', 'year', 'first_name', 'last_name'], fields: { all: { style: {} @@ -286,7 +288,9 @@ export class Recurly extends Emitter { deepAssign(this.config.fields, options.fields); } - this.config.required = options.required || this.config.required || []; + if (Array.isArray(options.required)) { + this.config.required = uniq([...this.config.required, ...options.required]); + } // Begin parent role configuration and setup if (this.config.parent) { diff --git a/lib/recurly/element/card-cvv.js b/lib/recurly/element/card-cvv.js index 6dde05a10..cdc4002f7 100644 --- a/lib/recurly/element/card-cvv.js +++ b/lib/recurly/element/card-cvv.js @@ -7,4 +7,5 @@ export function factory (options) { export class CardCvvElement extends Element { static type = 'cvv'; static elementClassName = 'CardCvvElement'; + static supportsTokenization = true; } diff --git a/lib/recurly/elements.js b/lib/recurly/elements.js index beb307284..a2778c6b6 100644 --- a/lib/recurly/elements.js +++ b/lib/recurly/elements.js @@ -32,7 +32,8 @@ export default class Elements extends Emitter { static VALID_SETS = [ [ CardElement ], - [ CardNumberElement, CardMonthElement, CardYearElement, CardCvvElement ] + [ CardCvvElement ], + [ CardNumberElement, CardMonthElement, CardYearElement, CardCvvElement ], ]; constructor ({ recurly }) { diff --git a/lib/recurly/hosted-fields.js b/lib/recurly/hosted-fields.js index b57d28aed..4f46ab81a 100644 --- a/lib/recurly/hosted-fields.js +++ b/lib/recurly/hosted-fields.js @@ -88,10 +88,12 @@ export class HostedFields extends Emitter { } }); - // If we have a card hosted field, clear all missing target errors. + // If we have a card/cvv hosted field, clear all missing target errors. const cardFieldMissingErrorPresent = this.errors.some(e => e.type === 'card'); - if (cardFieldMissingErrorPresent) { + const onlyCvvFieldPresent = this.fields.length === 1 && this.fields[0].type === 'cvv'; + if (cardFieldMissingErrorPresent && !onlyCvvFieldPresent) { // If we are only missing the card field, clear the error + // If we only have a cvv field, clear the errors const missingFieldErrors = this.errors.filter(e => e.name === 'missing-hosted-field-target'); if (missingFieldErrors.length === 1) { this.errors = this.errors.filter(e => !(e.name === 'missing-hosted-field-target' && e.type === 'card')); diff --git a/lib/recurly/token.js b/lib/recurly/token.js index 6357c60b8..6c1fa333e 100644 --- a/lib/recurly/token.js +++ b/lib/recurly/token.js @@ -173,13 +173,17 @@ function token (customerData, bus, done) { } const { number, month, year, cvv } = inputs; - Risk.preflight({ recurly: this, number, month, year, cvv }) - .then(({ risk, tokenType }) => { - inputs.risk = risk; - if (tokenType) inputs.type = tokenType; - }) - .then(() => this.request.post({ route: '/token', data: inputs, done: complete })) - .done(); + if (number && month && year) { + Risk.preflight({ recurly: this, number, month, year, cvv }) + .then(({ risk, tokenType }) => { + inputs.risk = risk; + if (tokenType) inputs.type = tokenType; + }) + .then(() => this.request.post({ route: '/token', data: inputs, done: complete })) + .done(); + } else { + this.request.post({ route: '/token', data: inputs, done: complete }); + } } function complete (err, res) { diff --git a/lib/recurly/validate.js b/lib/recurly/validate.js index 3c35ac0fc..4b713b763 100644 --- a/lib/recurly/validate.js +++ b/lib/recurly/validate.js @@ -1,6 +1,6 @@ /*jshint -W058 */ -import { FIELDS as CARD_FIELDS } from './token'; +import { FIELDS as ADDRESS_FIELDS } from './token'; import each from 'component-each'; import find from 'component-find'; import { parseCard } from '../util/parse-card'; @@ -8,6 +8,14 @@ import CREDIT_CARD_TYPES from '../const/credit-card-types.json'; const debug = require('debug')('recurly:validate'); +const CARD_FIELDS = [ + ...ADDRESS_FIELDS, + 'number', + 'month', + 'year', + 'cvv', +]; + /** * Validation error messages * @type {String} @@ -197,25 +205,15 @@ export function validateCardInputs (recurly, inputs) { const format = formatFieldValidationError; let errors = []; - if (!cardNumber(inputs.number)) { + if (inputs.number && !cardNumber(inputs.number)) { errors.push(format('number', INVALID)); } - if (!expiry(inputs.month, inputs.year)) { + if (inputs.month && inputs.year && !expiry(inputs.month, inputs.year)) { errors.push(format('month', INVALID), format('year', INVALID)); } - if (!inputs.first_name) { - errors.push(format('first_name', BLANK)); - } - - if (!inputs.last_name) { - errors.push(format('last_name', BLANK)); - } - - if (~recurly.config.required.indexOf('cvv') && !inputs.cvv) { - errors.push(format('cvv', BLANK)); - } else if ((~recurly.config.required.indexOf('cvv') || inputs.cvv) && !cvv(inputs.cvv)) { + if (inputs.cvv && !cvv(inputs.cvv)) { errors.push(format('cvv', INVALID)); } diff --git a/packages/public-api-fixture-server/fixtures/field.html.ejs b/packages/public-api-fixture-server/fixtures/field.html.ejs index 1309f9e61..be99f92d4 100644 --- a/packages/public-api-fixture-server/fixtures/field.html.ejs +++ b/packages/public-api-fixture-server/fixtures/field.html.ejs @@ -19,9 +19,13 @@ } // Stub broker behavior - if (config().type === 'number') { - window.addEventListener('message', receivePostMessage, false); + function setStubTokenizationElementName (name) { + window.stubTokenizationElementName = name; + if (config().type === name) { + window.addEventListener('message', receivePostMessage, false); + } } + setStubTokenizationElementName('number'); sendMessage(prefix + ':ready', { type: config().type }); @@ -39,6 +43,9 @@ function onToken (body) { var recurly = new parent.recurly.Recurly(getRecurlyConfig()); + if (stubTokenizationElementName === 'cvv') { + recurly.config.required = ['cvv']; + } var inputs = body.inputs; var id = body.id; diff --git a/packages/public-api-fixture-server/views/e2e/hosted-fields-cvv.html.ejs b/packages/public-api-fixture-server/views/e2e/hosted-fields-cvv.html.ejs new file mode 100644 index 000000000..f7144204d --- /dev/null +++ b/packages/public-api-fixture-server/views/e2e/hosted-fields-cvv.html.ejs @@ -0,0 +1,4 @@ +<%- include('_head'); -%> + +
+<%- include('_foot'); -%> diff --git a/test/e2e/display.test.js b/test/e2e/display.test.js index dc4fcfa65..bb2437f0f 100644 --- a/test/e2e/display.test.js +++ b/test/e2e/display.test.js @@ -7,6 +7,7 @@ const { environmentIs, fillCardElement, fillDistinctCardElements, + fillCvvElement, init } = require('./support/helpers'); @@ -34,7 +35,7 @@ maybeDescribe('Display', () => { it('matches distinct elements baseline', async function () { const { CardElement, ...distinctElements } = ELEMENT_TYPES; for (const element in distinctElements) { - await createElement(element, { style: { fontFamily: 'Pacifico' }}); + await createElement(element, { style: { fontFamily: 'Pacifico' } }); } await fillDistinctCardElements(); await clickFirstName(); diff --git a/test/e2e/implementation.state.test.js b/test/e2e/implementation.state.test.js index 07269cb18..8ec37998f 100644 --- a/test/e2e/implementation.state.test.js +++ b/test/e2e/implementation.state.test.js @@ -8,6 +8,8 @@ const { environmentIs, EXAMPLES, fillCardElement, + fillDistinctCardElements, + fillElement, init } = require('./support/helpers'); @@ -68,6 +70,17 @@ describe('Field State', elementAndFieldSuite({ ); }); }, + cvvElement: async () => { + it('displays field state on the page', async function () { + await setupElementsStateOutput(); + await assertInputStateChange(() => fillElement(0, '.recurly-hosted-field-input', '123'), 0, { + empty: false, + length: 3, + focus: false, + valid: true + }); + }); + }, cardHostedField: async function () { it('displays field state on the page', async function () { // Skip Electron due to element blur incompatibility @@ -115,6 +128,19 @@ describe('Field State', elementAndFieldSuite({ hostedFieldState({ number, month, year, cvv }) ); }); + }, + cvvHostedField: async () => { + it('displays field state on the page', async function () { + const cvv = { + empty: false, + length: 3, + focus: false, + valid: true, + }; + + await setupHostedFieldStateOutput(); + await assertInputStateChange(() => fillElement(0, '.recurly-hosted-field-input', '123'), 0, { fields: { cvv } }); + }); } })); @@ -141,6 +167,7 @@ async function setupHostedFieldStateOutput () { } async function assertCardBehavior ({ wrap = obj => obj } = {}) { + const FRAME = 0; const expect = { valid: false, firstSix: '', @@ -164,29 +191,9 @@ async function assertCardBehavior ({ wrap = obj => obj } = {}) { valid: false } }; + const expectation = (changes) => wrap(Object.assign({}, expect, changes)); - const firstName = await $(sel.firstName); - const output = await $(sel.output); - const actual = async () => JSON.parse(await output.getText()); - const assertStateOutputIs = async changes => { - assert.deepStrictEqual( - await actual(), - wrap(Object.assign({}, expect, changes)) - ); - }; - - // await browser.switchToFrame(0); - // const number = await $(sel.number); - // await number.setValue(EXAMPLES.NUMBER); - // await browser.waitUntil(async () => (await number.getValue()).length >= 19); - // await browser.switchToFrame(null); - await fillCardElement({ - expiry: '', - cvv: '' - }); - await firstName.click(); - - await assertStateOutputIs({ + await assertInputStateChange(() => fillCardElement({ expiry: '', cvv: '' }), FRAME, expectation({ firstSix: '411111', lastFour: '1111', brand: 'visa', @@ -197,14 +204,9 @@ async function assertCardBehavior ({ wrap = obj => obj } = {}) { focus: false, valid: true } - }); - - await fillCardElement({ - cvv: '' - }); - await firstName.click(); + })); - await assertStateOutputIs({ + await assertInputStateChange(() => fillCardElement({ cvv: '' }), FRAME, expectation({ firstSix: '411111', lastFour: '1111', brand: 'visa', @@ -220,15 +222,9 @@ async function assertCardBehavior ({ wrap = obj => obj } = {}) { focus: false, valid: true } - }); + })); - // await browser.switchToFrame(0); - // await (await $(sel.cvv)).addValue(EXAMPLES.CVV); - // await browser.switchToFrame(null); - await fillCardElement(); - await firstName.click(); - - await assertStateOutputIs({ + await assertInputStateChange(() => fillCardElement(), FRAME, expectation({ firstSix: '411111', lastFour: '1111', brand: 'visa', @@ -250,7 +246,7 @@ async function assertCardBehavior ({ wrap = obj => obj } = {}) { focus: false, valid: true } - }); + })); } async function assertDistinctCardBehavior (...expectations) { @@ -260,28 +256,24 @@ async function assertDistinctCardBehavior (...expectations) { '28', '123' ]; - const firstName = await $(sel.firstName); - const output = await $(sel.output); - const actual = async () => JSON.parse(await output.getText()); - const assertStateOutputIs = async expect => assert.deepStrictEqual( - await actual(), - expect - ); for (const entry of entries) { const i = entries.indexOf(entry); - await browser.switchToFrame(i); - const input = await $('.recurly-hosted-field-input'); - await input.addValue(entry); - if (environmentIs(BROWSERS.EDGE)) { - await browser.waitUntil(async () => (await input.getValue()).replace(/ /g, '') === entry); - } - await browser.switchToFrame(null); - await firstName.click(); - await assertStateOutputIs(expectations[i]); + await assertInputStateChange(() => fillElement(i, '.recurly-hosted-field-input', entry), i, expectations[i]); } } +async function assertInputStateChange(example, frame, expectation) { + const blurTriggerEl = await $(sel.firstName); + const output = await $(sel.output); + + await example(); + + await blurTriggerEl.click(); + const actual = JSON.parse(await output.getText()); + assert.deepStrictEqual(actual, expectation); +} + function hostedFieldState ({ number, month, year, cvv }) { return { fields: { diff --git a/test/e2e/recurly.test.js b/test/e2e/recurly.test.js index 0fa6a467c..835f962a4 100644 --- a/test/e2e/recurly.test.js +++ b/test/e2e/recurly.test.js @@ -3,6 +3,7 @@ const { assertIsAToken, EXAMPLES, getValue, + fillElement, init, recurlyEnvironment, tokenize @@ -100,6 +101,17 @@ describe('Recurly.js', async function () { assertIsAToken(tokenWith); }); }); + + describe('when using standalone cvv hosted field', async function () { + beforeEach(init({ fixture: 'hosted-fields-cvv' })); + + it('creates a token', async function () { + await fillElement(0, sel.hostedFieldInput, EXAMPLES.CVV); + const [err, token] = await tokenize(sel.form); + assert.strictEqual(err, null); + assertIsAToken(token); + }); + }); }); describe('Bacs bank account', async function () { diff --git a/test/e2e/support/helpers.js b/test/e2e/support/helpers.js index 3d86f842c..1c2ccedf1 100644 --- a/test/e2e/support/helpers.js +++ b/test/e2e/support/helpers.js @@ -22,7 +22,7 @@ const ELEMENT_TYPES = { CardNumberElement: 'CardNumberElement', CardMonthElement: 'CardMonthElement', CardYearElement: 'CardYearElement', - CardCvvElement: 'CardCvvElement' + CardCvvElement: 'CardCvvElement', }; const FIELD_TYPES = { @@ -76,6 +76,8 @@ module.exports = { FIELD_TYPES, fillCardElement, fillDistinctCardElements, + fillCvvElement, + fillElement, getValue, init, recurlyEnvironment, @@ -106,8 +108,10 @@ module.exports = { function elementAndFieldSuite ({ cardElement, distinctCardElements, + cvvElement, cardHostedField, distinctCardHostedFields, + cvvHostedField, any }) { return () => { @@ -132,6 +136,13 @@ function elementAndFieldSuite ({ }); maybeRun(distinctCardElements); }); + + describe('distinct CardCvvElement', function () { + beforeEach(async function () { + await createElement(ELEMENT_TYPES.CardCvvElement); + }); + maybeRun(cvvElement); + }); }); describe('when using a card Hosted Field', function () { @@ -143,6 +154,11 @@ function elementAndFieldSuite ({ beforeEach(init({ fixture: 'hosted-fields-card-distinct' })); maybeRun(distinctCardHostedFields); }); + + describe('when using a cvv Hosted Field', function () { + beforeEach(init({ fixture: 'hosted-fields-cvv' })); + maybeRun(cvvHostedField); + }); }; } @@ -207,30 +223,9 @@ async function fillCardElement ({ expiry = EXAMPLES.EXPIRY, cvv = EXAMPLES.CVV } = {}) { - await browser.switchToFrame(0); - - const numberInput = await $(SELECTORS.CARD_ELEMENT.NUMBER); - const expiryInput = await $(SELECTORS.CARD_ELEMENT.EXPIRY); - const cvvInput = await $(SELECTORS.CARD_ELEMENT.CVV); - - // setvalue's underlying elementSendKeys is slow to act on Android. Thus we chunk the input. - if (environmentIs(DEVICES.ANDROID)) { - await numberInput.clearValue(); - for (const chunk of number.match(/.{1,2}/g)) { - await numberInput.addValue(chunk); - } - } else { - await numberInput.setValue(number); - } - - if (environmentIs(BROWSERS.EDGE)) { - await browser.waitUntil(async () => (await numberInput.getValue()).replace(/ /g, '') === number); - } - - await expiryInput.setValue(expiry); - await cvvInput.setValue(cvv); - - await browser.switchToFrame(null); + await fillElement(0, SELECTORS.CARD_ELEMENT.NUMBER, number); + await fillElement(0, SELECTORS.CARD_ELEMENT.EXPIRY, expiry); + await fillElement(0, SELECTORS.CARD_ELEMENT.CVV, cvv); } /** @@ -250,13 +245,40 @@ async function fillDistinctCardElements ({ const examples = [number, month, year, cvv]; for (const example of examples) { const i = examples.indexOf(example); - await browser.switchToFrame(i); - const input = await $(SELECTORS.HOSTED_FIELD_INPUT); - await input.setValue(example); - await browser.switchToFrame(null); + await fillElement(i, SELECTORS.HOSTED_FIELD_INPUT, example); } } +/** + * Fills a cvv Hosted Field with the given value + * + * @param {String} options.cvv + */ +async function fillCvvElement ({ cvv = EXAMPLES.CVV } = {}) { + await fillElement(0, SELECTORS.HOSTED_FIELD_INPUT, cvv); +} + +async function fillElement (frame, selector, val) { + await browser.switchToFrame(frame); + const input = await $(selector); + + // setvalue's underlying elementSendKeys is slow to act on Android. Thus we chunk the input. + if (environmentIs(DEVICES.ANDROID)) { + await input.clearValue(); + for (const chunk of val.match(/.{1,2}/g)) { + await input.addValue(chunk); + } + } else { + await input.setValue(val); + + if (environmentIs(BROWSERS.EDGE)) { + await browser.waitUntil(async () => (await input.getValue()).replace(/ /g, '') === val); + } + } + + await browser.switchToFrame(null); +} + // Action helpers /** diff --git a/test/unit/configure.test.js b/test/unit/configure.test.js index f1fafdaa5..9ce495f19 100644 --- a/test/unit/configure.test.js +++ b/test/unit/configure.test.js @@ -51,8 +51,6 @@ describe('Recurly.configure', function () { { publicKey: 'test', currency: 'USD' }, { publicKey: 'test', currency: 'AUD', api }, { publicKey: 'test', currency: 'AUD', api, cors: true }, - { publicKey: 'test', currency: 'USD', api, required: ['country'] }, - { publicKey: 'test', currency: 'USD', api, required: ['postal_code', 'country'] } ]; }); @@ -108,6 +106,14 @@ describe('Recurly.configure', function () { }); }); + describe('when options.required is given', function () { + it('appends the given value to the defaults', function () { + const { recurly } = this; + recurly.configure({ publicKey: 'test', required: ['number', 'postal_code'] }); + assert.deepEqual(recurly.config.required, ['number', 'month', 'year', 'first_name', 'last_name', 'postal_code']); + }); + }); + describe('when options.style is given (deprecated)', function () { const { api } = this; const example = { diff --git a/test/unit/elements.test.js b/test/unit/elements.test.js index ab77cc5c4..ddc12cc98 100644 --- a/test/unit/elements.test.js +++ b/test/unit/elements.test.js @@ -2,7 +2,6 @@ import assert from 'assert'; import Element from '../../lib/recurly/element'; import Elements from '../../lib/recurly/elements'; import { initRecurly } from './support/helpers'; -import { Recurly } from '../../lib/recurly'; const noop = () => {}; @@ -26,7 +25,7 @@ describe('Elements', function () { 'CardNumberElement', 'CardMonthElement', 'CardYearElement', - 'CardCvvElement' + 'CardCvvElement', ].forEach(elementName => { const elements = new Elements({ recurly: this.recurly }); const element = elements[elementName](); diff --git a/test/unit/support/fixtures.js b/test/unit/support/fixtures.js index cceca4ebd..5bbe40296 100644 --- a/test/unit/support/fixtures.js +++ b/test/unit/support/fixtures.js @@ -230,7 +230,7 @@ export function applyFixtures () { } export function fixture (name, opts = {}) { - const tpl = FIXTURES[name] || (() => {}); + const tpl = typeof name === 'function' ? name : FIXTURES[name] || (() => {}); testBed().innerHTML = tpl(opts); } diff --git a/test/unit/token.test.js b/test/unit/token.test.js index cdb412a57..a66a6c287 100644 --- a/test/unit/token.test.js +++ b/test/unit/token.test.js @@ -1,14 +1,12 @@ import assert from 'assert'; import after from 'lodash.after'; -import merge from 'lodash.merge'; -import each from 'lodash.foreach'; import clone from 'component-clone'; import Promise from 'promise'; import { Recurly } from '../../lib/recurly'; import { applyFixtures } from './support/fixtures'; import { initRecurly, testBed } from './support/helpers'; -describe(`Recurly.token`, function () { +describe('Recurly.token', function () { // Some of these tests can take a while to stand up fields and receive reponses this.timeout(15000); @@ -120,7 +118,7 @@ describe(`Recurly.token`, function () { cardMonthElement.attach(container); cardYearElement.attach(container); - recurly.token(form, (err, token) => { + recurly.token(form, (err) => { assert.strictEqual(err.code, 'elements-tokenization-not-possible'); assert.deepEqual(err.found, ['CardMonthElement', 'CardYearElement']); done(); @@ -129,16 +127,65 @@ describe(`Recurly.token`, function () { }); }); - function buildRecurly () { + describe('Cvv standalone', function () { + applyFixtures(); + buildRecurly(); + + describe('when using a HostedField', function () { + this.ctx.fixture = () => ` +
+
+ +
+ `; + beforeEach(function () { + this.recurly.hostedFields.fields[0].iframe.contentWindow.setStubTokenizationElementName('cvv'); + }); + + describe('when called with a plain object', function () { + cvvSuite(plainObjectBuilder); + }); + + describe('when called with an HTMLFormElement', function () { + cvvSuite(formBuilder); + }); + }); + + describe('when called with an Elements instance', function () { + this.ctx.fixture = () => ` +
+
+ +
+ `; + + function setupStub (builder) { + return function (...args) { + return builder.call(this, ...args).then(res => { + this.elements.elements[0].iframe.contentWindow.setStubTokenizationElementName('cvv'); + return res; + }); + }; + } + + cvvSuite(setupStub(elementsBuilder)); + + describe('when called with an HTMLFormElement', function () { + cvvSuite(setupStub(elementsFormOnlyBuilder)); + }); + }); + }); + + function buildRecurly (opts) { beforeEach(function (done) { - this.recurly = initRecurly(); + this.recurly = initRecurly(opts); this.recurly.ready(() => done()); }); afterEach(function () { this.recurly.destroy(); }); - }; + } /** * For each example, updates corresponding hosted fields and returns all others @@ -183,14 +230,14 @@ describe(`Recurly.token`, function () { */ function elementsBuilder (example) { const form = window.document.querySelector('#test-form'); - const container = form.querySelector(`#recurly-elements`); + const container = form.querySelector('#recurly-elements'); const elements = this.elements = this.recurly.Elements(); return Promise.all(Object.keys(example).map(key => { const val = example[key]; let el = form.querySelector(`[data-recurly=${key}]`); - return new Promise((resolve, reject) => { + return new Promise((resolve) => { if (el && 'value' in el) { el.value = val; resolve(); @@ -490,58 +537,7 @@ describe(`Recurly.token`, function () { this.recurly.configure({ required: ['cvv'] }); }); - describe('when cvv is blank', function () { - prepareExample(Object.assign({}, valid, { - cvv: '' - }), builder); - it('produces a validation error', function (done) { - this.subject((err, token) => { - assert.strictEqual(err.code, 'validation'); - assert.strictEqual(err.fields.length, 1); - assert(~err.fields.indexOf('cvv')); - assert.strictEqual(err.details.length, 1); - assert.strictEqual(err.details[0].field, 'cvv'); - assert.strictEqual(err.details[0].messages.length, 1); - assert.strictEqual(err.details[0].messages[0], "can't be blank"); - assert(!token); - done(); - }); - }); - }); - - describe('when cvv is invalid', function () { - prepareExample(Object.assign({}, valid, { - cvv: '23783564' - }), builder); - - it('produces a validation error', function (done) { - this.subject((err, token) => { - assert.strictEqual(err.code, 'validation'); - assert.strictEqual(err.fields.length, 1); - assert(~err.fields.indexOf('cvv')); - assert.strictEqual(err.details.length, 1); - assert.strictEqual(err.details[0].field, 'cvv'); - assert.strictEqual(err.details[0].messages.length, 1); - assert.strictEqual(err.details[0].messages[0], 'is invalid'); - assert(!token); - done(); - }); - }); - }); - - describe('when cvv is valid', function () { - prepareExample(Object.assign({}, valid, { - cvv: '123' - }), builder); - - it('yields a token', function (done) { - this.subject((err, token) => { - assert(!err); - assert(token); - done(); - }); - }); - }); + cvvSuite(builder, valid); }); describe('when a tax_identifier is provided', function () { @@ -594,6 +590,61 @@ describe(`Recurly.token`, function () { }); } + function cvvSuite (builder, valid) { + describe('when cvv is blank', function () { + prepareExample(Object.assign({}, valid, { + cvv: '' + }), builder); + it('produces a validation error', function (done) { + this.subject((err, token) => { + assert.strictEqual(err.code, 'validation'); + assert.strictEqual(err.fields.length, 1); + assert(~err.fields.indexOf('cvv')); + assert.strictEqual(err.details.length, 1); + assert.strictEqual(err.details[0].field, 'cvv'); + assert.strictEqual(err.details[0].messages.length, 1); + assert.strictEqual(err.details[0].messages[0], "can't be blank"); + assert(!token); + done(); + }); + }); + }); + + describe('when cvv is invalid', function () { + prepareExample(Object.assign({}, valid, { + cvv: '23783564' + }), builder); + + it('produces a validation error', function (done) { + this.subject((err, token) => { + assert.strictEqual(err.code, 'validation'); + assert.strictEqual(err.fields.length, 1); + assert(~err.fields.indexOf('cvv')); + assert.strictEqual(err.details.length, 1); + assert.strictEqual(err.details[0].field, 'cvv'); + assert.strictEqual(err.details[0].messages.length, 1); + assert.strictEqual(err.details[0].messages[0], 'is invalid'); + assert(!token); + done(); + }); + }); + }); + + describe('when cvv is valid', function () { + prepareExample(Object.assign({}, valid, { + cvv: '123' + }), builder); + + it('yields a token', function (done) { + this.subject((err, token) => { + assert(!err); + assert(token); + done(); + }); + }); + }); + } + function tokenAllMarkupSuite (builder) { describe('when given additional required fields', function () { beforeEach(function (done) { @@ -636,7 +687,7 @@ describe(`Recurly.token`, function () { done(); }); }); - }) + }); }); describe('when given a blank postal_code', function () { diff --git a/wdio.ci.conf.js b/wdio.ci.conf.js index 0757eb086..ec9b102e4 100644 --- a/wdio.ci.conf.js +++ b/wdio.ci.conf.js @@ -7,7 +7,7 @@ const { } = require('./test/conf/browserstack'); const { - BROWSER = 'BrowserStackChrome', + BROWSER = 'Chrome-Remote', BROWSER_STACK_USERNAME: user, BROWSER_STACK_ACCESS_KEY: key, GITHUB_RUN_ID