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/api.hg"
7 "code.secondbit.org/auth.hg"
8 "code.secondbit.org/trout.hg"
9 "code.secondbit.org/uuid.hg"
11 "code.secondbit.org/ducky/subscriptions.hg"
13 "golang.org/x/net/context"
17 ScopeSubscription = auth.Scope{ID: "subscriptions", Name: "Manage Subscriptions", Description: "Read and update your subscription information."}
18 ScopeSubscriptionAdmin = auth.Scope{ID: "subscriptions_admin", Name: "Administer Subscriptions", Description: "Read and update subscription information, bypassing ACL."}
21 // changingSystemProperties takes a SubscriptionChange and returns a
22 // slice of JSON pointers to the properties that are changing but are
23 // also designated as "system properties" in the API. If no properties
24 // meet these criteria, an empty slice is returned.
25 func changingSystemProperties(change subscriptions.SubscriptionChange) []string {
27 if change.StripeSubscription != nil {
28 changes = append(changes, "/stripe_subscription")
30 if change.Status != nil {
31 changes = append(changes, "/status")
33 if change.TrialStart != nil {
34 changes = append(changes, "/trial_start")
36 if change.TrialEnd != nil {
37 changes = append(changes, "/trial_end")
39 if change.PeriodStart != nil {
40 changes = append(changes, "/period_start")
42 if change.PeriodEnd != nil {
43 changes = append(changes, "/period_end")
45 if change.CanceledAt != nil {
46 changes = append(changes, "/canceled_at")
48 if change.FailedChargeAttempts != nil {
49 changes = append(changes, "/failed_charge_attempts")
51 if change.LastFailedCharge != nil {
52 changes = append(changes, "/last_failed_charge")
54 if change.LastNotified != nil {
55 changes = append(changes, "/last_notified")
60 func HandleSubscriptions(router *trout.Router, c context.Context) {
61 router.Endpoint("/subscriptions").Methods("POST", "OPTIONS").Handler(
62 api.CORSMiddleware(api.NegotiateMiddleware(api.ContextWrapper(c, CreateSubscriptionHandler))))
63 router.Endpoint("/subscriptions/{id}").Methods("GET", "OPTIONS").Handler(
64 api.CORSMiddleware(api.NegotiateMiddleware(api.ContextWrapper(c, GetSubscriptionHandler))))
65 router.Endpoint("/subscriptions/{id}").Methods("PATCH", "OPTIONS").Handler(
66 api.CORSMiddleware(api.NegotiateMiddleware(api.ContextWrapper(c, PatchSubscriptionHandler))))
69 func CreateSubscriptionHandler(w http.ResponseWriter, r *http.Request, c context.Context) {
70 store, err := getSubscriptionStore(c)
72 api.Encode(w, r, http.StatusInternalServerError, Response{Errors: api.ActOfGodError})
75 stripe, err := getStripeClient(c)
77 api.Encode(w, r, http.StatusInternalServerError, Response{Errors: api.ActOfGodError})
80 if !api.CheckScopes(r, ScopeSubscriptionAdmin.ID) {
81 api.Encode(w, r, http.StatusUnauthorized, Response{Errors: []api.RequestError{{Slug: api.RequestErrAccessDenied}}})
84 var req subscriptions.SubscriptionChange
85 err = api.Decode(r, &req)
87 api.Encode(w, r, http.StatusBadRequest, Response{Errors: api.InvalidFormatError})
90 // BUG(paddy): Need to validate the request when creating a subscription
91 sub, err := subscriptions.New(req, stripe, store)
93 var rErr api.RequestError
96 case subscriptions.ErrSubscriptionAlreadyExists:
97 rErr = api.RequestError{Slug: api.RequestErrConflict, Field: "/user_id"}
98 code = http.StatusBadRequest
99 case subscriptions.ErrStripeSubscriptionAlreadyExists:
100 rErr = api.RequestError{Slug: api.RequestErrConflict, Field: "/stripe_token"}
101 code = http.StatusBadRequest
103 rErr = api.RequestError{Slug: api.RequestErrActOfGod}
104 code = http.StatusInternalServerError
106 api.Encode(w, r, code, Response{Errors: []api.RequestError{rErr}})
109 resp := Response{Subscriptions: []subscriptions.Subscription{sub}}
110 api.Encode(w, r, http.StatusCreated, resp)
113 func GetSubscriptionHandler(w http.ResponseWriter, r *http.Request, c context.Context) {
114 store, err := getSubscriptionStore(c)
116 api.Encode(w, r, http.StatusInternalServerError, Response{Errors: api.ActOfGodError})
119 vars := trout.RequestVars(r)
120 rawID := vars.Get("id")
122 api.Encode(w, r, http.StatusBadRequest, Response{Errors: []api.RequestError{{Slug: api.RequestErrMissing, Param: "id"}}})
125 id, err := uuid.Parse(rawID)
127 api.Encode(w, r, http.StatusBadRequest, Response{Errors: []api.RequestError{{Slug: api.RequestErrInvalidFormat, Param: "id"}}})
130 if !api.CheckScopes(r, ScopeSubscription.ID) {
131 api.Encode(w, r, http.StatusUnauthorized, Response{Errors: []api.RequestError{{Slug: api.RequestErrAccessDenied}}})
134 userID, err := api.AuthUser(r)
136 api.Encode(w, r, http.StatusUnauthorized, Response{Errors: []api.RequestError{{Slug: api.RequestErrAccessDenied}}})
139 if !id.Equal(userID) && !api.CheckScopes(r, ScopeSubscriptionAdmin.ID) {
140 api.Encode(w, r, http.StatusUnauthorized, Response{Errors: []api.RequestError{{Slug: api.RequestErrAccessDenied}}})
143 subs, err := store.GetSubscriptions([]uuid.ID{id})
145 api.Encode(w, r, http.StatusInternalServerError, Response{Errors: api.ActOfGodError})
148 sub, ok := subs[id.String()]
150 api.Encode(w, r, http.StatusNotFound, Response{Errors: []api.RequestError{{Slug: api.RequestErrNotFound, Param: "id"}}})
153 resp := Response{Subscriptions: []subscriptions.Subscription{sub}}
154 api.Encode(w, r, http.StatusOK, resp)
157 func PatchSubscriptionHandler(w http.ResponseWriter, r *http.Request, c context.Context) {
158 store, err := getSubscriptionStore(c)
160 api.Encode(w, r, http.StatusInternalServerError, Response{Errors: api.ActOfGodError})
163 stripe, err := getStripeClient(c)
165 api.Encode(w, r, http.StatusInternalServerError, Response{Errors: api.ActOfGodError})
168 if !api.CheckScopes(r, ScopeSubscription.ID) {
169 api.Encode(w, r, http.StatusUnauthorized, Response{Errors: []api.RequestError{{Slug: api.RequestErrAccessDenied}}})
172 userID, err := api.AuthUser(r)
174 api.Encode(w, r, http.StatusUnauthorized, Response{Errors: []api.RequestError{{Slug: api.RequestErrAccessDenied}}})
177 vars := trout.RequestVars(r)
178 rawID := vars.Get("id")
180 api.Encode(w, r, http.StatusBadRequest, Response{Errors: []api.RequestError{{Slug: api.RequestErrMissing, Param: "id"}}})
183 id, err := uuid.Parse(rawID)
185 api.Encode(w, r, http.StatusBadRequest, Response{Errors: []api.RequestError{{Slug: api.RequestErrInvalidFormat, Param: "id"}}})
189 var req subscriptions.SubscriptionChange
190 err = api.Decode(r, &req)
192 api.Encode(w, r, http.StatusBadRequest, Response{Errors: api.InvalidFormatError})
195 // BUG(paddy): Need to validate the request when updating a subscription
197 // only admin users can update the system-controlled properties
198 changedSysProps := changingSystemProperties(req)
199 if len(changedSysProps) > 0 && !api.CheckScopes(r, ScopeSubscriptionAdmin.ID) {
200 errs := make([]api.RequestError, len(changedSysProps))
201 for pos, prop := range changedSysProps {
202 errs[pos] = api.RequestError{Slug: api.RequestErrAccessDenied, Field: prop}
204 api.Encode(w, r, http.StatusBadRequest, Response{Errors: errs})
208 subs, err := store.GetSubscriptions([]uuid.ID{userID})
210 api.Encode(w, r, http.StatusInternalServerError, Response{Errors: api.ActOfGodError})
213 sub, ok := subs[userID.String()]
215 api.Encode(w, r, http.StatusNotFound, Response{Errors: []api.RequestError{{Slug: api.RequestErrNotFound, Param: "id"}}})
218 stripeSub, err := subscriptions.UpdateStripeSubscription(sub.StripeSubscription, req.Plan, req.StripeSource, stripe)
220 api.Encode(w, r, http.StatusInternalServerError, Response{Errors: api.ActOfGodError})
223 change := subscriptions.StripeSubscriptionChange(sub, *stripeSub)
224 if change.IsEmpty() {
225 resp := Response{Subscriptions: []subscriptions.Subscription{sub}}
226 api.Encode(w, r, http.StatusOK, resp)
229 err = store.UpdateSubscription(id, change)
231 api.Encode(w, r, http.StatusInternalServerError, Response{Errors: api.ActOfGodError})
234 sub.ApplyChange(change)
235 resp := Response{Subscriptions: []subscriptions.Subscription{sub}}
236 api.Encode(w, r, http.StatusOK, resp)