From 5421acaba772c6bb6d4c874835de02130243aced Mon Sep 17 00:00:00 2001 From: Alex Goth <64845621+GAlexIHU@users.noreply.github.com> Date: Tue, 21 Jan 2025 09:59:21 +0100 Subject: [PATCH] Add more Tests for Subscription Edit (#2121) --- openmeter/entitlement/adapter/entitlement.go | 2 +- .../repo/subscriptionphaserepo.go | 1 + openmeter/subscription/service/sync.go | 90 ----- .../service/workflowservice_test.go | 315 +++++++++++++++++- openmeter/subscription/testutils/service.go | 33 +- openmeter/testutils/time.go | 8 + test/subscription/framework_test.go | 48 +++ test/subscription/scenario_editcancel_test.go | 210 ++++++++++++ 8 files changed, 601 insertions(+), 106 deletions(-) create mode 100644 test/subscription/framework_test.go create mode 100644 test/subscription/scenario_editcancel_test.go diff --git a/openmeter/entitlement/adapter/entitlement.go b/openmeter/entitlement/adapter/entitlement.go index cbe9e072a..0cc9f03de 100644 --- a/openmeter/entitlement/adapter/entitlement.go +++ b/openmeter/entitlement/adapter/entitlement.go @@ -85,7 +85,7 @@ func (a *entitlementDBAdapter) GetActiveEntitlementOfSubjectAt(ctx context.Conte db_entitlement.Namespace(namespace), db_entitlement.FeatureKey(featureKey), ). - First(ctx) + First(ctx) // FIXME: to better enforce consistency we should not use .First() but assert that there is only one result! if err != nil { if db.IsNotFound(err) { return nil, &entitlement.NotFoundError{ diff --git a/openmeter/subscription/repo/subscriptionphaserepo.go b/openmeter/subscription/repo/subscriptionphaserepo.go index e84dd00bd..d92f7ff69 100644 --- a/openmeter/subscription/repo/subscriptionphaserepo.go +++ b/openmeter/subscription/repo/subscriptionphaserepo.go @@ -28,6 +28,7 @@ func (r *subscriptionPhaseRepo) GetForSubscriptionAt(ctx context.Context, subscr return entutils.TransactingRepo(ctx, r, func(ctx context.Context, repo *subscriptionPhaseRepo) ([]subscription.SubscriptionPhase, error) { phases, err := repo.db.SubscriptionPhase.Query(). Where(dbsubscriptionphase.SubscriptionID(subscriptionID.ID)). + Where(dbsubscriptionphase.Namespace(subscriptionID.Namespace)). Where(dbsubscriptionphase.Or( dbsubscriptionphase.DeletedAtIsNil(), dbsubscriptionphase.DeletedAtGT(at), diff --git a/openmeter/subscription/service/sync.go b/openmeter/subscription/service/sync.go index 014d1fc49..955d3bc17 100644 --- a/openmeter/subscription/service/sync.go +++ b/openmeter/subscription/service/sync.go @@ -161,17 +161,6 @@ func (s *service) sync(ctx context.Context, view subscription.SubscriptionView, return def, fmt.Errorf("failed to convert item to entity input: %w", err) } - // Let's try to figure out what the cadence of new items would be - cadenceOfThisPhaseBasedOnNewSpec, err := newSpec.GetPhaseCadence(matchingPhaseFromNewSpec.PhaseKey) - if err != nil { - return def, fmt.Errorf("failed to get cadence for phase %s: %w", matchingPhaseFromNewSpec.PhaseKey, err) - } - - cadenceForItem, err := matchingItemFromNewSpec.GetCadence(cadenceOfThisPhaseBasedOnNewSpec) - if err != nil { - return def, fmt.Errorf("failed to get cadence for item %s: %w", matchingItemFromNewSpec.ItemKey, err) - } - // Here we don't preamptively know all the properties but fortunately all we need to know is whether they'd change or not // We're prepopulating changing fields with invalid values, which is a lie and a bad method, but it's necessary for now due to the hard linking @@ -208,63 +197,6 @@ func (s *service) sync(ctx context.Context, view subscription.SubscriptionView, // There's nothing more to be done here, so lets skip to the next one continue } - - // Second, let's check if the entitlement needs to be changed - // Let's not pollute the scope - { - // Let's figure out what the cadence for the new entitlement should be - cadenceForEntitlement := cadenceForItem - - hasCurrEnt := currentItemView.Entitlement != nil - newEntInp, hasNewEnt, err := matchingItemFromNewSpec.ToScheduleSubscriptionEntitlementInput( - view.Customer, - cadenceForEntitlement, - ) - if err != nil { - return def, fmt.Errorf("failed to determine entitlement input for item %s: %w", currentItemView.SubscriptionItem.Key, err) - } - - // If there was an entitlement and now there isnt we should delete it - if hasCurrEnt && !hasNewEnt { - if err := s.EntitlementAdapter.DeleteByItemID(ctx, currentItemView.SubscriptionItem.NamespacedID); err != nil { - return def, fmt.Errorf("failed to delete entitlement: %w", err) - } - - dirty.mark(NewEntitlementPath(currentItemView.Spec.PhaseKey, currentItemView.Spec.ItemKey, currentItemIdx, currentItemView.Entitlement.Entitlement.FeatureKey)) - - // nothing more to do here - continue - } - - // If there was an entitlement and now its different we should delete it - if hasCurrEnt && hasNewEnt { - // Let's compare if it needs changing - - // We can compare the two to see if it needs changing - currToCompare := currentItemView.Entitlement.ToScheduleSubscriptionEntitlementInput() - if err := newEntInp.CreateEntitlementInputs.Validate(); err != nil { - return def, fmt.Errorf("failed to validate new entitlement input: %w", err) - } - - // We have to be careful of feature comparison, the current will have feature ID information while the new will not - if newEntInp.CreateEntitlementInputs.FeatureID == nil { - currToCompare.CreateEntitlementInputs.FeatureID = nil - } else if newEntInp.CreateEntitlementInputs.FeatureKey == nil { - currToCompare.CreateEntitlementInputs.FeatureKey = nil - } - - if !currToCompare.Equal(newEntInp) { - if err := s.EntitlementAdapter.DeleteByItemID(ctx, currentItemView.SubscriptionItem.NamespacedID); err != nil { - return def, fmt.Errorf("failed to delete entitlement: %w", err) - } - - dirty.mark(NewEntitlementPath(currentItemView.Spec.PhaseKey, currentItemView.Spec.ItemKey, currentItemIdx, currentItemView.Entitlement.Entitlement.FeatureKey)) - - // nothing more to do here - continue - } - } - } } } } @@ -337,28 +269,6 @@ func (s *service) sync(ctx context.Context, view subscription.SubscriptionView, // There's nothing more to be done for this item, so lets skip to the next one continue } - - // Finally, let's check the entitlement of it - - // First lets get the item cadence - itemCadence, err := matchingItemFromNewSpec.GetCadence(newPhaseCadence) - if err != nil { - return def, fmt.Errorf("failed to get cadence for item %s: %w", matchingItemFromNewSpec.ItemKey, err) - } - - newEntInp, hasNewEnt, err := matchingItemFromNewSpec.ToScheduleSubscriptionEntitlementInput( - view.Customer, - itemCadence, // entitlement cadence will be same as item cadence - ) - if err != nil { - return def, fmt.Errorf("failed to determine entitlement input for item %s: %w", currentItemView.SubscriptionItem.Key, err) - } - - if hasNewEnt && dirty.isTouched(NewEntitlementPath(currentItemView.Spec.PhaseKey, currentItemView.Spec.ItemKey, currentItemIdx, currentItemView.Entitlement.Entitlement.FeatureKey)) { - if _, err := s.EntitlementAdapter.ScheduleEntitlement(ctx, newEntInp); err != nil { - return def, fmt.Errorf("failed to schedule entitlement for item %s: %w", currentItemView.SubscriptionItem.Key, err) - } - } } } } diff --git a/openmeter/subscription/service/workflowservice_test.go b/openmeter/subscription/service/workflowservice_test.go index f73434563..b7eb93f7b 100644 --- a/openmeter/subscription/service/workflowservice_test.go +++ b/openmeter/subscription/service/workflowservice_test.go @@ -17,7 +17,9 @@ import ( "github.com/openmeterio/openmeter/openmeter/productcatalog" "github.com/openmeterio/openmeter/openmeter/productcatalog/feature" "github.com/openmeterio/openmeter/openmeter/productcatalog/plan" + "github.com/openmeterio/openmeter/openmeter/registry" "github.com/openmeterio/openmeter/openmeter/subscription" + "github.com/openmeterio/openmeter/openmeter/subscription/patch" "github.com/openmeterio/openmeter/openmeter/subscription/service" subscriptiontestutils "github.com/openmeterio/openmeter/openmeter/subscription/testutils" "github.com/openmeterio/openmeter/openmeter/testutils" @@ -511,7 +513,196 @@ func TestEditRunning(t *testing.T) { } func TestEditingCurrentPhase(t *testing.T) { - t.Skip("TODO: implement me") + type testCaseDeps struct { + CurrentTime time.Time + SubView subscription.SubscriptionView + Customer customerentity.Customer + WorkflowService subscription.WorkflowService + Service subscription.Service + ItemRepo subscription.SubscriptionItemRepository + DBDeps *subscriptiontestutils.DBDeps + Plan subscription.Plan + EntReg *registry.Entitlement + } + + testCases := []struct { + Name string + Handler func(t *testing.T, deps testCaseDeps) + }{ + { + Name: "Should remove item WITHOUT entitlement from the current phase starting now", + Handler: func(t *testing.T, deps testCaseDeps) { + second_phase_key := "test_phase_2" + item_key := "rate-card-2" + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + // Let's assert we have two items in the second phase + require.GreaterOrEqual(t, len(deps.SubView.Phases), 2, "expected at least two phases") + require.GreaterOrEqual(t, len(deps.SubView.Phases[1].ItemsByKey), 2, "expected at least two items in the second phase") + require.Equal(t, second_phase_key, deps.SubView.Phases[1].SubscriptionPhase.Key, "expected the second phase to be of known key") + itOrigi, ok := deps.SubView.Phases[1].ItemsByKey[item_key] + require.True(t, ok, "expected item to be present in the second phase") + require.Len(t, itOrigi, 1, "expected one item to be present") + + // Let's assert the second phase starts when we expect it to + require.Equal(t, deps.CurrentTime.AddDate(0, 1, 0), deps.SubView.Phases[1].SubscriptionPhase.ActiveFrom, "expected the second phase to start in a month") + + // Let's advance the clock into the 2nd phase where we have two items + currentTime := deps.CurrentTime.AddDate(0, 1, 1) + clock.SetTime(currentTime) + // Let's freeze the time so we can assert properly + + // Let's remove the item without feature & entitlement + s, err := deps.WorkflowService.EditRunning(ctx, deps.SubView.Subscription.NamespacedID, []subscription.Patch{ + patch.PatchRemoveItem{ + PhaseKey: second_phase_key, + ItemKey: item_key, + }, + }) + require.Nil(t, err) + require.NotNil(t, s) + + // Let's fetch the edited subscription and check that the item was removed effective now + subView, err := deps.Service.GetView(ctx, deps.SubView.Subscription.NamespacedID) + require.Nil(t, err) + + // Let's assert that the item is present and has been marked as inactive at the given time + items, ok := subView.Phases[1].ItemsByKey[item_key] + require.True(t, ok, "expected item to be present in the second phase") + assert.Len(t, items, 1, "expected one item to be present") + + tolerance := 5 * time.Second + testutils.TimeEqualsApproximately(t, currentTime, *items[0].SubscriptionItem.ActiveTo, tolerance) + + // Let's check that the item did get deleted in the background + // For this, we'll need to do a bit of time travel + timeBeforeTravel := clock.Now() + clock.SetTime(currentTime.AddDate(0, 0, -1)) + + it, err := deps.ItemRepo.GetByID(ctx, itOrigi[0].SubscriptionItem.NamespacedID) + require.NoError(t, err) + + testutils.TimeEqualsApproximately(t, currentTime, *it.DeletedAt, tolerance) + + clock.SetTime(timeBeforeTravel) + }, + }, + { + Name: "Should remove item WITH entitlement from the current phase starting now", + Handler: func(t *testing.T, deps testCaseDeps) { + second_phase_key := "test_phase_2" + item_key := subscriptiontestutils.ExampleFeatureKey + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + // Let's assert we have two items in the second phase + require.GreaterOrEqual(t, len(deps.SubView.Phases), 2, "expected at least two phases") + require.GreaterOrEqual(t, len(deps.SubView.Phases[1].ItemsByKey), 2, "expected at least two items in the second phase") + require.Equal(t, second_phase_key, deps.SubView.Phases[1].SubscriptionPhase.Key, "expected the second phase to be of known key") + itOrigi, ok := deps.SubView.Phases[1].ItemsByKey[item_key] + require.True(t, ok, "expected item to be present in the second phase") + require.Len(t, itOrigi, 1, "expected one item to be present") + + // Let's assert the second phase starts when we expect it to + require.Equal(t, deps.CurrentTime.AddDate(0, 1, 0), deps.SubView.Phases[1].SubscriptionPhase.ActiveFrom, "expected the second phase to start in a month") + + // Let's advance the clock into the 2nd phase where we have two items + currentTime := deps.CurrentTime.AddDate(0, 1, 1) + clock.SetTime(currentTime) + // Let's freeze the time so we can assert properly + + // Let's remove the item without + s, err := deps.WorkflowService.EditRunning(ctx, deps.SubView.Subscription.NamespacedID, []subscription.Patch{ + patch.PatchRemoveItem{ + PhaseKey: second_phase_key, + ItemKey: item_key, + }, + }) + require.Nil(t, err) + require.NotNil(t, s) + + // Let's fetch the edited subscription and check that the item was removed effective now + subView, err := deps.Service.GetView(ctx, deps.SubView.Subscription.NamespacedID) + require.Nil(t, err) + + // Let's assert that the item is present and has been marked as inactive at the given time + items, ok := subView.Phases[1].ItemsByKey[item_key] + require.True(t, ok, "expected item to be present in the second phase") + assert.Len(t, items, 1, "expected one item to be present") + + tolerance := 5 * time.Second + testutils.TimeEqualsApproximately(t, currentTime, *items[0].SubscriptionItem.ActiveTo, tolerance) + + // Let's check that the item & entitlement did get deleted in the background + // For this, we'll need to do a bit of time travel + timeBeforeTravel := clock.Now() + clock.SetTime(currentTime.AddDate(0, 0, -1)) + + it, err := deps.ItemRepo.GetByID(ctx, itOrigi[0].SubscriptionItem.NamespacedID) + require.NoError(t, err) + + testutils.TimeEqualsApproximately(t, currentTime, *it.DeletedAt, tolerance) + + require.NotNil(t, it.EntitlementID) + + ent, err := deps.EntReg.EntitlementRepo.GetEntitlement(ctx, models.NamespacedID{ + Namespace: it.Namespace, + ID: *it.EntitlementID, + }) + require.Nil(t, err) + + testutils.TimeEqualsApproximately(t, currentTime, *ent.DeletedAt, tolerance) + + clock.SetTime(timeBeforeTravel) + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.Name, func(t *testing.T) { + tcDeps := testCaseDeps{ + CurrentTime: testutils.GetRFC3339Time(t, "2021-01-01T00:00:00Z"), + } + + clock.SetTime(tcDeps.CurrentTime) + + // Let's build the dependencies + dbDeps := subscriptiontestutils.SetupDBDeps(t) + require.NotNil(t, dbDeps) + defer dbDeps.Cleanup(t) + + services, deps := subscriptiontestutils.NewService(t, dbDeps) + deps.FeatureConnector.CreateExampleFeature(t) + plan := deps.PlanHelper.CreatePlan(t, subscriptiontestutils.GetExamplePlanInput(t)) + cust := deps.CustomerAdapter.CreateExampleCustomer(t) + require.NotNil(t, cust) + + // Let's create an example subscription + sub, err := services.WorkflowService.CreateFromPlan(context.Background(), subscription.CreateSubscriptionWorkflowInput{ + ChangeSubscriptionWorkflowInput: subscription.ChangeSubscriptionWorkflowInput{ + ActiveFrom: tcDeps.CurrentTime, + Name: "Example Subscription", + }, + CustomerID: cust.ID, + Namespace: subscriptiontestutils.ExampleNamespace, + }, plan) + require.Nil(t, err) + + tcDeps.SubView = sub + tcDeps.Customer = *cust + tcDeps.DBDeps = dbDeps + tcDeps.Service = services.Service + tcDeps.WorkflowService = services.WorkflowService + tcDeps.Plan = plan + tcDeps.ItemRepo = deps.ItemRepo + tcDeps.EntReg = deps.EntitlementRegistry + + tc.Handler(t, tcDeps) + }) + } } func TestChangeToPlan(t *testing.T) { @@ -703,3 +894,125 @@ func TestChangeToPlan(t *testing.T) { }) }) } + +func TestEditCombinations(t *testing.T) { + examplePlanInput1 := subscriptiontestutils.GetExamplePlanInput(t) + + // Let's define what deps a test case needs + type testCaseDeps struct { + CurrentTime time.Time + Customer customerentity.Customer + WorkflowService subscription.WorkflowService + Service subscription.Service + DBDeps *subscriptiontestutils.DBDeps + Plan1 subscription.Plan + } + + withDeps := func(t *testing.T) func(fn func(t *testing.T, deps testCaseDeps)) { + return func(fn func(t *testing.T, deps testCaseDeps)) { + tcDeps := testCaseDeps{ + CurrentTime: testutils.GetRFC3339Time(t, "2021-01-01T00:00:00Z"), + } + + clock.SetTime(tcDeps.CurrentTime) + + // Let's build the dependencies + dbDeps := subscriptiontestutils.SetupDBDeps(t) + require.NotNil(t, dbDeps) + defer dbDeps.Cleanup(t) + + services, deps := subscriptiontestutils.NewService(t, dbDeps) + deps.FeatureConnector.CreateExampleFeature(t) + + // Let's create the plan + plan1 := deps.PlanHelper.CreatePlan(t, examplePlanInput1) + + cust := deps.CustomerAdapter.CreateExampleCustomer(t) + require.NotNil(t, cust) + + tcDeps.Customer = *cust + tcDeps.DBDeps = dbDeps + tcDeps.Service = services.Service + tcDeps.WorkflowService = services.WorkflowService + tcDeps.Plan1 = plan1 + + fn(t, tcDeps) + } + } + + t.Run("Should be able to cancel an edited subscription", func(t *testing.T) { + withDeps(t)(func(t *testing.T, deps testCaseDeps) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + // Let's create an example subscription + sub, err := deps.WorkflowService.CreateFromPlan(context.Background(), subscription.CreateSubscriptionWorkflowInput{ + ChangeSubscriptionWorkflowInput: subscription.ChangeSubscriptionWorkflowInput{ + ActiveFrom: deps.CurrentTime, + Name: "Example Subscription", + }, + CustomerID: deps.Customer.ID, + Namespace: subscriptiontestutils.ExampleNamespace, + }, deps.Plan1) + require.Nil(t, err) + + // Let's make sure the sub looks as we expect it to + require.Equal(t, "test_phase_1", sub.Phases[0].SubscriptionPhase.Key) + + // Let's make sure it has the rate card we're editing + values, ok := sub.Phases[1].ItemsByKey[subscriptiontestutils.ExampleFeatureKey] + require.True(t, ok) + require.Equal(t, 1, len(values)) + val := values[0] + + // Let's edit the subscription + edits := []subscription.Patch{ + // Let's edit an Item that has an Entitlement Associated + patch.PatchRemoveItem{ + PhaseKey: "test_phase_1", + ItemKey: subscriptiontestutils.ExampleFeatureKey, + }, + patch.PatchAddItem{ + PhaseKey: "test_phase_1", + ItemKey: subscriptiontestutils.ExampleFeatureKey, + CreateInput: subscription.SubscriptionItemSpec{ + CreateSubscriptionItemInput: subscription.CreateSubscriptionItemInput{ + CreateSubscriptionItemPlanInput: subscription.CreateSubscriptionItemPlanInput{ + PhaseKey: "test_phase_1", + ItemKey: subscriptiontestutils.ExampleFeatureKey, + RateCard: subscription.RateCard{ + Name: val.Spec.RateCard.Name, + Description: val.Spec.RateCard.Description, + FeatureKey: val.Spec.RateCard.FeatureKey, + EntitlementTemplate: val.Spec.RateCard.EntitlementTemplate, + TaxConfig: val.Spec.RateCard.TaxConfig, + Price: productcatalog.NewPriceFrom(productcatalog.UnitPrice{Amount: alpacadecimal.NewFromInt(19)}), + BillingCadence: val.Spec.RateCard.BillingCadence, + }, + }, + CreateSubscriptionItemCustomerInput: subscription.CreateSubscriptionItemCustomerInput{}, + }, + }, + }, + } + + // Let 5 minutes pass + clock.SetTime(deps.CurrentTime.Add(5 * time.Minute)) + + _, err = deps.WorkflowService.EditRunning(ctx, sub.Subscription.NamespacedID, edits) + require.Nil(t, err) + + // Now let's fetch the view + view, err := deps.Service.GetView(ctx, sub.Subscription.NamespacedID) + require.Nil(t, err) + + require.Equal(t, sub.Subscription.NamespacedID, view.Subscription.NamespacedID) + + // Now let's cancel the subscription + s, err := deps.Service.Cancel(ctx, sub.Subscription.NamespacedID, clock.Now().Add(-time.Minute)) + require.Nil(t, err) + + require.Equal(t, subscription.SubscriptionStatusInactive, s.GetStatusAt(clock.Now())) + }) + }) +} diff --git a/openmeter/subscription/testutils/service.go b/openmeter/subscription/testutils/service.go index 17d8de9ad..a348432ba 100644 --- a/openmeter/subscription/testutils/service.go +++ b/openmeter/subscription/testutils/service.go @@ -10,6 +10,7 @@ import ( "github.com/openmeterio/openmeter/openmeter/productcatalog/plan" planrepo "github.com/openmeterio/openmeter/openmeter/productcatalog/plan/adapter" planservice "github.com/openmeterio/openmeter/openmeter/productcatalog/plan/service" + "github.com/openmeterio/openmeter/openmeter/registry" registrybuilder "github.com/openmeterio/openmeter/openmeter/registry/builder" streamingtestutils "github.com/openmeterio/openmeter/openmeter/streaming/testutils" "github.com/openmeterio/openmeter/openmeter/subscription" @@ -21,13 +22,15 @@ import ( ) type ExposedServiceDeps struct { - CustomerAdapter *testCustomerRepo - CustomerService customer.Service - FeatureConnector *testFeatureConnector - EntitlementAdapter subscription.EntitlementAdapter - PlanHelper *planHelper - PlanService plan.Service - DBDeps *DBDeps + ItemRepo subscription.SubscriptionItemRepository + CustomerAdapter *testCustomerRepo + CustomerService customer.Service + FeatureConnector *testFeatureConnector + EntitlementAdapter subscription.EntitlementAdapter + PlanHelper *planHelper + PlanService plan.Service + DBDeps *DBDeps + EntitlementRegistry *registry.Entitlement } type services struct { @@ -100,12 +103,14 @@ func NewService(t *testing.T, dbDeps *DBDeps) (services, ExposedServiceDeps) { Service: svc, WorkflowService: workflowSvc, }, ExposedServiceDeps{ - CustomerAdapter: customerAdapter, - CustomerService: customer, - FeatureConnector: NewTestFeatureConnector(entitlementRegistry.Feature), - EntitlementAdapter: entitlementAdapter, - DBDeps: dbDeps, - PlanHelper: planHelper, - PlanService: planService, + CustomerAdapter: customerAdapter, + CustomerService: customer, + FeatureConnector: NewTestFeatureConnector(entitlementRegistry.Feature), + EntitlementAdapter: entitlementAdapter, + DBDeps: dbDeps, + PlanHelper: planHelper, + PlanService: planService, + ItemRepo: subItemRepo, + EntitlementRegistry: entitlementRegistry, } } diff --git a/openmeter/testutils/time.go b/openmeter/testutils/time.go index d5f82166a..336b80811 100644 --- a/openmeter/testutils/time.go +++ b/openmeter/testutils/time.go @@ -24,3 +24,11 @@ func GetISODuration(t *testing.T, durationString string) datex.Period { } return d } + +func TimeEqualsApproximately(t *testing.T, expected time.Time, actual time.Time, tolerance time.Duration) { + t.Helper() + if expected.Before(actual.Add(tolerance)) && expected.After(actual.Add(-tolerance)) { + return + } + t.Fatalf("Expected %v but got %v, outside tolerance of %v", expected, actual, tolerance) +} diff --git a/test/subscription/framework_test.go b/test/subscription/framework_test.go new file mode 100644 index 000000000..cf28f2f18 --- /dev/null +++ b/test/subscription/framework_test.go @@ -0,0 +1,48 @@ +package subscription_test + +import ( + "testing" + + "github.com/stretchr/testify/require" + + pcsubscription "github.com/openmeterio/openmeter/openmeter/productcatalog/subscription" + pcsubscriptionservice "github.com/openmeterio/openmeter/openmeter/productcatalog/subscription/service" + "github.com/openmeterio/openmeter/openmeter/subscription" + subscriptiontestutils "github.com/openmeterio/openmeter/openmeter/subscription/testutils" + "github.com/openmeterio/openmeter/openmeter/testutils" +) + +type testDeps struct { + subscriptiontestutils.ExposedServiceDeps + pcSubscriptionService pcsubscription.PlanSubscriptionService + subscriptionService subscription.Service + subscriptionWorkflowService subscription.WorkflowService + cleanup func(t *testing.T) // Cleanup function +} + +type setupConfig struct{} + +func setup(t *testing.T, _ setupConfig) testDeps { + t.Helper() + + // Let's build the dependencies + dbDeps := subscriptiontestutils.SetupDBDeps(t) + require.NotNil(t, dbDeps) + + services, deps := subscriptiontestutils.NewService(t, dbDeps) + + pcSubsService := pcsubscriptionservice.New(pcsubscriptionservice.Config{ + WorkflowService: services.WorkflowService, + SubscriptionService: services.Service, + PlanService: deps.PlanService, + Logger: testutils.NewLogger(t), + }) + + return testDeps{ + ExposedServiceDeps: deps, + pcSubscriptionService: pcSubsService, + subscriptionService: services.Service, + subscriptionWorkflowService: services.WorkflowService, + cleanup: dbDeps.Cleanup, + } +} diff --git a/test/subscription/scenario_editcancel_test.go b/test/subscription/scenario_editcancel_test.go new file mode 100644 index 000000000..4f227257e --- /dev/null +++ b/test/subscription/scenario_editcancel_test.go @@ -0,0 +1,210 @@ +package subscription_test + +import ( + "context" + "fmt" + "testing" + "time" + + "github.com/alpacahq/alpacadecimal" + "github.com/samber/lo" + "github.com/stretchr/testify/require" + + customerentity "github.com/openmeterio/openmeter/openmeter/customer/entity" + "github.com/openmeterio/openmeter/openmeter/productcatalog" + "github.com/openmeterio/openmeter/openmeter/productcatalog/feature" + "github.com/openmeterio/openmeter/openmeter/productcatalog/plan" + pcsubscription "github.com/openmeterio/openmeter/openmeter/productcatalog/subscription" + "github.com/openmeterio/openmeter/openmeter/subscription" + "github.com/openmeterio/openmeter/openmeter/subscription/patch" + subscriptiontestutils "github.com/openmeterio/openmeter/openmeter/subscription/testutils" + "github.com/openmeterio/openmeter/openmeter/testutils" + "github.com/openmeterio/openmeter/pkg/clock" + "github.com/openmeterio/openmeter/pkg/models" +) + +func TestEditingAndCanceling(t *testing.T) { + // Let's declare our variables + namespace := "example" + + currentTime := testutils.GetRFC3339Time(t, "2025-01-20T13:11:07Z") + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + tDeps := setup(t, setupConfig{}) + defer tDeps.cleanup(t) + + clock.SetTime(currentTime) + + // First, let's create the features + f, err := tDeps.FeatureConnector.CreateFeature(ctx, feature.CreateFeatureInputs{ + Name: "Example Feature", + Key: "test_feature_1", + Namespace: namespace, + MeterSlug: lo.ToPtr(subscriptiontestutils.ExampleFeatureMeterSlug), + }) + require.NoError(t, err) + + // Second, let's create the plan + p, err := tDeps.PlanService.CreatePlan(ctx, plan.CreatePlanInput{ + NamespacedModel: models.NamespacedModel{ + Namespace: namespace, + }, + Plan: productcatalog.Plan{ + PlanMeta: productcatalog.PlanMeta{ + Name: "Test Plan", + Key: "test_plan", + Currency: "USD", + }, + Phases: []productcatalog.Phase{ + { + PhaseMeta: productcatalog.PhaseMeta{ + Key: "default", + Name: "Default Phase", + Duration: nil, + }, + RateCards: productcatalog.RateCards{ + &productcatalog.UsageBasedRateCard{ + RateCardMeta: productcatalog.RateCardMeta{ + Key: "test_feature_1", + Name: "Test Rate Card", + Feature: &f, + Price: productcatalog.NewPriceFrom(productcatalog.UnitPrice{ + Amount: alpacadecimal.NewFromInt(100), + }), + TaxConfig: &productcatalog.TaxConfig{ + Stripe: &productcatalog.StripeTaxConfig{ + Code: "txcd_10000000", + }, + }, + EntitlementTemplate: productcatalog.NewEntitlementTemplateFrom(productcatalog.BooleanEntitlementTemplate{}), + }, + BillingCadence: testutils.GetISODuration(t, "P1M"), + }, + }, + }, + }, + }, + }) + require.NoError(t, err) + + p, err = tDeps.PlanService.PublishPlan(ctx, plan.PublishPlanInput{ + NamespacedID: p.NamespacedID, + EffectivePeriod: productcatalog.EffectivePeriod{ + EffectiveFrom: lo.ToPtr(currentTime), + }, + }) + require.NoError(t, err) + + // Third, let's create the customer + c, err := tDeps.CustomerService.CreateCustomer(ctx, customerentity.CreateCustomerInput{ + Namespace: namespace, + CustomerMutate: customerentity.CustomerMutate{ + Name: "Test Customer", + UsageAttribution: customerentity.CustomerUsageAttribution{ + SubjectKeys: []string{"subject_1"}, + }, + }, + }) + require.NoError(t, err) + + pi := &pcsubscription.PlanInput{} + pi.FromRef(&pcsubscription.PlanRefInput{ + Key: p.Key, + Version: &p.Version, + }) + + // And let's create extra customers + custs := []*customerentity.Customer{} + for i := 0; i < 10; i++ { + c, err := tDeps.CustomerService.CreateCustomer(ctx, customerentity.CreateCustomerInput{ + Namespace: namespace, + CustomerMutate: customerentity.CustomerMutate{ + Name: "Test Customer", + UsageAttribution: customerentity.CustomerUsageAttribution{ + SubjectKeys: []string{fmt.Sprintf("subject_%d", i+2)}, + }, + }, + }) + require.NoError(t, err) + custs = append(custs, c) + } + + // Fourth, let's create the subscription + s, err := tDeps.pcSubscriptionService.Create(ctx, pcsubscription.CreateSubscriptionRequest{ + WorkflowInput: subscription.CreateSubscriptionWorkflowInput{ + Namespace: namespace, + CustomerID: c.ID, + ChangeSubscriptionWorkflowInput: subscription.ChangeSubscriptionWorkflowInput{ + ActiveFrom: currentTime, + Name: "Test Subscription", + }, + }, + PlanInput: *pi, + }) + require.NoError(t, err) + require.NotNil(t, s) + + // And let's subscribe the extra customers + for _, cust := range custs { + s, err := tDeps.pcSubscriptionService.Create(ctx, pcsubscription.CreateSubscriptionRequest{ + WorkflowInput: subscription.CreateSubscriptionWorkflowInput{ + Namespace: namespace, + CustomerID: cust.ID, + ChangeSubscriptionWorkflowInput: subscription.ChangeSubscriptionWorkflowInput{ + ActiveFrom: currentTime, + Name: "Test Subscription", + }, + }, + PlanInput: *pi, + }) + require.NoError(t, err) + require.NotNil(t, s) + } + + currentTime = currentTime.Add(time.Minute) + clock.SetTime(currentTime) + + // Fifth, let's edit the subscription + _, err = tDeps.subscriptionWorkflowService.EditRunning(ctx, s.NamespacedID, []subscription.Patch{ + patch.PatchRemoveItem{ + ItemKey: "test_feature_1", + PhaseKey: "default", + }, + patch.PatchAddItem{ + PhaseKey: "default", + ItemKey: "test_feature_1", + CreateInput: subscription.SubscriptionItemSpec{ + CreateSubscriptionItemInput: subscription.CreateSubscriptionItemInput{ + CreateSubscriptionItemPlanInput: subscription.CreateSubscriptionItemPlanInput{ + PhaseKey: "default", + ItemKey: "test_feature_1", + RateCard: subscription.RateCard{ + Name: "Test Rate Card", + FeatureKey: lo.ToPtr("test_feature_1"), + EntitlementTemplate: productcatalog.NewEntitlementTemplateFrom(productcatalog.BooleanEntitlementTemplate{}), + Price: productcatalog.NewPriceFrom(productcatalog.UnitPrice{ + Amount: alpacadecimal.NewFromInt(101), + }), + TaxConfig: &productcatalog.TaxConfig{ + Stripe: &productcatalog.StripeTaxConfig{ + Code: "txcd_10000000", + }, + }, + BillingCadence: lo.ToPtr(testutils.GetISODuration(t, "P1M")), + }, + }, + CreateSubscriptionItemCustomerInput: subscription.CreateSubscriptionItemCustomerInput{}, + }, + }, + }, + }) + require.NoError(t, err) + + currentTime = currentTime.Add(time.Minute) + clock.SetTime(currentTime) + + // Sixth, let's cancel the subscription + _, err = tDeps.subscriptionService.Cancel(ctx, s.NamespacedID, currentTime) + require.NoError(t, err) +}