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