ducky/subscriptions

Paddy 2015-06-11 Child:f1a22fc2321d

0:56a2bef197cd Browse Files

First implementation of datastore interface. Define Subscriptions, and the SubscriptionChange type that can modify Subscriptions. Define the subscriptionStore, which describes how Subscriptions are to be created, retrieved, updated, and deleted in/from the storage backend they're stored in. Create a Memstore implementation of the subscriptionStore, which stores all our data in-memory, for use in testing. Write tests to cover the subscriptionStore interface, testing the entire Memstore. We'll plug future storage backends into these tests, to make sure they all behave the same, and to exercise each storage backend without requiring a suite of tests for each. At this point, we have 100% test coverage and no complaints from golint or go vet. I expect it's all downhill from here.

.hgignore memstore.go subscription.go subscription_memstore.go subscription_store_test.go

     1.1 --- /dev/null	Thu Jan 01 00:00:00 1970 +0000
     1.2 +++ b/.hgignore	Thu Jun 11 23:15:01 2015 -0400
     1.3 @@ -0,0 +1,2 @@
     1.4 +cover.out
     1.5 +.swp
     2.1 --- /dev/null	Thu Jan 01 00:00:00 1970 +0000
     2.2 +++ b/memstore.go	Thu Jun 11 23:15:01 2015 -0400
     2.3 @@ -0,0 +1,28 @@
     2.4 +package subscriptions
     2.5 +
     2.6 +import (
     2.7 +	"sync"
     2.8 +)
     2.9 +
    2.10 +// Memstore is an in-memory version of our datastores, useful
    2.11 +// for testing. It should not be used in production.
    2.12 +type Memstore struct {
    2.13 +	subscriptions    map[string]Subscription
    2.14 +	subscriptionLock sync.RWMutex
    2.15 +}
    2.16 +
    2.17 +// NewMemstore returns a pointer to a Memstore object, ready
    2.18 +// to be used as a datastore.
    2.19 +func NewMemstore() *Memstore {
    2.20 +	return &Memstore{
    2.21 +		subscriptions: map[string]Subscription{},
    2.22 +	}
    2.23 +}
    2.24 +
    2.25 +func (m *Memstore) reset() error {
    2.26 +	m.subscriptionLock.Lock()
    2.27 +	defer m.subscriptionLock.Unlock()
    2.28 +
    2.29 +	m.subscriptions = map[string]Subscription{}
    2.30 +	return nil
    2.31 +}
     3.1 --- /dev/null	Thu Jan 01 00:00:00 1970 +0000
     3.2 +++ b/subscription.go	Thu Jun 11 23:15:01 2015 -0400
     3.3 @@ -0,0 +1,149 @@
     3.4 +package subscriptions
     3.5 +
     3.6 +import (
     3.7 +	"errors"
     3.8 +	"time"
     3.9 +
    3.10 +	"code.secondbit.org/uuid.hg"
    3.11 +)
    3.12 +
    3.13 +const (
    3.14 +	// MonthlyPeriod represents a period of once a month.
    3.15 +	MonthlyPeriod period = "monthly"
    3.16 +)
    3.17 +
    3.18 +var (
    3.19 +	// ErrSubscriptionAlreadyExists is returned when a Subscription
    3.20 +	// with an identical ID already exists in the subscriptionStore.
    3.21 +	ErrSubscriptionAlreadyExists = errors.New("Subscription already exists")
    3.22 +	// ErrSubscriptionNotFound is returned when a single Subscription
    3.23 +	// is acted upon or requested, but cannot be found.
    3.24 +	ErrSubscriptionNotFound = errors.New("Subscription not found")
    3.25 +	// ErrStripeCustomerAlreadyExists is returned when a Subscription
    3.26 +	// is created or updates its StripeCustomer property, but that
    3.27 +	// StripeCustomer is already associated with another Subscription.
    3.28 +	ErrStripeCustomerAlreadyExists = errors.New("Stripe customer already assigned to another Subscription")
    3.29 +	// ErrSubscriptionChangeEmpty is returned when a SubscriptionChange
    3.30 +	// is empty but is passed to subscriptionStore.UpdateSubscription
    3.31 +	// anyways.
    3.32 +	ErrSubscriptionChangeEmpty = errors.New("SubscriptionChange is empty")
    3.33 +	// ErrNoSubscriptionID is returned when one or more Subscription IDs
    3.34 +	// are required, but none are provided.
    3.35 +	ErrNoSubscriptionID = errors.New("no Subscription ID provided")
    3.36 +)
    3.37 +
    3.38 +type period string
    3.39 +
    3.40 +// Subscription represents the state of a user's payments. It holds
    3.41 +// metadata about the last time a user was charged, how much a user
    3.42 +// should be charged, how to charge a user and how much to charge
    3.43 +// the user.
    3.44 +type Subscription struct {
    3.45 +	ID             uuid.ID
    3.46 +	UserID         uuid.ID
    3.47 +	StripeCustomer string
    3.48 +	Amount         int
    3.49 +	Period         period
    3.50 +	Created        time.Time
    3.51 +	BeginCharging  time.Time
    3.52 +	LastCharged    time.Time
    3.53 +	LastNotified   time.Time
    3.54 +	InLockout      bool
    3.55 +}
    3.56 +
    3.57 +// SubscriptionChange represents desired changes to a Subscription
    3.58 +// object. A nil value means that property should remain unchanged.
    3.59 +type SubscriptionChange struct {
    3.60 +	StripeCustomer *string
    3.61 +	Amount         *int
    3.62 +	Period         *period
    3.63 +	BeginCharging  *time.Time
    3.64 +	LastCharged    *time.Time
    3.65 +	LastNotified   *time.Time
    3.66 +	InLockout      *bool
    3.67 +}
    3.68 +
    3.69 +// IsEmpty returns true if the SubscriptionChange doesn't request
    3.70 +// a change to any property of the Subscription.
    3.71 +func (change SubscriptionChange) IsEmpty() bool {
    3.72 +	if change.StripeCustomer != nil {
    3.73 +		return false
    3.74 +	}
    3.75 +	if change.Amount != nil {
    3.76 +		return false
    3.77 +	}
    3.78 +	if change.Period != nil {
    3.79 +		return false
    3.80 +	}
    3.81 +	if change.BeginCharging != nil {
    3.82 +		return false
    3.83 +	}
    3.84 +	if change.LastCharged != nil {
    3.85 +		return false
    3.86 +	}
    3.87 +	if change.LastNotified != nil {
    3.88 +		return false
    3.89 +	}
    3.90 +	if change.InLockout != nil {
    3.91 +		return false
    3.92 +	}
    3.93 +	return true
    3.94 +}
    3.95 +
    3.96 +// ApplyChange updates a Subscription based on the changes requested
    3.97 +// by a SubscriptionChange.
    3.98 +func (s *Subscription) ApplyChange(change SubscriptionChange) {
    3.99 +	if change.StripeCustomer != nil {
   3.100 +		s.StripeCustomer = *change.StripeCustomer
   3.101 +	}
   3.102 +	if change.Amount != nil {
   3.103 +		s.Amount = *change.Amount
   3.104 +	}
   3.105 +	if change.Period != nil {
   3.106 +		s.Period = *change.Period
   3.107 +	}
   3.108 +	if change.BeginCharging != nil {
   3.109 +		s.BeginCharging = *change.BeginCharging
   3.110 +	}
   3.111 +	if change.LastCharged != nil {
   3.112 +		s.LastCharged = *change.LastCharged
   3.113 +	}
   3.114 +	if change.LastNotified != nil {
   3.115 +		s.LastNotified = *change.LastNotified
   3.116 +	}
   3.117 +	if change.InLockout != nil {
   3.118 +		s.InLockout = *change.InLockout
   3.119 +	}
   3.120 +}
   3.121 +
   3.122 +// ByLastChargeDate allows us to sort a []Subscription by the LastCharged
   3.123 +// property, with the lowest LastCharged date first.
   3.124 +type ByLastChargeDate []Subscription
   3.125 +
   3.126 +// Len returns the length the SubscriptionsByLastChargeDate. It fulfills
   3.127 +// the sort.Interface interface.
   3.128 +func (s ByLastChargeDate) Len() int {
   3.129 +	return len(s)
   3.130 +}
   3.131 +
   3.132 +// Swap puts the item in position i in position j, and the item in position
   3.133 +// j in position i. It fulfills the sort.Interface interface.
   3.134 +func (s ByLastChargeDate) Swap(i, j int) {
   3.135 +	s[i], s[j] = s[j], s[i]
   3.136 +}
   3.137 +
   3.138 +// Less returns true if the item in position i should be sorted before the
   3.139 +// item in position j.
   3.140 +func (s ByLastChargeDate) Less(i, j int) bool {
   3.141 +	return s[i].LastCharged.Before(s[j].LastCharged)
   3.142 +}
   3.143 +
   3.144 +type subscriptionStore interface {
   3.145 +	reset() error
   3.146 +	createSubscription(sub Subscription) error
   3.147 +	updateSubscription(id uuid.ID, change SubscriptionChange) error
   3.148 +	deleteSubscription(id uuid.ID) error
   3.149 +	listSubscriptionsLastChargedBefore(time.Time) ([]Subscription, error)
   3.150 +	getSubscriptions(ids []uuid.ID) (map[string]Subscription, error)
   3.151 +	getSubscriptionByUser(id uuid.ID) (Subscription, error)
   3.152 +}
     4.1 --- /dev/null	Thu Jan 01 00:00:00 1970 +0000
     4.2 +++ b/subscription_memstore.go	Thu Jun 11 23:15:01 2015 -0400
     4.3 @@ -0,0 +1,116 @@
     4.4 +package subscriptions
     4.5 +
     4.6 +import (
     4.7 +	"sort"
     4.8 +	"time"
     4.9 +
    4.10 +	"code.secondbit.org/uuid.hg"
    4.11 +)
    4.12 +
    4.13 +func stripeCustomerInMemstore(stripeCustomer string, m *Memstore) bool {
    4.14 +	for _, sub := range m.subscriptions {
    4.15 +		if sub.StripeCustomer == stripeCustomer {
    4.16 +			return true
    4.17 +		}
    4.18 +	}
    4.19 +	return false
    4.20 +}
    4.21 +
    4.22 +func (m *Memstore) createSubscription(sub Subscription) error {
    4.23 +	m.subscriptionLock.Lock()
    4.24 +	defer m.subscriptionLock.Unlock()
    4.25 +
    4.26 +	if _, ok := m.subscriptions[sub.ID.String()]; ok {
    4.27 +		return ErrSubscriptionAlreadyExists
    4.28 +	}
    4.29 +	if stripeCustomerInMemstore(sub.StripeCustomer, m) {
    4.30 +		return ErrStripeCustomerAlreadyExists
    4.31 +	}
    4.32 +	m.subscriptions[sub.ID.String()] = sub
    4.33 +	return nil
    4.34 +}
    4.35 +
    4.36 +func (m *Memstore) updateSubscription(id uuid.ID, change SubscriptionChange) error {
    4.37 +	if change.IsEmpty() {
    4.38 +		return ErrSubscriptionChangeEmpty
    4.39 +	}
    4.40 +
    4.41 +	m.subscriptionLock.Lock()
    4.42 +	defer m.subscriptionLock.Unlock()
    4.43 +
    4.44 +	s, ok := m.subscriptions[id.String()]
    4.45 +	if !ok {
    4.46 +		return ErrSubscriptionNotFound
    4.47 +	}
    4.48 +	if change.StripeCustomer != nil {
    4.49 +		if stripeCustomerInMemstore(*change.StripeCustomer, m) {
    4.50 +			return ErrStripeCustomerAlreadyExists
    4.51 +		}
    4.52 +	}
    4.53 +	s.ApplyChange(change)
    4.54 +	m.subscriptions[id.String()] = s
    4.55 +	return nil
    4.56 +}
    4.57 +
    4.58 +func (m *Memstore) deleteSubscription(id uuid.ID) error {
    4.59 +	m.subscriptionLock.Lock()
    4.60 +	defer m.subscriptionLock.Unlock()
    4.61 +
    4.62 +	_, ok := m.subscriptions[id.String()]
    4.63 +	if !ok {
    4.64 +		return ErrSubscriptionNotFound
    4.65 +	}
    4.66 +	delete(m.subscriptions, id.String())
    4.67 +	return nil
    4.68 +}
    4.69 +
    4.70 +func (m *Memstore) listSubscriptionsLastChargedBefore(cutoff time.Time) ([]Subscription, error) {
    4.71 +	m.subscriptionLock.RLock()
    4.72 +	defer m.subscriptionLock.RUnlock()
    4.73 +
    4.74 +	var result []Subscription
    4.75 +	for _, s := range m.subscriptions {
    4.76 +		if cutoff.Before(s.LastCharged) {
    4.77 +			continue
    4.78 +		}
    4.79 +		result = append(result, s)
    4.80 +	}
    4.81 +
    4.82 +	sorted := ByLastChargeDate(result)
    4.83 +	sort.Sort(sorted)
    4.84 +	result = []Subscription(sorted)
    4.85 +
    4.86 +	return result, nil
    4.87 +}
    4.88 +
    4.89 +func (m *Memstore) getSubscriptions(ids []uuid.ID) (map[string]Subscription, error) {
    4.90 +	if len(ids) < 1 {
    4.91 +		return map[string]Subscription{}, ErrNoSubscriptionID
    4.92 +	}
    4.93 +	m.subscriptionLock.RLock()
    4.94 +	defer m.subscriptionLock.RUnlock()
    4.95 +
    4.96 +	result := map[string]Subscription{}
    4.97 +
    4.98 +	for _, id := range ids {
    4.99 +		s, ok := m.subscriptions[id.String()]
   4.100 +		if !ok {
   4.101 +			continue
   4.102 +		}
   4.103 +		result[s.ID.String()] = s
   4.104 +	}
   4.105 +	return result, nil
   4.106 +}
   4.107 +
   4.108 +func (m *Memstore) getSubscriptionByUser(id uuid.ID) (Subscription, error) {
   4.109 +	m.subscriptionLock.RLock()
   4.110 +	defer m.subscriptionLock.RUnlock()
   4.111 +
   4.112 +	for _, subscription := range m.subscriptions {
   4.113 +		if subscription.UserID.Equal(id) {
   4.114 +			return subscription, nil
   4.115 +		}
   4.116 +	}
   4.117 +
   4.118 +	return Subscription{}, ErrSubscriptionNotFound
   4.119 +}
     5.1 --- /dev/null	Thu Jan 01 00:00:00 1970 +0000
     5.2 +++ b/subscription_store_test.go	Thu Jun 11 23:15:01 2015 -0400
     5.3 @@ -0,0 +1,628 @@
     5.4 +package subscriptions
     5.5 +
     5.6 +import (
     5.7 +	"strconv"
     5.8 +	"testing"
     5.9 +	"time"
    5.10 +
    5.11 +	"code.secondbit.org/uuid.hg"
    5.12 +)
    5.13 +
    5.14 +const (
    5.15 +	subscriptionChangeStripeCustomer = 1 << iota
    5.16 +	subscriptionChangeAmount
    5.17 +	subscriptionChangePeriod
    5.18 +	subscriptionChangeBeginCharging
    5.19 +	subscriptionChangeLastCharged
    5.20 +	subscriptionChangeLastNotified
    5.21 +	subscriptionChangeInLockout
    5.22 +)
    5.23 +
    5.24 +var testSubscriptionStores = []subscriptionStore{
    5.25 +	NewMemstore(),
    5.26 +}
    5.27 +
    5.28 +func compareSubscriptions(sub1, sub2 Subscription) (bool, string, interface{}, interface{}) {
    5.29 +	if !sub1.ID.Equal(sub2.ID) {
    5.30 +		return false, "ID", sub1.ID, sub2.ID
    5.31 +	}
    5.32 +	if !sub1.UserID.Equal(sub2.UserID) {
    5.33 +		return false, "UserID", sub1.UserID, sub2.UserID
    5.34 +	}
    5.35 +	if sub1.StripeCustomer != sub2.StripeCustomer {
    5.36 +		return false, "StripeCustomer", sub1.StripeCustomer, sub2.StripeCustomer
    5.37 +	}
    5.38 +	if sub1.Amount != sub2.Amount {
    5.39 +		return false, "Amount", sub1.Amount, sub2.Amount
    5.40 +	}
    5.41 +	if sub1.Period != sub2.Period {
    5.42 +		return false, "Period", sub1.Period, sub2.Period
    5.43 +	}
    5.44 +	if !sub1.Created.Equal(sub2.Created) {
    5.45 +		return false, "Created", sub1.Created, sub2.Created
    5.46 +	}
    5.47 +	if !sub1.BeginCharging.Equal(sub2.BeginCharging) {
    5.48 +		return false, "BeginCharging", sub1.BeginCharging, sub2.BeginCharging
    5.49 +	}
    5.50 +	if !sub1.LastCharged.Equal(sub2.LastCharged) {
    5.51 +		return false, "LastCharged", sub1.LastCharged, sub2.LastCharged
    5.52 +	}
    5.53 +	if !sub1.LastNotified.Equal(sub2.LastNotified) {
    5.54 +		return false, "LastNotified", sub1.LastNotified, sub2.LastNotified
    5.55 +	}
    5.56 +	if sub1.InLockout != sub2.InLockout {
    5.57 +		return false, "InLockout", sub1.InLockout, sub2.InLockout
    5.58 +	}
    5.59 +	return true, "", nil, nil
    5.60 +}
    5.61 +
    5.62 +func subscriptionMapContains(subscriptionMap map[string]Subscription, subscriptions ...Subscription) (bool, []Subscription) {
    5.63 +	var missing []Subscription
    5.64 +	for _, sub := range subscriptions {
    5.65 +		if _, ok := subscriptionMap[sub.ID.String()]; !ok {
    5.66 +			missing = append(missing, sub)
    5.67 +		}
    5.68 +	}
    5.69 +	if len(missing) > 0 {
    5.70 +		return false, missing
    5.71 +	}
    5.72 +	return true, missing
    5.73 +}
    5.74 +
    5.75 +func TestCreateSubscription(t *testing.T) {
    5.76 +	for _, store := range testSubscriptionStores {
    5.77 +		err := store.reset()
    5.78 +		if err != nil {
    5.79 +			t.Fatalf("Error resetting %T: %+v\n", store, err)
    5.80 +		}
    5.81 +		customerID := uuid.NewID()
    5.82 +		sub := Subscription{
    5.83 +			ID:             uuid.NewID(),
    5.84 +			UserID:         customerID,
    5.85 +			StripeCustomer: "stripeCustomer1",
    5.86 +			Amount:         200,
    5.87 +			Period:         MonthlyPeriod,
    5.88 +			Created:        time.Now(),
    5.89 +			BeginCharging:  time.Now().Add(time.Hour),
    5.90 +		}
    5.91 +		err = store.createSubscription(sub)
    5.92 +		if err != nil {
    5.93 +			t.Errorf("Error creating subscription in %T: %+v\n", store, err)
    5.94 +		}
    5.95 +		retrieved, err := store.getSubscriptions([]uuid.ID{sub.ID})
    5.96 +		if err != nil {
    5.97 +			t.Errorf("Error retrieving subscription from %T: %+v\n", store, err)
    5.98 +		}
    5.99 +		if _, returned := retrieved[sub.ID.String()]; !returned {
   5.100 +			t.Errorf("Error retrieving subscription from %T: %s wasn't in the results.", store, sub.ID)
   5.101 +		}
   5.102 +		ok, field, expected, result := compareSubscriptions(sub, retrieved[sub.ID.String()])
   5.103 +		if !ok {
   5.104 +			t.Errorf("Expected %s to be %v, got %v from %T\n", field, expected, result, store)
   5.105 +		}
   5.106 +		err = store.createSubscription(sub)
   5.107 +		if err != ErrSubscriptionAlreadyExists {
   5.108 +			t.Errorf("Unexpected error creating subscription in %T (wanted %+v): %+v\n", store, ErrSubscriptionAlreadyExists, err)
   5.109 +		}
   5.110 +		sub.ID = uuid.NewID()
   5.111 +		err = store.createSubscription(sub)
   5.112 +		if err != ErrStripeCustomerAlreadyExists {
   5.113 +			t.Errorf("Unexpected error creating subscription in %T (wanted %+v): %+v\n", store, ErrStripeCustomerAlreadyExists, err)
   5.114 +		}
   5.115 +		sub.StripeCustomer = "stripeCustomer2"
   5.116 +		err = store.createSubscription(sub)
   5.117 +		if err != nil {
   5.118 +			t.Errorf("Error creating subscription in %T: %+v\n", store, err)
   5.119 +		}
   5.120 +	}
   5.121 +}
   5.122 +
   5.123 +func TestUpdateSubscription(t *testing.T) {
   5.124 +	variations := 1 << 7
   5.125 +	sub := Subscription{
   5.126 +		ID:             uuid.NewID(),
   5.127 +		UserID:         uuid.NewID(),
   5.128 +		StripeCustomer: "default",
   5.129 +		Amount:         -1,
   5.130 +		Period:         MonthlyPeriod,
   5.131 +		Created:        time.Now().Add(time.Hour * -24),
   5.132 +		BeginCharging:  time.Now().Add(time.Hour * -24),
   5.133 +		LastCharged:    time.Now().Add(time.Hour * -24),
   5.134 +		LastNotified:   time.Now().Add(time.Hour * -24),
   5.135 +		InLockout:      true,
   5.136 +	}
   5.137 +	sub2 := Subscription{
   5.138 +		ID:             uuid.NewID(),
   5.139 +		UserID:         uuid.NewID(),
   5.140 +		StripeCustomer: "stripeCustomer2",
   5.141 +		Amount:         -2,
   5.142 +		Period:         MonthlyPeriod,
   5.143 +		Created:        time.Now(),
   5.144 +		BeginCharging:  time.Now(),
   5.145 +		LastCharged:    time.Now(),
   5.146 +		LastNotified:   time.Now(),
   5.147 +		InLockout:      false,
   5.148 +	}
   5.149 +
   5.150 +	for i := 1; i < variations; i++ {
   5.151 +		var stripeCustomer string
   5.152 +		var amount int
   5.153 +		var inLockout bool
   5.154 +		var per period
   5.155 +		var beginCharging, lastCharged, lastNotified time.Time
   5.156 +
   5.157 +		change := SubscriptionChange{}
   5.158 +		empty := change.IsEmpty()
   5.159 +		if !empty {
   5.160 +			t.Errorf("Expected empty to be %t, was %t\n", true, empty)
   5.161 +		}
   5.162 +		expectation := sub
   5.163 +		result := sub
   5.164 +		strI := strconv.Itoa(i)
   5.165 +
   5.166 +		if i&subscriptionChangeStripeCustomer != 0 {
   5.167 +			stripeCustomer = "stripeCustomer-" + strI
   5.168 +			change.StripeCustomer = &stripeCustomer
   5.169 +			expectation.StripeCustomer = stripeCustomer
   5.170 +		}
   5.171 +
   5.172 +		if i&subscriptionChangeAmount != 0 {
   5.173 +			amount = i
   5.174 +			change.Amount = &amount
   5.175 +			expectation.Amount = amount
   5.176 +		}
   5.177 +
   5.178 +		if i&subscriptionChangePeriod != 0 {
   5.179 +			per = period("period-" + strI)
   5.180 +			change.Period = &per
   5.181 +			expectation.Period = per
   5.182 +		}
   5.183 +
   5.184 +		if i&subscriptionChangeBeginCharging != 0 {
   5.185 +			beginCharging = time.Now().Add(time.Hour * time.Duration(i))
   5.186 +			change.BeginCharging = &beginCharging
   5.187 +			expectation.BeginCharging = beginCharging
   5.188 +		}
   5.189 +
   5.190 +		if i&subscriptionChangeLastCharged != 0 {
   5.191 +			lastCharged = time.Now().Add(time.Hour * time.Duration(i))
   5.192 +			change.LastCharged = &lastCharged
   5.193 +			expectation.LastCharged = lastCharged
   5.194 +		}
   5.195 +
   5.196 +		if i&subscriptionChangeLastNotified != 0 {
   5.197 +			lastNotified = time.Now().Add(time.Hour * time.Duration(i))
   5.198 +			change.LastNotified = &lastNotified
   5.199 +			expectation.LastNotified = lastNotified
   5.200 +		}
   5.201 +
   5.202 +		if i&subscriptionChangeInLockout != 0 {
   5.203 +			inLockout = i%2 == 0
   5.204 +			change.InLockout = &inLockout
   5.205 +			expectation.InLockout = inLockout
   5.206 +		}
   5.207 +
   5.208 +		empty = change.IsEmpty()
   5.209 +		if empty {
   5.210 +			t.Errorf("Expected empty to be %t, was %t\n", false, empty)
   5.211 +		}
   5.212 +
   5.213 +		result.ApplyChange(change)
   5.214 +		match, field, expected, got := compareSubscriptions(expectation, result)
   5.215 +		if !match {
   5.216 +			t.Errorf("Expected field `%s` to be `%v`, got `%v`\n", field, expected, got)
   5.217 +		}
   5.218 +		for _, store := range testSubscriptionStores {
   5.219 +			err := store.reset()
   5.220 +			if err != nil {
   5.221 +				t.Fatalf("Error resetting %T: %+v\n", store, err)
   5.222 +			}
   5.223 +			err = store.createSubscription(sub)
   5.224 +			if err != nil {
   5.225 +				t.Fatalf("Error saving subscription in %T: %s\n", store, err)
   5.226 +			}
   5.227 +			err = store.updateSubscription(sub.ID, change)
   5.228 +			if err != nil {
   5.229 +				t.Errorf("Error updating subscription in %T: %s\n", store, err)
   5.230 +			}
   5.231 +			retrieved, err := store.getSubscriptions([]uuid.ID{sub.ID})
   5.232 +			if err != nil {
   5.233 +				t.Errorf("Error getting subscription from %T: %s\n", store, err)
   5.234 +			}
   5.235 +			ok, missing := subscriptionMapContains(retrieved, sub)
   5.236 +			if !ok {
   5.237 +				t.Errorf("Expected to retrieve %s from %T, but missing was %+v\n", sub.ID.String(), store, missing)
   5.238 +			}
   5.239 +			match, field, expected, got = compareSubscriptions(expectation, retrieved[sub.ID.String()])
   5.240 +			if !match {
   5.241 +				t.Errorf("Expected field `%s` to be `%v`, got `%v` from %T\n", field, expected, got, store)
   5.242 +			}
   5.243 +		}
   5.244 +	}
   5.245 +	for _, store := range testSubscriptionStores {
   5.246 +		err := store.reset()
   5.247 +		if err != nil {
   5.248 +			t.Fatalf("Error resetting %T: %+v\n", store, err)
   5.249 +		}
   5.250 +		err = store.createSubscription(sub)
   5.251 +		if err != nil {
   5.252 +			t.Fatalf("Error saving subscription in %T: %+v\n", store, err)
   5.253 +		}
   5.254 +		err = store.createSubscription(sub2)
   5.255 +		if err != nil {
   5.256 +			t.Fatalf("Error saving subscription in %T: %+v\n", store, err)
   5.257 +		}
   5.258 +		change := SubscriptionChange{}
   5.259 +		err = store.updateSubscription(sub.ID, change)
   5.260 +		if err != ErrSubscriptionChangeEmpty {
   5.261 +			t.Errorf("Expected err to be %+v, but got %+v from %T\n", ErrSubscriptionChangeEmpty, err, store)
   5.262 +		}
   5.263 +		stripeCustomer := sub2.StripeCustomer
   5.264 +		change.StripeCustomer = &stripeCustomer
   5.265 +		err = store.updateSubscription(uuid.NewID(), change)
   5.266 +		if err != ErrSubscriptionNotFound {
   5.267 +			t.Errorf("Expected err to be %+v, but got %+v from %T\n", ErrSubscriptionNotFound, err, store)
   5.268 +		}
   5.269 +		err = store.updateSubscription(sub.ID, change)
   5.270 +		if err != ErrStripeCustomerAlreadyExists {
   5.271 +			t.Errorf("Expected err to be %+v, but got %+v from %T\n", ErrStripeCustomerAlreadyExists, err, store)
   5.272 +		}
   5.273 +	}
   5.274 +}
   5.275 +
   5.276 +func TestDeleteSubscription(t *testing.T) {
   5.277 +	for _, store := range testSubscriptionStores {
   5.278 +		err := store.reset()
   5.279 +		if err != nil {
   5.280 +			t.Fatalf("Error resetting %T: %+v\n", store, err)
   5.281 +		}
   5.282 +		sub1 := Subscription{
   5.283 +			ID:             uuid.NewID(),
   5.284 +			UserID:         uuid.NewID(),
   5.285 +			StripeCustomer: "stripeCustomer1",
   5.286 +		}
   5.287 +		sub2 := Subscription{
   5.288 +			ID:             uuid.NewID(),
   5.289 +			UserID:         uuid.NewID(),
   5.290 +			StripeCustomer: "stripeCustomer2",
   5.291 +		}
   5.292 +		err = store.createSubscription(sub1)
   5.293 +		if err != nil {
   5.294 +			t.Fatalf("Error creating %+v in %T: %+v\n", sub1, store, err)
   5.295 +		}
   5.296 +		err = store.createSubscription(sub2)
   5.297 +		if err != nil {
   5.298 +			t.Fatalf("Error creating %+v in %T: %+v\n", sub1, store, err)
   5.299 +		}
   5.300 +		err = store.deleteSubscription(sub1.ID)
   5.301 +		if err != nil {
   5.302 +			t.Fatalf("Error deleting %+v in %T: %+v\n", sub1, store, err)
   5.303 +		}
   5.304 +		retrieved, err := store.getSubscriptions([]uuid.ID{sub1.ID, sub2.ID})
   5.305 +		if err != nil {
   5.306 +			t.Errorf("Error retrieving subscriptions from %T: %+v\n", store, err)
   5.307 +		}
   5.308 +		ok, missing := subscriptionMapContains(retrieved, sub1)
   5.309 +		if ok {
   5.310 +			t.Errorf("Expected not to retrieve %s from %T, but missing was %+v\n", sub1.ID.String(), store, missing)
   5.311 +		}
   5.312 +		ok, missing = subscriptionMapContains(retrieved, sub2)
   5.313 +		if !ok {
   5.314 +			t.Errorf("Expected to retrieve %s from %T, but missing was %+v\n", sub2.ID.String(), store, missing)
   5.315 +		}
   5.316 +		_, err = store.getSubscriptionByUser(sub1.UserID)
   5.317 +		if err != ErrSubscriptionNotFound {
   5.318 +			t.Errorf("Expected err to be %+v, but got %+v from %T\n", ErrSubscriptionNotFound, err, store)
   5.319 +		}
   5.320 +		err = store.deleteSubscription(sub1.ID)
   5.321 +		if err != ErrSubscriptionNotFound {
   5.322 +			t.Errorf("Expected err to be %+v, but got %+v from %T\n", ErrSubscriptionNotFound, err, store)
   5.323 +		}
   5.324 +	}
   5.325 +}
   5.326 +
   5.327 +func TestListSubscriptionsLastChargedBefore(t *testing.T) {
   5.328 +	for _, store := range testSubscriptionStores {
   5.329 +		err := store.reset()
   5.330 +		if err != nil {
   5.331 +			t.Fatalf("Error resetting %T: %+v\n", store, err)
   5.332 +		}
   5.333 +		sub1 := Subscription{
   5.334 +			ID:             uuid.NewID(),
   5.335 +			UserID:         uuid.NewID(),
   5.336 +			StripeCustomer: "stripeCustomer1",
   5.337 +			Amount:         200,
   5.338 +			Period:         MonthlyPeriod,
   5.339 +			Created:        time.Now().Add(time.Hour * -24 * 32),
   5.340 +			BeginCharging:  time.Now().Add(time.Hour * -24),
   5.341 +			LastCharged:    time.Now().Add(time.Hour * -24),
   5.342 +		}
   5.343 +		sub2 := Subscription{
   5.344 +			ID:             uuid.NewID(),
   5.345 +			UserID:         uuid.NewID(),
   5.346 +			StripeCustomer: "stripeCustomer2",
   5.347 +			Amount:         300,
   5.348 +			Period:         MonthlyPeriod,
   5.349 +			Created:        time.Now().Add(time.Hour * -24 * 61),
   5.350 +			BeginCharging:  time.Now().Add(time.Hour * -24 * 31),
   5.351 +			LastCharged:    time.Now().Add(time.Hour * -24 * 31),
   5.352 +		}
   5.353 +		sub3 := Subscription{
   5.354 +			ID:             uuid.NewID(),
   5.355 +			UserID:         uuid.NewID(),
   5.356 +			StripeCustomer: "stripeCustomer3",
   5.357 +			Amount:         100,
   5.358 +			Period:         MonthlyPeriod,
   5.359 +			Created:        time.Now().Add(time.Hour * -1),
   5.360 +			BeginCharging:  time.Now().Add(time.Hour * 31),
   5.361 +			LastCharged:    time.Time{},
   5.362 +		}
   5.363 +		err = store.createSubscription(sub1)
   5.364 +		if err != nil {
   5.365 +			t.Fatalf("Error creating %+v in %T: %+v\n", sub1, store, err)
   5.366 +		}
   5.367 +		err = store.createSubscription(sub2)
   5.368 +		if err != nil {
   5.369 +			t.Fatalf("Error creating %+v in %T: %+v\n", sub1, store, err)
   5.370 +		}
   5.371 +		err = store.createSubscription(sub3)
   5.372 +		if err != nil {
   5.373 +			t.Fatalf("Error creating %+v in %T: %+v\n", sub1, store, err)
   5.374 +		}
   5.375 +		t.Logf("sub1: %+v\n", sub1)
   5.376 +		t.Logf("sub2: %+v\n", sub2)
   5.377 +		t.Logf("sub3: %+v\n", sub3)
   5.378 +		// subscriptions last charged before right now
   5.379 +		// should be sub1, sub2, and sub3
   5.380 +		results, err := store.listSubscriptionsLastChargedBefore(time.Now())
   5.381 +		if err != nil {
   5.382 +			t.Errorf("Unexpected error listing subscriptions in %T: %+v\n", store, err)
   5.383 +		}
   5.384 +		if len(results) != 3 {
   5.385 +			t.Errorf("Expected three results from %T, got %+v\n", store, results)
   5.386 +		}
   5.387 +		ok, field, expected, result := compareSubscriptions(sub3, results[0])
   5.388 +		if !ok {
   5.389 +			t.Errorf("Expected %s in pos 0 to be %+v, got %+v from %T", field, expected, result, store)
   5.390 +		}
   5.391 +		ok, field, expected, result = compareSubscriptions(sub2, results[1])
   5.392 +		if !ok {
   5.393 +			t.Errorf("Expected %s in pos 1 to be %+v, got %+v from %T", field, expected, result, store)
   5.394 +		}
   5.395 +		ok, field, expected, result = compareSubscriptions(sub1, results[2])
   5.396 +		if !ok {
   5.397 +			t.Errorf("Expected %s in pos 2 to be %+v, got %+v from %T", field, expected, result, store)
   5.398 +		}
   5.399 +		// subscriptions last charged before a week ago
   5.400 +		// should be sub2, sub3
   5.401 +		results, err = store.listSubscriptionsLastChargedBefore(time.Now().Add(time.Hour * -24 * 7))
   5.402 +		if err != nil {
   5.403 +			t.Errorf("Unexpected error listing subscriptions in %T: %+v\n", store, err)
   5.404 +		}
   5.405 +		if len(results) != 2 {
   5.406 +			t.Errorf("Expected two results from %T, got %+v\n", store, results)
   5.407 +		}
   5.408 +		ok, field, expected, result = compareSubscriptions(sub3, results[0])
   5.409 +		if !ok {
   5.410 +			t.Errorf("Expected %s in pos 0 to be %+v, got %+v from %T", field, expected, result, store)
   5.411 +		}
   5.412 +		ok, field, expected, result = compareSubscriptions(sub2, results[1])
   5.413 +		if !ok {
   5.414 +			t.Errorf("Expected %s in pos 1 to be %+v, got %+v from %T", field, expected, result, store)
   5.415 +		}
   5.416 +		// subscriptions last charged before 32 days ago
   5.417 +		// should be sub3
   5.418 +		results, err = store.listSubscriptionsLastChargedBefore(time.Now().Add(time.Hour * -24 * 32))
   5.419 +		if err != nil {
   5.420 +			t.Errorf("Unexpected error listing subscriptions in %T: %+v\n", store, err)
   5.421 +		}
   5.422 +		if len(results) != 1 {
   5.423 +			t.Errorf("Expected one result from %T, got %+v\n", store, results)
   5.424 +		}
   5.425 +		ok, field, expected, result = compareSubscriptions(sub3, results[0])
   5.426 +		if !ok {
   5.427 +			t.Errorf("Expected %s in pos 0 to be %+v, got %+v from %T", field, expected, result, store)
   5.428 +		}
   5.429 +	}
   5.430 +}
   5.431 +
   5.432 +func TestGetSubscriptions(t *testing.T) {
   5.433 +	for _, store := range testSubscriptionStores {
   5.434 +		err := store.reset()
   5.435 +		if err != nil {
   5.436 +			t.Fatalf("Error resetting %T: %+v\n", store, err)
   5.437 +		}
   5.438 +		sub1 := Subscription{
   5.439 +			ID:             uuid.NewID(),
   5.440 +			UserID:         uuid.NewID(),
   5.441 +			StripeCustomer: "stripeCustomer1",
   5.442 +			Amount:         200,
   5.443 +			Period:         MonthlyPeriod,
   5.444 +			Created:        time.Now(),
   5.445 +			BeginCharging:  time.Now().Add(time.Hour),
   5.446 +		}
   5.447 +		sub2 := Subscription{
   5.448 +			ID:             uuid.NewID(),
   5.449 +			UserID:         uuid.NewID(),
   5.450 +			StripeCustomer: "stripeCustomer2",
   5.451 +			Amount:         300,
   5.452 +			Period:         MonthlyPeriod,
   5.453 +			Created:        time.Now().Add(time.Hour * -720),
   5.454 +			BeginCharging:  time.Now().Add(time.Hour*-720 + time.Hour*2),
   5.455 +			LastCharged:    time.Now(),
   5.456 +		}
   5.457 +		sub3 := Subscription{
   5.458 +			ID:             uuid.NewID(),
   5.459 +			UserID:         uuid.NewID(),
   5.460 +			StripeCustomer: "stripeCustomer3",
   5.461 +			Amount:         100,
   5.462 +			Period:         MonthlyPeriod,
   5.463 +			Created:        time.Now().Add(time.Hour * -1440),
   5.464 +			BeginCharging:  time.Now().Add(time.Hour * -1440),
   5.465 +			LastNotified:   time.Now().Add(time.Hour * -720),
   5.466 +			InLockout:      true,
   5.467 +		}
   5.468 +		err = store.createSubscription(sub1)
   5.469 +		if err != nil {
   5.470 +			t.Fatalf("Error creating %+v in %T: %+v\n", sub1, store, err)
   5.471 +		}
   5.472 +		err = store.createSubscription(sub2)
   5.473 +		if err != nil {
   5.474 +			t.Fatalf("Error creating %+v in %T: %+v\n", sub1, store, err)
   5.475 +		}
   5.476 +		err = store.createSubscription(sub3)
   5.477 +		if err != nil {
   5.478 +			t.Fatalf("Error creating %+v in %T: %+v\n", sub1, store, err)
   5.479 +		}
   5.480 +		retrieved, err := store.getSubscriptions([]uuid.ID{})
   5.481 +		if err != ErrNoSubscriptionID {
   5.482 +			t.Errorf("Error retrieving no subscriptions from %T. Expected %+v, got %+v\n", store, ErrNoSubscriptionID, err)
   5.483 +		}
   5.484 +		retrieved, err = store.getSubscriptions([]uuid.ID{sub1.ID})
   5.485 +		if err != nil {
   5.486 +			t.Errorf("Error retrieving %s from %T: %+v\n", sub1.ID, store, err)
   5.487 +		}
   5.488 +		ok, missing := subscriptionMapContains(retrieved, sub1)
   5.489 +		if !ok {
   5.490 +			t.Logf("Results: %+v\n", retrieved)
   5.491 +			t.Errorf("Expected %+v to be in the results, was not for %T.\n", missing, store)
   5.492 +		}
   5.493 +		retrieved, err = store.getSubscriptions([]uuid.ID{sub1.ID, sub2.ID})
   5.494 +		if err != nil {
   5.495 +			t.Errorf("Error retrieving %s and %s from %T: %+v\n", sub1.ID, sub2.ID, store, err)
   5.496 +		}
   5.497 +		ok, missing = subscriptionMapContains(retrieved, sub1, sub2)
   5.498 +		if !ok {
   5.499 +			t.Logf("Results: %+v\n", retrieved)
   5.500 +			t.Errorf("Expected %+v to be in the results, was not for %T.\n", missing, store)
   5.501 +		}
   5.502 +		retrieved, err = store.getSubscriptions([]uuid.ID{sub1.ID, sub3.ID})
   5.503 +		if err != nil {
   5.504 +			t.Errorf("Error retrieving %s and %s from %T: %+v\n", sub1.ID, sub3.ID, store, err)
   5.505 +		}
   5.506 +		ok, missing = subscriptionMapContains(retrieved, sub1, sub3)
   5.507 +		if !ok {
   5.508 +			t.Logf("Results: %+v\n", retrieved)
   5.509 +			t.Errorf("Expected %+v to be in the results, was not for %T.\n", missing, store)
   5.510 +		}
   5.511 +		retrieved, err = store.getSubscriptions([]uuid.ID{sub1.ID, sub2.ID, sub3.ID})
   5.512 +		if err != nil {
   5.513 +			t.Errorf("Error retrieving %s, %s, and %s from %T: %+v\n", sub1.ID, sub2.ID, sub3.ID, store, err)
   5.514 +		}
   5.515 +		ok, missing = subscriptionMapContains(retrieved, sub1, sub2, sub3)
   5.516 +		if !ok {
   5.517 +			t.Logf("Results: %+v\n", retrieved)
   5.518 +			t.Errorf("Expected %+v to be in the results, was not for %T.\n", missing, store)
   5.519 +		}
   5.520 +		retrieved, err = store.getSubscriptions([]uuid.ID{sub2.ID})
   5.521 +		if err != nil {
   5.522 +			t.Errorf("Error retrieving %s from %T: %+v\n", sub2.ID, store, err)
   5.523 +		}
   5.524 +		ok, missing = subscriptionMapContains(retrieved, sub2)
   5.525 +		if !ok {
   5.526 +			t.Logf("Results: %+v\n", retrieved)
   5.527 +			t.Errorf("Expected %+v to be in the results, was not for %T.\n", missing, store)
   5.528 +		}
   5.529 +		retrieved, err = store.getSubscriptions([]uuid.ID{sub2.ID, sub3.ID})
   5.530 +		if err != nil {
   5.531 +			t.Errorf("Error retrieving %s and %s from %T: %+v\n", sub2.ID, sub3.ID, store, err)
   5.532 +		}
   5.533 +		ok, missing = subscriptionMapContains(retrieved, sub2, sub3)
   5.534 +		if !ok {
   5.535 +			t.Logf("Results: %+v\n", retrieved)
   5.536 +			t.Errorf("Expected %+v to be in the results, was not for %T.\n", missing, store)
   5.537 +		}
   5.538 +		retrieved, err = store.getSubscriptions([]uuid.ID{sub3.ID})
   5.539 +		if err != nil {
   5.540 +			t.Errorf("Error retrieving %s from %T: %+v\n", sub3.ID, store, err)
   5.541 +		}
   5.542 +		ok, missing = subscriptionMapContains(retrieved, sub3)
   5.543 +		if !ok {
   5.544 +			t.Logf("Results: %+v\n", retrieved)
   5.545 +			t.Errorf("Expected %+v to be in the results, was not for %T.\n", missing, store)
   5.546 +		}
   5.547 +		retrieved, err = store.getSubscriptions([]uuid.ID{uuid.NewID()})
   5.548 +		if err != nil {
   5.549 +			t.Errorf("Error retrieving non-existent ID from %T: %+v\n", store, err)
   5.550 +		}
   5.551 +		if len(retrieved) != 0 {
   5.552 +			t.Errorf("Expected no results, %T returned %+v\n", store, retrieved)
   5.553 +		}
   5.554 +		retrieved, err = store.getSubscriptions([]uuid.ID{sub1.ID, sub2.ID, uuid.NewID(), sub3.ID})
   5.555 +		if err != nil {
   5.556 +			t.Errorf("Error retrieving non-existent ID from %T: %+v\n", store, err)
   5.557 +		}
   5.558 +		if len(retrieved) != 3 {
   5.559 +			t.Errorf("Expected 3 results, %T returned %+v\n", store, retrieved)
   5.560 +		}
   5.561 +		ok, missing = subscriptionMapContains(retrieved, sub1, sub2, sub3)
   5.562 +		if !ok {
   5.563 +			t.Logf("Results: %+v\n", retrieved)
   5.564 +			t.Errorf("Expected %+v to be in the results, was not for %T.\n", missing, store)
   5.565 +		}
   5.566 +	}
   5.567 +}
   5.568 +
   5.569 +func TestGetSubscriptionByUser(t *testing.T) {
   5.570 +	for _, store := range testSubscriptionStores {
   5.571 +		err := store.reset()
   5.572 +		if err != nil {
   5.573 +			t.Fatalf("Error resetting %T: %+v\n", store, err)
   5.574 +		}
   5.575 +		sub1 := Subscription{
   5.576 +			ID:             uuid.NewID(),
   5.577 +			UserID:         uuid.NewID(),
   5.578 +			StripeCustomer: "stripeCustomer1",
   5.579 +		}
   5.580 +		sub2 := Subscription{
   5.581 +			ID:             uuid.NewID(),
   5.582 +			UserID:         uuid.NewID(),
   5.583 +			StripeCustomer: "stripeCustomer2",
   5.584 +		}
   5.585 +		sub3 := Subscription{
   5.586 +			ID:             uuid.NewID(),
   5.587 +			UserID:         uuid.NewID(),
   5.588 +			StripeCustomer: "stripeCustomer3",
   5.589 +		}
   5.590 +		err = store.createSubscription(sub1)
   5.591 +		if err != nil {
   5.592 +			t.Fatalf("Error creating %+v in %T: %+v\n", sub1, store, err)
   5.593 +		}
   5.594 +		err = store.createSubscription(sub2)
   5.595 +		if err != nil {
   5.596 +			t.Fatalf("Error creating %+v in %T: %+v\n", sub1, store, err)
   5.597 +		}
   5.598 +		err = store.createSubscription(sub3)
   5.599 +		if err != nil {
   5.600 +			t.Fatalf("Error creating %+v in %T: %+v\n", sub1, store, err)
   5.601 +		}
   5.602 +		retrieved, err := store.getSubscriptionByUser(sub1.UserID)
   5.603 +		if err != nil {
   5.604 +			t.Errorf("Error retrieving subscription %+v from %T: %+v\n", sub1, store, err)
   5.605 +		}
   5.606 +		ok, field, expected, result := compareSubscriptions(sub1, retrieved)
   5.607 +		if !ok {
   5.608 +			t.Errorf("Expected %s to be %+v, but was %+v in %T\n", field, expected, result, store)
   5.609 +		}
   5.610 +		retrieved, err = store.getSubscriptionByUser(sub2.UserID)
   5.611 +		if err != nil {
   5.612 +			t.Errorf("Error retrieving subscription %+v from %T: %+v\n", sub2, store, err)
   5.613 +		}
   5.614 +		ok, field, expected, result = compareSubscriptions(sub2, retrieved)
   5.615 +		if !ok {
   5.616 +			t.Errorf("Expected %s to be %+v, but was %+v in %T\n", field, expected, result, store)
   5.617 +		}
   5.618 +		retrieved, err = store.getSubscriptionByUser(sub3.UserID)
   5.619 +		if err != nil {
   5.620 +			t.Errorf("Error retrieving subscription %+v from %T: %+v\n", sub3, store, err)
   5.621 +		}
   5.622 +		ok, field, expected, result = compareSubscriptions(sub3, retrieved)
   5.623 +		if !ok {
   5.624 +			t.Errorf("Expected %s to be %+v, but was %+v in %T\n", field, expected, result, store)
   5.625 +		}
   5.626 +		retrieved, err = store.getSubscriptionByUser(uuid.NewID())
   5.627 +		if err != ErrSubscriptionNotFound {
   5.628 +			t.Errorf("Expected err to be %+v, got %+v from %T\n", ErrSubscriptionNotFound, err, store)
   5.629 +		}
   5.630 +	}
   5.631 +}