| 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 TestV3PlanAddonAttachLifecycle(t *testing.T) { |
| c := newV3Client(t) |
|
|
| |
| |
| trialPhase := validPlanPhase("trial", false ) |
| standardPhase := validPlanPhase("standard", true ) |
|
|
| planBody := validPlanRequest("test_v3_plan_addon_golden") |
| planBody.Phases = []v3sdk.PlanPhaseInput{trialPhase, standardPhase} |
|
|
| addonBody := validAddonRequest("test_v3_plan_addon_golden_addon") |
|
|
| var planID, addonID, planAddonID string |
|
|
| t.Run("Should create a draft plan with two phases", func(t *testing.T) { |
| plan, err := c.Plans.Create(t.Context(), planBody) |
| c.requireStatus(http.StatusCreated, err) |
| require.NotNil(t, plan) |
| require.Len(t, plan.Phases, 2) |
|
|
| planID = plan.ID |
| }) |
|
|
| t.Run("Should create and publish the addon", func(t *testing.T) { |
| addon, err := c.Addons.Create(t.Context(), addonBody) |
| c.requireStatus(http.StatusCreated, err) |
| require.NotNil(t, addon) |
| addonID = addon.ID |
|
|
| addon, err = c.Addons.Publish(t.Context(), addonID) |
| c.requireStatus(http.StatusOK, err) |
| require.NotNil(t, addon) |
| require.Equal(t, v3sdk.AddonStatusActive, addon.Status) |
| }) |
|
|
| t.Run("Should attach the addon to the plan at the standard phase", func(t *testing.T) { |
| require.NotEmpty(t, planID) |
| require.NotEmpty(t, addonID) |
|
|
| body := validPlanAddonRequest(standardPhase.Key, addonID) |
|
|
| planAddon, err := c.PlanAddons.Create(t.Context(), planID, body) |
| c.requireStatus(http.StatusCreated, err) |
| require.NotNil(t, planAddon) |
|
|
| assert.Equal(t, standardPhase.Key, planAddon.FromPlanPhase) |
| assert.Equal(t, addonID, planAddon.Addon.ID) |
|
|
| planAddonID = planAddon.ID |
| }) |
|
|
| t.Run("Should get the plan-addon junction", func(t *testing.T) { |
| require.NotEmpty(t, planAddonID) |
|
|
| planAddon, err := c.PlanAddons.Get(t.Context(), planID, planAddonID) |
| c.requireStatus(http.StatusOK, err) |
| require.NotNil(t, planAddon) |
|
|
| assert.Equal(t, planAddonID, planAddon.ID) |
| assert.Equal(t, standardPhase.Key, planAddon.FromPlanPhase) |
| assert.Equal(t, addonID, planAddon.Addon.ID) |
| }) |
|
|
| t.Run("Should list plan-addons and find the attached addon", func(t *testing.T) { |
| require.NotEmpty(t, planAddonID) |
|
|
| page, err := c.PlanAddons.List(t.Context(), planID, v3sdk.PlanAddonListParams{}) |
| c.requireStatus(http.StatusOK, err) |
| require.NotNil(t, page) |
|
|
| found := false |
| for _, pa := range page.Data { |
| if pa.ID == planAddonID { |
| found = true |
| break |
| } |
| } |
| assert.True(t, found, "attached plan-addon not found in list") |
| }) |
|
|
| t.Run("Should update the junction to attach from the trial phase", func(t *testing.T) { |
| require.NotEmpty(t, planAddonID) |
|
|
| update := v3sdk.UpsertPlanAddonRequest{ |
| Name: "Test Plan Addon", |
| FromPlanPhase: trialPhase.Key, |
| } |
|
|
| planAddon, err := c.PlanAddons.Update(t.Context(), planID, planAddonID, update) |
| c.requireStatus(http.StatusOK, err) |
| require.NotNil(t, planAddon) |
|
|
| assert.Equal(t, trialPhase.Key, planAddon.FromPlanPhase) |
| }) |
|
|
| t.Run("Should detach the addon from the plan", func(t *testing.T) { |
| require.NotEmpty(t, planAddonID) |
|
|
| err := c.PlanAddons.Delete(t.Context(), planID, planAddonID) |
| c.requireStatus(http.StatusNoContent, err) |
| }) |
|
|
| t.Run("Should not find the junction in the list after detach", func(t *testing.T) { |
| require.NotEmpty(t, planAddonID) |
|
|
| page, err := c.PlanAddons.List(t.Context(), planID, v3sdk.PlanAddonListParams{}) |
| c.requireStatus(http.StatusOK, err) |
| require.NotNil(t, page) |
|
|
| for _, pa := range page.Data { |
| assert.NotEqual(t, planAddonID, pa.ID, "detached plan-addon still present in list") |
| } |
| }) |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| func TestV3PlanAddonAttachStatusMatrix(t *testing.T) { |
| cases := []struct { |
| name string |
| planStatus v3sdk.PlanStatus |
| addonStatus v3sdk.AddonStatus |
| expectedStatus int |
| expectedDetailIn string |
| }{ |
| { |
| name: "draft plan + active addon → 201", |
| planStatus: v3sdk.PlanStatusDraft, |
| addonStatus: v3sdk.AddonStatusActive, |
| expectedStatus: http.StatusCreated, |
| }, |
| { |
| name: "active plan + active addon → 400 (plan must be draft or scheduled)", |
| planStatus: v3sdk.PlanStatusActive, |
| addonStatus: v3sdk.AddonStatusActive, |
| expectedStatus: http.StatusBadRequest, |
| expectedDetailIn: "invalid active status, allowed statuses: [draft scheduled]", |
| }, |
| { |
| name: "archived plan + active addon → 400", |
| planStatus: v3sdk.PlanStatusArchived, |
| addonStatus: v3sdk.AddonStatusActive, |
| expectedStatus: http.StatusBadRequest, |
| expectedDetailIn: "invalid archived status, allowed statuses: [draft scheduled]", |
| }, |
| { |
| name: "draft plan + draft addon → 400 (addon must be active)", |
| planStatus: v3sdk.PlanStatusDraft, |
| addonStatus: v3sdk.AddonStatusDraft, |
| expectedStatus: http.StatusBadRequest, |
| expectedDetailIn: "invalid draft status, allowed statuses: [active]", |
| }, |
| { |
| name: "draft plan + archived addon → 400", |
| planStatus: v3sdk.PlanStatusDraft, |
| addonStatus: v3sdk.AddonStatusArchived, |
| expectedStatus: http.StatusBadRequest, |
| expectedDetailIn: "invalid archived status, allowed statuses: [active]", |
| }, |
| } |
|
|
| for _, tc := range cases { |
| t.Run(tc.name, func(t *testing.T) { |
| c := newV3Client(t) |
|
|
| plan := makePlanWithStatus(t, c, "matrix_plan", tc.planStatus) |
| addon := makeAddonWithStatus(t, c, "matrix_addon", tc.addonStatus) |
|
|
| phaseKey := plan.Phases[0].Key |
| planAddon, err := c.PlanAddons.Create(t.Context(), plan.ID, validPlanAddonRequest(phaseKey, addon.ID)) |
|
|
| if tc.expectedDetailIn != "" { |
| problem := requireProblem(t, err, tc.expectedStatus) |
| assertProblemDetail(t, problem, tc.expectedDetailIn) |
| } else { |
| c.requireStatus(tc.expectedStatus, err) |
| require.NotNil(t, planAddon) |
| } |
| }) |
| } |
| } |
|
|
| |
| |
| |
| |
| func TestV3PlanAddonInstanceTypeMaxQuantityMatrix(t *testing.T) { |
| cases := []struct { |
| name string |
| instanceType v3sdk.AddonInstanceType |
| maxQuantity *int64 |
| expectedStatus int |
| expectedRule string |
| }{ |
| { |
| name: "single + nil max_quantity → 201", |
| instanceType: v3sdk.AddonInstanceTypeSingle, |
| maxQuantity: nil, |
| expectedStatus: http.StatusCreated, |
| }, |
| { |
| name: "multiple + nil max_quantity → 201", |
| instanceType: v3sdk.AddonInstanceTypeMultiple, |
| maxQuantity: nil, |
| expectedStatus: http.StatusCreated, |
| }, |
| { |
| name: "multiple + max_quantity=5 → 201", |
| instanceType: v3sdk.AddonInstanceTypeMultiple, |
| maxQuantity: lo.ToPtr(int64(5)), |
| expectedStatus: http.StatusCreated, |
| }, |
| { |
| |
| |
| name: "multiple + max_quantity=0 → 400 schema minimum", |
| instanceType: v3sdk.AddonInstanceTypeMultiple, |
| maxQuantity: lo.ToPtr(int64(0)), |
| expectedStatus: http.StatusBadRequest, |
| expectedRule: "minimum", |
| }, |
| } |
|
|
| for _, tc := range cases { |
| t.Run(tc.name, func(t *testing.T) { |
| c := newV3Client(t) |
|
|
| plan, err := c.Plans.Create(t.Context(), validPlanRequest("iq_plan")) |
| c.requireStatus(http.StatusCreated, err) |
| require.NotNil(t, plan) |
|
|
| addonBody := validAddonRequest("iq_addon") |
| addonBody.InstanceType = tc.instanceType |
|
|
| addon, err := c.Addons.Create(t.Context(), addonBody) |
| 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) |
|
|
| body := validPlanAddonRequest(plan.Phases[0].Key, addon.ID) |
| body.MaxQuantity = tc.maxQuantity |
|
|
| planAddon, err := c.PlanAddons.Create(t.Context(), plan.ID, body) |
|
|
| switch { |
| case tc.expectedRule != "": |
| problem := requireProblem(t, err, tc.expectedStatus) |
| assertInvalidParameterRule(t, problem, tc.expectedRule) |
| case tc.expectedStatus >= 400: |
| problem := requireProblem(t, err, tc.expectedStatus) |
| require.NotNil(t, problem, "expected a problem response") |
| assert.NotEmpty(t, problem.Detail, "expected problem.Detail to be populated") |
| default: |
| c.requireStatus(tc.expectedStatus, err) |
| require.NotNil(t, planAddon) |
| } |
| }) |
| } |
| } |
|
|
| |
| |
| |
| func TestV3PlanAddonDetachRulesByPlanStatus(t *testing.T) { |
| t.Run("detach from a draft plan → 204", func(t *testing.T) { |
| c := newV3Client(t) |
|
|
| planID, planAddonID := setupAttachedPlanAddon(t, c, "detach_draft") |
|
|
| err := c.PlanAddons.Delete(t.Context(), planID, planAddonID) |
| c.requireStatus(http.StatusNoContent, err) |
| }) |
|
|
| t.Run("detach from an active plan → 400", func(t *testing.T) { |
| c := newV3Client(t) |
|
|
| planID, planAddonID := setupAttachedPlanAddon(t, c, "detach_active") |
|
|
| _, err := c.Plans.Publish(t.Context(), planID) |
| c.requireStatus(http.StatusOK, err) |
|
|
| err = c.PlanAddons.Delete(t.Context(), planID, planAddonID) |
| requireProblem(t, err, http.StatusBadRequest) |
| }) |
|
|
| t.Run("detach from a deleted plan → 400 'plan is deleted'", func(t *testing.T) { |
| |
| |
| c := newV3Client(t) |
|
|
| planID, planAddonID := setupAttachedPlanAddon(t, c, "detach_deleted") |
|
|
| |
| _, err := c.Plans.Publish(t.Context(), planID) |
| c.requireStatus(http.StatusOK, err) |
| _, err = c.Plans.Archive(t.Context(), planID) |
| c.requireStatus(http.StatusOK, err) |
| err = c.Plans.Delete(t.Context(), planID) |
| c.requireStatus(http.StatusNoContent, err) |
|
|
| err = c.PlanAddons.Delete(t.Context(), planID, planAddonID) |
| problem := requireProblem(t, err, http.StatusBadRequest) |
| assertProblemDetail(t, problem, "plan is deleted") |
| }) |
| } |
|
|
| |
| |
| func setupAttachedPlanAddon(t *testing.T, c *v3Client, keyPrefix string) (string, string) { |
| t.Helper() |
|
|
| plan, err := c.Plans.Create(t.Context(), validPlanRequest(keyPrefix+"_plan")) |
| c.requireStatus(http.StatusCreated, err) |
|
|
| addon, err := c.Addons.Create(t.Context(), validAddonRequest(keyPrefix+"_addon")) |
| c.requireStatus(http.StatusCreated, err) |
| _, err = c.Addons.Publish(t.Context(), addon.ID) |
| c.requireStatus(http.StatusOK, err) |
|
|
| planAddon, err := c.PlanAddons.Create(t.Context(), plan.ID, validPlanAddonRequest(plan.Phases[0].Key, addon.ID)) |
| c.requireStatus(http.StatusCreated, err) |
|
|
| return plan.ID, planAddon.ID |
| } |
|
|
| |
| func TestV3PlanAddonPublishAgainstArchivedAddon(t *testing.T) { |
| c := newV3Client(t) |
|
|
| planID, _ := setupAttachedPlanAddon(t, c, "publish_vs_archived_addon") |
|
|
| |
| |
| list, err := c.PlanAddons.List(t.Context(), planID, v3sdk.PlanAddonListParams{}) |
| c.requireStatus(http.StatusOK, err) |
| require.Len(t, list.Data, 1) |
| addonID := list.Data[0].Addon.ID |
|
|
| _, err = c.Addons.Archive(t.Context(), addonID) |
| c.requireStatus(http.StatusOK, err) |
|
|
| _, err = c.Plans.Publish(t.Context(), planID) |
| requireProblem(t, err, http.StatusBadRequest) |
| } |
|
|
| |
| func TestV3PlanAddonDuplicateAttachmentRejected(t *testing.T) { |
| c := newV3Client(t) |
|
|
| plan, err := c.Plans.Create(t.Context(), validPlanRequest("dup_attach_plan")) |
| c.requireStatus(http.StatusCreated, err) |
|
|
| addon, err := c.Addons.Create(t.Context(), validAddonRequest("dup_attach_addon")) |
| c.requireStatus(http.StatusCreated, err) |
| _, err = c.Addons.Publish(t.Context(), addon.ID) |
| c.requireStatus(http.StatusOK, err) |
|
|
| body := validPlanAddonRequest(plan.Phases[0].Key, addon.ID) |
|
|
| _, err = c.PlanAddons.Create(t.Context(), plan.ID, body) |
| c.requireStatus(http.StatusCreated, err) |
|
|
| _, err = c.PlanAddons.Create(t.Context(), plan.ID, body) |
| requireProblem(t, err, http.StatusConflict) |
| } |
|
|
| |
| |
| func TestV3PlanAddonPublishWithAttachedAddon(t *testing.T) { |
| c := newV3Client(t) |
|
|
| plan, err := c.Plans.Create(t.Context(), validPlanRequest("publish_attached_plan")) |
| c.requireStatus(http.StatusCreated, err) |
| require.NotNil(t, plan) |
|
|
| addon, err := c.Addons.Create(t.Context(), validAddonRequest("publish_attached_addon")) |
| c.requireStatus(http.StatusCreated, err) |
| require.NotNil(t, addon) |
|
|
| _, err = c.Addons.Publish(t.Context(), addon.ID) |
| c.requireStatus(http.StatusOK, err) |
|
|
| planAddon, err := c.PlanAddons.Create(t.Context(), plan.ID, validPlanAddonRequest(plan.Phases[0].Key, addon.ID)) |
| c.requireStatus(http.StatusCreated, err) |
| require.NotNil(t, planAddon) |
| planAddonID := planAddon.ID |
|
|
| published, err := c.Plans.Publish(t.Context(), plan.ID) |
| c.requireStatus(http.StatusOK, err) |
| require.NotNil(t, published) |
| assert.Equal(t, v3sdk.PlanStatusActive, published.Status) |
|
|
| page, err := c.PlanAddons.List(t.Context(), plan.ID, v3sdk.PlanAddonListParams{}) |
| c.requireStatus(http.StatusOK, err) |
| require.NotNil(t, page) |
|
|
| found := false |
| for _, pa := range page.Data { |
| if pa.ID == planAddonID { |
| found = true |
| assert.Equal(t, addon.ID, pa.Addon.ID) |
| break |
| } |
| } |
| assert.True(t, found, "attached plan-addon missing after plan publish") |
| } |
|
|
| |
| |
| func makePlanWithStatus(t *testing.T, c *v3Client, keyPrefix string, target v3sdk.PlanStatus) *v3sdk.Plan { |
| t.Helper() |
|
|
| plan, err := c.Plans.Create(t.Context(), validPlanRequest(keyPrefix)) |
| c.requireStatus(http.StatusCreated, err) |
| require.NotNil(t, plan) |
|
|
| switch target { |
| case v3sdk.PlanStatusDraft: |
| return plan |
| case v3sdk.PlanStatusActive: |
| plan, err = c.Plans.Publish(t.Context(), plan.ID) |
| c.requireStatus(http.StatusOK, err) |
| return plan |
| case v3sdk.PlanStatusArchived: |
| plan, err = c.Plans.Publish(t.Context(), plan.ID) |
| c.requireStatus(http.StatusOK, err) |
| plan, err = c.Plans.Archive(t.Context(), plan.ID) |
| c.requireStatus(http.StatusOK, err) |
| return plan |
| default: |
| t.Fatalf("unsupported plan target status %q", target) |
| return nil |
| } |
| } |
|
|
| |
| |
| func makeAddonWithStatus(t *testing.T, c *v3Client, keyPrefix string, target v3sdk.AddonStatus) *v3sdk.Addon { |
| t.Helper() |
|
|
| addon, err := c.Addons.Create(t.Context(), validAddonRequest(keyPrefix)) |
| c.requireStatus(http.StatusCreated, err) |
| require.NotNil(t, addon) |
|
|
| switch target { |
| case v3sdk.AddonStatusDraft: |
| return addon |
| case v3sdk.AddonStatusActive: |
| addon, err = c.Addons.Publish(t.Context(), addon.ID) |
| c.requireStatus(http.StatusOK, err) |
| return addon |
| case v3sdk.AddonStatusArchived: |
| addon, err = c.Addons.Publish(t.Context(), addon.ID) |
| c.requireStatus(http.StatusOK, err) |
| addon, err = c.Addons.Archive(t.Context(), addon.ID) |
| c.requireStatus(http.StatusOK, err) |
| return addon |
| default: |
| t.Fatalf("unsupported addon target status %q", target) |
| return nil |
| } |
| } |
|
|