openmeter / subscription /errors_test.go
Leon4gr45's picture
Upload folder using huggingface_hub (part 6)
d6f631f verified
Raw
History Blame Contribute Delete
15.3 kB
package subscription_test
import (
"encoding/json"
"net/http"
"testing"
"time"
"github.com/alpacahq/alpacadecimal"
"github.com/samber/lo"
"github.com/stretchr/testify/require"
"github.com/openmeterio/openmeter/openmeter/productcatalog"
"github.com/openmeterio/openmeter/openmeter/subscription"
"github.com/openmeterio/openmeter/pkg/currencyx"
"github.com/openmeterio/openmeter/pkg/datetime"
"github.com/openmeterio/openmeter/pkg/framework/commonhttp"
"github.com/openmeterio/openmeter/pkg/models"
"github.com/openmeterio/openmeter/pkg/slicesx"
)
func TestSubscriptionErrors(t *testing.T) {
t.Run("ErrSubscriptionBillingPeriodQueriedBeforeSubscriptionStart", func(t *testing.T) {
queriedAt := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
subscriptionStart := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
err := subscription.NewErrSubscriptionBillingPeriodQueriedBeforeSubscriptionStart(queriedAt, subscriptionStart)
require.True(t, subscription.IsValidationIssueWithCode(err, subscription.ErrCodeSubscriptionBillingPeriodQueriedBeforeSubscriptionStart))
issues, err := models.AsValidationIssues(err)
require.NoError(t, err)
exts := issues.AsErrorExtensions()
require.Len(t, exts, 1)
ext := exts[0]
require.Equal(t, subscription.ErrCodeSubscriptionBillingPeriodQueriedBeforeSubscriptionStart, ext["code"])
require.Equal(t, "billing period queried before subscription start", ext["message"])
require.Equal(t, subscriptionStart, ext["subscription_start"])
require.Equal(t, queriedAt, ext["queried_at"])
})
}
func TestSubscriptionSpecValidation(t *testing.T) {
t.Run("Should be a valid subscription", func(t *testing.T) {
spec := subscription.SubscriptionSpec{
CreateSubscriptionPlanInput: subscription.CreateSubscriptionPlanInput{
Plan: &subscription.PlanRef{
Key: "test",
Version: 1,
},
BillingCadence: datetime.MustParseDuration(t, "P1M"),
ProRatingConfig: productcatalog.ProRatingConfig{
Mode: productcatalog.ProRatingModeProratePrices,
},
},
CreateSubscriptionCustomerInput: subscription.CreateSubscriptionCustomerInput{
Name: "test",
CustomerId: "test",
Currency: currencyx.Code("USD"),
ActiveFrom: time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC),
BillingAnchor: time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC),
},
Phases: map[string]*subscription.SubscriptionPhaseSpec{
"phase1": {
CreateSubscriptionPhasePlanInput: subscription.CreateSubscriptionPhasePlanInput{
PhaseKey: "phase1",
StartAfter: datetime.MustParseDuration(t, "P0D"),
Name: "phase1",
},
CreateSubscriptionPhaseCustomerInput: subscription.CreateSubscriptionPhaseCustomerInput{},
ItemsByKey: map[string][]*subscription.SubscriptionItemSpec{
"item1": {
{
CreateSubscriptionItemInput: subscription.CreateSubscriptionItemInput{
CreateSubscriptionItemPlanInput: subscription.CreateSubscriptionItemPlanInput{
PhaseKey: "phase1",
ItemKey: "item1",
RateCard: &productcatalog.UsageBasedRateCard{
RateCardMeta: productcatalog.RateCardMeta{
Key: "item1",
Name: "item1",
FeatureKey: lo.ToPtr("item1"),
FeatureID: lo.ToPtr("item1"),
Price: productcatalog.NewPriceFrom(productcatalog.UnitPrice{
Amount: alpacadecimal.NewFromFloat(100),
}),
EntitlementTemplate: func() *productcatalog.EntitlementTemplate {
et := productcatalog.EntitlementTemplate{}
et.FromMetered(productcatalog.MeteredEntitlementTemplate{
IssueAfterReset: lo.ToPtr(10.0),
IssueAfterResetPriority: lo.ToPtr(uint8(1)),
UsagePeriod: datetime.MustParseDuration(t, "P1M"),
})
return &et
}(),
},
BillingCadence: datetime.MustParseDuration(t, "P1M"),
},
},
CreateSubscriptionItemCustomerInput: subscription.CreateSubscriptionItemCustomerInput{},
},
},
},
},
},
},
}
err := spec.Validate()
require.NoError(t, err)
})
t.Run("Should have expected errors", func(t *testing.T) {
spec := subscription.SubscriptionSpec{
CreateSubscriptionPlanInput: subscription.CreateSubscriptionPlanInput{
Plan: &subscription.PlanRef{
Key: "test",
Version: 1,
},
BillingCadence: datetime.MustParseDuration(t, "P1M"),
ProRatingConfig: productcatalog.ProRatingConfig{
Mode: productcatalog.ProRatingModeProratePrices,
},
},
CreateSubscriptionCustomerInput: subscription.CreateSubscriptionCustomerInput{
Name: "test",
CustomerId: "test",
Currency: currencyx.Code("USD"),
ActiveFrom: time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC),
BillingAnchor: time.Date(2025, 1, 1, 5, 0, 0, 0, time.UTC), // will error
},
Phases: map[string]*subscription.SubscriptionPhaseSpec{
"phase1": {
CreateSubscriptionPhasePlanInput: subscription.CreateSubscriptionPhasePlanInput{
PhaseKey: "phase1",
StartAfter: datetime.MustParseDuration(t, "P0D"),
Name: "phase1",
},
CreateSubscriptionPhaseCustomerInput: subscription.CreateSubscriptionPhaseCustomerInput{},
ItemsByKey: map[string][]*subscription.SubscriptionItemSpec{
"item1": {
{
CreateSubscriptionItemInput: subscription.CreateSubscriptionItemInput{
CreateSubscriptionItemPlanInput: subscription.CreateSubscriptionItemPlanInput{
PhaseKey: "phase1",
ItemKey: "item1",
RateCard: &productcatalog.UsageBasedRateCard{
RateCardMeta: productcatalog.RateCardMeta{
Key: "item1",
Name: "item1",
FeatureKey: lo.ToPtr("badkey"), // will error
FeatureID: lo.ToPtr("badid"),
Price: productcatalog.NewPriceFrom(productcatalog.UnitPrice{
Amount: alpacadecimal.NewFromFloat(100),
}),
EntitlementTemplate: func() *productcatalog.EntitlementTemplate {
et := productcatalog.EntitlementTemplate{}
et.FromMetered(productcatalog.MeteredEntitlementTemplate{
// IssueAfterReset: lo.ToPtr(10.0), // will error
IssueAfterResetPriority: lo.ToPtr(uint8(1)),
UsagePeriod: datetime.MustParseDuration(t, "P1M"),
})
return &et
}(),
},
BillingCadence: datetime.MustParseDuration(t, "P1M"),
},
},
CreateSubscriptionItemCustomerInput: subscription.CreateSubscriptionItemCustomerInput{},
},
},
},
},
},
"phase2": {
CreateSubscriptionPhasePlanInput: subscription.CreateSubscriptionPhasePlanInput{
PhaseKey: "phase2",
StartAfter: datetime.MustParseDuration(t, "-P2D"), // will error
Name: "phase2",
},
CreateSubscriptionPhaseCustomerInput: subscription.CreateSubscriptionPhaseCustomerInput{},
ItemsByKey: map[string][]*subscription.SubscriptionItemSpec{}, // will error
},
},
}
specValidateError := spec.Validate()
err := specValidateError
require.Error(t, err)
issues, err := models.AsValidationIssues(err)
require.NoError(t, err)
exts := issues.AsErrorExtensions()
byts, err := json.MarshalIndent(exts, "", " ")
require.NoError(t, err)
require.Len(t, issues, 5, "got %s", string(byts))
models.RequireValidationIssuesMatch(t, models.ValidationIssues{
models.NewValidationIssue(
subscription.ErrCodeSubscriptionPhaseStartAfterIsNegative,
"subscription phase start after cannot be negative",
models.WithField(
models.NewFieldSelectorGroup(
models.NewFieldSelector("phases"),
models.NewFieldSelector("phase2"),
models.NewFieldSelector("startAfter"),
),
),
commonhttp.WithHTTPStatusCodeAttribute(http.StatusBadRequest),
),
models.NewValidationIssue(
subscription.ErrCodeSubscriptionPhaseHasNoItems,
"subscription phase must have at least one item",
models.WithField(
models.NewFieldSelectorGroup(
models.NewFieldSelector("phases"),
models.NewFieldSelector("phase2"),
models.NewFieldSelector("items"),
),
),
subscription.AllowedDuringApplyingToSpecError(),
commonhttp.WithHTTPStatusCodeAttribute(http.StatusBadRequest),
),
models.NewValidationIssue(
productcatalog.ErrCodeEntitlementTemplateInvalidIssueAfterResetWithPriority,
"invalid entitlement template as issue after reset is required if issue after reset priority is set",
models.WithField(
models.NewFieldSelectorGroup(
models.NewFieldSelector("phases"),
models.NewFieldSelector("phase1"),
models.NewFieldSelector("itemsByKey"),
models.NewFieldSelector("item1").WithExpression(models.NewFieldArrIndex(0)),
models.NewFieldSelector("entitlementTemplate"),
models.NewFieldSelector("issueAfterReset"),
),
),
models.WithComponent("rateCard"),
models.WithWarningSeverity(),
commonhttp.WithHTTPStatusCodeAttribute(http.StatusBadRequest),
),
models.NewValidationIssue(
productcatalog.ErrCodeEntitlementTemplateIssueAfterResetRequired,
"issueAfterReset is required for metered entitlement templates",
models.WithField(
models.NewFieldSelectorGroup(
models.NewFieldSelector("phases"),
models.NewFieldSelector("phase1"),
models.NewFieldSelector("itemsByKey"),
models.NewFieldSelector("item1").WithExpression(models.NewFieldArrIndex(0)),
models.NewFieldSelector("entitlementTemplate"),
models.NewFieldSelector("issueAfterReset"),
),
),
models.WithComponent("rateCard"),
models.WithWarningSeverity(),
commonhttp.WithHTTPStatusCodeAttribute(http.StatusBadRequest),
),
models.NewValidationIssue(
productcatalog.ErrCodeRateCardKeyFeatureKeyMismatch,
"rate card key must match feature key",
models.WithField(
models.NewFieldSelectorGroup(
models.NewFieldSelector("phases"),
models.NewFieldSelector("phase1"),
models.NewFieldSelector("itemsByKey"),
models.NewFieldSelector("item1").WithExpression(models.NewFieldArrIndex(0)),
models.NewFieldSelector("key"),
),
),
models.WithComponent("rateCard"),
commonhttp.WithHTTPStatusCodeAttribute(http.StatusBadRequest),
),
}, issues)
// Expected issues:
// - BillingAnchor is a hours after subscription start hours after subscription startfter subscription start
// - Item FeatureKey does not match item key
// - RateCard IssueAfterReset is not set
// - Phase2 startAfter is negative
// - Phase2 has no items
t.Run("MapSubscriptionSpecValidationIssueFieldSelectors", func(t *testing.T) {
ogErr := specValidateError
require.Error(t, ogErr)
issues, err := models.AsValidationIssues(ogErr)
require.NoError(t, err)
mapped, err := slicesx.MapWithErr(issues, func(issue models.ValidationIssue) (models.ValidationIssue, error) {
return subscription.MapSubscriptionSpecValidationIssueField(issue)
})
require.NoError(t, err)
models.RequireValidationIssuesMatch(t, models.ValidationIssues{
models.NewValidationIssue(
subscription.ErrCodeSubscriptionPhaseStartAfterIsNegative,
"subscription phase start after cannot be negative",
models.WithField(
models.NewFieldSelectorGroup(
models.NewFieldSelector("phases").WithExpression(models.NewFieldAttrValue("key", "phase2")),
models.NewFieldSelector("startAfter"),
),
),
commonhttp.WithHTTPStatusCodeAttribute(http.StatusBadRequest),
),
models.NewValidationIssue(
subscription.ErrCodeSubscriptionPhaseHasNoItems,
"subscription phase must have at least one item",
models.WithField(
models.NewFieldSelectorGroup(
models.NewFieldSelector("phases").WithExpression(models.NewFieldAttrValue("key", "phase2")),
models.NewFieldSelector("items"),
),
),
subscription.AllowedDuringApplyingToSpecError(),
commonhttp.WithHTTPStatusCodeAttribute(http.StatusBadRequest),
),
models.NewValidationIssue(
productcatalog.ErrCodeEntitlementTemplateInvalidIssueAfterResetWithPriority,
"invalid entitlement template as issue after reset is required if issue after reset priority is set",
models.WithField(
models.NewFieldSelectorGroup(
models.NewFieldSelector("phases").WithExpression(models.NewFieldAttrValue("key", "phase1")),
models.NewFieldSelector("itemsByKey"),
models.NewFieldSelector("item1").WithExpression(models.NewFieldArrIndex(0)),
models.NewFieldSelector("entitlementTemplate"),
models.NewFieldSelector("issueAfterReset"),
),
),
models.WithComponent("rateCard"),
models.WithWarningSeverity(),
commonhttp.WithHTTPStatusCodeAttribute(http.StatusBadRequest),
),
models.NewValidationIssue(
productcatalog.ErrCodeEntitlementTemplateIssueAfterResetRequired,
"issueAfterReset is required for metered entitlement templates",
models.WithField(
models.NewFieldSelectorGroup(
models.NewFieldSelector("phases").WithExpression(models.NewFieldAttrValue("key", "phase1")),
models.NewFieldSelector("itemsByKey"),
models.NewFieldSelector("item1").WithExpression(models.NewFieldArrIndex(0)),
models.NewFieldSelector("entitlementTemplate"),
models.NewFieldSelector("issueAfterReset"),
),
),
models.WithComponent("rateCard"),
models.WithWarningSeverity(),
commonhttp.WithHTTPStatusCodeAttribute(http.StatusBadRequest),
),
models.NewValidationIssue(
productcatalog.ErrCodeRateCardKeyFeatureKeyMismatch,
"rate card key must match feature key",
models.WithField(
models.NewFieldSelectorGroup(
models.NewFieldSelector("phases").WithExpression(models.NewFieldAttrValue("key", "phase1")),
models.NewFieldSelector("itemsByKey"),
models.NewFieldSelector("item1").WithExpression(models.NewFieldArrIndex(0)),
models.NewFieldSelector("key"),
),
),
models.WithComponent("rateCard"),
commonhttp.WithHTTPStatusCodeAttribute(http.StatusBadRequest),
),
}, mapped)
t.Run("Should not map already mapped issue", func(t *testing.T) {
iss := models.NewValidationIssue(
productcatalog.ErrCodeRateCardKeyFeatureKeyMismatch,
"rate card key must match feature key",
models.WithField(
models.NewFieldSelector("phases").WithExpression(models.NewFieldAttrValue("key", "phase1")),
models.NewFieldSelector("itemsByKey"),
models.NewFieldSelector("item1").WithExpression(models.NewFieldArrIndex(0)),
models.NewFieldSelector("key"),
),
)
require.Equal(t, "$.phases[?(@.key=='phase1')].itemsByKey.item1[0].key", iss.Field().JSONPath())
mapped, err := subscription.MapSubscriptionSpecValidationIssueField(iss)
require.NoError(t, err)
models.RequireValidationIssuesMatch(t,
models.ValidationIssues{iss},
models.ValidationIssues{mapped},
)
})
})
})
}