From 4137e52ad1cfad4eb1863cfb393e504e45ec3487 Mon Sep 17 00:00:00 2001 From: Josh Smith Date: Wed, 9 Nov 2016 23:22:52 -0800 Subject: [PATCH] Implement donating, adding a new card, and selecting a card --- app/components/donation/card-item.js | 21 ++ app/components/donation/card-list.js | 19 ++ app/components/donation/credit-card.js | 84 ++++++- app/components/donation/donation-container.js | 16 +- app/components/donations/create-donation.js | 9 +- .../donations/donation-amount-button.js | 12 +- app/components/error-formatter.js | 86 ++++++- app/controllers/project/donate.js | 103 +++++++- app/models/stripe-card.js | 14 ++ app/models/stripe-customer.js | 9 + app/models/user.js | 4 +- app/router.js | 1 + app/routes/project/donate.js | 8 + app/routes/project/thankyou.js | 7 + app/styles/_buttons.scss | 15 +- app/styles/_forms.scss | 23 +- app/styles/app.scss | 1 + app/styles/components/donation-payment.scss | 22 +- app/styles/templates/project/thankyou.scss | 14 ++ .../components/donation/card-item.hbs | 1 + .../components/donation/card-list.hbs | 7 + .../components/donation/credit-card.hbs | 57 +++-- .../donation/donation-container.hbs | 45 ++-- .../components/donations/create-donation.hbs | 11 +- .../components/donations/donation-status.hbs | 2 +- app/templates/components/error-formatter.hbs | 2 +- app/templates/project/donate.hbs | 15 +- app/templates/project/thankyou.hbs | 8 + config/environment.js | 7 + mirage/config.js | 15 +- mirage/models/stripe-card.js | 5 + mirage/models/stripe-customer.js | 5 + package.json | 1 + tests/acceptance/project-donate-test.js | 227 ++++++++++++++++++ tests/acceptance/project-thankyou-test.js | 52 ++++ tests/acceptance/task-creation-test.js | 1 + .../components/donation/card-item-test.js | 69 ++++++ .../components/donation/card-list-test.js | 72 ++++++ .../components/donation/credit-card-test.js | 78 ++++-- .../donation/donation-container-test.js | 127 ++++++++-- .../donations/create-donation-test.js | 34 ++- .../donations/donation-amount-button-test.js | 29 ++- .../components/error-formatter-test.js | 88 +++++-- tests/pages/components/donation/card-item.js | 9 + tests/pages/components/donation/card-list.js | 11 + .../pages/components/donation/credit-card.js | 15 ++ .../components/donation/donation-container.js | 19 ++ tests/pages/components/error-formatter.js | 13 + tests/pages/project/donate.js | 15 ++ tests/pages/project/thankyou.js | 12 + tests/unit/models/stripe-card-test.js | 14 ++ tests/unit/models/stripe-customer-test.js | 14 ++ tests/unit/models/user-test.js | 2 + tests/unit/routes/project/donate-test.js | 10 + tests/unit/routes/project/thankyou-test.js | 10 + 55 files changed, 1390 insertions(+), 180 deletions(-) create mode 100644 app/components/donation/card-item.js create mode 100644 app/components/donation/card-list.js create mode 100644 app/models/stripe-card.js create mode 100644 app/models/stripe-customer.js create mode 100644 app/routes/project/donate.js create mode 100644 app/routes/project/thankyou.js create mode 100644 app/styles/templates/project/thankyou.scss create mode 100644 app/templates/components/donation/card-item.hbs create mode 100644 app/templates/components/donation/card-list.hbs create mode 100644 app/templates/project/thankyou.hbs create mode 100644 mirage/models/stripe-card.js create mode 100644 mirage/models/stripe-customer.js create mode 100644 tests/acceptance/project-donate-test.js create mode 100644 tests/acceptance/project-thankyou-test.js create mode 100644 tests/integration/components/donation/card-item-test.js create mode 100644 tests/integration/components/donation/card-list-test.js create mode 100644 tests/pages/components/donation/card-item.js create mode 100644 tests/pages/components/donation/card-list.js create mode 100644 tests/pages/components/donation/credit-card.js create mode 100644 tests/pages/components/donation/donation-container.js create mode 100644 tests/pages/components/error-formatter.js create mode 100644 tests/pages/project/donate.js create mode 100644 tests/pages/project/thankyou.js create mode 100644 tests/unit/models/stripe-card-test.js create mode 100644 tests/unit/models/stripe-customer-test.js create mode 100644 tests/unit/routes/project/donate-test.js create mode 100644 tests/unit/routes/project/thankyou-test.js diff --git a/app/components/donation/card-item.js b/app/components/donation/card-item.js new file mode 100644 index 000000000..c93c40007 --- /dev/null +++ b/app/components/donation/card-item.js @@ -0,0 +1,21 @@ +import Ember from 'ember'; + +const { + Component, + computed +} = Ember; + +export default Component.extend({ + classNames: ['card-item'], + classNameBindings: ['isSelected:selected'], + + isSelected: computed('card', 'selectedCard', function() { + return this.get('card.id') === this.get('selectedCard.id'); + }), + + selectedCard: null, + + click() { + this.get('select')(); + } +}); diff --git a/app/components/donation/card-list.js b/app/components/donation/card-list.js new file mode 100644 index 000000000..febdbe4f7 --- /dev/null +++ b/app/components/donation/card-list.js @@ -0,0 +1,19 @@ +import Ember from 'ember'; + +const { + Component, + computed: { alias, empty } +} = Ember; + +export default Component.extend({ + classNames: ['card-list'], + + cardNotSelected: empty('selectedCard'), + donationDisabled: alias('cardNotSelected'), + + selectedCard: null, + + selectCard(card) { + this.set('selectedCard', card); + } +}); diff --git a/app/components/donation/credit-card.js b/app/components/donation/credit-card.js index 8ac896308..a067c5d74 100644 --- a/app/components/donation/credit-card.js +++ b/app/components/donation/credit-card.js @@ -2,11 +2,89 @@ import Ember from 'ember'; const { Component, - computed: { not } + computed, + computed: { and, not, or }, + inject: { service } } = Ember; export default Component.extend({ classNames: ['credit-card-form'], - canDonate: true, - cannotDonate: not('canDonate') + month: '', + months: ['01', '02', '03', '04', '05', '06', '07', '08', '09', '10', '11', '12'], + year: '', + years: [], + + /** + @property stripe + @type Ember.Service + */ + stripe: service(), + + cardInvalid: not('cardValid'), + cardValid: and('isCardNumberValid', 'isCVCValid', 'isExpiryValid'), + + preventSubmit: or('isSubmitting', 'cardInvalid'), + + isSubmitting: false, + + date: computed('month', 'year', function() { + let month = this.get('month'); + let year = this.get('year'); + return `${month} ${year}`; + }), + + isCardNumberValid: computed('cardNumber', function() { + let stripe = this.get('stripe'); + let cardNumber = this.get('cardNumber'); + return stripe.card.validateCardNumber(cardNumber); + }), + + isCVCValid: computed('cvc', function() { + let stripe = this.get('stripe'); + let cvc = this.get('cvc'); + return stripe.card.validateCVC(cvc); + }), + + isExpiryValid: computed('date', function() { + let stripe = this.get('stripe'); + let date = this.get('date'); + return stripe.card.validateExpiry(date); + }), + + init() { + let date = new Date(); + let currentMonth = `0${date.getMonth() + 1}`.slice(-2); + this.set('month', currentMonth); + let currentYear = date.getFullYear(); + this.set('year', currentYear); + let years = this.generateYears(currentYear); + this.set('years', years); + this._super(...arguments); + }, + + generateYears(currentYear) { + let years = []; + let endYear = currentYear + 20; + while (endYear >= currentYear) { + years.push(currentYear++); + } + return years; + }, + + _afterSubmit() { + if (this.get('isDestroyed')) { + return; + } + this.set('isSubmitting', false); + }, + + actions: { + submit() { + this.set('isSubmitting', true); + let cardAttrs = this.getProperties('cvc', 'cardNumber', 'year', 'month'); + let onSubmit = this.get('submit'); + + onSubmit(cardAttrs).finally(() => this._afterSubmit()); + } + } }); diff --git a/app/components/donation/donation-container.js b/app/components/donation/donation-container.js index e6f63bfaf..fd8da6457 100644 --- a/app/components/donation/donation-container.js +++ b/app/components/donation/donation-container.js @@ -2,13 +2,25 @@ import Ember from 'ember'; const { Component, - computed: { bool } + computed, + computed: { + and, gt, not + } } = Ember; export default Component.extend({ + // canAddCard: true, classNames: ['donation-container'], donationAmount: 0, projectTitle: null, - canDonate: bool('projectTitle') + canAddCard: computed('hasCards', 'isAddingCard', function() { + let { hasCards, isAddingCard } = this.getProperties('hasCards', 'isAddingCard'); + return hasCards ? isAddingCard : true; + }), + canDonate: and('hasCards', 'isNotAddingCard'), + canShowCardList: and('hasCards', 'isNotAddingCard'), + hasCards: gt('cards.length', 0), + hasNoCards: not('hasCards'), + isNotAddingCard: not('isAddingCard') }); diff --git a/app/components/donations/create-donation.js b/app/components/donations/create-donation.js index 1c36fb4e9..ba21d0819 100644 --- a/app/components/donations/create-donation.js +++ b/app/components/donations/create-donation.js @@ -1,9 +1,14 @@ import Ember from 'ember'; -const { Component } = Ember; +const { Component, computed, isEmpty } = Ember; export default Component.extend({ classNames: ['create-donation'], presetAmounts: [10, 15, 25, 50], - amount: 15 + amount: 10, + + selectedAmount: computed('amount', 'customAmount', function() { + let { amount, customAmount } = this.getProperties('amount', 'customAmount'); + return isEmpty(customAmount) ? amount : customAmount; + }) }); diff --git a/app/components/donations/donation-amount-button.js b/app/components/donations/donation-amount-button.js index 4a0ab21a1..b0d7fb841 100644 --- a/app/components/donations/donation-amount-button.js +++ b/app/components/donations/donation-amount-button.js @@ -1,19 +1,21 @@ import Ember from 'ember'; -const { Component, computed } = Ember; +const { Component, computed, isEmpty } = Ember; export default Component.extend({ classNames: ['preset-amount'], classNameBindings: ['presetAmount', 'selected:default:clear'], tagName: 'button', - selected: computed('presetAmount', 'selectedAmount', function() { - let { presetAmount, selectedAmount } = this.getProperties('presetAmount', 'selectedAmount'); - return parseInt(presetAmount) === parseInt(selectedAmount); + selected: computed('customAmount', 'presetAmount', 'selectedAmount', function() { + let { customAmount, presetAmount, selectedAmount } = this.getProperties('customAmount', 'presetAmount', 'selectedAmount'); + let amountInPresets = parseInt(presetAmount) === parseInt(selectedAmount); + return isEmpty(customAmount) ? amountInPresets : false; }), click() { + this.sendAction('setCustomAmount', null); let presetAmount = this.get('presetAmount'); - this.sendAction('action', presetAmount); + this.sendAction('setAmount', presetAmount); } }); diff --git a/app/components/error-formatter.js b/app/components/error-formatter.js index abcb8687e..c7a41fcb4 100644 --- a/app/components/error-formatter.js +++ b/app/components/error-formatter.js @@ -1,6 +1,6 @@ import Ember from 'ember'; -const { Component, computed } = Ember; +const { Component, computed, Object, String } = Ember; /** `error-formatter' returns a formatted error message. Place within an 'if' @@ -36,9 +36,85 @@ export default Component.extend({ @property messages @type String */ - messages: computed('error.errors', function() { - return (this.get('error.errors') || []).map((e) => { - return `${e.title}: ${e.detail}`; + messages: computed('error', function() { + let errorResponse = Object.create(this.get('error')); + let handler = this._findHandler(errorResponse); + if (handler) { + return handler(errorResponse); + } + }), + + /** + * Determines the type of error from an error response and returns + * the correct messsage formatter function for it. + * + * @param {Object} errorResponse The error response received from the server + * @return {Function} Function which takes the error response and returns a list of messages + */ + _findHandler(errorResponse) { + if (errorResponse.get('isAdapterError')) { + return this._findHandlerForAdapterError(errorResponse); + } else if (errorResponse.get('error.type') === 'card_error') { + return this._stripeCardErrorMessages; + } + }, + + /** + * If the error response is determined to be an adapter error, this + * function further determines the type of adapter error and returns + * the correct message formatter for it. + * @param {Object} errorResponse The adapter error response received from the server + * @return {Function} Function which takes the response and returns a list of messages + */ + _findHandlerForAdapterError(errorResponse) { + let payloadContainsValidationErrors = errorResponse.errors.some((error) => error.status === 422); + + if (payloadContainsValidationErrors) { + return this._validationErrorMessages; + } else { + return this._adapterErrorMessages; + } + }, + + /** + * Formats messages for a validation error response + * In most cases, we do not need this, since we do not render those elements outside + * a form. In some cases, however, we are creating a record in the background, so + * we are forced to render those errors separate from a form. + * + * @param {Object} errorResponse The payload received from the server + * @return {Array} An array of string messages + */ + _validationErrorMessages(errorResponse) { + return (errorResponse.get('errors')).map((e) => { + let attributePathElements = e.source.pointer.split('/'); + let unformattedAttributeName = attributePathElements[attributePathElements.length - 1]; + let attributeName = String.capitalize(unformattedAttributeName); + return `${attributeName} ${e.detail}`; }); - }) + }, + + /** + * Formats messages for an adapter error response. + * An adapter error response contains an array of errors with a + * `title` and a `detail` property, but in most cases, that array only contains + * one error. + * @param {Object} errorResponse Response received from the server + * @return {Array} Array of message strings + */ + _adapterErrorMessages(errorResponse) { + return (errorResponse.get('errors')).map((e) => `${e.title}: ${e.detail}`); + }, + + /** + * Formats messages for a stripe card error response. + * + * The response contains an `error` object, for which the relevant key is + * the `message` property. + * @param {Object} errorResponse Error response received from the stripe service + * @return {Array} An array of string messages, containing single string + */ + _stripeCardErrorMessages(errorResponse) { + return [errorResponse.get('error.message')]; + } }); diff --git a/app/controllers/project/donate.js b/app/controllers/project/donate.js index 125d3666c..a28356198 100644 --- a/app/controllers/project/donate.js +++ b/app/controllers/project/donate.js @@ -1,8 +1,107 @@ import Ember from 'ember'; -const { Controller } = Ember; +const { + Controller, + computed: { alias }, + inject: { service } +} = Ember; export default Controller.extend({ + amount: null, + isAddingCard: false, queryParams: ['amount'], - amount: null + + currentUser: service(), + store: service(), + stripe: service(), + + project: alias('model'), + user: alias('currentUser.user'), + + actions: { + addCard(cardParams) { + return this._createCreditCardToken(cardParams) + .then((tokenData) => this._createCustomerAndCard(tokenData)) + .then((stripeCard) => this._addCard(stripeCard)) + .catch((reason) => this._handleError(reason)) + .finally(() => this._updateAddingCardState()); + }, + + donate(amount, stripeCard) { + // I don't think we need a token anymore once the card was created + // Should be enough to just pass in the selected stripe card. Stripe can use it + // via id, I think + return this._createSubscription(amount, stripeCard) + .then(() => this._transitionToThankYou()) + .catch((reason) => this._handleError(reason)); + } + }, + + _createCreditCardToken(cardParams) { + let stripeCard = this._tokenParams(cardParams); + let stripe = this.get('stripe'); + + return stripe.card.createToken(stripeCard); + }, + + _createCustomerAndCard({ id, card }) { + // TODO: Conditional create here + return this._createStripeCustomer(id) + .then((/* stripeCustomer */) => this._createStripeCard(card)); + }, + + _createStripeCustomer(/* token */) { + let { user, store } = this.getProperties('user', 'store'); + let email = user.get('email'); + + return store.createRecord('stripe-customer', { email, user }) + .save(); + }, + + _createStripeCard(cardData) { + let user = this.get('user'); + let params = this._cardParams(cardData); + + return user.get('stripeCards') + .createRecord(params) + .save(); + }, + + _addCard(stripeCard) { + return this.get('user.stripeCards').pushObject(stripeCard); + }, + + _createSubscription(amount, stripeCard) { + let { project, store, user } = this.getProperties('project', 'store', 'user'); + let subscription = store.createRecord('stripe-subscription', { amount, project, user, stripeCard }); + + return subscription.save(); + }, + + _transitionToThankYou() { + let project = this.get('project'); + return this.transitionToRoute('project.thankyou', project); + }, + + _handleError(error) { + this.set('error', error); + }, + + _tokenParams(cardParams) { + return { + number: cardParams.cardNumber, + cvc: cardParams.cvc, + exp_month: cardParams.month, + exp_year: cardParams.year + }; + }, + + _updateAddingCardState() { + this.set('isAddingCard', false); + }, + + _cardParams(params) { + let { last4, name, brand, country, exp_month, exp_year } = params; + return { brand, country, expMonth: exp_month, expYear: exp_year, last4, name }; + } }); diff --git a/app/models/stripe-card.js b/app/models/stripe-card.js new file mode 100644 index 000000000..5796b5691 --- /dev/null +++ b/app/models/stripe-card.js @@ -0,0 +1,14 @@ +import Model from 'ember-data/model'; +import attr from 'ember-data/attr'; +import { belongsTo } from 'ember-data/relationships'; + +export default Model.extend({ + brand: attr(), + country: attr(), + expMonth: attr(), + expYear: attr(), + last4: attr(), + name: attr(), + + user: belongsTo('user', { async: true }) +}); diff --git a/app/models/stripe-customer.js b/app/models/stripe-customer.js new file mode 100644 index 000000000..5d5d9205b --- /dev/null +++ b/app/models/stripe-customer.js @@ -0,0 +1,9 @@ +import Model from 'ember-data/model'; +import attr from 'ember-data/attr'; +import { belongsTo } from 'ember-data/relationships'; + +export default Model.extend({ + email: attr(), + + user: belongsTo('user', { async: true }) +}); diff --git a/app/models/user.js b/app/models/user.js index 7cb5e9ef1..c237c7f96 100644 --- a/app/models/user.js +++ b/app/models/user.js @@ -1,6 +1,6 @@ import Model from 'ember-data/model'; import attr from 'ember-data/attr'; -import { hasMany } from 'ember-data/relationships'; +import { belongsTo, hasMany } from 'ember-data/relationships'; import Ember from 'ember'; const { computed } = Ember; @@ -28,6 +28,8 @@ export default Model.extend({ userCategories: hasMany('user-category', { async: true }), userRoles: hasMany('user-role', { async: true }), userSkills: hasMany('user-skill', { async: true }), + stripeCards: hasMany('stripe-card', { async: true }), + stripeCustomer: belongsTo('stripe-customer', { async: true }), atUsername: computed('username', function() { return `@${this.get('username')}`; diff --git a/app/router.js b/app/router.js index 120dcc7ae..d69dc6864 100644 --- a/app/router.js +++ b/app/router.js @@ -57,6 +57,7 @@ AppRouter.map(function() { this.route('task', { path: '/:number' }); }); this.route('donate'); + this.route('thankyou'); }); this.route('projects', { diff --git a/app/routes/project/donate.js b/app/routes/project/donate.js new file mode 100644 index 000000000..16240adb3 --- /dev/null +++ b/app/routes/project/donate.js @@ -0,0 +1,8 @@ +import AuthenticatedRouteMixin from 'ember-simple-auth/mixins/authenticated-route-mixin'; +import Ember from 'ember'; + +const { + Route +} = Ember; + +export default Route.extend(AuthenticatedRouteMixin, {}); diff --git a/app/routes/project/thankyou.js b/app/routes/project/thankyou.js new file mode 100644 index 000000000..e78f5a40a --- /dev/null +++ b/app/routes/project/thankyou.js @@ -0,0 +1,7 @@ +import AuthenticatedRouteMixin from 'ember-simple-auth/mixins/authenticated-route-mixin'; +import Ember from 'ember'; + +const { Route } = Ember; + +export default Route.extend(AuthenticatedRouteMixin, { +}); diff --git a/app/styles/_buttons.scss b/app/styles/_buttons.scss index 171ee30bf..003759899 100644 --- a/app/styles/_buttons.scss +++ b/app/styles/_buttons.scss @@ -182,11 +182,10 @@ #{$all-buttons-hover}, .button:hover { cursor: pointer; - $darker-color: darken($primary-color, 5%); - background: $darker-color; - background-image: linear-gradient($default-color, $darker-color); - border-color: $darker-color; - @include transition(background 0.2s ease-in); + + &.default, &.clear, &.success, &.danger { + @include transition(background 0.2s ease-in); + } &.default { $darker-color: darken($default-color, 5%); @@ -229,7 +228,7 @@ } &:disabled { - cursor: default; + cursor: not-allowed; } } @@ -305,10 +304,6 @@ p { background: white; } } - - &:disabled { - cursor: not-allowed; - } } select { diff --git a/app/styles/_forms.scss b/app/styles/_forms.scss index ccf5fdd75..930631b18 100644 --- a/app/styles/_forms.scss +++ b/app/styles/_forms.scss @@ -46,10 +46,14 @@ $outer-border-radius: 4px; .row { display: flex; justify-content: space-between; + } - label { - width: 49%; - position: relative; + label { + span { + display: block; + font-size: $body-font-size-small; + font-weight: 700; + margin-bottom: 3px; } } @@ -186,14 +190,19 @@ form { } } +select { + border: 1px solid $border-default; + font-family: $body-font-family; + font-size: $body-font-size-normal; + height: 41px; + padding: 0 16px; + cursor: pointer; +} + .task-type { select { border: none; - font-family: $body-font-family; - font-size: $body-font-size-normal; height: 34px; - padding: 0 16px; - cursor: pointer; width: 100px; &:focus { diff --git a/app/styles/app.scss b/app/styles/app.scss index b9874a40a..8c75a02e7 100644 --- a/app/styles/app.scss +++ b/app/styles/app.scss @@ -81,6 +81,7 @@ @import "templates/about"; @import "templates/project/index"; +@import "templates/project/thankyou"; @import "templates/project/settings/contributors"; @import "templates/start/expertise"; @import "templates/start/hello"; diff --git a/app/styles/components/donation-payment.scss b/app/styles/components/donation-payment.scss index 5939b11c3..8c4ca0492 100644 --- a/app/styles/components/donation-payment.scss +++ b/app/styles/components/donation-payment.scss @@ -34,24 +34,10 @@ } } -.credit-card-label { - &:before { - @include sprite($credit-card); - } - - .icon-input { - padding-left: 45px; - } +select.month { + min-width: 50px; } -.lock-label { - &:before { - @include sprite($lock); - } -} - -.calendar-label { - &:before { - @include sprite($calendar); - } +select.year { + min-width: 70px; } diff --git a/app/styles/templates/project/thankyou.scss b/app/styles/templates/project/thankyou.scss new file mode 100644 index 000000000..8dc38e5fd --- /dev/null +++ b/app/styles/templates/project/thankyou.scss @@ -0,0 +1,14 @@ +.thankyou-container { + .icon, .header, .text, .project-link{ + width: 100%; + text-align: center; + } + + .icon img { + display: inline-block; + } + + .text { + margin-bottom: 18px; + } +} diff --git a/app/templates/components/donation/card-item.hbs b/app/templates/components/donation/card-item.hbs new file mode 100644 index 000000000..5b70cc722 --- /dev/null +++ b/app/templates/components/donation/card-item.hbs @@ -0,0 +1 @@ +{{card.brand}} ending in {{card.last4}} \ No newline at end of file diff --git a/app/templates/components/donation/card-list.hbs b/app/templates/components/donation/card-list.hbs new file mode 100644 index 000000000..75f4d611a --- /dev/null +++ b/app/templates/components/donation/card-list.hbs @@ -0,0 +1,7 @@ +{{#each cards as |card|}} + {{donation/card-item + card=card + select=(action selectCard card) + selectedCard=selectedCard + }} +{{/each}} diff --git a/app/templates/components/donation/credit-card.hbs b/app/templates/components/donation/credit-card.hbs index 7c6edcb0c..7654956b3 100644 --- a/app/templates/components/donation/credit-card.hbs +++ b/app/templates/components/donation/credit-card.hbs @@ -1,4 +1,5 @@

Add card information

+
visa american-express @@ -7,37 +8,49 @@ jcb diners-club
+
-
-
- + + +{{#if canCancel}} + Cancel +{{/if}} + +{{#unless isSubmitting}} + {{!validation errors can be passed in from parent, using a block}} + {{yield}} +{{/unless}} diff --git a/app/templates/components/donation/donation-container.hbs b/app/templates/components/donation/donation-container.hbs index 1a107a34e..aa594e4e9 100644 --- a/app/templates/components/donation/donation-container.hbs +++ b/app/templates/components/donation/donation-container.hbs @@ -2,27 +2,38 @@

{{format-currency donationAmount}}

given each month - edit

Payment information

-

- {{#if projectTitle}} - Your payment method will be charged {{format-currency donationAmount}} - per month to support {{projectTitle}}. - {{else}} - No project selected. - {{/if}} +

+ Your payment method will be charged {{format-currency donationAmount}} + per month to support {{projectTitle}}.

-{{donation/credit-card canDonate=canDonate}} +{{#if canShowCardList}} + {{donation/card-list cards=cards selectedCard=defaultCard}} +{{/if}} - +{{#if canAddCard}} + {{#donation/credit-card canSubmit=canSubmitAddCard submit=addCard canCancel=canShowCardList}} + {{!show errors}} + {{yield}} + {{/donation/credit-card}} +{{/if}} + +{{#if canDonate}} + + + {{!show errors}} + {{yield}} + + +{{/if}} diff --git a/app/templates/components/donations/create-donation.hbs b/app/templates/components/donations/create-donation.hbs index e720c4f9b..5dff34b47 100644 --- a/app/templates/components/donations/create-donation.hbs +++ b/app/templates/components/donations/create-donation.hbs @@ -1,16 +1,19 @@
{{#each presetAmounts as |presetAmount|}} {{donations/donation-amount-button - action=(action (mut amount)) + customAmount=customAmount presetAmount=presetAmount - selectedAmount=amount}} + selectedAmount=amount + setCustomAmount=(action (mut customAmount)) + setAmount=(action (mut amount)) + }} {{/each}}
$ - {{input class="amount" name="amount" type="number" min=0 placeholder="Custom amount" step=1 value=amount}} + {{input class="amount" name="amount" type="number" min=0 placeholder="Custom amount" step=1 value=customAmount}} per month
- + diff --git a/app/templates/components/donations/donation-status.hbs b/app/templates/components/donations/donation-status.hbs index 9d70978b9..5f64084d8 100644 --- a/app/templates/components/donations/donation-status.hbs +++ b/app/templates/components/donations/donation-status.hbs @@ -1,7 +1,7 @@ {{#if subscription}} {{donations/show-donation amount=subscription.amount}} {{else if processStarted}} - {{donations/create-donation amount=15 continue=(action createDonation)}} + {{donations/create-donation continue=(action createDonation)}} {{else}} {{donations/become-a-donor becomeADonor=(action (mut processStarted) true)}} {{/if}} diff --git a/app/templates/components/error-formatter.hbs b/app/templates/components/error-formatter.hbs index 92ac5b9a0..566782648 100644 --- a/app/templates/components/error-formatter.hbs +++ b/app/templates/components/error-formatter.hbs @@ -2,4 +2,4 @@

{{message}}

{{else}}

{{defaultMessage}}

-{{/each}} \ No newline at end of file +{{/each}} diff --git a/app/templates/project/donate.hbs b/app/templates/project/donate.hbs index 819128e9f..0b826e3bd 100644 --- a/app/templates/project/donate.hbs +++ b/app/templates/project/donate.hbs @@ -1,2 +1,13 @@ -{{project-header project=model}} -{{donation/donation-container donationAmount=amount projectTitle=model.title}} +{{project-header project=project}} +{{#donation/donation-container + addCard=(action 'addCard') + cards=user.stripeCards + donate=(action 'donate' amount) + donationAmount=amount + isAddingCard=isAddingCard + projectTitle=project.title +}} + {{#if error}} + {{error-formatter error=error}} + {{/if}} +{{/donation/donation-container}} diff --git a/app/templates/project/thankyou.hbs b/app/templates/project/thankyou.hbs new file mode 100644 index 000000000..74fe61f62 --- /dev/null +++ b/app/templates/project/thankyou.hbs @@ -0,0 +1,8 @@ +{{project-header project=model}} + +
+
Project Icon
+

Thank you!

+
From all the volunteers on the {{model.title}} team.
+ +
\ No newline at end of file diff --git a/config/environment.js b/config/environment.js index ddab714b3..7404cb700 100644 --- a/config/environment.js +++ b/config/environment.js @@ -78,6 +78,8 @@ module.exports = function(environment) { sentry: { dsn: 'https://cecdf7d399e74b72bc73dc8e4e62737d@app.getsentry.com/82741' }, + + stripe: {} }; if (environment === 'development') { @@ -92,6 +94,8 @@ module.exports = function(environment) { ENV.sentry.development = true; + ENV.stripe.publishableKey = 'pk_test_hiQ7tWKKdLSw8jJdE98NSW74'; + ENV['ember-cli-mirage'] = { enabled: false }; @@ -158,6 +162,9 @@ module.exports = function(environment) { ENV.sentry.development = true; + ENV.stripe.publishableKey = 'pk_test_hiQ7tWKKdLSw8jJdE98NSW74'; + ENV.LOG_STRIPE_SERVICE = true, + ENV['simple-auth'] = { store: 'simple-auth-session-store:ephemeral' }; diff --git a/mirage/config.js b/mirage/config.js index 47b630b17..52b7fe8b7 100644 --- a/mirage/config.js +++ b/mirage/config.js @@ -65,7 +65,8 @@ function generatePreviewMentions(schema, preview) { const routes = [ 'categories', 'comment-user-mentions', 'comments', 'donation-goals', 'organizations', 'task-user-mentions', 'tasks', 'previews', 'projects', 'project-categories', - 'slugged-routes', 'stripe-subscriptions', 'user-categories', 'users' + 'slugged-routes', 'stripe-cards', 'stripe-customers', 'stripe-subscriptions', + 'user-categories', 'users' ]; export default function() { @@ -401,6 +402,18 @@ export default function() { this.post('/stripe-subscriptions'); + /** + * Stripe cards + */ + + this.post('/stripe-cards'); + + /** + * Stripe customers + */ + + this.post('/stripe-customers'); + /** * Task user mentions */ diff --git a/mirage/models/stripe-card.js b/mirage/models/stripe-card.js new file mode 100644 index 000000000..feceaa785 --- /dev/null +++ b/mirage/models/stripe-card.js @@ -0,0 +1,5 @@ +import { Model, belongsTo } from 'ember-cli-mirage'; + +export default Model.extend({ + user: belongsTo() +}); diff --git a/mirage/models/stripe-customer.js b/mirage/models/stripe-customer.js new file mode 100644 index 000000000..feceaa785 --- /dev/null +++ b/mirage/models/stripe-customer.js @@ -0,0 +1,5 @@ +import { Model, belongsTo } from 'ember-cli-mirage'; + +export default Model.extend({ + user: belongsTo() +}); diff --git a/package.json b/package.json index 6257ce3cc..987ab2f78 100644 --- a/package.json +++ b/package.json @@ -79,6 +79,7 @@ "ember-simple-auth": "1.1.0", "ember-simple-auth-token": "^1.2.0", "ember-sinon": "0.5.1", + "ember-stripe-service": "4.4.2", "ember-tether": "0.3.1", "ember-tooltips": "2.4.0", "ember-truth-helpers": "1.2.0", diff --git a/tests/acceptance/project-donate-test.js b/tests/acceptance/project-donate-test.js new file mode 100644 index 000000000..3660b1cec --- /dev/null +++ b/tests/acceptance/project-donate-test.js @@ -0,0 +1,227 @@ +import { test } from 'qunit'; +import moduleForAcceptance from 'code-corps-ember/tests/helpers/module-for-acceptance'; +import Ember from 'ember'; +import Mirage from 'ember-cli-mirage'; + +import { authenticateSession } from 'code-corps-ember/tests/helpers/ember-simple-auth'; +import createOrganizationWithSluggedRoute from 'code-corps-ember/tests/helpers/mirage/create-organization-with-slugged-route'; +import projectDonatePage from '../pages/project/donate'; + +const { + RSVP, + Service +} = Ember; + +// NOTE: Don't think these mocks can be moved, unless we can make them more generic than they are +// As is, they mock specifically the `stripe` injection for the project.donate controller + +const stripeCardError = { + error: { + type: 'card_error', + code: 'invalid_expiry_year', + message: "Your card's expiration year is invalid.", + param: 'exp_year' + } +}; + +const stripeTokenResponse = { + id: 'tok_something', // Token identifier + card: { // Dictionary of the card used to create the token + name: null, + address_line1: '12 Main Street', + address_line2: 'Apt 42', + address_city: 'Palo Alto', + address_state: 'CA', + address_zip: '94301', + address_country: 'US', + country: 'US', + exp_month: 2, + exp_year: 2017, + last4: '4242', + object: 'card', + brand: 'Visa', + funding: 'credit' + }, + created: 1478892395, // Timestamp of when token was created + livemode: true, // Whether this token was created with a live API key + type: 'card', + object: 'token', // Type of object, always "token" + used: false // Whether this token has been used +}; + +const stripeMockSuccess = { + card: { + createToken: () => RSVP.resolve(stripeTokenResponse) + } +}; + +const stripeMockFailure = { + card: { + createToken: () => RSVP.reject(stripeCardError) + } +}; + +function stubStripe(context, mock) { + let mockService = Service.create(mock); + context.application.__container__.lookup('controller:project.donate').set('stripe', mockService); +} + +moduleForAcceptance('Acceptance | Project - Donate'); + +test('It requires authentication', function(assert) { + assert.expect(1); + + let organization = createOrganizationWithSluggedRoute(); + let project = server.create('project', { organization }); + + projectDonatePage.visit({ + amount: 10, + organization: organization.slug, + project: project.slug + }); + + andThen(() => { + assert.equal(currentRouteName(), 'login'); + }); +}); + +test('Allows adding a card and donating (creating a subscription)', function(assert) { + assert.expect(8); + + stubStripe(this, stripeMockSuccess); + + let user = server.create('user'); + authenticateSession(this.application, { 'user_id': user.id }); + + let organization = createOrganizationWithSluggedRoute(); + let project = server.create('project', { organization }); + + projectDonatePage.visit({ + amount: 10, + organization: organization.slug, + project: project.slug + }); + + andThen(() => { + projectDonatePage.creditCard.cardNumber('4242-4242-4242-4242'); + projectDonatePage.creditCard.cardCVC('123'); + projectDonatePage.creditCard.cardMonth('12'); + projectDonatePage.creditCard.cardYear('2020'); + }); + + andThen(() => { + projectDonatePage.creditCard.clickSubmit(); + }); + + andThen(() => { + projectDonatePage.cardList.cards(0).clickCard(); + projectDonatePage.clickDonate(); + }); + + andThen(() => { + let customer = server.schema.stripeCustomers.findBy({ email: user.email }); + assert.ok(customer, 'Customer was created with proper attributes.'); + assert.equal(customer.userId, user.id, 'Customer was assigned to current user'); + + // we use attributes set in the mock stripe token response + let card = server.schema.stripeCards.findBy({ brand: 'Visa', last4: '4242', expMonth: 2, expYear: 2017 }); + assert.ok(card, 'Card was created with proper attributes.'); + assert.equal(card.userId, user.id, 'Card was assigned to current user'); + + // amount is 1000, in cents + let subscription = server.schema.stripeSubscriptions.findBy({ amount: 1000 }); + assert.ok(subscription, 'Subscription was created sucessfully.'); + assert.equal(subscription.userId, user.id, 'User was set to current user.'); + assert.equal(subscription.projectId, project.id, 'Project was set to current project.'); + assert.equal(currentRouteName(), 'project.thankyou', 'User was redirected to the thank you route.'); + }); +}); + +test('Shows stripe errors when creating card token fails', function(assert) { + assert.expect(3); + + stubStripe(this, stripeMockFailure); + + let user = server.create('user'); + authenticateSession(this.application, { 'user_id': user.id }); + + let organization = createOrganizationWithSluggedRoute(); + let project = server.create('project', { organization }); + + projectDonatePage.visit({ + amount: 10, + organization: organization.slug, + project: project.slug + }); + + andThen(() => { + projectDonatePage.creditCard.cardNumber('4242-4242-4242-4242'); + projectDonatePage.creditCard.cardCVC('123'); + projectDonatePage.creditCard.cardMonth('12'); + projectDonatePage.creditCard.cardYear('2020'); + }); + + andThen(() => { + projectDonatePage.creditCard.clickSubmit(); + }); + + andThen(() => { + assert.equal(currentRouteName(), 'project.donate'); + assert.equal(projectDonatePage.errorFormatter.errors().count, 1, 'Correct number of errors is displayed.'); + assert.equal(projectDonatePage.errorFormatter.errors(0).message, stripeCardError.error.message, 'Correct error is displayed.'); + }); +}); + +test('Shows validation errors when creating subscription fails', function(assert) { + assert.expect(4); + + stubStripe(this, stripeMockSuccess); + + let user = server.create('user'); + authenticateSession(this.application, { 'user_id': user.id }); + + let organization = createOrganizationWithSluggedRoute(); + let project = server.create('project', { organization }); + + projectDonatePage.visit({ + amount: 0, + organization: organization.slug, + project: project.slug + }); + + andThen(() => { + projectDonatePage.creditCard.cardNumber('4242-4242-4242-4242'); + projectDonatePage.creditCard.cardCVC('123'); + projectDonatePage.creditCard.cardMonth('12'); + projectDonatePage.creditCard.cardYear('2020'); + }); + + andThen(() => { + projectDonatePage.creditCard.clickSubmit(); + }); + + andThen(() => { + let done = assert.async(); + + server.post('/stripe-subscriptions', function() { + done(); + return new Mirage.Response(422, {}, { + errors: [{ + id: 'VALIDATION_ERROR', + source: { pointer: 'data/attributes/amount' }, + detail: 'is invalid', + status: 422 + }] + }); + }); + projectDonatePage.cardList.cards(0).clickCard(); + projectDonatePage.clickDonate(); + }); + + andThen(() => { + assert.notOk(server.schema.stripeSubscriptions.findBy({ amount: 1000 }), 'Subscription was not created.'); + assert.equal(currentRouteName(), 'project.donate'); + assert.equal(projectDonatePage.errorFormatter.errors().count, 1, 'Correct number of errors is displayed.'); + assert.equal(projectDonatePage.errorFormatter.errors(0).message, 'Amount is invalid', 'Correct error is displayed.'); + }); +}); diff --git a/tests/acceptance/project-thankyou-test.js b/tests/acceptance/project-thankyou-test.js new file mode 100644 index 000000000..6cc8535c2 --- /dev/null +++ b/tests/acceptance/project-thankyou-test.js @@ -0,0 +1,52 @@ +import { test } from 'qunit'; +import moduleForAcceptance from 'code-corps-ember/tests/helpers/module-for-acceptance'; + +import { authenticateSession } from 'code-corps-ember/tests/helpers/ember-simple-auth'; +import createOrganizationWithSluggedRoute from 'code-corps-ember/tests/helpers/mirage/create-organization-with-slugged-route'; +import projectThankYouPage from '../pages/project/thankyou'; + +moduleForAcceptance('Acceptance | Project - ThankYou'); + +test('It requires authentication', function(assert) { + assert.expect(1); + + let organization = createOrganizationWithSluggedRoute(); + let project = server.create('project', { organization }); + + projectThankYouPage.visit({ + organization: organization.slug, + project: project.slug + }); + + andThen(() => { + assert.equal(currentRouteName(), 'login'); + }); +}); + +test('It renders project header', function(assert) { + assert.expect(5); + + let user = server.create('user'); + authenticateSession(this.application, { 'user_id': user.id }); + + let organization = createOrganizationWithSluggedRoute(); + let project = server.create('project', { organization }); + + projectThankYouPage.visit({ + organization: organization.slug, + project: project.slug + }); + + andThen(() => { + assert.ok(projectThankYouPage.rendersProjectHeader, 'Project header is rendered on the page.'); + assert.ok(projectThankYouPage.rendersIcon, 'The success icon is rendered on the page.'); + assert.ok(projectThankYouPage.rendersThankYouHeader, 'A thank you header is rendered on the page.'); + assert.ok(projectThankYouPage.rendersText, 'A descriptive text is rendered on the page.'); + + projectThankYouPage.clickProjectLink(); + }); + + andThen(() => { + assert.equal(currentRouteName(), 'project.index', 'Link leads to project page.'); + }); +}); diff --git a/tests/acceptance/task-creation-test.js b/tests/acceptance/task-creation-test.js index 48fe9e6e9..5f768d030 100644 --- a/tests/acceptance/task-creation-test.js +++ b/tests/acceptance/task-creation-test.js @@ -149,6 +149,7 @@ test('When task creation fails due to validation, validation errors are displaye let taskCreationDone = assert.async(); server.post('/tasks', function() { taskCreationDone(); + return new Mirage.Response(422, {}, { errors: [ { diff --git a/tests/integration/components/donation/card-item-test.js b/tests/integration/components/donation/card-item-test.js new file mode 100644 index 000000000..bee410188 --- /dev/null +++ b/tests/integration/components/donation/card-item-test.js @@ -0,0 +1,69 @@ +import { moduleForComponent, test } from 'ember-qunit'; +import Ember from 'ember'; +import hbs from 'htmlbars-inline-precompile'; +import PageObject from 'ember-cli-page-object'; + +import cardItemComponent from '../../../pages/components/donation/card-item'; + +let page = PageObject.create(cardItemComponent); + +const { + K +} = Ember; + +let setHandler = function(context, selectHandler = K) { + context.set('selectHandler', selectHandler); +}; + +moduleForComponent('donation/card-item', 'Integration | Component | donation/card item', { + integration: true, + beforeEach() { + setHandler(this); + page.setContext(this); + }, + afterEach() { + page.removeContext(); + } +}); + +test('it renders proper information', function(assert) { + assert.expect(1); + + this.set('card', { id: 1, brand: 'Visa', last4: 4242 }); + + page.render(hbs`{{donation/card-item card=card select=selectHandler}}`); + + assert.equal(page.cardDescription, 'Visa ending in 4242', 'Card description is correct'); +}); + +test('it allows selection of card', function(assert) { + assert.expect(1); + + function selectHandler() { + assert.ok(true, 'Action got called.'); + } + + this.set('card', { id: 1, brand: 'Visa', last4: 4242 }); + + setHandler(this, selectHandler); + + page.render(hbs`{{donation/card-item card=card select=selectHandler}}`) + .clickCard(); +}); + +test('it shows card as selected if selected', function(assert) { + assert.expect(2); + + let ourCard = { id: 1, brand: 'Visa', last4: 4242 }; + let selectedCard = { id: 2, brand: 'Visa', last4: 4242 }; + this.set('card', ourCard); + this.set('selectedCard', selectedCard); + + page.render(hbs`{{donation/card-item card=card select=selectHandler selectedCard=selectedCard}}`); + + assert.notOk(page.isSelected, 'Card is not displaying as selected.'); + + this.set('selectedCard', ourCard); + + assert.ok(page.isSelected, 'Card is displaying as selected.'); +}); diff --git a/tests/integration/components/donation/card-list-test.js b/tests/integration/components/donation/card-list-test.js new file mode 100644 index 000000000..699297e2c --- /dev/null +++ b/tests/integration/components/donation/card-list-test.js @@ -0,0 +1,72 @@ + +import { moduleForComponent, test } from 'ember-qunit'; +import Ember from 'ember'; +import hbs from 'htmlbars-inline-precompile'; +import PageObject from 'ember-cli-page-object'; + +import cardListComponent from '../../../pages/components/donation/card-list'; + +let page = PageObject.create(cardListComponent); + +const { + Object, + RSVP +} = Ember; + +let setHandlers = function(context, { selectCardHandler = RSVP } = {}) { + context.set('selectCardHandler', selectCardHandler); +}; + +moduleForComponent('donation/card-list', 'Integration | Component | donation/card list', { + integration: true, + beforeEach() { + setHandlers(this); + page.setContext(this); + }, + afterEach() { + page.removeContext(); + } +}); + +test('it renders proper information', function(assert) { + assert.expect(3); + + this.set('cards', [ + { id: 1, brand: 'Visa', last4: '4242' }, + { id: 2, brand: 'Diners', last4: '9999' } + ]); + + page.render(hbs`{{donation/card-list cards=cards donate=donateHandler}}`); + + assert.equal(page.cards().count, 2, 'Renders correct number of cards.'); + assert.equal(page.cards(0).cardDescription, 'Visa ending in 4242', 'First card is rendered correctly.'); + assert.equal(page.cards(1).cardDescription, 'Diners ending in 9999', 'Second card is rendered correctly.'); +}); + +test('it allows user to select card', function(assert) { + assert.expect(4); + + let visa = Object.create({ id: 1, brand: 'Visa', last4: '4242' }); + let diners = Object.create({ id: 2, brand: 'Diners', last4: '9999' }); + this.set('cards', [ + visa, diners + ]); + this.set('selectedCard', visa); + + function selectCardHandler(card) { + assert.deepEqual(card, visa, 'Card was passed correctly.'); + return RSVP.resolve(); + } + + setHandlers(this, { selectCardHandler }); + + page.render(hbs`{{donation/card-list cards=cards selectedCard=selectedCard}}`); + + assert.ok(page.cards(0).isSelected, 'First card is selected.'); + assert.notOk(page.cards(1).isSelected, 'Second card is not selected.'); + + page.cards(1).clickCard(); + + assert.notOk(page.cards(0).isSelected, 'First card got unselected.'); + assert.ok(page.cards(1).isSelected, 'Second card got selected.'); +}); diff --git a/tests/integration/components/donation/credit-card-test.js b/tests/integration/components/donation/credit-card-test.js index 47ddfca37..541d974ab 100644 --- a/tests/integration/components/donation/credit-card-test.js +++ b/tests/integration/components/donation/credit-card-test.js @@ -1,29 +1,77 @@ import { moduleForComponent, test } from 'ember-qunit'; +import Ember from 'ember'; import hbs from 'htmlbars-inline-precompile'; +import PageObject from 'ember-cli-page-object'; +import stubService from 'code-corps-ember/tests/helpers/stub-service'; +import wait from 'ember-test-helpers/wait'; + +import creditCardComponent from '../../../pages/components/donation/credit-card'; + +let page = PageObject.create(creditCardComponent); + +const { + K, + RSVP, + run +} = Ember; + +let setHandler = function(context, submitHandler = K) { + context.set('submitHandler', submitHandler); +}; moduleForComponent('donation/credit-card', 'Integration | Component | donation/credit card', { - integration: true + integration: true, + beforeEach() { + setHandler(this); + page.setContext(this); + }, + afterEach() { + page.removeContext(); + } }); -test('inputs can be disabled', function(assert) { +test('it sends submit with credit card fields when button is clicked', function(assert) { + let done = assert.async(); assert.expect(3); - this.set('canDonate', false); - this.render(hbs`{{donation/credit-card canDonate=canDonate}}`); - let inputs = this.$().find('input').get(); + this.set('canDonate', true); - inputs.forEach((input) => { - assert.ok($(input).prop('disabled')); - }); -}); + let expectedProps = { + cardNumber: '1234-5678-9012-3456', + cvc: '123', + month: '12', + year: '2020' + }; -test('inputs are enabled by default', function(assert) { - assert.expect(3); - this.render(hbs`{{donation/credit-card}}`); + let submitHandler = function(actualProps) { + return new RSVP.Promise((fulfill) => { + run.next(() => { + assert.deepEqual(actualProps, expectedProps, 'Action was called with proper attributes.'); + assert.ok(page.submitDisabled, 'Submit button is disabled'); + assert.equal(page.submitButtonText, 'Adding...', 'Submit button changed text'); + fulfill(); + }); + }); + }; + + setHandler(this, submitHandler); + + stubService(this, 'stripe', { + card: { + validateCardNumber: () => true, + validateCVC: () => true, + validateExpiry: () => true + } + }); - let inputs = this.$().find('input').get(); + page.render(hbs`{{donation/credit-card canDonate=canDonate submit=submitHandler}}`) + .cardNumber(expectedProps.cardNumber) + .cardMonth(expectedProps.month) + .cardYear(expectedProps.year) + .cardCVC(expectedProps.cvc) + .clickSubmit(); - inputs.forEach((input) => { - assert.notOk($(input).prop('disabled')); + wait().then(() => { + done(); }); }); diff --git a/tests/integration/components/donation/donation-container-test.js b/tests/integration/components/donation/donation-container-test.js index 7ada42cd3..9762ad0be 100644 --- a/tests/integration/components/donation/donation-container-test.js +++ b/tests/integration/components/donation/donation-container-test.js @@ -1,35 +1,130 @@ import { moduleForComponent, test } from 'ember-qunit'; +import donationContainerComponent from '../../../pages/components/donation/donation-container'; +import Ember from 'ember'; import hbs from 'htmlbars-inline-precompile'; +import PageObject from 'ember-cli-page-object'; +import stubService from 'code-corps-ember/tests/helpers/stub-service'; + +let page = PageObject.create(donationContainerComponent); + +const { + K, + RSVP +} = Ember; + +let mockStripeCard = { + brand: 'Visa', + exp_month: '12', + exp_year: '2020', + last4: '4242' +}; + +function setHandlers(context, { donateHandler = K, addCardHandler = RSVP } = {}) { + context.set('donateHandler', donateHandler); + context.set('addCardHandler', addCardHandler); +} moduleForComponent('donation/donation-container', 'Integration | Component | donation/donation container', { - integration: true + integration: true, + beforeEach() { + setHandlers(this); + page.setContext(this); + }, + afterEach() { + page.removeContext(); + } }); -test('it renders no project error when no project is passed in', function(assert) { - assert.expect(1); +test('it renders basic elements', function(assert) { + assert.expect(2); + + this.set('amount', 100); + this.set('projectTitle', 'CodeCorps'); + + page.render(hbs`{{donation/donation-container donate=(action donateHandler) donationAmount=amount projectTitle=projectTitle}}`); + + assert.equal(page.donationAmountText, '$100.00 given each month', 'Amount text is rendered correctly'); + assert.equal( + page.paymentInformationText, + 'Your payment method will be charged $100.00 per month to support CodeCorps.', + 'Payment information renders correctly.' + ); +}); + +test('it renders the right elements when adding a card and has existing cards', function(assert) { + assert.expect(2); + + this.set('cards', [mockStripeCard]); + + page.render(hbs`{{donation/donation-container cards=cards isAddingCard=true}}`); - this.render(hbs`{{donation/donation-container}}`); + assert.ok(page.cardFormIsVisible, 'Credit card form component is rendered.'); + assert.notOk(page.cardListIsVisible, 'Credit card list is not rendered.'); +}); + +test('it renders the right elements when not adding a card and has existing cards', function(assert) { + assert.expect(3); + + this.set('cards', [mockStripeCard]); - let mainContent = this.$().find('p').eq(0); - assert.equal(mainContent.text().trim(), 'No project selected.'); + page.render(hbs`{{donation/donation-container canDonate=true cards=cards donate=(action donateHandler amount) isAddingCard=false}}`); + + assert.notOk(page.cardFormIsVisible, 'Credit card form component is not rendered.'); + assert.ok(page.cardListIsVisible, 'Credit card list is rendered.'); + assert.ok(page.donationButtonIsVisible, 'Donation button renders'); }); -test('it renders donation amount and frequency', function(assert) { +test('it handles adding a card correctly', function(assert) { assert.expect(1); - this.set('projectTitle', 'Funtown'); - this.render(hbs`{{donation/donation-container projectTitle=projectTitle}}`); + let cardDetails = { + cardNumber: '1234-5678-9012-3456', + cvc: '123', + month: '12', + year: '2020' + }; + + function addCardHandler(actualProps) { + assert.deepEqual(actualProps, cardDetails, 'Card parameters were passed correctly.'); + return RSVP.resolve(); + } + + stubService(this, 'stripe', { + card: { + validateCardNumber: K, + validateCVC: K, + validateExpiry: K + } + }); - let mainContent = this.$().find('p').eq(0); - assert.ok(mainContent.text().match('Your payment method will be charged')); + setHandlers(this, { addCardHandler }); + + page.render(hbs`{{donation/donation-container addCard=(action addCardHandler)}}`); + + page.creditCard + .cardNumber(cardDetails.cardNumber) + .cardMonth(cardDetails.month) + .cardYear(cardDetails.year) + .cardCVC(cardDetails.cvc) + .clickSubmit(); }); -test('it renders donation amount and frequency', function(assert) { +test('it handles donating correctly', function(assert) { assert.expect(1); - this.set('amount', 100); // in cents - this.render(hbs`{{donation/donation-container donationAmount=amount}}`); + let amount = 100; + this.set('amount', amount); + + this.set('cards', [mockStripeCard]); + + function donateHandler(actualProps) { + assert.deepEqual(actualProps, amount, 'The proper amount is sent correctly.'); + return RSVP.resolve(); + } + + setHandlers(this, { donateHandler }); + + page.render(hbs`{{donation/donation-container cards=cards donate=(action donateHandler amount) donationAmount=amount}} isAddingCard`); - let donationInfo = this.$().find('h3').eq(0); - assert.equal(donationInfo.text().trim(), '$100.00'); + page.clickSubmit(); }); diff --git a/tests/integration/components/donations/create-donation-test.js b/tests/integration/components/donations/create-donation-test.js index ec22f3bcb..ed1396957 100644 --- a/tests/integration/components/donations/create-donation-test.js +++ b/tests/integration/components/donations/create-donation-test.js @@ -39,22 +39,31 @@ test('it renders properly', function(assert) { assert.ok(page.rendersContinueButton, 'Continue button is rendered.'); }); -test('preset amount buttons work', function(assert) { - assert.expect(4); +test('preset amount buttons unset the input value', function(assert) { + assert.expect(1); page.render(hbs`{{donations/create-donation continue=continueHandler}}`); page.setTo10.clickButton(); - assert.equal(page.customAmountValue, 10, 'Button for preset amount of 10 was clicked, so proper value should be set.'); + assert.equal(page.customAmountValue, '', 'Button was clicked, so custom value should be unset.'); +}); + +test('clicking "continue" calls action with present amount as argument', function(assert) { + assert.expect(1); + + function continueHandler(amount) { + assert.equal(amount, 15, 'Proper amount was sent via action.'); + } + + setHandler(this, continueHandler); + + page.render(hbs`{{donations/create-donation continue=continueHandler}}`); + page.setTo15.clickButton(); - assert.equal(page.customAmountValue, 15, 'Button for preset amount of 15 was clicked, so proper value should be set.'); - page.setTo25.clickButton(); - assert.equal(page.customAmountValue, 25, 'Button for preset amount of 25 was clicked, so proper value should be set.'); - page.setTo50.clickButton(); - assert.equal(page.customAmountValue, 50, 'Button for preset amount of 50 was clicked, so proper value should be set.'); + page.clickContinue(); }); -test('clicking "continue" calls action with amount as argument', function(assert) { +test('clicking "continue" calls action with custom amount as argument', function(assert) { assert.expect(1); function continueHandler(amount) { @@ -69,12 +78,11 @@ test('clicking "continue" calls action with amount as argument', function(assert page.clickContinue(); }); -test('buttons activate properly when custom amount is set to preset value', function(assert) { +test('buttons do not activate when custom amount is set to preset value', function(assert) { assert.expect(1); page.render(hbs`{{donations/create-donation continue=continueHandler}}`); - page.customAmount(22); - page.customAmount(15); - assert.ok(page.setTo15.isActive, 'Proper button should be active.'); + page.customAmount(50); + assert.notOk(page.setTo50.isActive, 'Related button should not be active.'); }); diff --git a/tests/integration/components/donations/donation-amount-button-test.js b/tests/integration/components/donations/donation-amount-button-test.js index f8a0703c5..045a8196a 100644 --- a/tests/integration/components/donations/donation-amount-button-test.js +++ b/tests/integration/components/donations/donation-amount-button-test.js @@ -11,14 +11,15 @@ const { let page = PageObject.create(donationAmountButtonComponent); -function setHandler(context, { actionHandler = K } = {}) { - context.set('actionHandler', actionHandler); +function setHandlers(context, { amountHandler = K, customAmountHandler = K } = {}) { + context.set('amountHandler', amountHandler); + context.set('customAmountHandler', customAmountHandler); } moduleForComponent('donations/donation-amount-button', 'Integration | Component | donations/donation amount button', { integration: true, beforeEach() { - setHandler(this); + setHandlers(this); page.setContext(this); }, afterEach() { @@ -26,23 +27,27 @@ moduleForComponent('donations/donation-amount-button', 'Integration | Component } }); -test('it sends action with specified amount when clicked', function(assert) { - assert.expect(1); +test('it sends actions when clicked', function(assert) { + assert.expect(2); + + let amountHandler = function(amount) { + assert.equal(amount, 5, 'Proper amount was sent'); + }; - let actionHandler = function(amount) { - assert.equal(amount, 5, 'Proper amount was sent with action'); + let customAmountHandler = function(customAmount) { + assert.equal(customAmount, null, 'A null custom amount was sent'); }; - setHandler(this, { actionHandler }); + setHandlers(this, { amountHandler, customAmountHandler }); - page.render(hbs`{{donations/donation-amount-button action=actionHandler presetAmount=5}}`) - .clickButton(); + page.render(hbs`{{donations/donation-amount-button setAmount=amountHandler setCustomAmount=customAmountHandler presetAmount=5}}`); + page.clickButton(); }); test('it shows as active if button is selected', function(assert) { assert.expect(1); - page.render(hbs`{{donations/donation-amount-button action=actionHandler presetAmount=5 selectedAmount=5}}`); + page.render(hbs`{{donations/donation-amount-button setAmount=actionHandler presetAmount=5 selectedAmount=5}}`); assert.ok(page.isActive, 'Button is rendered as active'); }); @@ -50,7 +55,7 @@ test('it shows as active if button is selected', function(assert) { test('it shows as inactive if button is not selected', function(assert) { assert.expect(1); - page.render(hbs`{{donations/donation-amount-button action=actionHandler presetAmount=5 selectedAmount=10}}`); + page.render(hbs`{{donations/donation-amount-button setAmount=actionHandler presetAmount=5 selectedAmount=10}}`); assert.ok(page.isInactive, 'Button is rendered as inActive'); }); diff --git a/tests/integration/components/error-formatter-test.js b/tests/integration/components/error-formatter-test.js index a3f43fb14..e8abf3219 100644 --- a/tests/integration/components/error-formatter-test.js +++ b/tests/integration/components/error-formatter-test.js @@ -1,42 +1,88 @@ import { moduleForComponent, test } from 'ember-qunit'; import hbs from 'htmlbars-inline-precompile'; -import Ember from 'ember'; +import PageObject from 'ember-cli-page-object'; -const { Object } = Ember; +import errorFormatterComponent from '../../pages/components/error-formatter'; -moduleForComponent('error-formatter', 'Integration | Component | error formatter', { - integration: true -}); - -test('it renders', function(assert) { - assert.expect(1); +let page = PageObject.create(errorFormatterComponent); - this.render(hbs`{{error-formatter}}`); - assert.equal(this.$('.error-formatter').length, 1, "The component's element renders"); +moduleForComponent('error-formatter', 'Integration | Component | error formatter', { + integration: true, + beforeEach() { + page.setContext(this); + }, + afterEach() { + page.removeContext(); + } }); -let mockResponseWithMultipleErrors = Object.create({ +const mockAdapterError = { + isAdapterError: true, errors: [ { title: 'First', detail: 'error' }, { title: 'Second', detail: 'error' } ] +}; + +const mockValidationErrors = { + isAdapterError: true, + errors: [ + { + id: 'VALIDATION_ERROR', + source: { pointer: 'data/attributes/amount' }, + detail: 'is all wrong', + status: 422 + }, { + id: 'VALIDATION_ERROR', + source: { pointer: 'data/attributes/description' }, + detail: "is bad, m'kay?", + status: 422 + } + ] +}; + +const mockStripeError = { + error: { + type: 'card_error', + code: 'invalid_expiry_year', + message: "Your card's expiration year is invalid.", + param: 'exp_year' + } +}; + +test('it formats adapter error properly', function(assert) { + assert.expect(3); + + this.set('error', mockAdapterError); + page.render(hbs`{{error-formatter error=error}}`); + assert.equal(page.errors().count, 2, 'Each error message is rendered'); + assert.equal(page.errors(0).text, 'First: error', 'First message is rendered'); + assert.equal(page.errors(1).text, 'Second: error', 'Second message is rendered'); }); -test('it displays a message for each error in the response', function(assert) { +test('it formats adapter validation error properly', function(assert) { assert.expect(3); - this.set('error', mockResponseWithMultipleErrors); - this.render(hbs`{{error-formatter error=error}}`); - assert.equal(this.$('.error-formatter .error').length, 2, 'Each error message is rendered'); - assert.equal(this.$('.error-formatter .error:eq(0)').text().trim(), 'First: error', 'First message is rendered'); - assert.equal(this.$('.error-formatter .error:eq(1)').text().trim(), 'Second: error', 'Second message is rendered'); + this.set('error', mockValidationErrors); + page.render(hbs`{{error-formatter error=error}}`); + assert.equal(page.errors().count, 2, 'Each error message is rendered'); + assert.equal(page.errors(0).text, 'Amount is all wrong', 'First message is rendered'); + assert.equal(page.errors(1).text, "Description is bad, m'kay?", 'Second message is rendered'); +}); + +test('it formats stripe card error properly', function(assert) { + assert.expect(1); + + this.set('error', mockStripeError); + page.render(hbs`{{error-formatter error=error}}`); + assert.equal(page.errors(0).text, mockStripeError.error.message, 'Message is rendered'); }); -test('it displays a default message if there are no errors in the response', function(assert) { +test('it displays a default message if the error structure is not supported', function(assert) { assert.expect(2); this.set('error', {}); - this.render(hbs`{{error-formatter error=error}}`); - assert.equal(this.$('.error-formatter .error').length, 1, 'Each error message is rendered'); - assert.equal(this.$('.error-formatter .error:eq(0)').text().trim(), 'An unexpected error has occured', 'Default message is rendered'); + page.render(hbs`{{error-formatter error=error}}`); + assert.equal(page.errors().count, 1, 'Each error message is rendered'); + assert.equal(page.errors(0).text, 'An unexpected error has occured', 'Default message is rendered'); }); diff --git a/tests/pages/components/donation/card-item.js b/tests/pages/components/donation/card-item.js new file mode 100644 index 000000000..7ccdb15b0 --- /dev/null +++ b/tests/pages/components/donation/card-item.js @@ -0,0 +1,9 @@ +import { clickable, hasClass, text } from 'ember-cli-page-object'; + +export default { + scope: '.card-item', + + cardDescription: text(''), + clickCard: clickable(''), + isSelected: hasClass('selected', '') +}; diff --git a/tests/pages/components/donation/card-list.js b/tests/pages/components/donation/card-list.js new file mode 100644 index 000000000..6618b598c --- /dev/null +++ b/tests/pages/components/donation/card-list.js @@ -0,0 +1,11 @@ +import { collection } from 'ember-cli-page-object'; +import cardItem from './card-item'; + +export default { + scope: '.card-list', + + cards: collection({ + itemScope: '.card-item', + item: cardItem + }) +}; diff --git a/tests/pages/components/donation/credit-card.js b/tests/pages/components/donation/credit-card.js new file mode 100644 index 000000000..6b3437708 --- /dev/null +++ b/tests/pages/components/donation/credit-card.js @@ -0,0 +1,15 @@ +import { clickable, fillable, is, selectable, text } from 'ember-cli-page-object'; + +export default { + scope: '.credit-card-form', + + cardCVC: fillable('[name=card-cvc]'), + cardNumber: fillable('[name=card-number]'), + cardMonth: selectable('select.month'), + cardYear: selectable('select.year'), + + clickSubmit: clickable('button'), + + submitButtonText: text('button'), + submitDisabled: is(':disabled', 'button') +}; diff --git a/tests/pages/components/donation/donation-container.js b/tests/pages/components/donation/donation-container.js new file mode 100644 index 000000000..af8828f6c --- /dev/null +++ b/tests/pages/components/donation/donation-container.js @@ -0,0 +1,19 @@ +import { clickable, isVisible, text } from 'ember-cli-page-object'; +import creditCard from './credit-card'; +import cardList from './card-list'; + +export default { + scope: '.donation-container', + + cardList, + creditCard, + + cardFormIsVisible: isVisible('.credit-card-form'), + cardListIsVisible: isVisible('.card-list'), + donationButtonIsVisible: isVisible('button.donate'), + + clickSubmit: clickable('button.donate'), + + donationAmountText: text('.donation-amount'), + paymentInformationText: text('.payment-information') +}; diff --git a/tests/pages/components/error-formatter.js b/tests/pages/components/error-formatter.js new file mode 100644 index 000000000..9bbe01af1 --- /dev/null +++ b/tests/pages/components/error-formatter.js @@ -0,0 +1,13 @@ +import { collection, text } from 'ember-cli-page-object'; + +export default { + scope: '.error-formatter', + + errors: collection({ + itemScope: '.error', + + item: { + message: text() + } + }) +}; diff --git a/tests/pages/project/donate.js b/tests/pages/project/donate.js new file mode 100644 index 000000000..bddae7af6 --- /dev/null +++ b/tests/pages/project/donate.js @@ -0,0 +1,15 @@ +import { clickable, create, is, visitable } from 'ember-cli-page-object'; +import cardList from '../components/donation/card-list'; +import creditCard from '../components/donation/credit-card'; +import errorFormatter from '../components/error-formatter'; + +export default create({ + visit: visitable(':organization/:project/donate'), + + clickDonate: clickable('button.donate'), + donateButtonIsDisabled: is(':disabled', 'button.donate'), + + cardList, + creditCard, + errorFormatter +}); diff --git a/tests/pages/project/thankyou.js b/tests/pages/project/thankyou.js new file mode 100644 index 000000000..2bc076ac5 --- /dev/null +++ b/tests/pages/project/thankyou.js @@ -0,0 +1,12 @@ +import { clickable, create, isVisible, visitable } from 'ember-cli-page-object'; + +export default create({ + visit: visitable(':organization/:project/thankyou'), + + clickProjectLink: clickable('.project-link a'), + + rendersProjectHeader: isVisible('.project-header'), + rendersIcon: isVisible('.icon img'), + rendersThankYouHeader: isVisible('.header'), + rendersText: isVisible('.text') +}); diff --git a/tests/unit/models/stripe-card-test.js b/tests/unit/models/stripe-card-test.js new file mode 100644 index 000000000..e90c09a21 --- /dev/null +++ b/tests/unit/models/stripe-card-test.js @@ -0,0 +1,14 @@ +import { moduleForModel, test } from 'ember-qunit'; + +moduleForModel('stripe-card', 'Unit | Model | stripe card', { + // Specify the other units that are required for this test. + needs: [ + 'model:user' + ] +}); + +test('it exists', function(assert) { + let model = this.subject(); + // let store = this.store(); + assert.ok(!!model); +}); diff --git a/tests/unit/models/stripe-customer-test.js b/tests/unit/models/stripe-customer-test.js new file mode 100644 index 000000000..a58674b7f --- /dev/null +++ b/tests/unit/models/stripe-customer-test.js @@ -0,0 +1,14 @@ +import { moduleForModel, test } from 'ember-qunit'; + +moduleForModel('stripe-customer', 'Unit | Model | stripe customer', { + // Specify the other units that are required for this test. + needs: [ + 'model:user' + ] +}); + +test('it exists', function(assert) { + let model = this.subject(); + // let store = this.store(); + assert.ok(!!model); +}); diff --git a/tests/unit/models/user-test.js b/tests/unit/models/user-test.js index 484f0cf72..0ede77546 100644 --- a/tests/unit/models/user-test.js +++ b/tests/unit/models/user-test.js @@ -5,6 +5,8 @@ import { testForHasMany } from '../../helpers/relationship'; moduleForModel('user', 'Unit | Model | user', { needs: [ 'model:organization-membership', + 'model:stripe-card', + 'model:stripe-customer', 'model:stripe-subscription', 'model:user-category', 'model:user-role', diff --git a/tests/unit/routes/project/donate-test.js b/tests/unit/routes/project/donate-test.js new file mode 100644 index 000000000..19aa0e3b1 --- /dev/null +++ b/tests/unit/routes/project/donate-test.js @@ -0,0 +1,10 @@ +import { moduleFor, test } from 'ember-qunit'; + +moduleFor('route:project/donate', 'Unit | Route | project/donate', { + needs: ['service:metrics'] +}); + +test('it exists', function(assert) { + let route = this.subject(); + assert.ok(route); +}); diff --git a/tests/unit/routes/project/thankyou-test.js b/tests/unit/routes/project/thankyou-test.js new file mode 100644 index 000000000..2279468af --- /dev/null +++ b/tests/unit/routes/project/thankyou-test.js @@ -0,0 +1,10 @@ +import { moduleFor, test } from 'ember-qunit'; + +moduleFor('route:project/thankyou', 'Unit | Route | project/thankyou', { + needs: ['service:metrics'] +}); + +test('it exists', function(assert) { + let route = this.subject(); + assert.ok(route); +});