|
| 1 | +package e2e |
| 2 | + |
| 3 | +import ( |
| 4 | + "net/http" |
| 5 | + "testing" |
| 6 | + |
| 7 | + "github.com/samber/lo" |
| 8 | + "github.com/stretchr/testify/assert" |
| 9 | + "github.com/stretchr/testify/require" |
| 10 | + |
| 11 | + api "github.com/openmeterio/openmeter/api/client/go" |
| 12 | + apiv3 "github.com/openmeterio/openmeter/api/v3" |
| 13 | +) |
| 14 | + |
| 15 | +const unitConfigNotRepresentableCode = "unit_config_not_representable" |
| 16 | + |
| 17 | +// Flow: |
| 18 | +// - v1 plan GET on a unit_config plan → 400 with the typed code (not a stripped 200) |
| 19 | +// - v1 plan GET on a plain plan → 200 (exclusion is content-derived, not blanket) |
| 20 | +// - v1 plan LIST omits the unit_config plan, keeps the plain one, and reports an exact TotalCount |
| 21 | +// - v1 subscribe-by-plan-key from the unit_config plan still succeeds (read ≠ subscribe; OM-399) |
| 22 | +// - v1 subscription GET on that subscription → 400 with the same typed code |
| 23 | +func TestV1ReadSurfaceExcludesUnitConfig(t *testing.T) { |
| 24 | + c := newV3Client(t) |
| 25 | + v1 := initClient(t) |
| 26 | + |
| 27 | + uniq := uniqueKey("ucread") |
| 28 | + meterKey := "ucread_meter_" + uniq |
| 29 | + eventType := "ucread_event_" + uniq |
| 30 | + featureKey := "ucread_feature_" + uniq |
| 31 | + customerKey := "ucread_customer_" + uniq |
| 32 | + subjectKey := "ucread_subject_" + uniq |
| 33 | + ucPlanKey := "ucread_uc_plan_" + uniq |
| 34 | + plainPlanKey := "ucread_plain_plan_" + uniq |
| 35 | + |
| 36 | + var ucPlan *apiv3.BillingPlan |
| 37 | + var plainPlan *apiv3.BillingPlan |
| 38 | + |
| 39 | + // given: |
| 40 | + // - a unit_config plan (usage-based rate card, divide-by-1000 ceiling) and a plain plan, both |
| 41 | + // authored and published via v3 (v1 cannot author unit_config). |
| 42 | + runRequired(t, "creates a unit_config plan and a plain plan", func(t *testing.T) { |
| 43 | + status, meter, problem := c.CreateMeter(apiv3.CreateMeterRequest{ |
| 44 | + Key: meterKey, |
| 45 | + Name: "UC Read Meter " + uniq, |
| 46 | + Aggregation: apiv3.MeterAggregationSum, |
| 47 | + EventType: eventType, |
| 48 | + ValueProperty: lo.ToPtr("$.value"), |
| 49 | + }) |
| 50 | + require.Equal(t, http.StatusCreated, status, "problem: %+v", problem) |
| 51 | + require.NotNil(t, meter) |
| 52 | + |
| 53 | + status, feature, problem := c.CreateFeature(apiv3.CreateFeatureRequest{ |
| 54 | + Key: featureKey, |
| 55 | + Name: "UC Read Feature " + uniq, |
| 56 | + Meter: &apiv3.FeatureMeterReference{Id: meter.Id}, |
| 57 | + }) |
| 58 | + require.Equal(t, http.StatusCreated, status, "problem: %+v", problem) |
| 59 | + require.NotNil(t, feature) |
| 60 | + |
| 61 | + cadence := apiv3.ISO8601Duration("P1M") |
| 62 | + term := apiv3.BillingPricePaymentTermInArrears |
| 63 | + price := apiv3.BillingPrice{} |
| 64 | + require.NoError(t, price.FromBillingPriceUnit(apiv3.BillingPriceUnit{ |
| 65 | + Type: apiv3.BillingPriceUnitTypeUnit, |
| 66 | + Amount: "0.10", |
| 67 | + })) |
| 68 | + ucRateCard := apiv3.BillingRateCard{ |
| 69 | + Key: feature.Key, |
| 70 | + Name: "UC Read Rate Card " + uniq, |
| 71 | + Price: price, |
| 72 | + BillingCadence: &cadence, |
| 73 | + PaymentTerm: &term, |
| 74 | + Feature: &apiv3.FeatureReference{Id: feature.Id}, |
| 75 | + UnitConfig: &apiv3.BillingUnitConfig{ |
| 76 | + Operation: apiv3.BillingUnitConfigOperationDivide, |
| 77 | + ConversionFactor: "1000", |
| 78 | + Rounding: lo.ToPtr(apiv3.BillingUnitConfigRoundingModeCeiling), |
| 79 | + Precision: lo.ToPtr(0), |
| 80 | + }, |
| 81 | + } |
| 82 | + |
| 83 | + status, createdUC, problem := c.CreatePlan(apiv3.CreatePlanRequest{ |
| 84 | + Key: ucPlanKey, |
| 85 | + Name: "UC Read Plan " + uniq, |
| 86 | + Currency: "USD", |
| 87 | + BillingCadence: apiv3.ISO8601Duration("P1M"), |
| 88 | + Phases: []apiv3.BillingPlanPhase{{ |
| 89 | + Key: "phase_1", |
| 90 | + Name: "UC Phase", |
| 91 | + RateCards: []apiv3.BillingRateCard{ucRateCard}, |
| 92 | + }}, |
| 93 | + }) |
| 94 | + require.Equal(t, http.StatusCreated, status, "problem: %+v", problem) |
| 95 | + require.NotNil(t, createdUC) |
| 96 | + status, ucPlan, problem = c.PublishPlan(createdUC.Id) |
| 97 | + require.Equal(t, http.StatusOK, status, "problem: %+v", problem) |
| 98 | + require.NotNil(t, ucPlan) |
| 99 | + |
| 100 | + status, createdPlain, problem := c.CreatePlan(apiv3.CreatePlanRequest{ |
| 101 | + Key: plainPlanKey, |
| 102 | + Name: "Plain Read Plan " + uniq, |
| 103 | + Currency: "USD", |
| 104 | + BillingCadence: apiv3.ISO8601Duration("P1M"), |
| 105 | + Phases: []apiv3.BillingPlanPhase{validPlanPhase("plain_phase", true /* isLast */)}, |
| 106 | + }) |
| 107 | + require.Equal(t, http.StatusCreated, status, "problem: %+v", problem) |
| 108 | + require.NotNil(t, createdPlain) |
| 109 | + status, plainPlan, problem = c.PublishPlan(createdPlain.Id) |
| 110 | + require.Equal(t, http.StatusOK, status, "problem: %+v", problem) |
| 111 | + require.NotNil(t, plainPlan) |
| 112 | + }) |
| 113 | + |
| 114 | + // then: |
| 115 | + // - v1 GET on the unit_config plan is rejected with the typed code (not a silently-stripped 200). |
| 116 | + runRequired(t, "v1 plan GET rejects the unit_config plan", func(t *testing.T) { |
| 117 | + resp, err := v1.GetPlanWithResponse(t.Context(), ucPlan.Id, nil) |
| 118 | + require.NoError(t, err) |
| 119 | + require.Equal(t, http.StatusBadRequest, resp.StatusCode(), "body: %s", string(resp.Body)) |
| 120 | + require.NotNil(t, resp.ApplicationproblemJSON400) |
| 121 | + assert.Contains(t, string(resp.Body), unitConfigNotRepresentableCode, "the 400 must carry the typed unit_config code") |
| 122 | + }) |
| 123 | + |
| 124 | + // and: |
| 125 | + // - the plain plan is still gettable via v1 (the exclusion is content-derived, not a blanket block). |
| 126 | + runRequired(t, "v1 plan GET returns the plain plan", func(t *testing.T) { |
| 127 | + resp, err := v1.GetPlanWithResponse(t.Context(), plainPlan.Id, nil) |
| 128 | + require.NoError(t, err) |
| 129 | + require.Equal(t, http.StatusOK, resp.StatusCode(), "body: %s", string(resp.Body)) |
| 130 | + require.NotNil(t, resp.JSON200) |
| 131 | + assert.Equal(t, plainPlanKey, resp.JSON200.Key) |
| 132 | + }) |
| 133 | + |
| 134 | + // and: |
| 135 | + // - v1 LIST omits the unit_config plan, keeps the plain one, and TotalCount reflects the filtered set |
| 136 | + // (the exclusion runs at the query layer, before the COUNT — so the count stays exact). |
| 137 | + runRequired(t, "v1 plan LIST excludes the unit_config plan with an exact TotalCount", func(t *testing.T) { |
| 138 | + resp, err := v1.ListPlansWithResponse(t.Context(), &api.ListPlansParams{ |
| 139 | + Key: &[]string{ucPlanKey, plainPlanKey}, |
| 140 | + PageSize: lo.ToPtr(api.PaginationPageSize(1000)), |
| 141 | + }) |
| 142 | + require.NoError(t, err) |
| 143 | + require.Equal(t, http.StatusOK, resp.StatusCode(), "body: %s", string(resp.Body)) |
| 144 | + require.NotNil(t, resp.JSON200) |
| 145 | + |
| 146 | + keys := lo.Map(resp.JSON200.Items, func(p api.Plan, _ int) string { return p.Key }) |
| 147 | + assert.NotContains(t, keys, ucPlanKey, "unit_config plan must be excluded from the v1 list") |
| 148 | + assert.Contains(t, keys, plainPlanKey, "plain plan must remain in the v1 list") |
| 149 | + assert.Equal(t, 1, resp.JSON200.TotalCount, "TotalCount must reflect the filtered set, not the raw count") |
| 150 | + }) |
| 151 | + |
| 152 | + // and: |
| 153 | + // - a v1 subscription created from the unit_config plan BY KEY still succeeds (read ≠ subscribe; the |
| 154 | + // server rates it correctly per OM-399, only the read surfaces are restricted). |
| 155 | + var subscriptionID string |
| 156 | + runRequired(t, "v1 subscribe-by-plan-key from the unit_config plan succeeds", func(t *testing.T) { |
| 157 | + customer := CreateCustomerWithSubject(t, v1, customerKey, subjectKey) |
| 158 | + require.NotNil(t, customer) |
| 159 | + |
| 160 | + timing := &api.SubscriptionTiming{} |
| 161 | + require.NoError(t, timing.FromSubscriptionTimingEnum(api.SubscriptionTimingEnumImmediate)) |
| 162 | + |
| 163 | + body := api.SubscriptionCreate{} |
| 164 | + require.NoError(t, body.FromPlanSubscriptionCreate(api.PlanSubscriptionCreate{ |
| 165 | + Timing: timing, |
| 166 | + CustomerId: lo.ToPtr(customer.Id), |
| 167 | + Plan: api.PlanReferenceInput{ |
| 168 | + Key: ucPlanKey, |
| 169 | + Version: lo.ToPtr(1), |
| 170 | + }, |
| 171 | + })) |
| 172 | + |
| 173 | + resp, err := v1.CreateSubscriptionWithResponse(t.Context(), body) |
| 174 | + require.NoError(t, err) |
| 175 | + require.Equal(t, http.StatusCreated, resp.StatusCode(), "body: %s", string(resp.Body)) |
| 176 | + require.NotNil(t, resp.JSON201) |
| 177 | + subscriptionID = resp.JSON201.Id |
| 178 | + }) |
| 179 | + |
| 180 | + // then: |
| 181 | + // - v1 GET on that subscription is rejected with the same typed code (its items carry the unit_config). |
| 182 | + runRequired(t, "v1 subscription GET rejects the unit_config subscription", func(t *testing.T) { |
| 183 | + require.NotEmpty(t, subscriptionID) |
| 184 | + resp, err := v1.GetSubscriptionWithResponse(t.Context(), subscriptionID, nil) |
| 185 | + require.NoError(t, err) |
| 186 | + require.Equal(t, http.StatusBadRequest, resp.StatusCode(), "body: %s", string(resp.Body)) |
| 187 | + require.NotNil(t, resp.ApplicationproblemJSON400) |
| 188 | + assert.Contains(t, string(resp.Body), unitConfigNotRepresentableCode, "the 400 must carry the typed unit_config code") |
| 189 | + }) |
| 190 | +} |
0 commit comments