diff --git a/api/account/tests/features/add_account.feature b/api/account/tests/features/add_account.feature new file mode 100644 index 00000000..63d97a05 --- /dev/null +++ b/api/account/tests/features/add_account.feature @@ -0,0 +1,22 @@ +Feature: Add a new account + Test the API call to add an account to the database. + + Scenario: Successful account addition + Given a user completes new account setup + When the new account request is submitted + Then the request will be successful + And the account will be added to the system + + Scenario: Request missing a required field + Given a user completes new account setup + And user does not specify an email address + When the new account request is submitted + Then the request will fail with a bad request error + + Scenario: Successful on-boarding with membership + Given a new account without a membership + And user with username bar is authenticated + When a user opts into a membership during on-boarding + Then the request will be successful + And the account will be updated with the membership + diff --git a/api/account/tests/features/new_account.feature b/api/account/tests/features/new_account.feature deleted file mode 100644 index 2c0ffd31..00000000 --- a/api/account/tests/features/new_account.feature +++ /dev/null @@ -1,29 +0,0 @@ -Feature: Add a new account - Test the API call to add an account to the database. - - Scenario: Successful account addition with membership - Given a user completes on-boarding - And user opts into a membership - When the new account request is submitted - Then the request will be successful - And the account will be added to the system with a membership - - Scenario: Successful account addition without membership - Given a user completes on-boarding - And user opts out of membership - When the new account request is submitted - Then the account will be added to the system without a membership - - Scenario: Request missing a required field - Given a user completes on-boarding - And user does not specify an email address - When the new account request is submitted - Then the request will fail with a bad request error - - Scenario: Successful account deletion with membership - Given a user completes on-boarding - And user opts into a membership - When the new account request is submitted - And the account is deleted - Then the request will be successful - And the membership is removed from stripe \ No newline at end of file diff --git a/api/account/tests/features/steps/add_account.py b/api/account/tests/features/steps/add_account.py new file mode 100644 index 00000000..7df26878 --- /dev/null +++ b/api/account/tests/features/steps/add_account.py @@ -0,0 +1,141 @@ +from binascii import b2a_base64 +from datetime import date, datetime +from dataclasses import dataclass +from unittest.mock import call, patch + +from behave import given, then, when +from flask import json +from hamcrest import assert_that, equal_to, is_in, none, not_none + +from selene.data.account import ( + AccountRepository, + MONTHLY_MEMBERSHIP, + PRIVACY_POLICY, + TERMS_OF_USE +) +from selene.testing.account import add_account + +new_account_request = dict( + termsOfUse=True, + privacyPolicy=True, + login=dict( + federatedPlatform=None, + federatedToken=None, + email=b2a_base64(b'bar@mycroft.ai').decode(), + password=b2a_base64(b'bar').decode() + ) +) + + +@dataclass +class StripeMock(object): + """Object intended to behave like stripe Customer/Subscription create""" + id: str + + +@given('a user completes new account setup') +def build_new_account_request(context): + context.new_account_request = new_account_request + + +@given('user opts out of membership') +def add_maybe_later_membership(context): + context.new_account_request['support'].update( + membership=None, + paymentMethod=None, + paymentAccountId=None + ) + + +@given('user opts into a membership') +def change_membership_option(context): + context.new_account_request['support'].update( + membership='Monthly Membership', + paymentMethod='Stripe', + paymentToken='tok_visa' + ) + + +@given('user does not specify an email address') +def remove_email_from_request(context): + del(context.new_account_request['login']['email']) + + +@given('a new account without a membership') +def add_account_no_membership(context): + new_account_overrides = dict( + email_address='bar@mycroft.ai', + username='bar', + membership=None + ) + context.bar_account = add_account(context.db, **new_account_overrides) + + +@when('the new account request is submitted') +def call_add_account_endpoint(context): + context.client.content_type = 'application/json' + context.response = context.client.post( + '/api/account', + data=json.dumps(context.new_account_request), + content_type='application/json' + ) + + +@when('a user opts into a membership during on-boarding') +def call_update_account_endpoint(context): + onboarding_request = dict( + membership=dict( + newMembership=True, + membershipType=MONTHLY_MEMBERSHIP, + paymentToken='test_payment_token', + paymentMethod='Stripe' + ) + ) + with patch('stripe.Subscription.create') as subscription_patch: + subscription_patch.return_value = StripeMock(id='test_subscription') + with patch('stripe.Customer.create') as customer_patch: + customer_patch.return_value = StripeMock(id='test_customer') + context.response = context.client.patch( + '/api/account', + data=json.dumps(onboarding_request), + content_type='application/json' + ) + assert_that( + customer_patch.mock_calls, + equal_to([call( + email='bar@mycroft.ai', + source='test_payment_token' + )]) + ) + assert_that( + subscription_patch.mock_calls, + equal_to([call( + customer='test_customer', + items=[{'plan': 'monthly_premium'}]) + ])) + + +@then('the account will be added to the system') +def check_db_for_account(context): + acct_repository = AccountRepository(context.db) + account = acct_repository.get_account_by_email('bar@mycroft.ai') + assert_that(account, not_none()) + assert_that( + account.email_address, equal_to('bar@mycroft.ai') + ) + + assert_that(len(account.agreements), equal_to(2)) + for agreement in account.agreements: + assert_that(agreement.type, is_in((PRIVACY_POLICY, TERMS_OF_USE))) + assert_that(agreement.accept_date, equal_to(str(date.today()))) + + +@then('the account will be updated with the membership') +def validate_membership_update(context): + account_repository = AccountRepository(context.db) + membership = account_repository.get_active_account_membership( + context.account.id + ) + assert_that(membership.type, equal_to(MONTHLY_MEMBERSHIP)) + assert_that(membership.start_date, equal_to(datetime.utcnow().date())) + assert_that(membership.end_date, none()) diff --git a/api/account/tests/features/steps/new_account.py b/api/account/tests/features/steps/new_account.py deleted file mode 100644 index 12a2ecd9..00000000 --- a/api/account/tests/features/steps/new_account.py +++ /dev/null @@ -1,110 +0,0 @@ -from binascii import b2a_base64 -from datetime import date -import os - -import stripe -from behave import given, then, when -from flask import json -from hamcrest import assert_that, equal_to, is_in, none, not_none, starts_with -from stripe.error import InvalidRequestError - -from selene.data.account import AccountRepository, PRIVACY_POLICY, TERMS_OF_USE -from selene.util.db import connect_to_db - -new_account_request = dict( - username='barfoo', - termsOfUse=True, - privacyPolicy=True, - login=dict( - federatedPlatform=None, - federatedToken=None, - userEnteredEmail=b2a_base64(b'bar@mycroft.ai').decode(), - password=b2a_base64(b'bar').decode() - ), - support=dict(openDataset=True) -) - - -@given('a user completes on-boarding') -def build_new_account_request(context): - context.new_account_request = new_account_request - - -@given('user opts out of membership') -def add_maybe_later_membership(context): - context.new_account_request['support'].update( - membership=None, - paymentMethod=None, - paymentAccountId=None - ) - - -@given('user opts into a membership') -def change_membership_option(context): - context.new_account_request['support'].update( - membership='Monthly Membership', - paymentMethod='Stripe', - paymentToken='tok_visa' - ) - - -@given('user does not specify an email address') -def remove_email_from_request(context): - del(context.new_account_request['login']['userEnteredEmail']) - - -@when('the new account request is submitted') -def call_add_account_endpoint(context): - context.client.content_type = 'application/json' - context.response = context.client.post( - '/api/account', - data=json.dumps(context.new_account_request), - content_type='application/json' - ) - - -@then('the account will be added to the system {membership_option}') -def check_db_for_account(context, membership_option): - db = connect_to_db(context.client_config['DB_CONNECTION_CONFIG']) - acct_repository = AccountRepository(db) - account = acct_repository.get_account_by_email('bar@mycroft.ai') - assert_that(account, not_none()) - assert_that( - account.email_address, equal_to('bar@mycroft.ai') - ) - assert_that(account.username, equal_to('barfoo')) - if membership_option == 'with a membership': - assert_that(account.membership.type, equal_to('Monthly Membership')) - assert_that( - account.membership.payment_account_id, - starts_with('cus') - ) - elif membership_option == 'without a membership': - assert_that(account.membership, none()) - - assert_that(len(account.agreements), equal_to(2)) - for agreement in account.agreements: - assert_that(agreement.type, is_in((PRIVACY_POLICY, TERMS_OF_USE))) - assert_that(agreement.accept_date, equal_to(str(date.today()))) - - -@when('the account is deleted') -def account_deleted(context): - db = connect_to_db(context.client_config['DB_CONNECTION_CONFIG']) - acct_repository = AccountRepository(db) - account = acct_repository.get_account_by_email('bar@mycroft.ai') - context.stripe_id = account.membership.payment_id - context.response = context.client.delete('/api/account') - - -@then('he membership is removed from stripe') -def check_stripe(context): - stripe_id = context.stripe_id - assert_that(stripe_id, not_none()) - stripe.api_key = os.environ['STRIPE_PRIVATE_KEY'] - subscription_not_found = False - try: - stripe.Subscription.retrieve(stripe_id) - except InvalidRequestError: - subscription_not_found = True - assert_that(subscription_not_found, equal_to(True)) diff --git a/shared/selene/api/endpoints/account.py b/shared/selene/api/endpoints/account.py index af211e3c..eee68f43 100644 --- a/shared/selene/api/endpoints/account.py +++ b/shared/selene/api/endpoints/account.py @@ -286,8 +286,8 @@ class AccountEndpoint(SeleneEndpoint): self._add_membership(membership_change, active_membership) def _get_active_membership(self): - membership_repo = MembershipRepository(self.db) - active_membership = membership_repo.get_active_account_membership( + acct_repository = AccountRepository(self.db) + active_membership = acct_repository.get_active_account_membership( self.account.id ) @@ -325,8 +325,8 @@ class AccountEndpoint(SeleneEndpoint): def _cancel_membership(self, active_membership): cancel_stripe_subscription(active_membership.payment_id) active_membership.end_date = datetime.utcnow() - membership_repository = MembershipRepository(self.db) - membership_repository.finish_membership(active_membership) + account_repository = AccountRepository(self.db) + account_repository.end_membership(active_membership) def _update_username(self, username): self.account_repository.update_username(self.account.id, username) diff --git a/shared/selene/data/account/repository/account.py b/shared/selene/data/account/repository/account.py index dd379b1d..297521be 100644 --- a/shared/selene/data/account/repository/account.py +++ b/shared/selene/data/account/repository/account.py @@ -63,20 +63,6 @@ class AccountRepository(RepositoryBase): ) self.cursor.insert(request) - def add_membership(self, acct_id: str, membership: AccountMembership): - """A membership is optional, add it if one was selected""" - request = self._build_db_request( - sql_file_name='add_account_membership.sql', - args=dict( - account_id=acct_id, - membership_type=membership.type, - payment_method=membership.payment_method, - payment_account_id=membership.payment_account_id, - payment_id=membership.payment_id - ) - ) - self.cursor.insert(request) - def remove(self, account: Account): """Delete and account and all of its children""" request = self._build_db_request( @@ -278,3 +264,42 @@ class AccountRepository(RepositoryBase): 'thirtyDaysMinus': report_30_days['paid_minus'] }] return report_table + + def add_membership(self, acct_id: str, membership: AccountMembership): + """A membership is optional, add it if one was selected""" + request = self._build_db_request( + sql_file_name='add_account_membership.sql', + args=dict( + account_id=acct_id, + membership_type=membership.type, + payment_method=membership.payment_method, + payment_account_id=membership.payment_account_id, + payment_id=membership.payment_id + ) + ) + self.cursor.insert(request) + + def end_membership(self, membership: AccountMembership): + db_request = self._build_db_request( + sql_file_name='end_membership.sql', + args=dict( + id=membership.id, + membership_ts_range='[{start},{end}]'.format( + start=membership.start_date, + end=membership.end_date + ) + ) + ) + self.cursor.update(db_request) + + def get_active_account_membership(self, account_id) -> AccountMembership: + account_membership = None + db_request = self._build_db_request( + sql_file_name='get_active_membership_by_account_id.sql', + args=dict(account_id=account_id) + ) + db_result = self.cursor.select_one(db_request) + if db_result: + account_membership = AccountMembership(**db_result) + + return account_membership diff --git a/shared/selene/data/account/repository/membership.py b/shared/selene/data/account/repository/membership.py index 3af80852..ddbde4e4 100644 --- a/shared/selene/data/account/repository/membership.py +++ b/shared/selene/data/account/repository/membership.py @@ -26,18 +26,6 @@ class MembershipRepository(RepositoryBase): db_result = self.cursor.select_one(db_request) return Membership(**db_result) - def get_active_account_membership(self, account_id) -> AccountMembership: - account_membership = None - db_request = self._build_db_request( - sql_file_name='get_active_membership_by_account_id.sql', - args=dict(account_id=account_id) - ) - db_result = self.cursor.select_one(db_request) - if db_result: - account_membership = AccountMembership(**db_result) - - return account_membership - def add(self, membership: Membership): db_request = self._build_db_request( 'add_membership.sql', @@ -57,16 +45,3 @@ class MembershipRepository(RepositoryBase): args=dict(membership_id=membership.id) ) self.cursor.delete(db_request) - - def finish_membership(self, membership: AccountMembership): - db_request = self._build_db_request( - sql_file_name='finish_membership.sql', - args=dict( - id=membership.id, - membership_ts_range='[{start},{end}]'.format( - start=membership.start_date, - end=membership.end_date - ) - ) - ) - self.cursor.update(db_request) diff --git a/shared/selene/data/account/repository/sql/finish_membership.sql b/shared/selene/data/account/repository/sql/end_membership.sql similarity index 100% rename from shared/selene/data/account/repository/sql/finish_membership.sql rename to shared/selene/data/account/repository/sql/end_membership.sql diff --git a/shared/selene/data/account/repository/sql/get_active_membership_by_account_id.sql b/shared/selene/data/account/repository/sql/get_active_membership_by_account_id.sql index ca1c50ae..d3724e95 100644 --- a/shared/selene/data/account/repository/sql/get_active_membership_by_account_id.sql +++ b/shared/selene/data/account/repository/sql/get_active_membership_by_account_id.sql @@ -1,7 +1,7 @@ SELECT acc_mem.id, mem.type, - LOWER(acc_mem.membership_ts_range) start_date, + LOWER(acc_mem.membership_ts_range)::date start_date, acc_mem.payment_method, payment_account_id, payment_id