openmeter / e2e /addons_v3_test.go
Leon4gr45's picture
Upload folder using huggingface_hub (part 5)
b47934a verified
Raw
History Blame Contribute Delete
12.3 kB
package e2e
import (
"net/http"
"testing"
"github.com/samber/lo"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
v3sdk "github.com/openmeterio/openmeter/api/v3/client"
)
func TestV3Addon(t *testing.T) {
c := newV3Client(t)
addonBody := validAddonRequest("test_v3_addon")
addonKey := addonBody.Key
var addonID string
t.Run("Should create an addon in draft status", func(t *testing.T) {
addon, err := c.Addons.Create(t.Context(), addonBody)
c.requireStatus(http.StatusCreated, err)
require.NotNil(t, addon)
assert.Equal(t, addonKey, addon.Key)
assert.EqualValues(t, 1, addon.Version)
assert.Equal(t, v3sdk.AddonStatusDraft, addon.Status)
assert.Nil(t, addon.EffectiveFrom)
addonID = addon.ID
})
t.Run("Should get the addon", func(t *testing.T) {
require.NotEmpty(t, addonID)
addon, err := c.Addons.Get(t.Context(), addonID)
c.requireStatus(http.StatusOK, err)
require.NotNil(t, addon)
assert.Equal(t, addonID, addon.ID)
assert.Equal(t, v3sdk.AddonStatusDraft, addon.Status)
})
t.Run("Should list addons and find the created addon", func(t *testing.T) {
// Bump page size so a shared DB with prior fixtures doesn't push this
// freshly-created addon off page 1.
page, err := c.Addons.List(t.Context(), v3sdk.AddonListParams{
Page: &v3sdk.PageParams{Size: lo.ToPtr(1000)},
})
c.requireStatus(http.StatusOK, err)
require.NotNil(t, page)
found := false
for _, a := range page.Data {
if a.ID == addonID {
found = true
assert.NotEmpty(t, a.Currency)
assert.NotEmpty(t, a.Status)
assert.NotEmpty(t, a.RateCards)
break
}
}
assert.True(t, found, "created addon not found in list")
})
t.Run("Should update the addon", func(t *testing.T) {
require.NotEmpty(t, addonID)
updateBody := v3sdk.UpsertAddonRequest{
Name: "Test V3 Addon Updated",
InstanceType: addonBody.InstanceType,
RateCards: addonBody.RateCards,
}
addon, err := c.Addons.Update(t.Context(), addonID, updateBody)
c.requireStatus(http.StatusOK, err)
require.NotNil(t, addon)
assert.Equal(t, "Test V3 Addon Updated", addon.Name)
assert.Equal(t, v3sdk.AddonStatusDraft, addon.Status)
})
t.Run("Should publish the addon", func(t *testing.T) {
require.NotEmpty(t, addonID)
addon, err := c.Addons.Publish(t.Context(), addonID)
c.requireStatus(http.StatusOK, err)
require.NotNil(t, addon)
assert.Equal(t, v3sdk.AddonStatusActive, addon.Status)
assert.NotNil(t, addon.EffectiveFrom)
})
t.Run("Should not allow deleting an active addon", func(t *testing.T) {
require.NotEmpty(t, addonID)
err := c.Addons.Delete(t.Context(), addonID)
requireProblem(t, err, http.StatusBadRequest)
})
t.Run("Should archive the published addon", func(t *testing.T) {
require.NotEmpty(t, addonID)
addon, err := c.Addons.Archive(t.Context(), addonID)
c.requireStatus(http.StatusOK, err)
require.NotNil(t, addon)
assert.Equal(t, v3sdk.AddonStatusArchived, addon.Status)
assert.NotNil(t, addon.EffectiveTo)
})
t.Run("Should delete an archived addon", func(t *testing.T) {
require.NotEmpty(t, addonID)
err := c.Addons.Delete(t.Context(), addonID)
c.requireStatus(http.StatusNoContent, err)
})
t.Run("Should return deleted_at after deletion", func(t *testing.T) {
require.NotEmpty(t, addonID)
addon, err := c.Addons.Get(t.Context(), addonID)
c.requireStatus(http.StatusOK, err)
require.NotNil(t, addon)
assert.NotNil(t, addon.DeletedAt)
})
}
// Mixed rate-card addon round-trip. Three rate cards: flat P1M in-advance,
// unit P1M in-arrears with 10% percentage discount, graduated tiered P1M.
// Publish + GET and verify all three survive intact.
func TestV3AddonMixedRateCardRoundTrip(t *testing.T) {
c := newV3Client(t)
eventTypes := []string{
uniqueKey("sanity_event"),
uniqueKey("sanity_event"),
}
meterKeys := []string{
uniqueKey("sanity_meter"),
uniqueKey("sanity_meter"),
}
meters := make([]v3sdk.Meter, 0, len(meterKeys))
for i := range meterKeys {
valueProperty := "$.value"
m, err := c.Meters.Create(t.Context(), v3sdk.CreateMeterRequest{
Key: meterKeys[i],
Name: "Test Meter " + meterKeys[i],
Aggregation: v3sdk.MeterAggregationSum,
EventType: eventTypes[i],
ValueProperty: &valueProperty,
})
c.requireStatus(http.StatusCreated, err)
require.NotNil(t, m)
require.NotEmpty(t, m.ID)
meters = append(meters, *m)
}
featureKeys := []string{
uniqueKey("mix_unit"),
uniqueKey("mix_graduated"),
}
features := make([]v3sdk.Feature, 0, len(featureKeys))
for i := range featureKeys {
f, err := c.Features.Create(t.Context(), v3sdk.CreateFeatureRequest{
Key: featureKeys[i],
Name: "Test Feature " + featureKeys[i],
Meter: &v3sdk.FeatureMeterReferenceInput{
ID: meters[i].ID,
},
})
c.requireStatus(http.StatusCreated, err)
require.NotNil(t, f)
require.NotEmpty(t, f.ID)
features = append(features, *f)
}
flat := validFlatRateCard("mix_flat")
unit := validUnitRateCard(features[0])
percent := float64(10)
unit.Discounts = &v3sdk.RateCardDiscounts{Percentage: &percent}
graduated := validGraduatedRateCard(features[1])
body := validAddonRequest("mixed_rc")
body.RateCards = []v3sdk.RateCardInput{flat, unit, graduated}
addon, err := c.Addons.Create(t.Context(), body)
c.requireStatus(http.StatusCreated, err)
require.NotNil(t, addon)
published, err := c.Addons.Publish(t.Context(), addon.ID)
c.requireStatus(http.StatusOK, err)
require.NotNil(t, published)
got, err := c.Addons.Get(t.Context(), addon.ID)
c.requireStatus(http.StatusOK, err)
require.NotNil(t, got)
require.Len(t, got.RateCards, 3)
byKey := map[string]v3sdk.RateCard{}
for _, rc := range got.RateCards {
byKey[rc.Key] = rc
}
gotFlat, ok := byKey[flat.Key]
require.True(t, ok, "flat rate card missing on round-trip")
flatPrice, err := gotFlat.Price.AsPriceFlat()
require.NoError(t, err, "flat price should decode as BillingPriceFlat")
assert.Equal(t, "10", flatPrice.Amount)
gotUnit, ok := byKey[unit.Key]
require.True(t, ok, "unit rate card missing on round-trip")
unitPrice, err := gotUnit.Price.AsPriceUnit()
require.NoError(t, err, "unit price should decode as BillingPriceUnit")
// Server normalizes decimals (trims trailing zeros): "0.10" → "0.1".
assert.Equal(t, "0.1", unitPrice.Amount)
require.NotNil(t, gotUnit.Discounts, "percentage discount missing on round-trip")
require.NotNil(t, gotUnit.Discounts.Percentage)
assert.InDelta(t, 10.0, float64(*gotUnit.Discounts.Percentage), 0.001)
gotGraduated, ok := byKey[graduated.Key]
require.True(t, ok, "graduated rate card missing on round-trip")
gradPrice, err := gotGraduated.Price.AsPriceGraduated()
require.NoError(t, err, "graduated price should decode as BillingPriceGraduated")
assert.Len(t, gradPrice.Tiers, 2, "graduated tiers not preserved")
}
// Addon versioning and auto-archive (the addon analog of
// TestV3PlanVersioningAndAutoArchive).
func TestV3AddonVersioningAndAutoArchive(t *testing.T) {
c := newV3Client(t)
createBody := validAddonRequest("addon_versioning")
sharedKey := createBody.Key
v1, err := c.Addons.Create(t.Context(), createBody)
c.requireStatus(http.StatusCreated, err)
require.NotNil(t, v1)
assert.EqualValues(t, 1, v1.Version)
v1Active, err := c.Addons.Publish(t.Context(), v1.ID)
c.requireStatus(http.StatusOK, err)
require.NotNil(t, v1Active)
require.Equal(t, v3sdk.AddonStatusActive, v1Active.Status)
v2Body := validAddonRequest("addon_versioning_v2")
v2Body.Key = sharedKey
v2, err := c.Addons.Create(t.Context(), v2Body)
c.requireStatus(http.StatusCreated, err)
require.NotNil(t, v2)
assert.EqualValues(t, 2, v2.Version)
assert.Equal(t, v3sdk.AddonStatusDraft, v2.Status)
assert.Equal(t, sharedKey, v2.Key)
assert.NotEqual(t, v1.ID, v2.ID)
v2Active, err := c.Addons.Publish(t.Context(), v2.ID)
c.requireStatus(http.StatusOK, err)
require.NotNil(t, v2Active)
require.Equal(t, v3sdk.AddonStatusActive, v2Active.Status)
require.NotNil(t, v2Active.EffectiveFrom)
v1After, err := c.Addons.Get(t.Context(), v1.ID)
c.requireStatus(http.StatusOK, err)
require.NotNil(t, v1After)
assert.Equal(t, v3sdk.AddonStatusArchived, v1After.Status)
require.NotNil(t, v1After.EffectiveTo)
assert.True(t, v1After.EffectiveTo.Equal(*v2Active.EffectiveFrom),
"v1.EffectiveTo (%s) must equal v2.EffectiveFrom (%s)",
v1After.EffectiveTo, v2Active.EffectiveFrom)
}
// Feature reference resolution.
//
// Only exercises the "non-existent feature id" path. Reference-by-key and
// id/key-mismatch aren't expressible in the v3 schema: FeatureReferenceItem
// on a rate card has only Id, no Key. The archived-feature case needs
// feature create/archive harness methods not yet in place and is deferred.
func TestV3AddonFeatureReferenceResolution(t *testing.T) {
c := newV3Client(t)
// Valid ULID format, chosen so it cannot collide with a real feature.
const fakeFeatureID = "01HZZ0000000000000000FAKE1"
rc := validFlatRateCard("feature_ref")
rc.Feature = &v3sdk.FeatureReference{ID: fakeFeatureID}
body := validAddonRequest("feature_ref")
body.RateCards = []v3sdk.RateCardInput{rc}
_, err := c.Addons.Create(t.Context(), body)
problem := requireProblem(t, err, http.StatusBadRequest)
assertValidationCode(t, problem, "rate_card_feature_not_found")
}
// Payment-term × price compatibility.
//
// The "unit + in_advance → 400 rate_card_only_flat_price_allowed" row is
// dropped: the validator constant exists in openmeter/productcatalog/errors.go
// but has no call sites, so the server accepts unit + in_advance and returns
// 201. Re-enable once the validator is wired.
func TestV3AddonPaymentTermPriceCompatibility(t *testing.T) {
t.Run("flat + in_advance → 201", func(t *testing.T) {
c := newV3Client(t)
// validFlatRateCard already uses in_advance.
body := validAddonRequest("payment_term_flat")
addon, err := c.Addons.Create(t.Context(), body)
c.requireStatus(http.StatusCreated, err)
require.NotNil(t, addon)
})
}
// Instance-type × price compatibility.
//
// Two planned rows are dropped because the
// `addon_invalid_ratecard_price_for_multi_instance` validator lives in
// Addon.Publishable() but isn't called from the addon create path — so
// multi-instance + unit/graduated rate cards silently return 201 at create.
// Re-enable those rows once the validator is hoisted into Addon.Validate().
//
// Only the "happy" rows are exercised — they confirm flat and unit prices are
// accepted in their respective valid combinations.
func TestV3AddonInstanceTypePriceCompatibility(t *testing.T) {
c := newV3Client(t)
meterKey := uniqueKey("single_unit")
m, err := c.Meters.Create(t.Context(), v3sdk.CreateMeterRequest{
Key: meterKey,
Name: "Test Meter " + meterKey,
Aggregation: v3sdk.MeterAggregationSum,
EventType: uniqueKey("sanity_event"),
ValueProperty: lo.ToPtr("$.value"),
})
c.requireStatus(http.StatusCreated, err)
require.NotNil(t, m)
require.NotEmpty(t, m.ID)
featureKey := uniqueKey("single_unit")
f, err := c.Features.Create(t.Context(), v3sdk.CreateFeatureRequest{
Key: featureKey,
Name: "Test Feature " + featureKey,
Meter: &v3sdk.FeatureMeterReferenceInput{
ID: m.ID,
},
})
c.requireStatus(http.StatusCreated, err)
require.NotNil(t, f)
require.NotEmpty(t, f.ID)
cases := []struct {
name string
instanceType v3sdk.AddonInstanceType
rateCard v3sdk.RateCardInput
expectedStatus int
}{
{
name: "multiple + flat rate card → 201",
instanceType: v3sdk.AddonInstanceTypeMultiple,
rateCard: validFlatRateCard("multi_flat"),
expectedStatus: http.StatusCreated,
},
{
name: "single + unit rate card → 201",
instanceType: v3sdk.AddonInstanceTypeSingle,
rateCard: validUnitRateCard(*f),
expectedStatus: http.StatusCreated,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
body := validAddonRequest("instance_type_price")
body.InstanceType = tc.instanceType
body.RateCards = []v3sdk.RateCardInput{tc.rateCard}
addon, err := c.Addons.Create(t.Context(), body)
c.requireStatus(tc.expectedStatus, err)
require.NotNil(t, addon)
})
}
}