Log Postgres test failures more verbosely, fix SubscriptionChange.IsEmpty.
SubscriptionChange.IsEmpty() would return false even if no actual database
operations are going to be performed. This is because we allow information we
_don't_ store in the database (Stripe source, Stripe email) to be specified in a
SubscriptionChange object, just so we can easily access them. Then we use the
Stripe API to store them in Stripe's databases, and turn them into data _we_
store in our database. Think of them as pre-processed values that are never
stored raw.
The problem is, we were treating these properties the same as the properties we
actually stored in the database, and (worse) were running database tests for
combinations of these properties, which was causing test failures because we
were trying to update no columns in the database. Whoops.
I removed these properties from the IsEmpty helper, and removed them from the
code that generates the SubscriptionChange permutations for testing. This allows
tests to pass, but also stays closer to what the system was designed to do.
In tracking down this bug, I discovered that the logging we had for errors when
running Postgres tests was inadequate, so I updated the logs when that failure
occurs while testing Postgres to help surface future failures faster.
6 "code.secondbit.org/uuid.hg"
9 "github.com/secondbit/pan"
12 // GetSQLTableName fulfills the pan.SQLTableNamer interface, allowing
13 // us to manipulate Subscriptions with pan.
14 func (s Subscription) GetSQLTableName() string {
15 return "subscriptions"
18 func (p Postgres) resetSQL() *pan.Query {
19 var subscription Subscription
20 query := pan.New(pan.POSTGRES, "TRUNCATE "+pan.GetTableName(subscription))
21 return query.FlushExpressions(" ")
24 // Reset returns the database to its initialised state. It is meant to
25 // be used during testing to reset the databases to a known state between
26 // tests. It should never be used in production.
27 func (p Postgres) Reset() error {
29 _, err := p.Exec(query.String(), query.Args...)
36 func (p Postgres) createSubscriptionSQL(subscription Subscription) *pan.Query {
37 fields, values := pan.GetFields(subscription)
38 query := pan.New(pan.POSTGRES, "INSERT INTO "+pan.GetTableName(subscription))
39 query.Include("(" + pan.QueryList(fields) + ")")
40 query.Include("VALUES")
41 query.Include("("+pan.VariableList(len(values))+")", values...)
42 return query.FlushExpressions(" ")
45 // CreateSubscription stores the passed Subscription in the database. If
46 // a Subscription sharing the same UserID already exists, an
47 // ErrSubscriptionAlreadyExists error will be returned. If a Subscription
48 // sharing the same StripeSubscription already exists, an
49 // ErrStripeSubscriptionAlreadyExists error will be returned.
50 func (p Postgres) CreateSubscription(sub Subscription) error {
51 query := p.createSubscriptionSQL(sub)
52 _, err := p.Exec(query.String(), query.Args...)
53 if e, ok := err.(*pq.Error); ok && e.Constraint == "subscriptions_pkey" {
54 err = ErrSubscriptionAlreadyExists
55 } else if e, ok := err.(*pq.Error); ok && e.Constraint == "subscriptions_stripe_subscription_key" {
56 err = ErrStripeSubscriptionAlreadyExists
61 func (p Postgres) updateSubscriptionSQL(id uuid.ID, change SubscriptionChange) *pan.Query {
62 var subscription Subscription
63 query := pan.New(pan.POSTGRES, "UPDATE "+pan.GetTableName(subscription)+" SET")
64 query.IncludeIfNotNil(pan.GetUnquotedColumn(subscription, "StripeSubscription")+" = ?", change.StripeSubscription)
65 query.IncludeIfNotNil(pan.GetUnquotedColumn(subscription, "Plan")+" = ?", change.Plan)
66 query.IncludeIfNotNil(pan.GetUnquotedColumn(subscription, "Status")+" = ?", change.Status)
67 query.IncludeIfNotNil(pan.GetUnquotedColumn(subscription, "Canceling")+" = ?", change.Canceling)
68 query.IncludeIfNotNil(pan.GetUnquotedColumn(subscription, "TrialStart")+" = ?", change.TrialStart)
69 query.IncludeIfNotNil(pan.GetUnquotedColumn(subscription, "TrialEnd")+" = ?", change.TrialEnd)
70 query.IncludeIfNotNil(pan.GetUnquotedColumn(subscription, "PeriodStart")+" = ?", change.PeriodStart)
71 query.IncludeIfNotNil(pan.GetUnquotedColumn(subscription, "PeriodEnd")+" = ?", change.PeriodEnd)
72 query.IncludeIfNotNil(pan.GetUnquotedColumn(subscription, "CanceledAt")+" = ?", change.CanceledAt)
73 query.IncludeIfNotNil(pan.GetUnquotedColumn(subscription, "FailedChargeAttempts")+" = ?", change.FailedChargeAttempts)
74 query.IncludeIfNotNil(pan.GetUnquotedColumn(subscription, "LastFailedCharge")+" = ?", change.LastFailedCharge)
75 query.IncludeIfNotNil(pan.GetUnquotedColumn(subscription, "LastNotified")+" = ?", change.LastNotified)
76 query.FlushExpressions(", ")
78 query.Include(pan.GetUnquotedColumn(subscription, "UserID")+" = ?", id)
79 return query.FlushExpressions(" ")
82 // UpdateSubscription applies the SubscriptionChange passed to the Subscription
83 // stored in the database associated with the passed ID. If change is empty,
84 // an ErrSubscriptionChangeEmpty error is returned. If no Subscription is found
85 // in the database with the passed ID, an ErrSubscriptionNotFound error is returned.
86 // If change is updating the StripeSubscription and a Subscription in the database
87 // already has that value set for StripeSubscription, an
88 // ErrStripeSubscriptionAlreadyExists error is returned.
89 func (p Postgres) UpdateSubscription(id uuid.ID, change SubscriptionChange) error {
91 return ErrSubscriptionChangeEmpty
94 query := p.updateSubscriptionSQL(id, change)
95 res, err := p.Exec(query.String(), query.Args...)
96 if e, ok := err.(*pq.Error); ok && e.Constraint == "subscriptions_stripe_subscription_key" {
97 return ErrStripeSubscriptionAlreadyExists
98 } else if err != nil {
101 rows, err := res.RowsAffected()
106 return ErrSubscriptionNotFound
111 func (p Postgres) deleteSubscriptionSQL(id uuid.ID) *pan.Query {
112 var subscription Subscription
113 query := pan.New(pan.POSTGRES, "DELETE FROM "+pan.GetTableName(subscription))
115 query.Include(pan.GetUnquotedColumn(subscription, "UserID")+" = ?", id)
116 return query.FlushExpressions(" ")
119 // DeleteSubscription removes the Subscription stored in the database associated
120 // with the passed ID from the database. If no Subscription is found
121 // in the database with the passed ID, an ErrSubscriptionNotFound error is returned.
122 func (p Postgres) DeleteSubscription(id uuid.ID) error {
123 query := p.deleteSubscriptionSQL(id)
124 res, err := p.Exec(query.String(), query.Args...)
128 rows, err := res.RowsAffected()
133 return ErrSubscriptionNotFound
138 func (p Postgres) getSubscriptionsSQL(ids []uuid.ID) *pan.Query {
139 var subscription Subscription
140 fields, _ := pan.GetFields(subscription)
141 intIDs := make([]interface{}, len(ids))
142 for pos, id := range ids {
145 query := pan.New(pan.POSTGRES, "SELECT "+pan.QueryList(fields)+" FROM "+pan.GetTableName(subscription))
147 query.Include(pan.GetUnquotedColumn(subscription, "UserID") + " IN")
148 query.Include("("+pan.VariableList(len(intIDs))+")", intIDs...)
149 return query.FlushExpressions(" ")
152 // GetSubscriptions retrieves the Subscriptions stored in the database associated
153 // with the passed IDs. If no IDs are passed, an ErrNoSubscriptionID error is
154 // returned. No matter how many of the IDs are found (including none), a map is
155 // returned, with the key being a String()ed version of the ID for the Subscription in
156 // the value. If no error is returned, the map will represent all of the Subscriptions
157 // matching the passed IDs that exist in the database, even if it's empty.
158 func (p Postgres) GetSubscriptions(ids []uuid.ID) (map[string]Subscription, error) {
159 results := map[string]Subscription{}
161 return results, ErrNoSubscriptionID
163 query := p.getSubscriptionsSQL(ids)
164 rows, err := p.Query(query.String(), query.Args...)
169 var subscription Subscription
170 err := pan.Unmarshal(rows, &subscription)
174 results[subscription.UserID.String()] = subscription
176 if err := rows.Err(); err != nil {
182 func (p Postgres) getSubscriptionStatsCountSQL() *pan.Query {
183 var subscription Subscription
184 query := pan.New(pan.POSTGRES, "SELECT COUNT(*) FROM")
185 query.Include(pan.GetTableName(subscription))
186 return query.FlushExpressions(" ")
189 func (p Postgres) getSubscriptionStatsCancelingSQL() *pan.Query {
190 var subscription Subscription
191 query := pan.New(pan.POSTGRES, "SELECT COUNT(*) FROM")
192 query.Include(pan.GetTableName(subscription))
194 query.Include(pan.GetUnquotedColumn(subscription, "Canceling")+" = ?", true)
195 return query.FlushExpressions(" ")
198 func (p Postgres) getSubscriptionStatsFailingSQL() *pan.Query {
199 var subscription Subscription
200 query := pan.New(pan.POSTGRES, "SELECT COUNT(*) FROM")
201 query.Include(pan.GetTableName(subscription))
203 statuses := []interface{}{"past_due", "unpaid"}
204 query.Include(pan.GetUnquotedColumn(subscription, "Status")+" IN ("+pan.VariableList(len(statuses))+")", statuses...)
205 return query.FlushExpressions(" ")
208 func (p Postgres) getSubscriptionStatsPlansSQL() *pan.Query {
209 var subscription Subscription
210 fields := []interface{}{pan.GetUnquotedColumn(subscription, "Plan"), "COUNT(*)"}
211 query := pan.New(pan.POSTGRES, "SELECT "+pan.QueryList(fields)+" FROM")
212 query.Include(pan.GetTableName(subscription))
213 query.Include("GROUP BY " + pan.GetUnquotedColumn(subscription, "Plan"))
214 return query.FlushExpressions(" ")
217 // GetSubscriptionStats returns statistics about the subscription data stored in the
218 // database as a SubscriptionStats variable. The number of Subscriptions, the
219 // breakdown of how many Subscriptions belong to each plan, the number of
220 // Subscriptions that are canceling, and the number of Subscriptions whose payment
221 // information is failing are all tracked.
222 func (p Postgres) GetSubscriptionStats() (SubscriptionStats, error) {
223 stats := SubscriptionStats{
224 Plans: map[string]int64{},
226 query := p.getSubscriptionStatsCountSQL()
227 err := p.QueryRow(query.String(), query.Args...).Scan(&stats.Number)
229 log.Printf("Error querying for total subscriptions: %+v\n", err)
232 query = p.getSubscriptionStatsCancelingSQL()
233 err = p.QueryRow(query.String(), query.Args...).Scan(&stats.Canceling)
235 log.Printf("Error querying for canceling subscriptions: %+v\n", err)
238 query = p.getSubscriptionStatsFailingSQL()
239 err = p.QueryRow(query.String(), query.Args...).Scan(&stats.Failing)
241 log.Printf("Error querying for failing subscriptions: %+v\n", err)
244 query = p.getSubscriptionStatsPlansSQL()
245 rows, err := p.Query(query.String(), query.Args...)
247 log.Printf("Error querying for plans: %+v\n", err)
253 err := rows.Scan(&plan, &count)
255 log.Printf("Error scanning database row for plans: %+v\n", err)
258 stats.Plans[plan] = count
260 if err := rows.Err(); err != nil {
261 log.Printf("Error querying for plans: %+v\n", err)