Merge remote-tracking branch 'remotes/origin/dev' into fix-device-last-update

pull/182/head
Chris Veilleux 2019-06-13 15:57:57 -05:00
commit 9ea5fd8e5d
31 changed files with 579 additions and 598 deletions

View File

@ -0,0 +1,14 @@
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

View File

@ -2,10 +2,11 @@ Feature: Pair a device
Test the device add endpoint
Scenario: Add a device
Given an authenticated user
And a device pairing code
When an API request is sent to add a device
Then the request will be successful
And the device is added to the database
And the pairing code is removed from cache
And the pairing token is added to cache
Given an account
And the account is authenticated
And a device pairing code
When an API request is sent to add a device
Then the request will be successful
And the device is added to the database
And the pairing code is removed from cache
And the pairing token is added to cache

View File

@ -1,6 +1,13 @@
Feature: Get the active Profile Policy agreement
Feature: Get the active agreements
We need to be able to retrieve an agreement and display it on the web app.
Scenario: Multiple versions of an agreement exist
When API request for Privacy Policy is made
Then version 999 of Privacy Policy is returned
Then the request will be successful
And Privacy Policy version 999 is returned
Scenario: Retrieve Terms of Use
When API request for Terms of Use is made
Then the request will be successful
And Terms of Use version 999 is returned

View File

@ -9,18 +9,24 @@ Feature: Authentication with JWTs
be the only place authentication logic needs to be tested.
Scenario: Request for user data includes valid access token
Given an authenticated user
Given an account with a valid access token
When a user requests their profile
Then the request will be successful
And the authentication tokens will remain unchanged
Scenario: Access token expired
Given an authenticated user with an expired access token
Given an account with an expired access token
When a user requests their profile
Then the request will be successful
And the authentication tokens will be refreshed
Scenario: Access token missing but refresh token valid
Given an account with a refresh token but no access token
When a user requests their profile
Then the request will be successful
And the authentication tokens will be refreshed
Scenario: Both access and refresh tokens expired
Given a previously authenticated user with expired tokens
Given an account with expired access and refresh tokens
When a user requests their profile
Then the request will fail with an unauthorized error

View File

@ -1,19 +1,9 @@
from datetime import date, timedelta
from behave import fixture, use_fixture
from account_api.api import acct
from selene.data.account import (
Account,
AccountAgreement,
AccountRepository,
AccountMembership,
Agreement,
AgreementRepository,
PRIVACY_POLICY,
TERMS_OF_USE
)
from selene.data.device import Geography, GeographyRepository
from selene.testing.account import add_account, remove_account
from selene.testing.account_geography import add_account_geography
from selene.testing.agreement import add_agreements, remove_agreements
from selene.util.cache import SeleneCache
from selene.util.db import connect_to_db
@ -27,96 +17,38 @@ def acct_api_client(context):
yield context.client
def before_feature(context, _):
def before_all(context):
use_fixture(acct_api_client, context)
context.db = connect_to_db(context.client_config['DB_CONNECTION_CONFIG'])
context.terms_of_use, context.privacy_policy = add_agreements(context.db)
def after_all(context):
remove_agreements(
context.db,
[context.privacy_policy, context.terms_of_use]
)
def before_scenario(context, _):
db = connect_to_db(context.client_config['DB_CONNECTION_CONFIG'])
_add_agreements(context, db)
_add_account(context, db)
_add_geography(context, db)
def _add_agreements(context, db):
context.privacy_policy = Agreement(
type=PRIVACY_POLICY,
version='999',
content='this is Privacy Policy version 999',
effective_date=date.today() - timedelta(days=5)
)
context.terms_of_use = Agreement(
type=TERMS_OF_USE,
version='999',
content='this is Terms of Use version 999',
effective_date=date.today() - timedelta(days=5)
)
agreement_repository = AgreementRepository(db)
agreement_id = agreement_repository.add(context.privacy_policy)
context.privacy_policy.id = agreement_id
agreement_id = agreement_repository.add(context.terms_of_use)
context.terms_of_use.id = agreement_id
def _add_account(context, db):
context.account = Account(
email_address='foo@mycroft.ai',
username='foobar',
membership=AccountMembership(
type='Monthly Membership',
start_date=date.today(),
payment_method='Stripe',
payment_account_id='foo',
payment_id='bar'
),
agreements=[
AccountAgreement(type=PRIVACY_POLICY, accept_date=date.today())
]
)
acct_repository = AccountRepository(db)
account_id = acct_repository.add(context.account, 'foo')
context.account.id = account_id
def _add_geography(context, db):
geography = Geography(
country='United States',
region='Missouri',
city='Kansas City',
time_zone='America/Chicago'
)
geo_repository = GeographyRepository(db, context.account.id)
context.geography_id = geo_repository.add(geography)
account = add_account(context.db)
context.accounts = dict(foo=account)
context.geography_id = add_account_geography(context.db, account)
def after_scenario(context, _):
db = connect_to_db(context.client_config['DB_CONNECTION_CONFIG'])
_delete_account(context, db)
_delete_agreements(context, db)
"""Scenario-level cleanup.
The database is setup with cascading deletes that take care of cleaning up[
referential integrity for us. All we have to do here is delete the account
and all rows on all tables related to that account will also be deleted.
"""
for account in context.accounts.values():
remove_account(context.db, account)
_clean_cache()
def _delete_account(context, db):
acct_repository = AccountRepository(db)
acct_repository.remove(context.account)
bar_acct = acct_repository.get_account_by_email('bar@mycroft.ai')
if bar_acct is not None:
acct_repository.remove(bar_acct)
foo_acct = acct_repository.get_account_by_email('foo@mycroft.ai')
if foo_acct is not None:
acct_repository.remove(foo_acct)
test_acct = acct_repository.get_account_by_email('test@mycroft.ai')
if test_acct is not None:
acct_repository.remove(test_acct)
def _delete_agreements(context, db):
agreement_repository = AgreementRepository(db)
agreement_repository.remove(context.privacy_policy, testing=True)
agreement_repository.remove(context.terms_of_use, testing=True)
def _clean_cache():
cache = SeleneCache()
cache.delete('pairing.token:this is a token')

View File

@ -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

View File

@ -3,6 +3,29 @@ Feature: Manage account profiles
settings.
Scenario: Retrieve authenticated user's account
Given an authenticated user
When a user requests their profile
Then user profile is returned
Given an account with a monthly membership
# And the account is authenticated
When a user requests their profile
Then the request will be successful
And user profile is returned
Scenario: user with free account opts into a membership
Given an account without a membership
And the account is authenticated
When a monthly membership is added
Then the request will be successful
And the account should have a monthly membership
Scenario: user opts out monthly membership
Given an account with a monthly membership
# And the account is authenticated
When the membership is cancelled
Then the request will be successful
And the account should have no membership
Scenario: user changes from a monthly membership to yearly membership
Given an account with a monthly membership
# And the account is authenticated
When the membership is changed to yearly
Then the request will be successful
And the account should have a yearly membership

View File

@ -0,0 +1,9 @@
Feature: Delete an account
Test the API call to delete an account and all its related data from the database.
Scenario: Successful account deletion
Given an account with a monthly membership
When the user's account is deleted
Then the request will be successful
And the membership is removed from stripe

View File

@ -0,0 +1,60 @@
from binascii import b2a_base64
from datetime import date
from behave import given, then, when
from flask import json
from hamcrest import assert_that, equal_to, is_in, not_none
from selene.data.account import (
AccountRepository,
PRIVACY_POLICY,
TERMS_OF_USE
)
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()
)
)
@given('a user completes new account setup')
def build_new_account_request(context):
context.new_account_request = new_account_request
@given('user does not specify an email address')
def remove_email_from_request(context):
del(context.new_account_request['login']['email'])
@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')
def check_db_for_account(context):
acct_repository = AccountRepository(context.db)
account = acct_repository.get_account_by_email('bar@mycroft.ai')
# add account to context so it will deleted by cleanup step
context.accounts['bar'] = account
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())))

View File

@ -1,7 +1,7 @@
import json
from behave import given, when, then
from hamcrest import assert_that, equal_to, has_key, none, not_none
from hamcrest import assert_that, equal_to, none, not_none
from selene.data.device import DeviceRepository
from selene.util.cache import SeleneCache
@ -26,6 +26,11 @@ def set_device_pairing_code(context):
context.pairing_code = 'ABC123'
@given('an account')
def define_account(context):
context.username = 'foo'
@when('an API request is sent to add a device')
def add_device(context):
device = dict(
@ -57,6 +62,7 @@ def validate_pairing_code_removal(context):
@then('the device is added to the database')
def validate_response(context):
device_id = context.response.data.decode()
account = context.accounts['foo']
db = connect_to_db(context.client_config['DB_CONNECTION_CONFIG'])
device_repository = DeviceRepository(db)
device = device_repository.get_device_by_id(device_id)
@ -64,7 +70,7 @@ def validate_response(context):
assert_that(device, not_none())
assert_that(device.name, equal_to('home'))
assert_that(device.placement, equal_to('kitchen'))
assert_that(device.account_id, equal_to(context.account.id))
assert_that(device.account_id, equal_to(account.id))
@then('the pairing token is added to cache')

View File

@ -1,20 +1,33 @@
from dataclasses import asdict
from http import HTTPStatus
import json
from behave import then, when
from hamcrest import assert_that, equal_to
@when('API request for Privacy Policy is made')
def call_agreement_endpoint(context):
context.response = context.client.get('/api/agreement/privacy-policy')
from selene.data.account import PRIVACY_POLICY, TERMS_OF_USE
@then('version {version} of Privacy Policy is returned')
def validate_response(context, version):
assert_that(context.response.status_code, equal_to(HTTPStatus.OK))
@when('API request for {agreement} is made')
def call_agreement_endpoint(context, agreement):
if agreement == PRIVACY_POLICY:
url = '/api/agreement/privacy-policy'
elif agreement == TERMS_OF_USE:
url = '/api/agreement/terms-of-use'
else:
raise ValueError('invalid agreement type')
context.response = context.client.get(url)
@then('{agreement} version {version} is returned')
def validate_response(context, agreement, version):
response_data = json.loads(context.response.data)
expected_response = asdict(context.privacy_policy)
if agreement == PRIVACY_POLICY:
expected_response = asdict(context.privacy_policy)
elif agreement == TERMS_OF_USE:
expected_response = asdict(context.terms_of_use)
else:
raise ValueError('invalid agreement type')
del(expected_response['effective_date'])
assert_that(response_data, equal_to(expected_response))

View File

@ -1,27 +1,61 @@
from behave import given, then
from hamcrest import assert_that, equal_to, has_item, is_not
from hamcrest import assert_that, equal_to, is_not
from selene.api.testing import (
from selene.testing.api import (
generate_access_token,
generate_refresh_token,
set_access_token_cookie,
set_refresh_token_cookie,
validate_token_cookies
)
from selene.data.account import AccountRepository
from selene.util.auth import AuthenticationToken
from selene.util.db import connect_to_db
EXPIRE_IMMEDIATELY = 0
@given('an authenticated user with an expired access token')
def generate_refresh_token_only(context):
generate_access_token(context, expire=True)
generate_refresh_token(context)
@given('an account with a valid access token')
def use_account_with_valid_access_token(context):
context.username = 'foo'
context.access_token = generate_access_token(context)
set_access_token_cookie(context)
context.refresh_token = generate_refresh_token(context)
set_refresh_token_cookie(context)
@given('an account with an expired access token')
def generate_expired_access_token(context):
context.username = 'foo'
context.access_token = generate_access_token(
context,
duration=EXPIRE_IMMEDIATELY
)
set_access_token_cookie(context, duration=EXPIRE_IMMEDIATELY)
context.refresh_token = generate_refresh_token(context)
set_refresh_token_cookie(context)
context.old_refresh_token = context.refresh_token.jwt
@given('a previously authenticated user with expired tokens')
@given('an account with a refresh token but no access token')
def generate_refresh_token_only(context):
context.username = 'foo'
context.refresh_token = generate_refresh_token(context)
set_refresh_token_cookie(context)
context.old_refresh_token = context.refresh_token.jwt
@given('an account with expired access and refresh tokens')
def expire_both_tokens(context):
generate_access_token(context, expire=True)
generate_refresh_token(context, expire=True)
context.username = 'foo'
context.access_token = generate_access_token(
context,
duration=EXPIRE_IMMEDIATELY
)
set_access_token_cookie(context, duration=EXPIRE_IMMEDIATELY)
context.refresh_token = generate_refresh_token(
context,
duration=EXPIRE_IMMEDIATELY
)
set_refresh_token_cookie(context, duration=EXPIRE_IMMEDIATELY)
@then('the authentication tokens will remain unchanged')
@ -37,10 +71,6 @@ def check_for_new_cookies(context):
context.refresh_token,
is_not(equal_to(context.old_refresh_token))
)
db = connect_to_db(context.client_config['DB_CONNECTION_CONFIG'])
acct_repository = AccountRepository(db)
account = acct_repository.get_account_by_id(context.account.id)
refresh_token = AuthenticationToken(
context.client_config['REFRESH_SECRET'],
0
@ -49,4 +79,6 @@ def check_for_new_cookies(context):
refresh_token.validate()
assert_that(refresh_token.is_valid, equal_to(True))
assert_that(refresh_token.is_expired, equal_to(False))
assert_that(refresh_token.account_id, equal_to(account.id))
assert_that(
refresh_token.account_id,
equal_to(context.accounts['foo'].id))

View File

@ -1,20 +1,30 @@
from http import HTTPStatus
from behave import given, then
from hamcrest import assert_that, equal_to
from hamcrest import assert_that, equal_to, is_in
from selene.api.testing import generate_access_token, generate_refresh_token
from selene.testing.api import (
generate_access_token,
generate_refresh_token,
set_access_token_cookie,
set_refresh_token_cookie
)
@given('an authenticated user')
def setup_authenticated_user(context):
generate_access_token(context)
generate_refresh_token(context)
@given('the account is authenticated')
def use_account_with_valid_access_token(context):
context.access_token = generate_access_token(context)
set_access_token_cookie(context)
context.refresh_token = generate_refresh_token(context)
set_refresh_token_cookie(context)
@then('the request will be successful')
def check_request_success(context):
assert_that(context.response.status_code, equal_to(HTTPStatus.OK))
assert_that(
context.response.status_code,
is_in([HTTPStatus.OK, HTTPStatus.NO_CONTENT])
)
@then('the request will fail with {error_type} error')

View File

@ -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))

View File

@ -1,25 +1,102 @@
from datetime import date
from http import HTTPStatus
import json
from datetime import date
from behave import then, when
from hamcrest import assert_that, equal_to, has_item, none
from behave import given, then, when
from hamcrest import assert_that, equal_to, has_item, none, starts_with
from selene.data.account import PRIVACY_POLICY
from selene.data.account import AccountRepository, PRIVACY_POLICY
from selene.testing.api import (
generate_access_token,
generate_refresh_token,
set_access_token_cookie,
set_refresh_token_cookie
)
from selene.testing.membership import MONTHLY_MEMBERSHIP, YEARLY_MEMBERSHIP
BAR_EMAIL_ADDRESS = 'bar@mycroft.ai'
STRIPE_METHOD = 'Stripe'
VISA_TOKEN = 'tok_visa'
@given('an account with a monthly membership')
def add_membership_to_account(context):
"""Use the API to add a monthly membership on Stripe
The API is used so that the Stripe API can be interacted with.
"""
context.username = 'foo'
context.access_token = generate_access_token(context)
set_access_token_cookie(context)
context.refresh_token = generate_refresh_token(context)
set_refresh_token_cookie(context)
add_membership_via_api(context)
@given('an account without a membership')
def get_account_no_membership(context):
context.username = 'foo'
@when('a user requests their profile')
def call_account_endpoint(context):
context.response = context.client.get('/api/account')
context.response = context.client.get(
'/api/account',
content_type='application/json'
)
@when('a monthly membership is added')
def add_monthly_membership(context):
context.response = add_membership_via_api(context)
@when('the membership is cancelled')
def cancel_membership(context):
membership_data = dict(
newMembership=False,
membershipType=None
)
context.response = context.client.patch(
'/api/account',
data=json.dumps(dict(membership=membership_data)),
content_type='application/json'
)
def add_membership_via_api(context):
membership_data = dict(
newMembership=True,
membershipType=MONTHLY_MEMBERSHIP,
paymentMethod=STRIPE_METHOD,
paymentToken=VISA_TOKEN
)
return context.client.patch(
'/api/account',
data=json.dumps(dict(membership=membership_data)),
content_type='application/json'
)
@when('the membership is changed to yearly')
def change_to_yearly_account(context):
membership_data = dict(
newMembership=False,
membershipType=YEARLY_MEMBERSHIP
)
context.response = context.client.patch(
'/api/account',
data=json.dumps(dict(membership=membership_data)),
content_type='application/json'
)
@then('user profile is returned')
def validate_response(context):
assert_that(context.response.status_code, equal_to(HTTPStatus.OK))
response_data = json.loads(context.response.data)
response_data = context.response.json
account = context.accounts['foo']
assert_that(
response_data['emailAddress'],
equal_to(context.account.email_address)
equal_to(account.email_address)
)
assert_that(
response_data['membership']['type'],
@ -30,7 +107,7 @@ def validate_response(context):
response_data['membership'], has_item('id')
)
assert_that(len(response_data['agreements']), equal_to(1))
assert_that(len(response_data['agreements']), equal_to(2))
agreement = response_data['agreements'][0]
assert_that(agreement['type'], equal_to(PRIVACY_POLICY))
assert_that(
@ -38,3 +115,37 @@ def validate_response(context):
equal_to(str(date.today().strftime('%B %d, %Y')))
)
assert_that(agreement, has_item('id'))
@then('the account should have a monthly membership')
def validate_monthly_account(context):
acct_repository = AccountRepository(context.db)
membership = acct_repository.get_active_account_membership(
context.accounts['foo'].id
)
assert_that(
membership.type,
equal_to(MONTHLY_MEMBERSHIP)
)
assert_that(membership.payment_account_id, starts_with('cus'))
assert_that(membership.start_date, equal_to(date.today()))
assert_that(membership.end_date, none())
@then('the account should have no membership')
def validate_absence_of_membership(context):
acct_repository = AccountRepository(context.db)
membership = acct_repository.get_active_account_membership(
context.accounts['foo'].id
)
assert_that(membership, none())
@then('the account should have a yearly membership')
def yearly_account(context):
acct_repository = AccountRepository(context.db)
membership = acct_repository.get_active_account_membership(
context.accounts['foo'].id
)
assert_that(membership.type, equal_to(YEARLY_MEMBERSHIP))
assert_that(membership.payment_account_id, starts_with('cus'))

View File

@ -0,0 +1,30 @@
import os
import stripe
from behave import then, when
from hamcrest import assert_that, equal_to
from stripe.error import InvalidRequestError
from selene.data.account import AccountRepository
@when('the user\'s account is deleted')
def account_deleted(context):
acct_repository = AccountRepository(context.db)
membership = acct_repository.get_active_account_membership(
context.accounts['foo'].id
)
context.accounts['foo'].membership = membership
context.response = context.client.delete('/api/account')
@then('the membership is removed from stripe')
def check_stripe(context):
account = context.accounts['foo']
stripe.api_key = os.environ['STRIPE_PRIVATE_KEY']
subscription_not_found = False
try:
stripe.Subscription.retrieve(account.membership.payment_account_id)
except InvalidRequestError:
subscription_not_found = True
assert_that(subscription_not_found, equal_to(True))

View File

@ -1,150 +0,0 @@
from binascii import b2a_base64
from datetime import date
import json
from behave import given, when, then
from hamcrest import assert_that, equal_to, starts_with, none
from selene.api.testing import generate_access_token, generate_refresh_token
from selene.data.account import (
AccountRepository,
Account,
AccountAgreement,
PRIVACY_POLICY
)
from selene.util.db import connect_to_db
TEST_EMAIL_ADDRESS = 'test@mycroft.ai'
new_account_request = dict(
username='test',
termsOfUse=True,
privacyPolicy=True,
login=dict(
federatedPlatform=None,
federatedToken=None,
email=b2a_base64(b'test@mycroft.ai').decode(),
password=b2a_base64(b'12345678').decode()
),
support=dict(
openDataset=True,
membership='Maybe Later',
paymentMethod=None,
paymentAccountId=None
)
)
MONTHLY_MEMBERSHIP = 'Monthly Membership'
STRIPE_METHOD = 'Stripe'
VISA_TOKEN = 'tok_visa'
YEARLY_MEMBERSHIP = 'Yearly Membership'
@given('a user with a free account')
def create_account(context):
context.account = Account(
email_address='test@mycroft.ai',
username='test',
membership=None,
agreements=[
AccountAgreement(type=PRIVACY_POLICY, accept_date=date.today())
]
)
db = connect_to_db(context.client_config['DB_CONNECTION_CONFIG'])
acct_repository = AccountRepository(db)
account_id = acct_repository.add(context.account, 'foo')
context.account.id = account_id
generate_access_token(context)
generate_refresh_token(context)
@when('a monthly membership is added')
def update_membership(context):
membership_data = dict(
newMembership=True,
membershipType=MONTHLY_MEMBERSHIP,
paymentMethod=STRIPE_METHOD,
paymentToken=VISA_TOKEN
)
context.response = context.client.patch(
'/api/account',
data=json.dumps(dict(membership=membership_data)),
content_type='application/json'
)
@when('the account is requested')
def request_account(context):
db = connect_to_db(context.client_config['DB_CONNECTION_CONFIG'])
context.response_account = AccountRepository(db).get_account_by_email(
TEST_EMAIL_ADDRESS
)
@then('the account should have a monthly membership')
def monthly_account(context):
account = context.response_account
assert_that(
account.membership.type,
equal_to(MONTHLY_MEMBERSHIP)
)
assert_that(account.membership.payment_account_id, starts_with('cus'))
@given('a user with a monthly membership')
def create_monthly_account(context):
new_account_request['support'].update(
membership=MONTHLY_MEMBERSHIP,
paymentMethod=STRIPE_METHOD,
paymentToken=VISA_TOKEN
)
context.client.post(
'/api/account',
data=json.dumps(new_account_request),
content_type='application/json'
)
db = connect_to_db(context.client_config['DB_CONNECTION_CONFIG'])
account_repository = AccountRepository(db)
account = account_repository.get_account_by_email(TEST_EMAIL_ADDRESS)
context.account = account
generate_access_token(context)
generate_refresh_token(context)
@when('the membership is cancelled')
def cancel_membership(context):
membership_data = dict(
newMembership=False,
membershipType=None
)
context.client.patch(
'/api/account',
data=json.dumps(dict(membership=membership_data)),
content_type='application/json'
)
@then('the account should have no membership')
def free_account(context):
account = context.response_account
assert_that(account.membership, none())
@when('the membership is changed to yearly')
def change_to_yearly_account(context):
membership_data = dict(
newMembership=False,
membershipType=YEARLY_MEMBERSHIP
)
context.client.patch(
'/api/account',
data=json.dumps(dict(membership=membership_data)),
content_type='application/json'
)
@then('the account should have a yearly membership')
def yearly_account(context):
account = context.response_account
assert_that(account.membership.type, equal_to(YEARLY_MEMBERSHIP))
assert_that(account.membership.payment_account_id, starts_with('cus'))

View File

@ -1,19 +0,0 @@
Feature: Test the API call to update a membership
Scenario: user with free account opts into a membership
Given a user with a free account
When a monthly membership is added
And the account is requested
Then the account should have a monthly membership
Scenario: user opts out monthly membership
Given a user with a monthly membership
When the membership is cancelled
And the account is requested
Then the account should have no membership
Scenario: user changes from a monthly membership to yearly membership
Given a user with a monthly membership
When the membership is changed to yearly
And the account is requested
Then the account should have a yearly membership

View File

@ -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)

View File

@ -1,8 +0,0 @@
from .authentication import (
ACCESS_TOKEN_COOKIE_KEY,
generate_access_token,
generate_refresh_token,
get_account,
REFRESH_TOKEN_COOKIE_KEY,
validate_token_cookies
)

View File

@ -9,5 +9,9 @@ from .entity.membership import Membership
from .entity.skill import AccountSkill
from .repository.account import AccountRepository
from .repository.agreement import AgreementRepository
from .repository.membership import MembershipRepository
from .repository.membership import (
MembershipRepository,
MONTHLY_MEMBERSHIP,
YEARLY_MEMBERSHIP
)
from .repository.skill import AccountSkillRepository

View File

@ -64,20 +64,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(
@ -287,3 +273,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

View File

@ -2,6 +2,9 @@ from selene.data.account import AccountMembership
from ..entity.membership import Membership
from ...repository_base import RepositoryBase
MONTHLY_MEMBERSHIP = 'Monthly Membership'
YEARLY_MEMBERSHIP = 'Yearly Membership'
class MembershipRepository(RepositoryBase):
def __init__(self, db):
@ -23,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',
@ -54,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)

View File

@ -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

View File

@ -1,4 +0,0 @@
from .account import arthur, insert_account, delete_account
from .agreement import insert_agreements, delete_agreements
from .membership import insert_memberships, delete_memberships
from .test_db import create_test_db, drop_test_db

View File

@ -1,50 +1,55 @@
from datetime import date
from selene.data.account import (
Account,
AccountAgreement,
AccountMembership,
AccountRepository,
)
_agree_to_terms = AccountAgreement(
type='Terms of Use',
accept_date=date(1975, 3, 14)
)
_agree_to_privacy = AccountAgreement(
type='Privacy Policy',
accept_date=date.today()
)
_membership = AccountMembership(
type='Monthly Supporter',
start_date=date.today(),
stripe_customer_id='killer_rabbit'
)
arthur = dict(
email_address='arthur@holy.grail',
username='kingofthebritons',
agreements=[_agree_to_terms, _agree_to_privacy],
membership=_membership
)
black_knight = dict(
email_address='blackknight@holy.grail',
username='fleshwound',
agreements=[_agree_to_terms, _agree_to_privacy]
PRIVACY_POLICY,
TERMS_OF_USE
)
def insert_account(db, account_attrs: dict) -> Account:
account = Account(**account_attrs)
account_repository = AccountRepository(db)
account.id = account_repository.add(account, password='holygrail')
def build_test_account(**overrides):
test_agreements = [
AccountAgreement(type=PRIVACY_POLICY, accept_date=date.today()),
AccountAgreement(type=TERMS_OF_USE, accept_date=date.today())
]
return Account(
email_address=overrides.get('email_address') or 'foo@mycroft.ai',
username=overrides.get('username') or 'foobar',
agreements=overrides.get('agreements') or test_agreements
)
def add_account(db, **overrides):
acct_repository = AccountRepository(db)
account = build_test_account(**overrides)
account.id = acct_repository.add(account, 'test_password')
if account.membership is not None:
acct_repository.add_membership(account.id, account.membership)
return account
def delete_account(db, account: Account):
def remove_account(db, account):
account_repository = AccountRepository(db)
account_repository.remove(account)
def build_test_membership(**overrides):
stripe_acct = 'test_stripe_acct_id'
return AccountMembership(
type=overrides.get('type') or 'Monthly Membership',
start_date=overrides.get('start_date') or date.today(),
payment_method=overrides.get('payment_method') or 'Stripe',
payment_account_id=overrides.get('payment_account_id') or stripe_acct,
payment_id=overrides.get('payment_id') or 'test_stripe_payment_id'
)
def add_account_membership(db, account_id, **overrides):
membership = build_test_membership(**overrides)
acct_repository = AccountRepository(db)
acct_repository.add_membership(account_id, membership)
return membership

View File

@ -0,0 +1,14 @@
from selene.data.device import Geography, GeographyRepository
def add_account_geography(db, account, **overrides):
geography = Geography(
country=overrides.get('country') or 'United States',
region=overrides.get('region') or 'Missouri',
city=overrides.get('city') or 'Kansas City',
time_zone=overrides.get('time_zone') or 'America/Chicago'
)
geo_repository = GeographyRepository(db, account.id)
account_geography_id = geo_repository.add(geography)
return account_geography_id

View File

@ -1,36 +1,46 @@
from datetime import date
from datetime import date, timedelta
from typing import Tuple, List
from selene.data.account import Agreement, AgreementRepository
terms_of_use_attrs = dict(
type='Terms of Use',
version='HolyGrail',
content='I agree that all the tests I write for this application will be '
'in the theme of Monty Python and the Holy Grail. If you do not '
'agree with these terms, I will be forced to say "Ni!" until such '
'time as you agree',
effective_date=date(1975, 3, 14)
)
privacy_policy_attrs = dict(
type='Privacy Policy',
version='GoT',
content='First, shalt thou take out the Holy Pin. Then shalt thou count'
'to three. No more. No less. Three shalt be the number thou'
'shalt count and the number of the counting shall be three. Four'
'shalt thou not count, nor either count thou two, excepting that'
'thou then proceed to three. Five is right out. Once the number'
'three, being the third number, be reached, then lobbest thou'
'Holy Hand Grenade of Antioch towards thy foe, who, being naughty'
'in My sight, shall snuff it.',
effective_date=date(1975, 3, 14)
from selene.data.account import (
Agreement,
AgreementRepository,
PRIVACY_POLICY,
TERMS_OF_USE
)
def insert_agreements(db) -> Tuple[Agreement, Agreement]:
terms_of_use = Agreement(**terms_of_use_attrs)
privacy_policy = Agreement(**privacy_policy_attrs)
def _build_test_terms_of_use():
return Agreement(
type='Terms of Use',
version='HolyGrail',
content='I agree that all the tests I write for this application will '
'be in the theme of Monty Python and the Holy Grail. If you '
'do not agree with these terms, I will be forced to say "Ni!" '
'until such time as you agree',
effective_date=date.today() - timedelta(days=1)
)
def _build_test_privacy_policy():
return Agreement(
type='Privacy Policy',
version='Holy Grail',
content='First, shalt thou take out the Holy Pin. Then shalt thou '
'count to three. No more. No less. Three shalt be the '
'number thou shalt count and the number of the counting shall '
'be three. Four shalt thou not count, nor either count thou '
'two, excepting that thou then proceed to three. Five is '
'right out. Once the number three, being the third number, '
'be reached, then lobbest thou Holy Hand Grenade of Antioch '
'towards thy foe, who, being naughty in My sight, '
'shall snuff it.',
effective_date=date.today() - timedelta(days=1)
)
def add_agreements(db) -> Tuple[Agreement, Agreement]:
terms_of_use = _build_test_terms_of_use()
privacy_policy = _build_test_privacy_policy()
agreement_repository = AgreementRepository(db)
terms_of_use.id = agreement_repository.add(terms_of_use)
privacy_policy.id = agreement_repository.add(privacy_policy)
@ -38,7 +48,7 @@ def insert_agreements(db) -> Tuple[Agreement, Agreement]:
return terms_of_use, privacy_policy
def delete_agreements(db, agreements: List[Agreement]):
def remove_agreements(db, agreements: List[Agreement]):
for agreement in agreements:
agreement_repository = AgreementRepository(db)
agreement_repository.remove(agreement, testing=True)

View File

@ -10,37 +10,43 @@ TWO_MINUTES = 120
REFRESH_TOKEN_COOKIE_KEY = 'seleneRefresh'
def generate_access_token(context, expire=False):
def generate_access_token(context, duration=ONE_MINUTE):
access_token = AuthenticationToken(
context.client_config['ACCESS_SECRET'],
ONE_MINUTE
duration
)
if not expire:
access_token.generate(context.account.id)
context.access_token = access_token
account = context.accounts[context.username]
access_token.generate(account.id)
return access_token
def set_access_token_cookie(context, duration=ONE_MINUTE):
context.client.set_cookie(
context.client_config['DOMAIN'],
ACCESS_TOKEN_COOKIE_KEY,
access_token.jwt,
max_age=0 if expire else ONE_MINUTE
context.access_token.jwt,
max_age=duration
)
def generate_refresh_token(context, expire=False):
def generate_refresh_token(context, duration=TWO_MINUTES):
refresh_token = AuthenticationToken(
context.client_config['REFRESH_SECRET'],
TWO_MINUTES
duration
)
if not expire:
refresh_token.generate(context.account.id)
context.refresh_token = refresh_token
account = context.accounts[context.username]
refresh_token.generate(account.id)
return refresh_token
def set_refresh_token_cookie(context, duration=TWO_MINUTES):
context.client.set_cookie(
context.client_config['DOMAIN'],
REFRESH_TOKEN_COOKIE_KEY,
refresh_token.jwt,
max_age=0 if expire else TWO_MINUTES
context.refresh_token.jwt,
max_age=duration
)

View File

@ -1,15 +1,20 @@
from decimal import Decimal
from selene.data.account import Membership, MembershipRepository
from selene.data.account import (
Membership,
MembershipRepository,
MONTHLY_MEMBERSHIP,
YEARLY_MEMBERSHIP
)
monthly_membership = dict(
type='Monthly Supporter',
type=MONTHLY_MEMBERSHIP,
rate=Decimal('1.99'),
rate_period='monthly'
)
yearly_membership = dict(
type='Yearly Supporter',
type=YEARLY_MEMBERSHIP,
rate=Decimal('19.99'),
rate_period='yearly'
)