Skip to content

Commit 37642a6

Browse files
authored
Merge pull request #71 from cyverse/fix-unforced-subscription-change
Fix the unforced subscription change path
2 parents ffeacf8 + f530fce commit 37642a6

3 files changed

Lines changed: 404 additions & 156 deletions

File tree

internal/apitest/divergence_test.go

Lines changed: 297 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"net/http"
66
"strings"
77
"testing"
8+
"time"
89
)
910

1011
// The behaviors pinned here are the ones the subscriptions service implements
@@ -97,75 +98,321 @@ func TestUsageWritesAnUpdatesRow(t *testing.T) {
9798
}
9899
}
99100

100-
// KNOWN BUG, recorded deliberately: POST /v1/subscriptions?force=false fails
101-
// for every request, and reports success while doing it.
102-
//
103-
// The unforced path is supposed to compare the plans' default cpu.hours
104-
// allocations and re-subscribe only on an upgrade. It never gets that far,
105-
// because GetActiveSubscriptionForDate builds
106-
//
107-
// .Or("? > subscriptions.effective_start_date AND ...")
108-
//
109-
// with no argument for the placeholder, so PostgreSQL rejects the statement
110-
// with `syntax error at or near ">"`. The handler turns that into a per-item
111-
// failure_reason and still answers 200, so terrain's
112-
// POST /terrain/admin/qms/subscriptions silently creates nothing whenever
113-
// force isn't set.
114-
//
115-
// This test pins the broken behavior only so the merge can't quietly alter it.
116-
// When the placeholder is fixed, replace it with the upgrade/refuse matrix the
117-
// rule is meant to implement — the plan allocations it depends on are already
118-
// correct (Basic 200, Pro 20000), and model.TestGetDefaultQuotaValue covers the
119-
// selector that reads them.
120-
func TestUnforcedSubscriptionChangeIsBroken(t *testing.T) {
121-
resetDB(t)
122-
123-
forced := fmt.Sprintf(
124-
`{"subscriptions": [{"username": %q, "plan_name": "Basic", "paid": true, "periods": 1}]}`,
125-
testUser,
126-
)
127-
if got := do(t, http.MethodPost, "/v1/subscriptions?force=true", forced); got.status != http.StatusOK {
128-
t.Fatalf("unable to establish the starting subscription: %s", got.body)
129-
}
130-
101+
// subscribe posts a single subscription request and returns the one result.
102+
func subscribe(t *testing.T, username, planName string, force bool) map[string]any {
103+
t.Helper()
131104
body := fmt.Sprintf(
132-
`{"subscriptions": [{"username": %q, "plan_name": "Pro", "paid": true, "periods": 1}]}`,
133-
testUser,
105+
`{"subscriptions": [{"username": %q, "plan_name": %q, "paid": true, "periods": 1}]}`,
106+
username, planName,
134107
)
135-
got := do(t, http.MethodPost, "/v1/subscriptions?force=false", body)
108+
got := do(t, http.MethodPost, fmt.Sprintf("/v1/subscriptions?force=%t", force), body)
136109
if got.status != http.StatusOK {
137-
t.Fatalf("status = %d, body %s", got.status, got.body)
110+
t.Fatalf("subscribing %s to %s failed: status %d, body %s", username, planName, got.status, got.body)
138111
}
139112

140-
decoded := mustDecode(t, got)
141-
results, ok := decoded["result"].([]any)
113+
results, ok := mustDecode(t, got)["result"].([]any)
142114
if !ok || len(results) != 1 {
143115
t.Fatalf("expected exactly one subscription result, got: %s", got.body)
144116
}
145117
result := results[0].(map[string]any)
146118

147-
reason, _ := result["failure_reason"].(string)
148-
if !strings.Contains(reason, "syntax error") {
149-
t.Errorf("failure_reason = %q, want the SQL syntax error this path currently produces", reason)
119+
if reason, failed := result["failure_reason"].(string); failed {
120+
t.Fatalf("subscribing %s to %s reported a failure: %s", username, planName, reason)
150121
}
122+
return result
123+
}
124+
125+
// activeSubscriptions matches the subscriptions in effect right now. It has to
126+
// agree with the service's own activeAsOf predicate, open-ended subscriptions
127+
// included, or these tests would silently stop seeing the rows they check.
128+
const activeSubscriptions = `
129+
FROM subscriptions s
130+
JOIN users ON s.user_id = users.id
131+
JOIN plans p ON p.id = s.plan_id
132+
WHERE users.username = $1
133+
AND s.effective_start_date <= CURRENT_TIMESTAMP
134+
AND (s.effective_end_date IS NULL OR s.effective_end_date >= CURRENT_TIMESTAMP)`
135+
136+
// subscribeForPeriod force-subscribes a user for an explicit period, which is
137+
// how a subscription that starts in the future gets created.
138+
func subscribeForPeriod(t *testing.T, username, planName string, start, end time.Time) {
139+
t.Helper()
140+
body := fmt.Sprintf(
141+
`{"subscriptions": [{"username": %q, "plan_name": %q, "paid": true, "start_date": %q, "end_date": %q}]}`,
142+
username, planName, start.Format(time.RFC3339), end.Format(time.RFC3339),
143+
)
144+
got := do(t, http.MethodPost, "/v1/subscriptions?force=true", body)
145+
if got.status != http.StatusOK {
146+
t.Fatalf("scheduling %s for %s failed: status %d, body %s", planName, username, got.status, got.body)
147+
}
148+
}
149+
150+
// activePlanFor returns the name of the plan the user is subscribed to now.
151+
// Every active plan is aggregated rather than one being picked arbitrarily, so
152+
// a test expecting a single plan fails loudly — naming both plans — if the
153+
// service ever leaves two subscriptions running at once.
154+
func activePlanFor(t *testing.T, username string) string {
155+
t.Helper()
156+
return queryString(t, `
157+
SELECT coalesce(string_agg(p.name, ',' ORDER BY s.effective_start_date DESC), '')`+
158+
activeSubscriptions, username)
159+
}
160+
161+
// insertOpenEndedSubscription creates a subscription with no effective end
162+
// date, the shape legacy QMS rows have. It's written directly because the API
163+
// always supplies an end date and can no longer produce one.
164+
func insertOpenEndedSubscription(t *testing.T, username, planName string) {
165+
t.Helper()
166+
167+
_, err := testDB.Exec(
168+
`INSERT INTO users (username) VALUES ($1) ON CONFLICT (username) DO NOTHING`, username,
169+
)
170+
if err != nil {
171+
t.Fatalf("unable to create the user: %s", err)
172+
}
173+
174+
result, err := testDB.Exec(`
175+
INSERT INTO subscriptions (user_id, plan_id, plan_rate_id, effective_start_date, effective_end_date)
176+
SELECT u.id, p.id, r.id, CURRENT_TIMESTAMP - interval '1 day', NULL
177+
FROM users u
178+
JOIN plans p ON p.name = $2
179+
JOIN plan_rates r ON r.plan_id = p.id
180+
WHERE u.username = $1
181+
ORDER BY r.effective_date DESC
182+
LIMIT 1`, username, planName)
183+
if err != nil {
184+
t.Fatalf("unable to insert an open-ended subscription: %s", err)
185+
}
186+
if rows, err := result.RowsAffected(); err != nil || rows != 1 {
187+
t.Fatalf("open-ended subscription insert affected %d rows, want 1 (err: %v)", rows, err)
188+
}
189+
}
190+
191+
// Without force, a subscription request only takes effect if it raises the
192+
// user's cpu.hours allocation, so an admin re-running a bulk subscription
193+
// can't silently downgrade anyone. The subscriptions service compares plan
194+
// *names* instead and would downgrade in either direction, so this is the
195+
// semantic the merge has to keep.
196+
func TestUnforcedSubscriptionChangeOnlyUpgrades(t *testing.T) {
197+
testCases := []struct {
198+
name string
199+
startingPlan string
200+
requestedPlan string
201+
wantNew bool
202+
wantEndingPlan string
203+
}{
204+
{
205+
name: "a larger plan is an upgrade",
206+
startingPlan: "Basic",
207+
requestedPlan: "Pro",
208+
wantNew: true,
209+
wantEndingPlan: "Pro",
210+
},
211+
{
212+
name: "a smaller plan is refused",
213+
startingPlan: "Pro",
214+
requestedPlan: "Basic",
215+
wantNew: false,
216+
wantEndingPlan: "Pro",
217+
},
218+
{
219+
name: "the same plan is refused",
220+
startingPlan: "Pro",
221+
requestedPlan: "Pro",
222+
wantNew: false,
223+
wantEndingPlan: "Pro",
224+
},
225+
}
226+
227+
for _, tc := range testCases {
228+
t.Run(tc.name, func(t *testing.T) {
229+
resetDB(t)
230+
231+
// Establish the starting subscription with force, so only the
232+
// request under test exercises the comparison rule.
233+
subscribe(t, testUser, tc.startingPlan, true)
234+
235+
result := subscribe(t, testUser, tc.requestedPlan, false)
236+
if newSubscription, _ := result["new_subscription"].(bool); newSubscription != tc.wantNew {
237+
t.Errorf("new_subscription = %v, want %v", newSubscription, tc.wantNew)
238+
}
239+
240+
// Whatever the response says, the active subscription in the
241+
// database is what the rest of the DE reads.
242+
if active := activePlanFor(t, testUser); active != tc.wantEndingPlan {
243+
t.Errorf("active plan = %q, want %q", active, tc.wantEndingPlan)
244+
}
245+
})
246+
}
247+
}
248+
249+
// A user QMS has never subscribed has no allocation to compare against, so an
250+
// unforced request has to create the subscription rather than treat the
251+
// missing one as a reason to refuse.
252+
func TestUnforcedSubscriptionForNewUser(t *testing.T) {
253+
resetDB(t)
254+
255+
result := subscribe(t, testUser, "Pro", false)
256+
if newSubscription, _ := result["new_subscription"].(bool); !newSubscription {
257+
t.Error("new_subscription = false, want true for a user with no prior subscription")
258+
}
259+
if active := activePlanFor(t, testUser); active != "Pro" {
260+
t.Errorf("active plan = %q, want \"Pro\"", active)
261+
}
262+
}
263+
264+
// A subscription with no effective end date runs indefinitely, so it is still
265+
// active and an unforced request for a lesser plan has to be refused. Nothing
266+
// else covers the null-end-date branch of the active-subscription lookup: the
267+
// API always writes an end date, so a merge could drop that branch and every
268+
// other test would stay green while legacy subscriptions stopped counting.
269+
func TestUnforcedChangeSeesAnOpenEndedSubscription(t *testing.T) {
270+
resetDB(t)
271+
insertOpenEndedSubscription(t, testUser, "Pro")
272+
273+
result := subscribe(t, testUser, "Basic", false)
151274
if newSubscription, _ := result["new_subscription"].(bool); newSubscription {
152-
t.Error("new_subscription = true, want false: the request failed")
275+
t.Error("new_subscription = true, want false: the open-ended subscription is still active")
153276
}
277+
if active := activePlanFor(t, testUser); active != "Pro" {
278+
t.Errorf("active plan = %q, want \"Pro\"", active)
279+
}
280+
}
281+
282+
// Upgrading over an open-ended subscription has to close it. The deactivation
283+
// queries compare against the effective end date, and every such comparison is
284+
// false for null, so an open-ended subscription survives unless it's handled
285+
// explicitly — leaving the user on two plans at once.
286+
func TestUnforcedUpgradeClosesAnOpenEndedSubscription(t *testing.T) {
287+
resetDB(t)
288+
insertOpenEndedSubscription(t, testUser, "Basic")
154289

155-
// The starting subscription is untouched, so the upgrade never happened.
156-
activePlan := queryString(t, `
157-
SELECT p.name
290+
subscribe(t, testUser, "Pro", false)
291+
292+
if active := activePlanFor(t, testUser); active != "Pro" {
293+
t.Errorf("active plan = %q, want \"Pro\" (both plans listed means neither was deactivated)", active)
294+
}
295+
}
296+
297+
// An unforced request covers a whole period, not just its first instant, so a
298+
// better subscription scheduled to start later in that period still counts as
299+
// the plan to beat. Comparing only against the subscription in effect on the
300+
// start date would find nothing and let the request cancel the scheduled one.
301+
func TestUnforcedChangeDoesNotCancelALaterSubscription(t *testing.T) {
302+
resetDB(t)
303+
304+
// A paid upgrade scheduled to start months from now.
305+
start := time.Now().AddDate(0, 5, 0)
306+
end := start.AddDate(0, 3, 0)
307+
subscribeForPeriod(t, testUser, "Pro", start, end)
308+
309+
result := subscribe(t, testUser, "Basic", false)
310+
if newSubscription, _ := result["new_subscription"].(bool); newSubscription {
311+
t.Error("new_subscription = true, want false: a better subscription is already scheduled")
312+
}
313+
314+
// A cancelled subscription is collapsed to zero length rather than deleted.
315+
scheduled := queryInt(t, `
316+
SELECT count(*)
158317
FROM subscriptions s
159318
JOIN users ON s.user_id = users.id
160-
JOIN plans p ON s.plan_id = p.id
319+
JOIN plans p ON p.id = s.plan_id
161320
WHERE users.username = $1
162-
AND CURRENT_TIMESTAMP BETWEEN s.effective_start_date AND s.effective_end_date`,
163-
testUser)
164-
if activePlan != "Basic" {
165-
t.Errorf("active plan = %q, want \"Basic\"", activePlan)
321+
AND p.name = 'Pro'
322+
AND s.effective_end_date > s.effective_start_date`, testUser)
323+
if scheduled != 1 {
324+
t.Errorf("intact scheduled Pro subscriptions = %d, want 1", scheduled)
325+
}
326+
}
327+
328+
// A request that can't be satisfied is reported per item, in a 200 response,
329+
// rather than as an HTTP error for the whole batch. Terrain's admin route
330+
// parses each result out of the body, so the status and the failure_reason
331+
// field are both part of the contract.
332+
func TestBulkSubscriptionFailuresAreReportedPerItem(t *testing.T) {
333+
resetDB(t)
334+
335+
body := fmt.Sprintf(
336+
`{"subscriptions": [{"username": %q, "plan_name": "No Such Plan", "paid": true, "periods": 1}]}`,
337+
testUser,
338+
)
339+
got := do(t, http.MethodPost, "/v1/subscriptions?force=true", body)
340+
if got.status != http.StatusOK {
341+
t.Fatalf("status = %d, want 200 even though the item failed: %s", got.status, got.body)
342+
}
343+
344+
results, ok := mustDecode(t, got)["result"].([]any)
345+
if !ok || len(results) != 1 {
346+
t.Fatalf("expected exactly one subscription result, got: %s", got.body)
347+
}
348+
reason, failed := results[0].(map[string]any)["failure_reason"].(string)
349+
if !failed {
350+
t.Fatalf("failure_reason is missing from the failed result: %s", got.body)
351+
}
352+
if !strings.Contains(reason, "No Such Plan") {
353+
t.Errorf("failure_reason = %q, want it to name the plan that doesn't exist", reason)
166354
}
167355
}
168356

357+
// The reads that answer "what is this user on right now" have to filter by the
358+
// active-subscription predicate, not just take the most recently starting row.
359+
// A subscription scheduled to start later sorts first by effective start date,
360+
// so dropping that filter would surface the future plan — and every other test
361+
// here would stay green, because they never give a user a subscription that
362+
// hasn't started.
363+
func TestCurrentPlanIgnoresASubscriptionThatHasNotStarted(t *testing.T) {
364+
resetDB(t)
365+
366+
// The bulk endpoint stores the username verbatim while the user-scoped
367+
// routes trim the suffix, so this user is named without one to keep both
368+
// halves of the test pointed at the same row.
369+
const username = "testuser"
370+
371+
subscribe(t, username, "Basic", true)
372+
start := time.Now().AddDate(0, 5, 0)
373+
subscribeForPeriod(t, username, "Pro", start, start.AddDate(0, 3, 0))
374+
375+
// GET /v1/users/{username}/plan backs terrain's /terrain/qms/user/plan.
376+
t.Run("the user's current subscription", func(t *testing.T) {
377+
got := do(t, http.MethodGet, "/v1/users/"+username+"/plan", "")
378+
if got.status != http.StatusOK {
379+
t.Fatalf("status = %d, body %s", got.status, got.body)
380+
}
381+
382+
result, ok := mustDecode(t, got)["result"].(map[string]any)
383+
if !ok {
384+
t.Fatalf("unexpected response shape: %s", got.body)
385+
}
386+
plan, _ := result["plan"].(map[string]any)
387+
if name, _ := plan["name"].(string); name != "Basic" {
388+
t.Errorf("current plan = %q, want \"Basic\": the Pro subscription hasn't started", name)
389+
}
390+
})
391+
392+
// GET /v1/subscriptions backs the paginated admin listing.
393+
t.Run("the admin subscription listing", func(t *testing.T) {
394+
got := do(t, http.MethodGet, "/v1/subscriptions?offset=0&limit=50", "")
395+
if got.status != http.StatusOK {
396+
t.Fatalf("status = %d, body %s", got.status, got.body)
397+
}
398+
399+
result, ok := mustDecode(t, got)["result"].(map[string]any)
400+
if !ok {
401+
t.Fatalf("unexpected response shape: %s", got.body)
402+
}
403+
listed, _ := result["subscriptions"].([]any)
404+
var names []string
405+
for _, subscription := range listed {
406+
plan, _ := subscription.(map[string]any)["plan"].(map[string]any)
407+
name, _ := plan["name"].(string)
408+
names = append(names, name)
409+
}
410+
if len(names) != 1 || names[0] != "Basic" {
411+
t.Errorf("listed plans = %v, want [Basic]: the Pro subscription hasn't started", names)
412+
}
413+
})
414+
}
415+
169416
// Creating an overlapping subscription deactivates the previous one, so a user
170417
// never has two active subscriptions at once. Anything reading "the current
171418
// plan" depends on that invariant holding.
@@ -182,13 +429,7 @@ func TestOverlappingSubscriptionsAreDeactivated(t *testing.T) {
182429
}
183430
}
184431

185-
activeCount := queryInt(t, `
186-
SELECT count(*)
187-
FROM subscriptions s
188-
JOIN users ON s.user_id = users.id
189-
WHERE users.username = $1
190-
AND CURRENT_TIMESTAMP BETWEEN s.effective_start_date AND s.effective_end_date`,
191-
testUser)
432+
activeCount := queryInt(t, `SELECT count(*)`+activeSubscriptions, testUser)
192433
if activeCount != 1 {
193434
t.Errorf("active subscriptions = %d, want exactly 1", activeCount)
194435
}

0 commit comments

Comments
 (0)