Skip to content

Commit 72dee01

Browse files
tothandrasborosr
authored andcommitted
chore: e2e
1 parent 13e649d commit 72dee01

2 files changed

Lines changed: 254 additions & 0 deletions

File tree

e2e/subscriptionaddons_v3_test.go

Lines changed: 237 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,237 @@
1+
package e2e
2+
3+
import (
4+
"net/http"
5+
"testing"
6+
"time"
7+
8+
"github.com/samber/lo"
9+
"github.com/stretchr/testify/assert"
10+
"github.com/stretchr/testify/require"
11+
12+
apiv3 "github.com/openmeterio/openmeter/api/v3"
13+
)
14+
15+
// TestV3SubscriptionAddonAttach exercises POST /subscriptions/{id}/addons end to end:
16+
// build a published plan + published addon, create a subscription, attach the addon,
17+
// verify the response shape (rate_cards/timeline arrays, never null), then confirm
18+
// the conflict path returns 409 when the same addon is attached twice.
19+
func TestV3SubscriptionAddonAttach(t *testing.T) {
20+
c := newV3Client(t)
21+
22+
// --- Fixture: customer ---
23+
24+
customerKey := uniqueKey("sub_addon_customer")
25+
custStatus, customer, custProblem := c.CreateCustomer(apiv3.CreateCustomerRequest{
26+
Key: customerKey,
27+
Name: "Subscription Addon Test Customer",
28+
Currency: lo.ToPtr(apiv3.CurrencyCode("USD")),
29+
PrimaryEmail: lo.ToPtr("test-" + customerKey + "@test.com"),
30+
UsageAttribution: &apiv3.BillingCustomerUsageAttribution{
31+
SubjectKeys: []string{customerKey},
32+
},
33+
})
34+
require.Equal(t, http.StatusCreated, custStatus, "problem: %+v", custProblem)
35+
require.NotNil(t, customer)
36+
37+
// --- Fixture: draft plan + published addon, attach addon, then publish plan ---
38+
// Order matters: addons can only be attached to a plan while it is still in draft,
39+
// and the addon must be published before attach.
40+
41+
planBody := validPlanRequest("sub_addon_plan")
42+
planStatus, plan, planProblem := c.CreatePlan(planBody)
43+
require.Equal(t, http.StatusCreated, planStatus, "problem: %+v", planProblem)
44+
require.NotNil(t, plan)
45+
require.NotEmpty(t, plan.Phases, "plan must have at least one phase to attach an addon")
46+
47+
addonBody := validAddonRequest("sub_addon")
48+
addonStatus, addon, addonProblem := c.CreateAddon(addonBody)
49+
require.Equal(t, http.StatusCreated, addonStatus, "problem: %+v", addonProblem)
50+
require.NotNil(t, addon)
51+
52+
pubAddonStatus, _, pubAddonProblem := c.PublishAddon(addon.Id)
53+
require.Equal(t, http.StatusOK, pubAddonStatus, "problem: %+v", pubAddonProblem)
54+
55+
attachStatus, _, attachProblem := c.AttachAddon(plan.Id, validPlanAddonRequest(plan.Phases[0].Key, addon.Id))
56+
require.Equal(t, http.StatusCreated, attachStatus, "problem: %+v", attachProblem)
57+
58+
pubStatus, _, pubProblem := c.PublishPlan(plan.Id)
59+
require.Equal(t, http.StatusOK, pubStatus, "problem: %+v", pubProblem)
60+
61+
// --- Fixture: subscription on the published plan ---
62+
63+
subBody := apiv3.BillingSubscriptionCreate{}
64+
subBody.Customer.Id = &customer.Id
65+
subBody.Plan.Id = &plan.Id
66+
67+
subStatus, sub, subProblem := c.CreateSubscription(subBody)
68+
require.Equal(t, http.StatusCreated, subStatus, "problem: %+v", subProblem)
69+
require.NotNil(t, sub)
70+
subscriptionID := sub.Id
71+
72+
// --- Test: attach addon ---
73+
74+
var subAddonID string
75+
76+
t.Run("Should attach addon with immediate timing and return 201", func(t *testing.T) {
77+
var timing apiv3.BillingSubscriptionEditTiming
78+
require.NoError(t, timing.FromBillingSubscriptionEditTimingEnum(apiv3.BillingSubscriptionEditTimingEnum("immediate")))
79+
80+
status, subAddon, problem := c.CreateSubscriptionAddon(subscriptionID, apiv3.CreateSubscriptionAddonRequest{
81+
Addon: apiv3.AddonReference{Id: addon.Id},
82+
Quantity: 1,
83+
Timing: timing,
84+
})
85+
require.Equal(t, http.StatusCreated, status, "problem: %+v", problem)
86+
require.NotNil(t, subAddon)
87+
88+
assert.NotEmpty(t, subAddon.Id)
89+
assert.Equal(t, addon.Id, subAddon.Addon.Id)
90+
assert.Equal(t, 1, subAddon.Quantity)
91+
// Regression guard for the nil-slice → JSON null bug: rate_cards must be a non-nil array
92+
// and every entry's affected_subscription_item_ids must be a non-nil array too.
93+
assert.NotNil(t, subAddon.RateCards, "rate_cards must not be null")
94+
for i, rc := range subAddon.RateCards {
95+
assert.NotNil(t, rc.AffectedSubscriptionItemIds, "rate_cards[%d].affected_subscription_item_ids must not be null", i)
96+
}
97+
// Timeline must be a non-nil array with at least one segment for an active addon.
98+
require.NotNil(t, subAddon.Timeline)
99+
require.NotEmpty(t, subAddon.Timeline)
100+
assert.Equal(t, 1, subAddon.Timeline[0].Quantity)
101+
102+
subAddonID = subAddon.Id
103+
})
104+
105+
t.Run("Should return 409 when attaching the same addon twice", func(t *testing.T) {
106+
require.NotEmpty(t, subAddonID, "first attach must have succeeded")
107+
108+
var timing apiv3.BillingSubscriptionEditTiming
109+
require.NoError(t, timing.FromBillingSubscriptionEditTimingEnum(apiv3.BillingSubscriptionEditTimingEnum("immediate")))
110+
111+
status, _, problem := c.CreateSubscriptionAddon(subscriptionID, apiv3.CreateSubscriptionAddonRequest{
112+
Addon: apiv3.AddonReference{Id: addon.Id},
113+
Quantity: 1,
114+
Timing: timing,
115+
})
116+
require.Equal(t, http.StatusConflict, status, "expected 409, got %d (problem: %+v)", status, problem)
117+
})
118+
119+
t.Run("Should reject invalid quantity 0", func(t *testing.T) {
120+
var timing apiv3.BillingSubscriptionEditTiming
121+
require.NoError(t, timing.FromBillingSubscriptionEditTimingEnum(apiv3.BillingSubscriptionEditTimingEnum("immediate")))
122+
123+
status, _, _ := c.CreateSubscriptionAddon(subscriptionID, apiv3.CreateSubscriptionAddonRequest{
124+
Addon: apiv3.AddonReference{Id: addon.Id},
125+
Quantity: 0,
126+
Timing: timing,
127+
})
128+
// TypeSpec @minValue(1) rejects this at schema-validation; workflow validation
129+
// would also reject it. Either is fine — assert 4xx.
130+
assert.GreaterOrEqual(t, status, http.StatusBadRequest, "expected 4xx for quantity=0, got %d", status)
131+
assert.Less(t, status, http.StatusInternalServerError, "expected 4xx not 5xx for quantity=0")
132+
})
133+
134+
t.Run("Should get the attached addon and surface rate_cards / timeline arrays", func(t *testing.T) {
135+
require.NotEmpty(t, subAddonID)
136+
137+
status, subAddon, problem := c.GetSubscriptionAddon(subscriptionID, subAddonID)
138+
require.Equal(t, http.StatusOK, status, "problem: %+v", problem)
139+
require.NotNil(t, subAddon)
140+
141+
assert.Equal(t, subAddonID, subAddon.Id)
142+
assert.NotNil(t, subAddon.RateCards, "GET: rate_cards must not be null")
143+
assert.NotNil(t, subAddon.Timeline, "GET: timeline must not be null")
144+
for i, rc := range subAddon.RateCards {
145+
assert.NotNil(t, rc.AffectedSubscriptionItemIds, "GET: rate_cards[%d].affected_subscription_item_ids must not be null", i)
146+
}
147+
})
148+
149+
t.Run("Should list subscription addons and find the attached addon", func(t *testing.T) {
150+
require.NotEmpty(t, subAddonID)
151+
152+
status, page, problem := c.ListSubscriptionAddons(subscriptionID, withPageSize(100))
153+
require.Equal(t, http.StatusOK, status, "problem: %+v", problem)
154+
require.NotNil(t, page)
155+
156+
found := false
157+
for _, sa := range page.Data {
158+
if sa.Id == subAddonID {
159+
found = true
160+
assert.NotNil(t, sa.RateCards, "LIST: rate_cards must not be null")
161+
assert.NotNil(t, sa.Timeline, "LIST: timeline must not be null")
162+
break
163+
}
164+
}
165+
assert.True(t, found, "attached subscription addon not found in list")
166+
})
167+
}
168+
169+
// TestV3SubscriptionAddonNextBillingCycle attaches an addon with timing=next_billing_cycle
170+
// and verifies the create endpoint returns 201 (not the pre-fix 404). The new instance's
171+
// active_from is in the future, so the no-current-instance fallback in toAPISubscriptionAddon
172+
// must return quantity 0 instead of erroring out.
173+
func TestV3SubscriptionAddonNextBillingCycle(t *testing.T) {
174+
c := newV3Client(t)
175+
176+
// --- Fixture: customer ---
177+
178+
customerKey := uniqueKey("sub_addon_nbc_customer")
179+
custStatus, customer, custProblem := c.CreateCustomer(apiv3.CreateCustomerRequest{
180+
Key: customerKey,
181+
Name: "Next Billing Cycle Test Customer",
182+
Currency: lo.ToPtr(apiv3.CurrencyCode("USD")),
183+
PrimaryEmail: lo.ToPtr("test-" + customerKey + "@test.com"),
184+
UsageAttribution: &apiv3.BillingCustomerUsageAttribution{
185+
SubjectKeys: []string{customerKey},
186+
},
187+
})
188+
require.Equal(t, http.StatusCreated, custStatus, "problem: %+v", custProblem)
189+
190+
// --- Fixture: draft plan + published addon, attach, then publish plan ---
191+
192+
planStatus, plan, planProblem := c.CreatePlan(validPlanRequest("sub_addon_nbc_plan"))
193+
require.Equal(t, http.StatusCreated, planStatus, "problem: %+v", planProblem)
194+
require.NotEmpty(t, plan.Phases)
195+
196+
addonStatus, addon, addonProblem := c.CreateAddon(validAddonRequest("sub_addon_nbc"))
197+
require.Equal(t, http.StatusCreated, addonStatus, "problem: %+v", addonProblem)
198+
199+
pubAddonStatus, _, pubAddonProblem := c.PublishAddon(addon.Id)
200+
require.Equal(t, http.StatusOK, pubAddonStatus, "problem: %+v", pubAddonProblem)
201+
202+
attachStatus, _, attachProblem := c.AttachAddon(plan.Id, validPlanAddonRequest(plan.Phases[0].Key, addon.Id))
203+
require.Equal(t, http.StatusCreated, attachStatus, "problem: %+v", attachProblem)
204+
205+
pubPlanStatus, _, pubPlanProblem := c.PublishPlan(plan.Id)
206+
require.Equal(t, http.StatusOK, pubPlanStatus, "problem: %+v", pubPlanProblem)
207+
208+
// Anchor the subscription at a past second so the next billing cycle is reliably
209+
// in the future at the moment of the addon attach.
210+
anchor := apiv3.DateTime(time.Now().Add(-time.Second))
211+
subBody := apiv3.BillingSubscriptionCreate{BillingAnchor: &anchor}
212+
subBody.Customer.Id = &customer.Id
213+
subBody.Plan.Id = &plan.Id
214+
215+
subStatus, sub, subProblem := c.CreateSubscription(subBody)
216+
require.Equal(t, http.StatusCreated, subStatus, "problem: %+v", subProblem)
217+
218+
t.Run("Should accept next_billing_cycle timing and return 201 with quantity 0", func(t *testing.T) {
219+
var timing apiv3.BillingSubscriptionEditTiming
220+
require.NoError(t, timing.FromBillingSubscriptionEditTimingEnum(apiv3.BillingSubscriptionEditTimingEnum("next_billing_cycle")))
221+
222+
status, subAddon, problem := c.CreateSubscriptionAddon(sub.Id, apiv3.CreateSubscriptionAddonRequest{
223+
Addon: apiv3.AddonReference{Id: addon.Id},
224+
Quantity: 1,
225+
Timing: timing,
226+
})
227+
require.Equal(t, http.StatusCreated, status, "problem: %+v", problem)
228+
require.NotNil(t, subAddon)
229+
230+
// Future-active addon: current quantity must be 0 (not an error). The timeline
231+
// segment carries the requested quantity at its future activation point.
232+
assert.Equal(t, 0, subAddon.Quantity, "current quantity must be 0 for future-active addon")
233+
require.NotEmpty(t, subAddon.Timeline)
234+
assert.Equal(t, 1, subAddon.Timeline[0].Quantity)
235+
assert.True(t, subAddon.Timeline[0].ActiveFrom.After(time.Now()), "next_billing_cycle timing must produce a future active_from")
236+
})
237+
}

e2e/v3helpers_test.go

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -307,6 +307,23 @@ func (c *v3Client) CreateSubscription(body apiv3.BillingSubscriptionCreate) (int
307307
return decodeTyped[apiv3.BillingSubscription](c, status, raw, problem, http.StatusCreated)
308308
}
309309

310+
// --- Subscription addons ---
311+
312+
func (c *v3Client) CreateSubscriptionAddon(subscriptionID string, body apiv3.CreateSubscriptionAddonRequest) (int, *apiv3.SubscriptionAddon, *v3Problem) {
313+
status, raw, problem := c.do(http.MethodPost, "/subscriptions/"+subscriptionID+"/addons", body)
314+
return decodeTyped[apiv3.SubscriptionAddon](c, status, raw, problem, http.StatusCreated)
315+
}
316+
317+
func (c *v3Client) GetSubscriptionAddon(subscriptionID, subscriptionAddonID string) (int, *apiv3.SubscriptionAddon, *v3Problem) {
318+
status, raw, problem := c.do(http.MethodGet, "/subscriptions/"+subscriptionID+"/addons/"+subscriptionAddonID, nil)
319+
return decodeTyped[apiv3.SubscriptionAddon](c, status, raw, problem, http.StatusOK)
320+
}
321+
322+
func (c *v3Client) ListSubscriptionAddons(subscriptionID string, opts ...listOption) (int, *apiv3.SubscriptionAddonPagePaginatedResponse, *v3Problem) {
323+
status, raw, problem := c.do(http.MethodGet, "/subscriptions/"+subscriptionID+"/addons"+buildPageQuery(opts), nil)
324+
return decodeTyped[apiv3.SubscriptionAddonPagePaginatedResponse](c, status, raw, problem, http.StatusOK)
325+
}
326+
310327
// --- List pagination options ---
311328

312329
// listOptions controls pagination query params for list endpoints. The server

0 commit comments

Comments
 (0)