diff --git a/internal/provider/provider.go b/internal/provider/provider.go index 16d0218d..56b11f8f 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -20,6 +20,7 @@ import ( datasourcetype "github.com/labd/terraform-provider-commercetools/internal/datasource/type" "github.com/labd/terraform-provider-commercetools/internal/resources/associate_role" "github.com/labd/terraform-provider-commercetools/internal/resources/attribute_group" + "github.com/labd/terraform-provider-commercetools/internal/resources/product" "github.com/labd/terraform-provider-commercetools/internal/resources/product_selection" "github.com/labd/terraform-provider-commercetools/internal/resources/project" "github.com/labd/terraform-provider-commercetools/internal/resources/state" @@ -196,5 +197,6 @@ func (p *ctProvider) Resources(_ context.Context) []func() resource.Resource { attribute_group.NewResource, associate_role.NewResource, product_selection.NewResource, + product.NewResource, } } diff --git a/internal/resources/product/model.go b/internal/resources/product/model.go new file mode 100644 index 00000000..97d5111d --- /dev/null +++ b/internal/resources/product/model.go @@ -0,0 +1,470 @@ +package product + +import ( + "encoding/json" + "reflect" + + "github.com/elliotchance/pie/v2" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-framework/types/basetypes" + "github.com/labd/commercetools-go-sdk/platform" + "github.com/labd/terraform-provider-commercetools/internal/customtypes" + "github.com/labd/terraform-provider-commercetools/internal/utils" +) + +// Product represents the main schema data. +type Product struct { + ID types.String `tfsdk:"id"` + Key types.String `tfsdk:"key"` + Version types.Int64 `tfsdk:"version"` + ProductTypeId types.String `tfsdk:"product_type_id"` + Name customtypes.LocalizedStringValue `tfsdk:"name"` + Slug customtypes.LocalizedStringValue `tfsdk:"slug"` + Description customtypes.LocalizedStringValue `tfsdk:"description"` + Categories []types.String `tfsdk:"categories"` + MetaTitle customtypes.LocalizedStringValue `tfsdk:"meta_title"` + MetaDescription customtypes.LocalizedStringValue `tfsdk:"meta_description"` + MetaKeywords customtypes.LocalizedStringValue `tfsdk:"meta_keywords"` + MasterVariant ProductVariant `tfsdk:"master_variant"` + Variants []ProductVariant `tfsdk:"variant"` + TaxCategoryId types.String `tfsdk:"tax_category_id"` + StateId types.String `tfsdk:"state_id"` + Publish types.Bool `tfsdk:"publish"` +} + +type ProductVariant struct { + ID types.Int64 `tfsdk:"id"` + Key types.String `tfsdk:"key"` + Sku types.String `tfsdk:"sku"` + Attributes []Attribute `tfsdk:"attribute"` + Prices []Price `tfsdk:"price"` +} + +type Attribute struct { + Name types.String `tfsdk:"name"` + Value types.String `tfsdk:"value"` +} + +type Price struct { + ID types.String `tfsdk:"id"` + Key types.String `tfsdk:"key"` + Value Money `tfsdk:"value"` +} + +type Money struct { + CentAmount types.Int64 `tfsdk:"cent_amount"` + CurrencyCode types.String `tfsdk:"currency_code"` +} + +func NewProductFromNative(p *platform.Product) Product { + product := Product{ + ID: types.StringValue(p.ID), + Key: utils.FromOptionalString(p.Key), + Version: types.Int64Value(int64(p.Version)), + ProductTypeId: types.StringValue(p.ProductType.ID), + Name: utils.FromLocalizedString(p.MasterData.Staged.Name), + Slug: utils.FromLocalizedString(p.MasterData.Staged.Slug), + Description: utils.FromOptionalLocalizedString(p.MasterData.Staged.Description), + MetaTitle: utils.FromOptionalLocalizedString(p.MasterData.Staged.MetaTitle), + MetaDescription: utils.FromOptionalLocalizedString(p.MasterData.Staged.MetaDescription), + MetaKeywords: utils.FromOptionalLocalizedString(p.MasterData.Staged.MetaKeywords), + MasterVariant: NewProductVariantFromNative(p.MasterData.Staged.MasterVariant), + Variants: pie.SortUsing(pie.Map(p.MasterData.Staged.Variants, NewProductVariantFromNative), func(a, b ProductVariant) bool { + return a.ID.ValueInt64() < b.ID.ValueInt64() + }), + Publish: utils.FromOptionalBool(&p.MasterData.Published), + } + + // Add product categories + if len(p.MasterData.Staged.Categories) > 0 { + product.Categories = pie.Map(p.MasterData.Staged.Categories, func(category platform.CategoryReference) types.String { + return types.StringValue(category.ID) + }) + } + + // Add Tax Category Id if defined + if p.TaxCategory != nil { + product.TaxCategoryId = types.StringValue(p.TaxCategory.ID) + } + + // Add State Id if defined + if p.State != nil { + product.StateId = types.StringValue(p.State.ID) + } + + return product +} + +func NewProductVariantFromNative(p platform.ProductVariant) ProductVariant { + return ProductVariant{ + ID: types.Int64Value(int64(p.ID)), + Key: utils.FromOptionalString(p.Key), + Sku: utils.FromOptionalString(p.Sku), + Attributes: pie.Map(p.Attributes, func(attribute platform.Attribute) Attribute { + return Attribute{ + Name: types.StringValue(attribute.Name), + Value: types.StringValue(marshalAttributeValue(attribute)), + } + }), + Prices: pie.Map(p.Prices, func(price platform.Price) Price { + return Price{ + ID: types.StringValue(price.ID), + Key: utils.FromOptionalString(price.Key), + Value: Money{ + CentAmount: types.Int64Value(int64(price.Value.(platform.CentPrecisionMoney).CentAmount)), + CurrencyCode: types.StringValue(price.Value.(platform.CentPrecisionMoney).CurrencyCode), + }, + } + }), + } +} + +func NewProductVariantFromNativeRef(p platform.ProductVariant) *ProductVariant { + ref := NewProductVariantFromNative(p) + return &ref +} + +func (p Product) draft() platform.ProductDraft { + productDraft := platform.ProductDraft{ + Key: p.Key.ValueStringPointer(), + ProductType: platform.ProductTypeResourceIdentifier{ + ID: p.ProductTypeId.ValueStringPointer(), + }, + Name: p.Name.ValueLocalizedString(), + Slug: p.Slug.ValueLocalizedString(), + Description: p.Description.ValueLocalizedStringRef(), + MetaTitle: p.MetaTitle.ValueLocalizedStringRef(), + MetaDescription: p.MetaDescription.ValueLocalizedStringRef(), + MetaKeywords: p.MetaKeywords.ValueLocalizedStringRef(), + MasterVariant: NewProductVariantDraftRef(p.MasterVariant), + Variants: pie.Map(p.Variants, NewProductVariantDraft), + Categories: pie.Map(p.Categories, func(categoryId basetypes.StringValue) platform.CategoryResourceIdentifier { + return platform.CategoryResourceIdentifier{ + ID: categoryId.ValueStringPointer(), + } + }), + Publish: p.Publish.ValueBoolPointer(), + } + + if !p.TaxCategoryId.IsNull() { + productDraft.TaxCategory = &platform.TaxCategoryResourceIdentifier{ + ID: p.TaxCategoryId.ValueStringPointer(), + } + } + + if !p.StateId.IsNull() { + productDraft.State = &platform.StateResourceIdentifier{ + ID: p.StateId.ValueStringPointer(), + } + } + + return productDraft +} + +func NewProductVariantDraft(p ProductVariant) platform.ProductVariantDraft { + return platform.ProductVariantDraft{ + Key: p.Key.ValueStringPointer(), + Sku: p.Sku.ValueStringPointer(), + Attributes: pie.Map(p.Attributes, NewProductVariantAttribute), + Prices: pie.Map(p.Prices, NewProductPriceDraft), + } +} + +func NewProductVariantDraftRef(p ProductVariant) *platform.ProductVariantDraft { + productVariantDraft := NewProductVariantDraft(p) + return &productVariantDraft +} + +func NewProductPriceDraft(p Price) platform.PriceDraft { + return platform.PriceDraft{ + Key: p.Key.ValueStringPointer(), + Value: platform.Money{ + CentAmount: int(p.Value.CentAmount.ValueInt64()), + CurrencyCode: p.Value.CurrencyCode.ValueString(), + }, + } +} + +func NewProductVariantAttribute(a Attribute) platform.Attribute { + return platform.Attribute{ + Name: a.Name.ValueString(), + Value: unmarshalAttributeValue(a.Value.ValueString()), + } +} + +func (p Product) updateActions(plan Product) platform.ProductUpdate { + result := platform.ProductUpdate{ + Version: int(p.Version.ValueInt64()), + Actions: []platform.ProductUpdateAction{}, + } + + // setKey + if p.Key != plan.Key { + result.Actions = append(result.Actions, platform.ProductSetKeyAction{ + Key: plan.Key.ValueStringPointer(), + }) + } + + // changeName + if !reflect.DeepEqual(p.Name, plan.Name) { + result.Actions = append(result.Actions, platform.ProductChangeNameAction{ + Name: plan.Name.ValueLocalizedString(), + Staged: utils.BoolRef(false), + }) + } + + // changeSlug + if !reflect.DeepEqual(p.Slug, plan.Slug) { + result.Actions = append(result.Actions, platform.ProductChangeSlugAction{ + Slug: plan.Slug.ValueLocalizedString(), + Staged: utils.BoolRef(false), + }) + } + + // setDescription + if !reflect.DeepEqual(p.Description, plan.Description) { + result.Actions = append(result.Actions, platform.ProductSetDescriptionAction{ + Description: plan.Description.ValueLocalizedStringRef(), + Staged: utils.BoolRef(false), + }) + } + + // setMetaTitle + if !reflect.DeepEqual(p.MetaTitle, plan.MetaTitle) { + result.Actions = append(result.Actions, platform.ProductSetMetaTitleAction{ + MetaTitle: plan.MetaTitle.ValueLocalizedStringRef(), + Staged: utils.BoolRef(false), + }) + } + + // setMetaDescription + if !reflect.DeepEqual(p.MetaDescription, plan.MetaDescription) { + result.Actions = append(result.Actions, platform.ProductSetMetaDescriptionAction{ + MetaDescription: plan.MetaDescription.ValueLocalizedStringRef(), + Staged: utils.BoolRef(false), + }) + } + + // setMetaKeywords + if !reflect.DeepEqual(p.MetaKeywords, plan.MetaKeywords) { + result.Actions = append(result.Actions, platform.ProductSetMetaKeywordsAction{ + MetaKeywords: plan.MetaKeywords.ValueLocalizedStringRef(), + Staged: utils.BoolRef(false), + }) + } + + // publish + if !p.Publish.ValueBool() && plan.Publish.ValueBool() { + all := platform.ProductPublishScopeAll + result.Actions = append(result.Actions, platform.ProductPublishAction{ + Scope: &all, + }) + } + + // unpublish + if p.Publish.ValueBool() && !plan.Publish.ValueBool() { + result.Actions = append(result.Actions, platform.ProductUnpublishAction{}) + } + + // setTaxCategory + if !p.TaxCategoryId.Equal(plan.TaxCategoryId) { + if plan.TaxCategoryId.IsNull() { + result.Actions = append(result.Actions, platform.ProductSetTaxCategoryAction{ + TaxCategory: nil, + }) + } else { + result.Actions = append(result.Actions, platform.ProductSetTaxCategoryAction{ + TaxCategory: &platform.TaxCategoryResourceIdentifier{ + ID: plan.TaxCategoryId.ValueStringPointer(), + }, + }) + } + } + + // transitionState + if !p.StateId.Equal(plan.StateId) { + result.Actions = append(result.Actions, platform.ProductTransitionStateAction{ + State: &platform.StateResourceIdentifier{ + ID: plan.StateId.ValueStringPointer(), + }, + Force: utils.BoolRef(false), + }) + } + + // # Category Actions + categoriesDiffAdd, categoriesDiffRemove := pie.Diff(p.Categories, plan.Categories) + // addToCategory + for _, categoryId := range categoriesDiffAdd { + result.Actions = append(result.Actions, platform.ProductAddToCategoryAction{ + Category: platform.CategoryResourceIdentifier{ + ID: categoryId.ValueStringPointer(), + }, + Staged: utils.BoolRef(false), + }) + } + + // removeFromCategory + for _, categoryId := range categoriesDiffRemove { + result.Actions = append(result.Actions, platform.ProductRemoveFromCategoryAction{ + Category: platform.CategoryResourceIdentifier{ + ID: categoryId.ValueStringPointer(), + }, + Staged: utils.BoolRef(false), + }) + } + + // # ProductVariants Actions + currentVariants := append(p.Variants, p.MasterVariant) + planVariants := append(plan.Variants, plan.MasterVariant) + variantsAdded, _, variantsRemoved := compare(currentVariants, planVariants, "Sku") + + // addVariant + for _, productVariant := range variantsAdded { + result.Actions = append(result.Actions, platform.ProductAddVariantAction{ + Sku: productVariant.Sku.ValueStringPointer(), + Key: productVariant.Key.ValueStringPointer(), + Attributes: pie.Map(productVariant.Attributes, NewProductVariantAttribute), + Prices: pie.Map(productVariant.Prices, NewProductPriceDraft), + Staged: utils.BoolRef(false), + }) + } + + // changeMasterVariant + if !p.MasterVariant.ID.Equal(plan.MasterVariant.ID) { + result.Actions = append(result.Actions, platform.ProductChangeMasterVariantAction{ + Sku: plan.MasterVariant.Sku.ValueStringPointer(), + Staged: utils.BoolRef(false), + }) + } + + // removeVariant + for _, productVariant := range variantsRemoved { + result.Actions = append(result.Actions, platform.ProductRemoveVariantAction{ + Sku: productVariant.Sku.ValueStringPointer(), + Staged: utils.BoolRef(false), + }) + } + + // # Compare Product Variant attributes and prices + for _, currentVariant := range currentVariants { + sku := currentVariant.Sku.ValueString() + planVariantRef := getProductVariantBySku(planVariants, sku) + if planVariantRef != nil { + // Process Attributes + attributesAdded, attributesModified, attributesRemoved := compare(currentVariant.Attributes, planVariantRef.Attributes, "Name") + for _, attribute := range append(attributesAdded, attributesModified...) { + // setAttribute + result.Actions = append(result.Actions, platform.ProductSetAttributeAction{ + Sku: &sku, + Name: attribute.Name.ValueString(), + Staged: utils.BoolRef(false), + Value: unmarshalAttributeValue(attribute.Value.ValueString()), + }) + } + for _, attribute := range attributesRemoved { + // removeAttribute + result.Actions = append(result.Actions, platform.ProductSetAttributeAction{ + Sku: &sku, + Name: attribute.Name.ValueString(), + Staged: utils.BoolRef(false), + }) + } + + // #Process Prices + pricesAdded, pricesModified, pricesRemoved := compare(currentVariant.Prices, planVariantRef.Prices, "Key") + + // addPrice + for _, price := range pricesAdded { + result.Actions = append(result.Actions, platform.ProductAddPriceAction{ + Sku: &sku, + Price: NewProductPriceDraft(price), + Staged: utils.BoolRef(false), + }) + } + + // changePrice + for _, price := range pricesModified { + result.Actions = append(result.Actions, platform.ProductChangePriceAction{ + PriceId: price.ID.ValueString(), + Price: NewProductPriceDraft(price), + Staged: utils.BoolRef(false), + }) + } + + // removePrice + for _, price := range pricesRemoved { + result.Actions = append(result.Actions, platform.ProductRemovePriceAction{ + Sku: &sku, + PriceId: price.ID.ValueString(), + Staged: utils.BoolRef(false), + }) + } + } + } + + return result +} + +func unmarshalAttributeValue(value string) any { + var data any + json.Unmarshal([]byte(value), &data) + return data +} + +func marshalAttributeValue(o platform.Attribute) string { + val, err := json.Marshal(o.Value) + if err != nil { + panic(err) + } + return string(val) +} + +type ProductVariantComparable interface { + ProductVariant | Attribute | Price +} + +func compare[T ProductVariantComparable](current, planned []T, id string) (added, modified, removed []T) { + currentSet := make(map[string]T, len(current)) + plannedSet := make(map[string]T, len(planned)) + + for _, c := range current { + key := reflect.ValueOf(c).FieldByName(id).Interface().(basetypes.StringValue).ValueString() + currentSet[key] = c + } + + for _, c := range planned { + key := reflect.ValueOf(c).FieldByName(id).Interface().(basetypes.StringValue).ValueString() + plannedSet[key] = c + } + + // Find added/modified items + for _, c := range planned { + key := reflect.ValueOf(c).FieldByName(id).Interface().(basetypes.StringValue).ValueString() + if cc, exists := currentSet[key]; !exists { + added = append(added, c) + } else { + if !reflect.DeepEqual(c, cc) { + modified = append(modified, c) + } + } + } + + // Find removed items + for _, c := range current { + key := reflect.ValueOf(c).FieldByName(id).Interface().(basetypes.StringValue).ValueString() + if _, exists := plannedSet[key]; !exists { + removed = append(removed, c) + } + } + + return +} + +func getProductVariantBySku(l []ProductVariant, sku string) *ProductVariant { + for _, p := range l { + if p.Sku.ValueString() == sku { + return &p + } + } + return nil +} diff --git a/internal/resources/product/model_test.go b/internal/resources/product/model_test.go new file mode 100644 index 00000000..1e6f0f3a --- /dev/null +++ b/internal/resources/product/model_test.go @@ -0,0 +1,670 @@ +package product + +import ( + "testing" + + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-framework/types/basetypes" + "github.com/labd/commercetools-go-sdk/platform" + "github.com/labd/terraform-provider-commercetools/internal/customtypes" + "github.com/labd/terraform-provider-commercetools/internal/utils" + "github.com/stretchr/testify/assert" +) + +func TestNewProductFromNative(t *testing.T) { + productData := platform.ProductData{ + Name: platform.LocalizedString{"en-US": "Test product name"}, + Categories: []platform.CategoryReference{ + { + ID: "00000000-0000-0000-0000-000000000000", + }, + }, + Slug: platform.LocalizedString{"en-US": "Test product slug"}, + Description: &platform.LocalizedString{"en-US": "Test product description"}, + MetaTitle: &platform.LocalizedString{"en-US": "Test product Meta Title"}, + MetaDescription: &platform.LocalizedString{"en-US": "Test product Meta Description"}, + MetaKeywords: &platform.LocalizedString{"en-US": "Test product Meta Keywords"}, + MasterVariant: platform.ProductVariant{ + ID: 1, + Sku: utils.StringRef("1001"), + Key: utils.StringRef("variant-1"), + Prices: []platform.Price{ + { + ID: "00000000-0000-0000-0000-000000000001", + Key: utils.StringRef("price-1"), + Value: platform.CentPrecisionMoney{ + CentAmount: 1000, + CurrencyCode: "USD", + }, + }, + }, + Attributes: []platform.Attribute{ + { + Name: "color", + Value: platform.LocalizedString{"en-US": "Red"}, + }, + }, + }, + Variants: []platform.ProductVariant{ + { + ID: 2, + Sku: utils.StringRef("1002"), + Key: utils.StringRef("variant-2"), + Prices: []platform.Price{ + { + ID: "00000000-0000-0000-0000-000000000002", + Key: utils.StringRef("price-1"), + Value: platform.CentPrecisionMoney{ + CentAmount: 1000, + CurrencyCode: "USD", + }, + }, + }, + Attributes: []platform.Attribute{ + { + Name: "color", + Value: platform.LocalizedString{"en-US": "Green"}, + }, + }, + }, + }, + } + + native := platform.Product{ + ID: "00000000-0000-0000-0000-000000000003", + Key: utils.StringRef("test-product"), + Version: 10, + ProductType: platform.ProductTypeReference{ + ID: "00000000-0000-0000-0000-000000000004", + }, + MasterData: platform.ProductCatalogData{ + Published: true, + Current: productData, + Staged: productData, + }, + TaxCategory: &platform.TaxCategoryReference{ + ID: "00000000-0000-0000-0000-000000000005", + }, + State: &platform.StateReference{ + ID: "00000000-0000-0000-0000-000000000006", + }, + } + productDraft := NewProductFromNative(&native) + + assert.Equal(t, Product{ + ID: types.StringValue("00000000-0000-0000-0000-000000000003"), + Key: types.StringValue("test-product"), + Version: types.Int64Value(10), + ProductTypeId: types.StringValue("00000000-0000-0000-0000-000000000004"), + Name: customtypes.NewLocalizedStringValue(map[string]attr.Value{ + "en-US": types.StringValue("Test product name"), + }), + Slug: customtypes.NewLocalizedStringValue(map[string]attr.Value{ + "en-US": types.StringValue("Test product slug"), + }), + Description: customtypes.NewLocalizedStringValue(map[string]attr.Value{ + "en-US": types.StringValue("Test product description"), + }), + Categories: []types.String{ + types.StringValue("00000000-0000-0000-0000-000000000000"), + }, + MetaTitle: customtypes.NewLocalizedStringValue(map[string]attr.Value{ + "en-US": types.StringValue("Test product Meta Title"), + }), + MetaDescription: customtypes.NewLocalizedStringValue(map[string]attr.Value{ + "en-US": types.StringValue("Test product Meta Description"), + }), + MetaKeywords: customtypes.NewLocalizedStringValue(map[string]attr.Value{ + "en-US": types.StringValue("Test product Meta Keywords"), + }), + MasterVariant: ProductVariant{ + ID: types.Int64Value(1), + Sku: types.StringValue("1001"), + Key: types.StringValue("variant-1"), + Prices: []Price{ + { + ID: types.StringValue("00000000-0000-0000-0000-000000000001"), + Key: types.StringValue("price-1"), + Value: Money{ + CentAmount: types.Int64Value(1000), + CurrencyCode: types.StringValue("USD"), + }, + }, + }, + Attributes: []Attribute{ + { + Name: types.StringValue("color"), + Value: types.StringValue("{\"en-US\":\"Red\"}"), + }, + }, + }, + Variants: []ProductVariant{ + { + ID: types.Int64Value(2), + Sku: types.StringValue("1002"), + Key: types.StringValue("variant-2"), + Prices: []Price{ + { + ID: types.StringValue("00000000-0000-0000-0000-000000000002"), + Key: types.StringValue("price-1"), + Value: Money{ + CentAmount: types.Int64Value(1000), + CurrencyCode: types.StringValue("USD"), + }, + }, + }, + Attributes: []Attribute{ + { + Name: types.StringValue("color"), + Value: types.StringValue("{\"en-US\":\"Green\"}"), + }, + }, + }, + }, + TaxCategoryId: types.StringValue("00000000-0000-0000-0000-000000000005"), + StateId: types.StringValue("00000000-0000-0000-0000-000000000006"), + Publish: types.BoolValue(true), + }, productDraft) + +} + +func Test_UpdateActions(t *testing.T) { + productPublishScopeAll := platform.ProductPublishScopeAll + productVariant1 := ProductVariant{ + ID: types.Int64Value(1), + Sku: types.StringValue("1001"), + Key: types.StringValue("product-variant-1"), + } + productVariant2 := ProductVariant{ + ID: types.Int64Value(2), + Sku: types.StringValue("1002"), + Key: types.StringValue("product-variant-2"), + } + productVariantWithAttribute1 := ProductVariant{ + ID: types.Int64Value(1), + Sku: types.StringValue("1001"), + Key: types.StringValue("product-variant-1"), + Attributes: []Attribute{ + { + Name: types.StringValue("color"), + Value: types.StringValue("{\"en-US\":\"Red\"}"), + }, + }, + } + productVariantWithAttribute2 := ProductVariant{ + ID: types.Int64Value(1), + Sku: types.StringValue("1001"), + Key: types.StringValue("product-variant-1"), + Attributes: []Attribute{ + { + Name: types.StringValue("color"), + Value: types.StringValue("{\"en-US\":\"Green\"}"), + }, + }, + } + productVariantWithPrice1 := ProductVariant{ + ID: types.Int64Value(1), + Sku: types.StringValue("1001"), + Key: types.StringValue("product-variant-1"), + Prices: []Price{ + { + ID: types.StringValue("price-1-id"), + Key: types.StringValue("price-1-key"), + Value: Money{ + CentAmount: types.Int64Value(1000), + CurrencyCode: types.StringValue("USD"), + }, + }, + }, + } + productVariantWithPrice2 := ProductVariant{ + ID: types.Int64Value(1), + Sku: types.StringValue("1001"), + Key: types.StringValue("product-variant-1"), + Prices: []Price{ + { + ID: types.StringValue("price-1-id"), + Key: types.StringValue("price-1-key"), + Value: Money{ + CentAmount: types.Int64Value(2000), + CurrencyCode: types.StringValue("USD"), + }, + }, + }, + } + testCases := []struct { + name string + state Product + plan Product + expected platform.ProductUpdate + }{ + { + "product setKey", + Product{ + Key: types.StringValue("product-key-1"), + }, + Product{ + Key: types.StringValue("product-key-2"), + }, + platform.ProductUpdate{ + Actions: []platform.ProductUpdateAction{ + platform.ProductSetKeyAction{ + Key: utils.StringRef("product-key-2"), + }, + }, + }, + }, + { + "product changeName", + Product{ + Name: customtypes.NewLocalizedStringValue(map[string]attr.Value{ + "en-US": types.StringValue("Product name"), + }), + }, + Product{ + Name: customtypes.NewLocalizedStringValue(map[string]attr.Value{ + "en-US": types.StringValue("New product name"), + }), + }, + platform.ProductUpdate{ + Actions: []platform.ProductUpdateAction{ + platform.ProductChangeNameAction{ + Name: platform.LocalizedString{"en-US": "New product name"}, + Staged: utils.BoolRef(false), + }, + }, + }, + }, + { + "product changeSlug", + Product{ + Slug: customtypes.NewLocalizedStringValue(map[string]attr.Value{ + "en-US": types.StringValue("Product slug"), + }), + }, + Product{ + Slug: customtypes.NewLocalizedStringValue(map[string]attr.Value{ + "en-US": types.StringValue("New product slug"), + }), + }, + platform.ProductUpdate{ + Actions: []platform.ProductUpdateAction{ + platform.ProductChangeSlugAction{ + Slug: platform.LocalizedString{"en-US": "New product slug"}, + Staged: utils.BoolRef(false), + }, + }, + }, + }, + { + "product setDescription", + Product{ + Description: customtypes.NewLocalizedStringValue(map[string]attr.Value{ + "en-US": types.StringValue("Product description"), + }), + }, + Product{ + Description: customtypes.NewLocalizedStringValue(map[string]attr.Value{ + "en-US": types.StringValue("New product description"), + }), + }, + platform.ProductUpdate{ + Actions: []platform.ProductUpdateAction{ + platform.ProductSetDescriptionAction{ + Description: &platform.LocalizedString{"en-US": "New product description"}, + Staged: utils.BoolRef(false), + }, + }, + }, + }, + { + "product setMetaTitle", + Product{ + MetaTitle: customtypes.NewLocalizedStringValue(map[string]attr.Value{ + "en-US": types.StringValue("Product meta title"), + }), + }, + Product{ + MetaTitle: customtypes.NewLocalizedStringValue(map[string]attr.Value{ + "en-US": types.StringValue("New product meta title"), + }), + }, + platform.ProductUpdate{ + Actions: []platform.ProductUpdateAction{ + platform.ProductSetMetaTitleAction{ + MetaTitle: &platform.LocalizedString{"en-US": "New product meta title"}, + Staged: utils.BoolRef(false), + }, + }, + }, + }, + { + "product setMetaDescription", + Product{ + MetaDescription: customtypes.NewLocalizedStringValue(map[string]attr.Value{ + "en-US": types.StringValue("Product meta description"), + }), + }, + Product{ + MetaDescription: customtypes.NewLocalizedStringValue(map[string]attr.Value{ + "en-US": types.StringValue("New product meta description"), + }), + }, + platform.ProductUpdate{ + Actions: []platform.ProductUpdateAction{ + platform.ProductSetMetaDescriptionAction{ + MetaDescription: &platform.LocalizedString{"en-US": "New product meta description"}, + Staged: utils.BoolRef(false), + }, + }, + }, + }, + { + "product setMetaKeywords", + Product{ + MetaKeywords: customtypes.NewLocalizedStringValue(map[string]attr.Value{ + "en-US": types.StringValue("Product meta keywords"), + }), + }, + Product{ + MetaKeywords: customtypes.NewLocalizedStringValue(map[string]attr.Value{ + "en-US": types.StringValue("New product meta keywords"), + }), + }, + platform.ProductUpdate{ + Actions: []platform.ProductUpdateAction{ + platform.ProductSetMetaKeywordsAction{ + MetaKeywords: &platform.LocalizedString{"en-US": "New product meta keywords"}, + Staged: utils.BoolRef(false), + }, + }, + }, + }, + { + "product publish", + Product{ + Publish: types.BoolValue(false), + }, + Product{ + Publish: types.BoolValue(true), + }, + platform.ProductUpdate{ + Actions: []platform.ProductUpdateAction{ + platform.ProductPublishAction{ + Scope: &productPublishScopeAll, + }, + }, + }, + }, + { + "product unpublish", + Product{ + Publish: types.BoolValue(true), + }, + Product{ + Publish: types.BoolValue(false), + }, + platform.ProductUpdate{ + Actions: []platform.ProductUpdateAction{ + platform.ProductUnpublishAction{}, + }, + }, + }, + { + "product setTaxCategory", + Product{ + TaxCategoryId: types.StringValue("category-id-1"), + }, + Product{ + TaxCategoryId: types.StringValue("category-id-2"), + }, + platform.ProductUpdate{ + Actions: []platform.ProductUpdateAction{ + platform.ProductSetTaxCategoryAction{ + TaxCategory: &platform.TaxCategoryResourceIdentifier{ + ID: utils.StringRef("category-id-2"), + }, + }, + }, + }, + }, + { + "product transitionState", + Product{ + StateId: types.StringValue("state-id-1"), + }, + Product{ + StateId: types.StringValue("state-id-2"), + }, + platform.ProductUpdate{ + Actions: []platform.ProductUpdateAction{ + platform.ProductTransitionStateAction{ + State: &platform.StateResourceIdentifier{ + ID: utils.StringRef("state-id-2"), + }, + Force: utils.BoolRef(false), + }, + }, + }, + }, + { + "product addToCategory", + Product{ + Categories: []basetypes.StringValue{}, + }, + Product{ + Categories: []basetypes.StringValue{ + types.StringValue("category-1"), + }, + }, + platform.ProductUpdate{ + Actions: []platform.ProductUpdateAction{ + platform.ProductAddToCategoryAction{ + Category: platform.CategoryResourceIdentifier{ + ID: utils.StringRef("category-1"), + }, + Staged: utils.BoolRef(false), + }, + }, + }, + }, + { + "product removeFromCategory", + Product{ + Categories: []basetypes.StringValue{ + types.StringValue("category-1"), + }, + }, + Product{ + Categories: []basetypes.StringValue{}, + }, + platform.ProductUpdate{ + Actions: []platform.ProductUpdateAction{ + platform.ProductRemoveFromCategoryAction{ + Category: platform.CategoryResourceIdentifier{ + ID: utils.StringRef("category-1"), + }, + Staged: utils.BoolRef(false), + }, + }, + }, + }, + { + "product addVariant", + Product{ + MasterVariant: productVariant1, + Variants: []ProductVariant{}, + }, + Product{ + MasterVariant: productVariant1, + Variants: []ProductVariant{ + productVariant2, + }, + }, + platform.ProductUpdate{ + Actions: []platform.ProductUpdateAction{ + platform.ProductAddVariantAction{ + Sku: productVariant2.Sku.ValueStringPointer(), + Key: productVariant2.Key.ValueStringPointer(), + Staged: utils.BoolRef(false), + }, + }, + }, + }, + { + "product changeMasterVariant", + Product{ + MasterVariant: productVariant1, + Variants: []ProductVariant{ + productVariant2, + }, + }, + Product{ + MasterVariant: productVariant2, + Variants: []ProductVariant{ + productVariant1, + }, + }, + platform.ProductUpdate{ + Actions: []platform.ProductUpdateAction{ + platform.ProductChangeMasterVariantAction{ + Sku: productVariant2.Sku.ValueStringPointer(), + Staged: utils.BoolRef(false), + }, + }, + }, + }, + { + "product removeVariant", + Product{ + MasterVariant: productVariant1, + Variants: []ProductVariant{ + productVariant2, + }, + }, + Product{ + MasterVariant: productVariant1, + Variants: []ProductVariant{}, + }, + platform.ProductUpdate{ + Actions: []platform.ProductUpdateAction{ + platform.ProductRemoveVariantAction{ + Sku: productVariant2.Sku.ValueStringPointer(), + Staged: utils.BoolRef(false), + }, + }, + }, + }, + { + "product setAttribute", + Product{ + MasterVariant: productVariantWithAttribute1, + }, + Product{ + MasterVariant: productVariantWithAttribute2, + }, + platform.ProductUpdate{ + Actions: []platform.ProductUpdateAction{ + platform.ProductSetAttributeAction{ + Sku: productVariantWithAttribute2.Sku.ValueStringPointer(), + Name: productVariantWithAttribute2.Attributes[0].Name.ValueString(), + Value: unmarshalAttributeValue(productVariantWithAttribute2.Attributes[0].Value.ValueString()), + Staged: utils.BoolRef(false), + }, + }, + }, + }, + { + "product removeAttribute", + Product{ + MasterVariant: productVariantWithAttribute1, + }, + Product{ + MasterVariant: productVariant1, + }, + platform.ProductUpdate{ + Actions: []platform.ProductUpdateAction{ + platform.ProductSetAttributeAction{ + Sku: productVariantWithAttribute1.Sku.ValueStringPointer(), + Name: productVariantWithAttribute1.Attributes[0].Name.ValueString(), + Staged: utils.BoolRef(false), + }, + }, + }, + }, + { + "product addPrice", + Product{ + MasterVariant: productVariant1, + }, + Product{ + MasterVariant: productVariantWithPrice1, + }, + platform.ProductUpdate{ + Actions: []platform.ProductUpdateAction{ + platform.ProductAddPriceAction{ + Sku: productVariant1.Sku.ValueStringPointer(), + Price: platform.PriceDraft{ + Key: productVariantWithPrice1.Prices[0].Key.ValueStringPointer(), + Value: platform.Money{ + CentAmount: int(productVariantWithPrice1.Prices[0].Value.CentAmount.ValueInt64()), + CurrencyCode: productVariantWithPrice1.Prices[0].Value.CurrencyCode.ValueString(), + }, + }, + Staged: utils.BoolRef(false), + }, + }, + }, + }, + { + "product changePrice", + Product{ + MasterVariant: productVariantWithPrice1, + }, + Product{ + MasterVariant: productVariantWithPrice2, + }, + platform.ProductUpdate{ + Actions: []platform.ProductUpdateAction{ + platform.ProductChangePriceAction{ + PriceId: productVariantWithPrice2.Prices[0].ID.ValueString(), + Price: platform.PriceDraft{ + Key: productVariantWithPrice2.Prices[0].Key.ValueStringPointer(), + Value: platform.Money{ + CentAmount: int(productVariantWithPrice2.Prices[0].Value.CentAmount.ValueInt64()), + CurrencyCode: productVariantWithPrice2.Prices[0].Value.CurrencyCode.ValueString(), + }, + }, + Staged: utils.BoolRef(false), + }, + }, + }, + }, + { + "product removePrice", + Product{ + MasterVariant: productVariantWithPrice1, + }, + Product{ + MasterVariant: productVariant1, + }, + platform.ProductUpdate{ + Actions: []platform.ProductUpdateAction{ + platform.ProductRemovePriceAction{ + PriceId: productVariantWithPrice1.Prices[0].ID.ValueString(), + Sku: productVariantWithPrice1.Sku.ValueStringPointer(), + Staged: utils.BoolRef(false), + }, + }, + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + result := tc.state.updateActions(tc.plan) + assert.EqualValues(t, tc.expected, result) + }) + } +} diff --git a/internal/resources/product/resource.go b/internal/resources/product/resource.go new file mode 100644 index 00000000..8c33c999 --- /dev/null +++ b/internal/resources/product/resource.go @@ -0,0 +1,574 @@ +package product + +import ( + "context" + "errors" + "fmt" + "reflect" + "regexp" + "time" + + "github.com/elliotchance/pie/v2" + "github.com/hashicorp/terraform-plugin-framework-validators/listvalidator" + "github.com/hashicorp/terraform-plugin-framework-validators/objectvalidator" + "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/int64planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/retry" + "github.com/labd/commercetools-go-sdk/platform" + + "github.com/labd/terraform-provider-commercetools/internal/customtypes" + "github.com/labd/terraform-provider-commercetools/internal/utils" +) + +// productResource is the resource implementation. +type productResource struct { + client *platform.ByProjectKeyRequestBuilder +} + +// NewResource is a helper function to simplify the provider implementation. +func NewResource() resource.Resource { + return &productResource{} +} + +// The Schema definition for the Attribute +var productVariantAttributeSchema = map[string]schema.Attribute{ + "name": schema.StringAttribute{ + Description: "Name of the Attribute.", + MarkdownDescription: "Name of the Attribute.", + Required: true, + }, + "value": schema.StringAttribute{ + Description: "The AttributeType determines the format of the Attribute value to be provided.", + MarkdownDescription: "The AttributeType determines the format of the Attribute value to be provided.", + Required: true, + }, +} + +// The Schema definition for the Product Price +var productPriceDraftSchema = map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "key": schema.StringAttribute{ + Description: "User-defined identifier for the Price. It must be unique per ProductVariant." + + "MinLength: 2, MaxLength: 256, Pattern: ^[A-Za-z0-9_-]+$", + MarkdownDescription: "User-defined identifier for the Price. It must be unique per [ProductVariant](https://docs.commercetools.com/api/projects/products#ctp:api:type:ProductVariant)." + + "`MinLength: 2` `MaxLength: 256` `Pattern: ^[A-Za-z0-9_-]+$`", + Optional: true, + Validators: []validator.String{ + stringvalidator.LengthBetween(2, 256), + stringvalidator.RegexMatches( + regexp.MustCompile("^[A-Za-z0-9_-]+$"), + "Key must match pattern ^[A-Za-z0-9_-]+$"), + }, + }, +} + +// The Schema definition for the Money +var moneySchema = map[string]schema.Attribute{ + "cent_amount": schema.Int64Attribute{ + Description: "Amount in the smallest indivisible unit of a currency, such as:" + + "Cents for EUR and USD, pence for GBP, or centime for CHF (5 CHF is specified as 500)." + + "The value in the major unit for currencies without minor units, like JPY (5 JPY is specified as 5).", + MarkdownDescription: "Amount in the smallest indivisible unit of a currency, such as:" + + "- Cents for EUR and USD, pence for GBP, or centime for CHF (5 CHF is specified as `500`)." + + "- The value in the major unit for currencies without minor units, like JPY (5 JPY is specified as `5`).", + Required: true, + }, + "currency_code": schema.StringAttribute{ + Description: "Currency code compliant to ISO 4217.", + MarkdownDescription: "Currency code compliant to [ISO 4217](https://en.wikipedia.org/wiki/ISO_4217).", + Required: true, + Validators: []validator.String{ + stringvalidator.RegexMatches( + regexp.MustCompile("^[A-Z]{3}$"), + "Currency Code must match pattern ^[A-Z]{3}$"), + }, + }, +} + +// The Schema definition for the ProductVariant +var productVariantSchema = map[string]schema.Attribute{ + "id": schema.Int64Attribute{ + Computed: true, + PlanModifiers: []planmodifier.Int64{ + int64planmodifier.UseStateForUnknown(), + }, + }, + "key": schema.StringAttribute{ + Description: "User-defined unique identifier for the ProductVariant.", + MarkdownDescription: "User-defined unique identifier for the ProductVariant.", + Optional: true, + }, + "sku": schema.StringAttribute{ + Description: "User-defined unique SKU of the Product Variant.", + MarkdownDescription: "User-defined unique SKU of the Product Variant.", + Optional: true, + }, +} + +// Schema defines the schema for the data source. +func (r *productResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + Version: 1, + Description: "An abstract sellable good with a set of Attributes defined by a Product Type. Products " + + "themselves are not sellable. Instead, they act as a parent structure for Product Variants. Each Product " + + "must have at least one Product Variant, which is called the Master Variant. A single Product " + + "representation contains the current and the staged representation of its product data.", + MarkdownDescription: "An abstract sellable good with a set of Attributes defined by a Product Type. Products " + + "themselves are not sellable. Instead, they act as a parent structure for Product Variants. Each Product " + + "must have at least one Product Variant, which is called the Master Variant. A single Product " + + "representation contains the *current* and the *staged* representation of its product data.", + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "version": schema.Int64Attribute{ + Computed: true, + }, + "key": schema.StringAttribute{ + Description: "User-defined unique identifier of the Product.", + MarkdownDescription: "User-defined unique identifier of the Product.", + Optional: true, + }, + "product_type_id": schema.StringAttribute{ + Description: "The Product Type defining the Attributes for the Product. Cannot be changed later.", + MarkdownDescription: "The Product Type defining the Attributes for the Product. Cannot be changed later.", + Required: true, + }, + "name": schema.MapAttribute{ + CustomType: customtypes.NewLocalizedStringType(), + Description: "Name of the Product.", + MarkdownDescription: "Name of the Product.", + Required: true, + }, + "slug": schema.MapAttribute{ + CustomType: customtypes.NewLocalizedStringType(), + Description: "User-defined identifier used in a deep-link URL for the Product. It must be unique " + + "across a Project, but a Product can have the same slug in different Locales. It must match the " + + "pattern [a-zA-Z0-9_-]{2,256}.", + MarkdownDescription: "User-defined identifier used in a deep-link URL for the Product. It must be " + + "unique across a Project, but a Product can have the same slug in different " + + "[Locales](https://docs.commercetools.com/api/types#ctp:api:type:Locale). It must match the " + + "pattern `[a-zA-Z0-9_-]{2,256}`.", + Required: true, + }, + "description": schema.MapAttribute{ + CustomType: customtypes.NewLocalizedStringType(), + Description: "Description of the Product.", + MarkdownDescription: "Description of the Product.", + Optional: true, + }, + "categories": schema.ListAttribute{ + ElementType: types.StringType, + Description: "Categories assigned to the Product.", + MarkdownDescription: "Categories assigned to the Product.", + Optional: true, + Validators: []validator.List{listvalidator.SizeAtLeast(1)}, + }, + "meta_title": schema.MapAttribute{ + CustomType: customtypes.NewLocalizedStringType(), + Description: "Title of the Product displayed in search results.", + MarkdownDescription: "Title of the Product displayed in search results.", + Optional: true, + }, + "meta_description": schema.MapAttribute{ + CustomType: customtypes.NewLocalizedStringType(), + Description: "Description of the Product displayed in search results.", + MarkdownDescription: "Description of the Product displayed in search results.", + Optional: true, + }, + "meta_keywords": schema.MapAttribute{ + CustomType: customtypes.NewLocalizedStringType(), + Description: "Keywords that give additional information about the Product to search engines.", + MarkdownDescription: "Keywords that give additional information about the Product to search engines.", + Optional: true, + }, + "tax_category_id": schema.StringAttribute{ + Description: "The Tax Category to be assigned to the Product.", + MarkdownDescription: "The Tax Category to be assigned to the Product.", + Optional: true, + }, + "state_id": schema.StringAttribute{ + Description: "State to be assigned to the Product.", + MarkdownDescription: "State to be assigned to the Product.", + Optional: true, + }, + "publish": schema.BoolAttribute{ + Description: "If true, the Product is published immediately to the current projection. Default: false", + MarkdownDescription: "If `true`, the Product is published immediately to the current projection. Default: `false`", + Optional: true, + }, + }, + Blocks: map[string]schema.Block{ + "master_variant": schema.SingleNestedBlock{ + Attributes: productVariantSchema, + Description: "The Product Variant to be the Master Variant for the Product. Required if variants are provided also.", + MarkdownDescription: "The Product Variant to be the Master Variant for the Product. Required if `variants` are provided also.", + Validators: []validator.Object{ + objectvalidator.IsRequired(), + }, + Blocks: map[string]schema.Block{ + "attribute": schema.ListNestedBlock{ + NestedObject: schema.NestedBlockObject{ + Attributes: productVariantAttributeSchema, + }, + }, + "price": schema.ListNestedBlock{ + NestedObject: schema.NestedBlockObject{ + Attributes: productPriceDraftSchema, + Blocks: map[string]schema.Block{ + "value": schema.SingleNestedBlock{ + Attributes: moneySchema, + }, + }, + }, + }, + }, + }, + "variant": schema.ListNestedBlock{ + NestedObject: schema.NestedBlockObject{ + Attributes: productVariantSchema, + Blocks: map[string]schema.Block{ + "attribute": schema.ListNestedBlock{ + NestedObject: schema.NestedBlockObject{ + Attributes: productVariantAttributeSchema, + }, + }, + "price": schema.ListNestedBlock{ + NestedObject: schema.NestedBlockObject{ + Attributes: productPriceDraftSchema, + Blocks: map[string]schema.Block{ + "value": schema.SingleNestedBlock{ + Attributes: moneySchema, + }, + }, + }, + }, + }, + }, + Description: "The additional Product Variants for the Product.", + MarkdownDescription: "The additional Product Variants for the Product.", + Validators: []validator.List{ + listvalidator.SizeAtLeast(1), + listvalidator.AlsoRequires( + path.MatchRelative().AtParent().AtName("master_variant"), + ), + }, + }, + }, + } +} + +// Metadata implements resource.Resource. +func (*productResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_product" +} + +// Create implements resource.Resource. +func (r *productResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var plan Product + diags := req.Plan.Get(ctx, &plan) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + draft := plan.draft() + + var product *platform.Product + err := retry.RetryContext(ctx, 20*time.Second, func() *retry.RetryError { + var err error + product, err = r.client.Products().Post(draft).Execute(ctx) + return utils.ProcessRemoteError(err) + }) + if err != nil { + resp.Diagnostics.AddError( + "Error creating product", + err.Error(), + ) + return + } + + current := NewProductFromNative(product) + + diags = resp.State.Set(ctx, current) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } +} + +// Delete implements resource.Resource. +func (r *productResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + // Get the current state. + var state Product + diags := req.State.Get(ctx, &state) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + err := retry.RetryContext( + ctx, + 5*time.Second, + func() *retry.RetryError { + _, err := r.client.Products(). + WithId(state.ID.ValueString()). + Delete(). + Version(int(state.Version.ValueInt64())). + Execute(ctx) + + return utils.ProcessRemoteError(err) + }) + if err != nil { + resp.Diagnostics.AddError( + "Error deleting product", + "Could not delete product, unexpected error: "+err.Error(), + ) + return + } +} + +// Read implements resource.Resource. +func (r *productResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + // Get the current state. + var state Product + diags := req.State.Get(ctx, &state) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + // Read remote product and check for errors. + product, err := r.client.Products().WithId(state.ID.ValueString()).Get().Execute(ctx) + if err != nil { + if errors.Is(err, platform.ErrNotFound) { + resp.State.RemoveResource(ctx) + return + } + + resp.Diagnostics.AddError( + "Error reading product", + "Could not retrieve the product, unexpected error: "+err.Error(), + ) + return + } + + // Transform the remote platform product to the + // tf schema matching representation. + current := NewProductFromNative(product) + + // Set current data as state. + diags = resp.State.Set(ctx, ¤t) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } +} + +// Update implements resource.Resource. +func (r *productResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + var plan Product + diags := req.Plan.Get(ctx, &plan) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + var state Product + diags = req.State.Get(ctx, &state) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + input := state.updateActions(plan) + var product *platform.Product + err := retry.RetryContext(ctx, 5*time.Second, func() *retry.RetryError { + var err error + product, err = r.client.Products(). + WithId(state.ID.ValueString()). + Post(input). + Execute(ctx) + + return utils.ProcessRemoteError(err) + }) + if err != nil { + resp.Diagnostics.AddError( + "Error updating product", + "Could not update product, unexpected error: "+err.Error(), + ) + return + } + + current := NewProductFromNative(product) + + diags = resp.State.Set(ctx, current) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } +} + +// Configure adds the provider configured client to the data source. +func (r *productResource) Configure(_ context.Context, req resource.ConfigureRequest, _ *resource.ConfigureResponse) { + if req.ProviderData == nil { + return + } + data := req.ProviderData.(*utils.ProviderData) + r.client = data.Client +} + +// ImportState implements resource.ResourceWithImportState. +func (r *productResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + product, err := r.client.Products().WithId(req.ID).Get().Execute(ctx) + if err != nil { + if errors.Is(err, platform.ErrNotFound) { + resp.State.RemoveResource(ctx) + return + } + resp.Diagnostics.AddError( + "Error reading product", + "Could not retrieve product, unexpected error: "+err.Error(), + ) + return + } + + current := NewProductFromNative(product) + + // Set refreshed state + diags := resp.State.Set(ctx, ¤t) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } +} + +var _ resource.ResourceWithModifyPlan = &productResource{} + +func (r productResource) ModifyPlan(ctx context.Context, req resource.ModifyPlanRequest, resp *resource.ModifyPlanResponse) { + if req.Plan.Raw.IsNull() || req.State.Raw.IsNull() || req.Config.Raw.IsNull() { + return + } + + var state Product + diags := req.State.Get(ctx, &state) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + var config Product + diags = req.Config.Get(ctx, &config) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + // Master variant change + if !state.MasterVariant.Sku.Equal(config.MasterVariant.Sku) { + // Check if new Master persits within the Variants + newMasterIndex := pie.FindFirstUsing(state.Variants, func(pv ProductVariant) bool { return pv.Sku.Equal(config.MasterVariant.Sku) }) + if newMasterIndex == -1 { + resp.Diagnostics.AddError( + "Master Variant must be within the Variants.", + fmt.Sprintf("Variant with sku %s not found on product %s", config.MasterVariant.Sku, state.ID), + ) + return + } else { + currentVariants := append(state.Variants, state.MasterVariant) + newMaster := config.MasterVariant + currentMasterVariant := getVariantBySku(currentVariants, config.MasterVariant.Sku.ValueString()) + newMaster.ID = currentMasterVariant.ID + updatedPrices := []Price{} + for _, p := range newMaster.Prices { + currentPrice := getPriceByKey(currentMasterVariant.Prices, p.Key.ValueString()) + if currentPrice != nil { + p.ID = currentPrice.ID + } else { + p.ID = types.StringUnknown() + } + updatedPrices = append(updatedPrices, p) + } + newMaster.Prices = updatedPrices + diags = resp.Plan.SetAttribute(ctx, path.Root("master_variant"), newMaster) + resp.Diagnostics.Append(diags...) + } + } + + // Variants change + if !reflect.DeepEqual(state.Variants, config.Variants) { + currentVariants := append(state.Variants, state.MasterVariant) + newVariants := []ProductVariant{} + for _, v := range config.Variants { + currentVariant := getVariantBySku(currentVariants, v.Sku.ValueString()) + if currentVariant != nil { + v.ID = currentVariant.ID + updatedPrices := []Price{} + for _, p := range v.Prices { + currentPrice := getPriceByKey(currentVariant.Prices, p.Key.ValueString()) + if currentPrice != nil { + p.ID = currentPrice.ID + } else { + p.ID = types.StringUnknown() + } + updatedPrices = append(updatedPrices, p) + } + v.Prices = updatedPrices + } else { + v.ID = types.Int64Unknown() + updatedPrices := []Price{} + for _, p := range v.Prices { + p.ID = types.StringUnknown() + updatedPrices = append(updatedPrices, p) + } + v.Prices = updatedPrices + } + newVariants = append(newVariants, v) + } + + diags = resp.Plan.SetAttribute(ctx, path.Root("variant"), + pie.SortUsing(newVariants, func(a, b ProductVariant) bool { + if a.ID.IsUnknown() { + return false + } + if b.ID.IsUnknown() { + return true + } + return a.ID.ValueInt64() < b.ID.ValueInt64() + })) + resp.Diagnostics.Append(diags...) + } +} + +func getVariantBySku(variants []ProductVariant, sku string) *ProductVariant { + for _, v := range variants { + if v.Sku.ValueString() == sku { + return &v + } + } + return nil +} + +func getPriceByKey(prices []Price, key string) *Price { + for _, p := range prices { + if p.Key.ValueString() == key { + return &p + } + } + return nil +} diff --git a/internal/resources/product/resource_test.go b/internal/resources/product/resource_test.go new file mode 100644 index 00000000..eaf1d977 --- /dev/null +++ b/internal/resources/product/resource_test.go @@ -0,0 +1,364 @@ +package product_test + +import ( + "bytes" + "context" + "fmt" + "html/template" + "testing" + + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" + "github.com/hashicorp/terraform-plugin-sdk/v2/terraform" + "github.com/labd/terraform-provider-commercetools/internal/acctest" +) + +var templateData = map[string]string{ + "identifier": "test_product", + "key": "test-product-key", + "name": "test-product-name", + "slug": "test-product-slug", + "description": "Test product description", + "metaTitle": "meta-title", + "metaDescription": "meta-description", + "metaKeywords": "meta-keywords", + "addVariant1": "false", + "addVariant2": "false", + "setTaxCategory": "true", + "taxCategoryRef": "external_shipping_tax", + "masterVariant": "master-variant-key", + "addPrice": "false", + "addPriceValue": "1000", + "addToCategory": "false", + "published": "false", + "stateName": "product_state_for_sale", + "masterVariantNameAttrValue": "Test product basic variant", +} + +func TestAccProductResource_Create(t *testing.T) { + testData := copyMap(templateData) + resourceName := "commercetools_product." + testData["identifier"] + + resource.Test(t, resource.TestCase{ + PreCheck: func() { acctest.TestAccPreCheck(t) }, + ProtoV5ProviderFactories: acctest.ProtoV5ProviderFactories, + CheckDestroy: testAccProductDestroy, + Steps: []resource.TestStep{ + { + Config: getResourceConfig(testData), + Check: resource.ComposeAggregateTestCheckFunc( + checkProductReference( + testData["identifier"], "tax_category_id", "commercetools_tax_category", testData["taxCategoryRef"]), + resource.TestCheckResourceAttr(resourceName, "key", testData["key"]), + resource.TestCheckResourceAttr(resourceName, "name.en-GB", testData["name"]), + resource.TestCheckResourceAttr(resourceName, "slug.en-GB", testData["slug"]), + resource.TestCheckResourceAttr(resourceName, "description.en-GB", "Test product description"), + resource.TestCheckResourceAttr(resourceName, "meta_title.en-GB", "meta-title"), + resource.TestCheckResourceAttr(resourceName, "meta_description.en-GB", "meta-description"), + resource.TestCheckResourceAttr(resourceName, "meta_keywords.en-GB", "meta-keywords"), + resource.TestCheckResourceAttr(resourceName, "publish", "false"), + resource.TestCheckResourceAttr(resourceName, "master_variant.key", "master-variant-key"), + resource.TestCheckResourceAttr(resourceName, "master_variant.sku", "100000"), + resource.TestCheckResourceAttr(resourceName, "master_variant.attribute.0.name", "name"), + resource.TestCheckResourceAttr(resourceName, "master_variant.attribute.0.value", "{\"en-GB\":\"Test product basic variant\"}"), + resource.TestCheckResourceAttr(resourceName, "master_variant.attribute.1.name", "description"), + resource.TestCheckResourceAttr(resourceName, "master_variant.attribute.1.value", "{\"en-GB\":\"Test product basic variant description\"}"), + resource.TestCheckResourceAttr(resourceName, "master_variant.price.0.key", "base_price_eur"), + resource.TestCheckResourceAttr(resourceName, "master_variant.price.0.value.cent_amount", "1000000"), + resource.TestCheckResourceAttr(resourceName, "master_variant.price.0.value.currency_code", "EUR"), + resource.TestCheckResourceAttr(resourceName, "master_variant.price.1.key", "base_price_gbr"), + resource.TestCheckResourceAttr(resourceName, "master_variant.price.1.value.cent_amount", "872795"), + resource.TestCheckResourceAttr(resourceName, "master_variant.price.1.value.currency_code", "GBP"), + resource.TestCheckResourceAttr(resourceName, "variant.0.key", "variant-1-key"), + resource.TestCheckResourceAttr(resourceName, "variant.0.sku", "100001"), + resource.TestCheckResourceAttr(resourceName, "variant.0.attribute.0.name", "name"), + resource.TestCheckResourceAttr(resourceName, "variant.0.attribute.0.value", "{\"en-GB\":\"Test product variant one\"}"), + resource.TestCheckResourceAttr(resourceName, "variant.0.attribute.1.name", "description"), + resource.TestCheckResourceAttr(resourceName, "variant.0.attribute.1.value", "{\"en-GB\":\"Test product variant one description\"}"), + resource.TestCheckResourceAttr(resourceName, "variant.0.price.0.key", "base_price_eur"), + resource.TestCheckResourceAttr(resourceName, "variant.0.price.0.value.cent_amount", "1010000"), + resource.TestCheckResourceAttr(resourceName, "variant.0.price.0.value.currency_code", "EUR"), + resource.TestCheckResourceAttr(resourceName, "variant.0.price.1.key", "base_price_gbr"), + resource.TestCheckResourceAttr(resourceName, "variant.0.price.1.value.cent_amount", "880299"), + resource.TestCheckResourceAttr(resourceName, "variant.0.price.1.value.currency_code", "GBP"), + ), + }, + }, + }) +} + +func TestAccProductResource_Update(t *testing.T) { + testData := copyMap(templateData) + resourceName := "commercetools_product." + testData["identifier"] + + resource.Test(t, resource.TestCase{ + PreCheck: func() { acctest.TestAccPreCheck(t) }, + ProtoV5ProviderFactories: acctest.ProtoV5ProviderFactories, + CheckDestroy: testAccProductDestroy, + Steps: []resource.TestStep{ + { + Config: getResourceConfig(testData), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr(resourceName, "key", testData["key"]), + ), + }, + { + // Test setKey action + PreConfig: func() { fmt.Println(" - Test setKey action") }, + Config: getUpdatedResourceConfig(testData, "key", "new-key-value"), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr(resourceName, "key", "new-key-value"), + ), + }, + { + // Test changeName action + PreConfig: func() { fmt.Println(" - Test changeName action") }, + Config: getUpdatedResourceConfig(testData, "name", "new-test-product-name"), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr(resourceName, "name.en-GB", "new-test-product-name"), + ), + }, + { + // Test changeSlug action + PreConfig: func() { fmt.Println(" - Test changeSlug action") }, + Config: getUpdatedResourceConfig(testData, "slug", "new-test-product-slug"), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr(resourceName, "slug.en-GB", "new-test-product-slug"), + ), + }, + { + // Test setDescription action + PreConfig: func() { fmt.Println(" - Test setDescription action") }, + Config: getUpdatedResourceConfig(testData, "description", "New Test product description"), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr(resourceName, "description.en-GB", "New Test product description"), + ), + }, + { + // Test setMetaTitle action + PreConfig: func() { fmt.Println(" - Test setMetaTitle action") }, + Config: getUpdatedResourceConfig(testData, "metaTitle", "new-meta-title"), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr(resourceName, "meta_title.en-GB", "new-meta-title"), + ), + }, + { + // Test setMetaDescription action + PreConfig: func() { fmt.Println(" - Test setMetaDescription action") }, + Config: getUpdatedResourceConfig(testData, "metaDescription", "new-meta-description"), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr(resourceName, "meta_description.en-GB", "new-meta-description"), + ), + }, + { + // Test setMetaKeywords action + PreConfig: func() { fmt.Println(" - Test setMetaKeywords action") }, + Config: getUpdatedResourceConfig(testData, "metaKeywords", "new-meta-keywords"), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr(resourceName, "meta_keywords.en-GB", "new-meta-keywords"), + ), + }, + { + // Test addVariant action 1 + PreConfig: func() { fmt.Println(" - Test addVariant action 1") }, + Config: getUpdatedResourceConfig(testData, "addVariant1", "true"), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr(resourceName, "variant.1.key", "variant-2-key"), + ), + }, + { + // Test addVariant action 2 + PreConfig: func() { fmt.Println(" - Test addVariant action 2") }, + Config: getUpdatedResourceConfig(testData, "addVariant2", "true"), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr(resourceName, "variant.2.key", "variant-3-key"), + ), + }, + { + // Test removeVariant action + PreConfig: func() { fmt.Println(" - Test removeVariant action") }, + Config: getUpdatedResourceConfig(testData, "addVariant1", "false"), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr(resourceName, "variant.1.key", "variant-3-key"), + ), + }, + { + // Test setTaxCategory action + PreConfig: func() { fmt.Println(" - Test setTaxCategory action") }, + Config: getUpdatedResourceConfig(testData, "taxCategoryRef", "vat_tax"), + Check: resource.ComposeTestCheckFunc( + checkProductReference( + testData["identifier"], "tax_category_id", "commercetools_tax_category", "vat_tax"), + ), + }, + { + // Test addPrice action + PreConfig: func() { fmt.Println(" - Test addPrice action") }, + Config: getUpdatedResourceConfig(testData, "addPrice", "true"), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr(resourceName, "master_variant.price.2.key", "base_price_usd"), + ), + }, + // #TBD Mock server bug, can't identify the issue for the moment + // Test passes successfully on the real environment + // { + // // Test changePrice action + // PreConfig: func() { fmt.Println(" - Test changePrice action") }, + // Config: getUpdatedResourceConfig(testData, "addPriceValue", "9999"), + // Check: resource.ComposeTestCheckFunc( + // resource.TestCheckResourceAttr(resourceName, "master_variant.price.2.value.cent_amount", "9999"), + // ), + // }, + { + // Test removePrice action + PreConfig: func() { fmt.Println(" - Test removePrice action") }, + Config: getUpdatedResourceConfig(testData, "addPrice", "false"), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckNoResourceAttr(resourceName, "master_variant.price.2.key"), + ), + }, + { + // Test addToCategory action + PreConfig: func() { fmt.Println(" - Test addToCategory action") }, + Config: getUpdatedResourceConfig(testData, "addToCategory", "true"), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttrSet(resourceName, "categories.1"), + ), + }, + { + // Test removeFromCategory action + PreConfig: func() { fmt.Println(" - Test removeFromCategory action") }, + Config: getUpdatedResourceConfig(testData, "addToCategory", "false"), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckNoResourceAttr(resourceName, "categories.1"), + ), + }, + { + // Test publish action + PreConfig: func() { fmt.Println(" - Test publish action") }, + Config: getUpdatedResourceConfig(testData, "published", "true"), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr(resourceName, "publish", "true"), + ), + }, + { + // Test unpublish action + PreConfig: func() { fmt.Println(" - Test unpublish action") }, + Config: getUpdatedResourceConfig(testData, "published", "false"), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr(resourceName, "publish", "false"), + ), + }, + { + // Test transitionState action + PreConfig: func() { fmt.Println(" - Test transitionState action") }, + Config: getUpdatedResourceConfig(testData, "stateName", "product_out_of_stock"), + Check: resource.ComposeTestCheckFunc( + checkProductReference( + testData["identifier"], "state_id", "commercetools_state", "product_out_of_stock"), + ), + }, + { + // Test setAttribute action + PreConfig: func() { fmt.Println(" - Test setAttribute action") }, + Config: getUpdatedResourceConfig(testData, "masterVariantNameAttrValue", "New name"), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr(resourceName, "master_variant.attribute.0.value", "{\"en-GB\":\"New name\"}"), + ), + }, + { + // Test changeMasterVariant action + PreConfig: func() { fmt.Println(" - Test changeMasterVariant action") }, + Config: getUpdatedResourceConfig(testData, "masterVariant", "variant-1-key"), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr(resourceName, "master_variant.sku", "100001"), + ), + }, + }, + }) +} + +func testAccProductDestroy(s *terraform.State) error { + client, err := acctest.GetClient() + if err != nil { + return err + } + + for _, rs := range s.RootModule().Resources { + if rs.Type != "commercetools_product" { + continue + } + response, err := client.Products().WithId(rs.Primary.ID).Get().Execute(context.Background()) + if err == nil { + if response != nil && response.ID == rs.Primary.ID { + return fmt.Errorf("product (%s) still exists", rs.Primary.ID) + } + return nil + } + if newErr := acctest.CheckApiResult(err); newErr != nil { + return newErr + } + } + return nil +} + +func getResourceConfig(data map[string]string) string { + // Load templates + tpl, err := template.ParseGlob("testdata/*") + if err != nil { + panic(err) + } + + var out bytes.Buffer + err = tpl.ExecuteTemplate(&out, "main", data) + if err != nil { + panic(err) + } + + return out.String() +} + +func getUpdatedResourceConfig(data map[string]string, key, value string) string { + // Update map value + data[key] = value + return getResourceConfig(data) +} + +func copyMap(srcMap map[string]string) map[string]string { + newMap := make(map[string]string, len(srcMap)) + + for k, v := range srcMap { + newMap[k] = v + } + + return newMap +} + +func checkProductReference(productName, productRefAttribute, refResourceType, refResourceName string) resource.TestCheckFunc { + return func(s *terraform.State) error { + // Retrieve product state + productResourceName := "commercetools_product." + productName + productResourceState, ok := s.RootModule().Resources[productResourceName] + if !ok { + return fmt.Errorf("Product '%v' not found", productResourceName) + } + productRefId := productResourceState.Primary.Attributes[productRefAttribute] + + // Retrieve referenced resource + refId := "" + if refResourceName != "" { // if empty string is passed, no reference is expected + // Retrieve referenced resource + refResourceName := fmt.Sprintf("%s.%s", refResourceType, refResourceName) + refResourceState, ok := s.RootModule().Resources[refResourceName] + if !ok { + return fmt.Errorf("Resource '%s' of type '%s' not found", refResourceName, refResourceType) + } + refId = refResourceState.Primary.ID + } + + if productRefId != refId { + return fmt.Errorf("Attribute '%s' expected '%s', got '%s'", productRefAttribute, refId, productRefId) + } + + return nil + } +} diff --git a/internal/resources/product/testdata/commercetools_category.tmpl b/internal/resources/product/testdata/commercetools_category.tmpl new file mode 100644 index 00000000..e2231ca7 --- /dev/null +++ b/internal/resources/product/testdata/commercetools_category.tmpl @@ -0,0 +1,35 @@ +{{ define "commercetools_category" }} +resource "commercetools_category" "category1" { + key = "category-1-key" + + name = { + en-GB = "Category One" + } + description = { + en-GB = "Category One description" + } + slug = { + en-GB = "category_1" + } + meta_title = { + en-GB = "Category One Meta Title" + } +} + +resource "commercetools_category" "category2" { + key = "category-2-key" + + name = { + en-GB = "Category Two" + } + description = { + en-GB = "Category Two description" + } + slug = { + en-GB = "category_2" + } + meta_title = { + en-GB = "Category Two Meta Title" + } +} +{{ end }} \ No newline at end of file diff --git a/internal/resources/product/testdata/commercetools_product.tmpl b/internal/resources/product/testdata/commercetools_product.tmpl new file mode 100644 index 00000000..1190c00b --- /dev/null +++ b/internal/resources/product/testdata/commercetools_product.tmpl @@ -0,0 +1,165 @@ +{{ define "commercetools_product" }} +resource "commercetools_product" "{{ .identifier }}" { + key = "{{ .key }}" + name = { + en-GB = "{{ .name }}" + } + slug = { + en-GB = "{{ .slug }}" + } + description = { + en-GB = "{{ .description }}" + } + product_type_id = commercetools_product_type.default_product_type.id + publish = {{ .published }} + categories = [ + commercetools_category.category1.id, + {{ if eq .addToCategory "true" }} + commercetools_category.category2.id, + {{ end }} + ] + {{ if eq .setTaxCategory "true" }} + tax_category_id = commercetools_tax_category.{{ .taxCategoryRef }}.id + {{ end }} + meta_title = { + en-GB = "{{ .metaTitle }}" + } + meta_description = { + en-GB = "{{ .metaDescription }}" + } + meta_keywords = { + en-GB = "{{ .metaKeywords }}" + } + state_id = commercetools_state.{{ .stateName }}.id + + {{ if eq .masterVariant "master-variant-key" }} + master_variant { + {{ else }} + variant { + {{ end }} + key = "master-variant-key" + sku = "100000" + attribute { + name = "name" + value = jsonencode({ "en-GB" : "{{ .masterVariantNameAttrValue }}" }) + } + attribute { + name = "description" + value = jsonencode({ "en-GB" : "Test product basic variant description" }) + } + price { + key = "base_price_eur" + value { + cent_amount = 1000000 + currency_code = "EUR" + } + } + price { + key = "base_price_gbr" + value { + cent_amount = 872795 + currency_code = "GBP" + } + } + {{ if eq .addPrice "true" }} + price { + key = "base_price_usd" + value { + cent_amount = {{ .addPriceValue }} + currency_code = "USD" + } + } + {{ end }} + + } + + {{ if eq .masterVariant "variant-1-key" }} + master_variant { + {{ else }} + variant { + {{ end }} + key = "variant-1-key" + sku = "100001" + attribute { + name = "name" + value = jsonencode({ "en-GB" : "Test product variant one" }) + } + attribute { + name = "description" + value = jsonencode({ "en-GB" : "Test product variant one description" }) + } + price { + key = "base_price_eur" + value { + cent_amount = 1010000 + currency_code = "EUR" + } + } + price { + key = "base_price_gbr" + value { + cent_amount = 880299 + currency_code = "GBP" + } + } + } + + {{ if eq .addVariant1 "true" }} + variant { + key = "variant-2-key" + sku = "100002" + attribute { + name = "name" + value = jsonencode({ "en-GB" : "Test product variant two" }) + } + attribute { + name = "description" + value = jsonencode({ "en-GB" : "Test product variant two description" }) + } + price { + key = "base_price_eur" + value { + cent_amount = 1231231 + currency_code = "EUR" + } + } + price { + key = "base_price_gbr" + value { + cent_amount = 123123 + currency_code = "GBP" + } + } + } + {{ end }} + + {{ if eq .addVariant2 "true" }} + variant { + key = "variant-3-key" + sku = "100003" + attribute { + name = "name" + value = jsonencode({ "en-GB" : "Test product variant three" }) + } + attribute { + name = "description" + value = jsonencode({ "en-GB" : "Test product variant three description" }) + } + price { + key = "base_price_eur" + value { + cent_amount = 30000 + currency_code = "EUR" + } + } + price { + key = "base_price_gbr" + value { + cent_amount = 333333 + currency_code = "GBP" + } + } + } + {{ end }} +} +{{ end }} \ No newline at end of file diff --git a/internal/resources/product/testdata/commercetools_product_type.tmpl b/internal/resources/product/testdata/commercetools_product_type.tmpl new file mode 100644 index 00000000..df3bc94a --- /dev/null +++ b/internal/resources/product/testdata/commercetools_product_type.tmpl @@ -0,0 +1,41 @@ +{{ define "commercetools_product_type" }} +resource "commercetools_product_type" "default_product_type" { + key = "default-product-type" + name = "Default Product Type" + description = "Default Product Type Description" + + attribute { + constraint = "None" + input_hint = "SingleLine" + input_tip = { + en-GB = "SKU name" + } + label = { + en-GB = "Name" + } + name = "name" + required = false + searchable = false + type { + name = "ltext" + } + } + + attribute { + constraint = "None" + input_hint = "SingleLine" + input_tip = { + en-GB = "SKU Description" + } + label = { + en-GB = "Description" + } + name = "description" + required = false + searchable = false + type { + name = "ltext" + } + } +} +{{ end }} \ No newline at end of file diff --git a/internal/resources/product/testdata/commercetools_state.tmpl b/internal/resources/product/testdata/commercetools_state.tmpl new file mode 100644 index 00000000..07c8db05 --- /dev/null +++ b/internal/resources/product/testdata/commercetools_state.tmpl @@ -0,0 +1,25 @@ +{{ define "commercetools_state" }} +resource "commercetools_state" "product_state_for_sale" { + key = "product-for-sale" + type = "ProductState" + name = { + en-GB = "For Sale" + } + description = { + en-GB = "Regularly stocked product." + } + initial = true +} + +resource "commercetools_state" "product_out_of_stock" { + key = "product-out-of-stock" + type = "ProductState" + name = { + en-GB = "Out of stock" + } + description = { + en-GB = "Out of stock product." + } + initial = false +} +{{ end }} \ No newline at end of file diff --git a/internal/resources/product/testdata/commercetools_tax_category.tmpl b/internal/resources/product/testdata/commercetools_tax_category.tmpl new file mode 100644 index 00000000..b199f29a --- /dev/null +++ b/internal/resources/product/testdata/commercetools_tax_category.tmpl @@ -0,0 +1,13 @@ +{{ define "commercetools_tax_category" }} +resource "commercetools_tax_category" "external_shipping_tax" { + key = "external-shipping-tax" + name = "External Shipping Tax" + description = "External Shipping Tax Description" +} + +resource "commercetools_tax_category" "vat_tax" { + key = "vat-tax" + name = "VAT Tax" + description = "VAT Tax Description" +} +{{ end }} \ No newline at end of file diff --git a/internal/resources/product/testdata/main.tmpl b/internal/resources/product/testdata/main.tmpl new file mode 100644 index 00000000..a6656a40 --- /dev/null +++ b/internal/resources/product/testdata/main.tmpl @@ -0,0 +1,7 @@ +{{ define "main" }} +{{ template "commercetools_product_type" }} +{{ template "commercetools_category" }} +{{ template "commercetools_tax_category" }} +{{ template "commercetools_state" }} +{{ template "commercetools_product" . }} +{{ end }} \ No newline at end of file diff --git a/internal/resources/product/testdata/model_test.go b/internal/resources/product/testdata/model_test.go new file mode 100644 index 00000000..6d6198ea --- /dev/null +++ b/internal/resources/product/testdata/model_test.go @@ -0,0 +1,686 @@ +package product + +import ( + "testing" + + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-framework/types/basetypes" + "github.com/labd/commercetools-go-sdk/platform" + "github.com/labd/terraform-provider-commercetools/internal/customtypes" + "github.com/labd/terraform-provider-commercetools/internal/utils" + "github.com/stretchr/testify/assert" +) + +func TestNewProductFromNative(t *testing.T) { + productData := platform.ProductData{ + Name: platform.LocalizedString{"en-US": "Test product name"}, + Categories: []platform.CategoryReference{ + { + ID: "00000000-0000-0000-0000-000000000000", + }, + }, + Slug: platform.LocalizedString{"en-US": "Test product slug"}, + Description: &platform.LocalizedString{"en-US": "Test product description"}, + MetaTitle: &platform.LocalizedString{"en-US": "Test product Meta Title"}, + MetaDescription: &platform.LocalizedString{"en-US": "Test product Meta Description"}, + MetaKeywords: &platform.LocalizedString{"en-US": "Test product Meta Keywords"}, + MasterVariant: platform.ProductVariant{ + ID: 1, + Sku: utils.StringRef("1001"), + Key: utils.StringRef("variant-1"), + Prices: []platform.Price{ + { + ID: "00000000-0000-0000-0000-000000000001", + Key: utils.StringRef("price-1"), + Value: platform.CentPrecisionMoney{ + CentAmount: 1000, + CurrencyCode: "USD", + }, + }, + }, + Attributes: []platform.Attribute{ + { + Name: "color", + Value: platform.LocalizedString{"en-US": "Red"}, + }, + }, + }, + Variants: []platform.ProductVariant{ + { + ID: 2, + Sku: utils.StringRef("1002"), + Key: utils.StringRef("variant-2"), + Prices: []platform.Price{ + { + ID: "00000000-0000-0000-0000-000000000002", + Key: utils.StringRef("price-1"), + Value: platform.CentPrecisionMoney{ + CentAmount: 1000, + CurrencyCode: "USD", + }, + }, + }, + Attributes: []platform.Attribute{ + { + Name: "color", + Value: platform.LocalizedString{"en-US": "Green"}, + }, + }, + }, + }, + } + + native := platform.Product{ + ID: "00000000-0000-0000-0000-000000000003", + Key: utils.StringRef("test-product"), + Version: 10, + ProductType: platform.ProductTypeReference{ + ID: "00000000-0000-0000-0000-000000000004", + }, + MasterData: platform.ProductCatalogData{ + Published: true, + Current: productData, + Staged: productData, + }, + TaxCategory: &platform.TaxCategoryReference{ + ID: "00000000-0000-0000-0000-000000000005", + }, + State: &platform.StateReference{ + ID: "00000000-0000-0000-0000-000000000006", + }, + } + productDraft := NewProductFromNative(&native) + + assert.Equal(t, Product{ + ID: types.StringValue("00000000-0000-0000-0000-000000000003"), + Key: types.StringValue("test-product"), + Version: types.Int64Value(10), + ProductTypeId: types.StringValue("00000000-0000-0000-0000-000000000004"), + Name: customtypes.NewLocalizedStringValue(map[string]attr.Value{ + "en-US": types.StringValue("Test product name"), + }), + Slug: customtypes.NewLocalizedStringValue(map[string]attr.Value{ + "en-US": types.StringValue("Test product slug"), + }), + Description: customtypes.NewLocalizedStringValue(map[string]attr.Value{ + "en-US": types.StringValue("Test product description"), + }), + Categories: []types.String{ + types.StringValue("00000000-0000-0000-0000-000000000000"), + }, + MetaTitle: customtypes.NewLocalizedStringValue(map[string]attr.Value{ + "en-US": types.StringValue("Test product Meta Title"), + }), + MetaDescription: customtypes.NewLocalizedStringValue(map[string]attr.Value{ + "en-US": types.StringValue("Test product Meta Description"), + }), + MetaKeywords: customtypes.NewLocalizedStringValue(map[string]attr.Value{ + "en-US": types.StringValue("Test product Meta Keywords"), + }), + MasterVariant: ProductVariant{ + ID: types.Int64Value(1), + Sku: types.StringValue("1001"), + Key: types.StringValue("variant-1"), + Prices: []Price{ + { + ID: types.StringValue("00000000-0000-0000-0000-000000000001"), + Key: types.StringValue("price-1"), + Value: Money{ + CentAmount: types.Int64Value(1000), + CurrencyCode: types.StringValue("USD"), + }, + }, + }, + Attributes: []Attribute{ + { + Name: types.StringValue("color"), + Value: types.StringValue("{\"en-US\":\"Red\"}"), + }, + }, + }, + Variants: []ProductVariant{ + { + ID: types.Int64Value(2), + Sku: types.StringValue("1002"), + Key: types.StringValue("variant-2"), + Prices: []Price{ + { + ID: types.StringValue("00000000-0000-0000-0000-000000000002"), + Key: types.StringValue("price-1"), + Value: Money{ + CentAmount: types.Int64Value(1000), + CurrencyCode: types.StringValue("USD"), + }, + }, + }, + Attributes: []Attribute{ + { + Name: types.StringValue("color"), + Value: types.StringValue("{\"en-US\":\"Green\"}"), + }, + }, + }, + }, + TaxCategoryId: types.StringValue("00000000-0000-0000-0000-000000000005"), + StateId: types.StringValue("00000000-0000-0000-0000-000000000006"), + Publish: types.BoolValue(true), + }, productDraft) + +} + +func Test_UpdateActions(t *testing.T) { + productPublishScopeAll := platform.ProductPublishScopeAll + productVariant1 := ProductVariant{ + ID: types.Int64Value(1), + Sku: types.StringValue("1001"), + Key: types.StringValue("product-variant-1"), + } + productVariant2 := ProductVariant{ + ID: types.Int64Value(2), + Sku: types.StringValue("1002"), + Key: types.StringValue("product-variant-2"), + } + productVariantWithAttribute1 := ProductVariant{ + ID: types.Int64Value(1), + Sku: types.StringValue("1001"), + Key: types.StringValue("product-variant-1"), + Attributes: []Attribute{ + { + Name: types.StringValue("color"), + Value: types.StringValue("{\"en-US\":\"Red\"}"), + }, + }, + } + productVariantWithAttribute2 := ProductVariant{ + ID: types.Int64Value(1), + Sku: types.StringValue("1001"), + Key: types.StringValue("product-variant-1"), + Attributes: []Attribute{ + { + Name: types.StringValue("color"), + Value: types.StringValue("{\"en-US\":\"Green\"}"), + }, + }, + } + productVariantWithPrice1 := ProductVariant{ + ID: types.Int64Value(1), + Sku: types.StringValue("1001"), + Key: types.StringValue("product-variant-1"), + Prices: []Price{ + { + ID: types.StringValue("price-1-id"), + Key: types.StringValue("price-1-key"), + Value: Money{ + CentAmount: types.Int64Value(1000), + CurrencyCode: types.StringValue("USD"), + }, + }, + }, + } + productVariantWithPrice2 := ProductVariant{ + ID: types.Int64Value(1), + Sku: types.StringValue("1001"), + Key: types.StringValue("product-variant-1"), + Prices: []Price{ + { + ID: types.StringValue("price-1-id"), + Key: types.StringValue("price-1-key"), + Value: Money{ + CentAmount: types.Int64Value(2000), + CurrencyCode: types.StringValue("USD"), + }, + }, + }, + } + // price1 := Price{ + // ID: types.StringValue("price-1-id"), + // Key: types.StringValue("price-1-key"), + // Value: Money{ + // CentAmount: types.Int64Value(1000), + // CurrencyCode: types.StringValue("USD"), + // }, + // } + // price2 := Price{ + // ID: types.StringValue("price-2-id"), + // Key: types.StringValue("price-2-key"), + // Value: Money{ + // CentAmount: types.Int64Value(2000), + // CurrencyCode: types.StringValue("USD"), + // }, + // } + testCases := []struct { + name string + state Product + plan Product + expected platform.ProductUpdate + }{ + { + "product setKey", + Product{ + Key: types.StringValue("product-key-1"), + }, + Product{ + Key: types.StringValue("product-key-2"), + }, + platform.ProductUpdate{ + Actions: []platform.ProductUpdateAction{ + platform.ProductSetKeyAction{ + Key: utils.StringRef("product-key-2"), + }, + }, + }, + }, + { + "product changeName", + Product{ + Name: customtypes.NewLocalizedStringValue(map[string]attr.Value{ + "en-US": types.StringValue("Product name"), + }), + }, + Product{ + Name: customtypes.NewLocalizedStringValue(map[string]attr.Value{ + "en-US": types.StringValue("New product name"), + }), + }, + platform.ProductUpdate{ + Actions: []platform.ProductUpdateAction{ + platform.ProductChangeNameAction{ + Name: platform.LocalizedString{"en-US": "New product name"}, + Staged: utils.BoolRef(false), + }, + }, + }, + }, + { + "product changeSlug", + Product{ + Slug: customtypes.NewLocalizedStringValue(map[string]attr.Value{ + "en-US": types.StringValue("Product slug"), + }), + }, + Product{ + Slug: customtypes.NewLocalizedStringValue(map[string]attr.Value{ + "en-US": types.StringValue("New product slug"), + }), + }, + platform.ProductUpdate{ + Actions: []platform.ProductUpdateAction{ + platform.ProductChangeSlugAction{ + Slug: platform.LocalizedString{"en-US": "New product slug"}, + Staged: utils.BoolRef(false), + }, + }, + }, + }, + { + "product setDescription", + Product{ + Description: customtypes.NewLocalizedStringValue(map[string]attr.Value{ + "en-US": types.StringValue("Product description"), + }), + }, + Product{ + Description: customtypes.NewLocalizedStringValue(map[string]attr.Value{ + "en-US": types.StringValue("New product description"), + }), + }, + platform.ProductUpdate{ + Actions: []platform.ProductUpdateAction{ + platform.ProductSetDescriptionAction{ + Description: &platform.LocalizedString{"en-US": "New product description"}, + Staged: utils.BoolRef(false), + }, + }, + }, + }, + { + "product setMetaTitle", + Product{ + MetaTitle: customtypes.NewLocalizedStringValue(map[string]attr.Value{ + "en-US": types.StringValue("Product meta title"), + }), + }, + Product{ + MetaTitle: customtypes.NewLocalizedStringValue(map[string]attr.Value{ + "en-US": types.StringValue("New product meta title"), + }), + }, + platform.ProductUpdate{ + Actions: []platform.ProductUpdateAction{ + platform.ProductSetMetaTitleAction{ + MetaTitle: &platform.LocalizedString{"en-US": "New product meta title"}, + Staged: utils.BoolRef(false), + }, + }, + }, + }, + { + "product setMetaDescription", + Product{ + MetaDescription: customtypes.NewLocalizedStringValue(map[string]attr.Value{ + "en-US": types.StringValue("Product meta description"), + }), + }, + Product{ + MetaDescription: customtypes.NewLocalizedStringValue(map[string]attr.Value{ + "en-US": types.StringValue("New product meta description"), + }), + }, + platform.ProductUpdate{ + Actions: []platform.ProductUpdateAction{ + platform.ProductSetMetaDescriptionAction{ + MetaDescription: &platform.LocalizedString{"en-US": "New product meta description"}, + Staged: utils.BoolRef(false), + }, + }, + }, + }, + { + "product setMetaKeywords", + Product{ + MetaKeywords: customtypes.NewLocalizedStringValue(map[string]attr.Value{ + "en-US": types.StringValue("Product meta keywords"), + }), + }, + Product{ + MetaKeywords: customtypes.NewLocalizedStringValue(map[string]attr.Value{ + "en-US": types.StringValue("New product meta keywords"), + }), + }, + platform.ProductUpdate{ + Actions: []platform.ProductUpdateAction{ + platform.ProductSetMetaKeywordsAction{ + MetaKeywords: &platform.LocalizedString{"en-US": "New product meta keywords"}, + Staged: utils.BoolRef(false), + }, + }, + }, + }, + { + "product publish", + Product{ + Publish: types.BoolValue(false), + }, + Product{ + Publish: types.BoolValue(true), + }, + platform.ProductUpdate{ + Actions: []platform.ProductUpdateAction{ + platform.ProductPublishAction{ + Scope: &productPublishScopeAll, + }, + }, + }, + }, + { + "product unpublish", + Product{ + Publish: types.BoolValue(true), + }, + Product{ + Publish: types.BoolValue(false), + }, + platform.ProductUpdate{ + Actions: []platform.ProductUpdateAction{ + platform.ProductUnpublishAction{}, + }, + }, + }, + { + "product setTaxCategory", + Product{ + TaxCategoryId: types.StringValue("category-id-1"), + }, + Product{ + TaxCategoryId: types.StringValue("category-id-2"), + }, + platform.ProductUpdate{ + Actions: []platform.ProductUpdateAction{ + platform.ProductSetTaxCategoryAction{ + TaxCategory: &platform.TaxCategoryResourceIdentifier{ + ID: utils.StringRef("category-id-2"), + }, + }, + }, + }, + }, + { + "product transitionState", + Product{ + StateId: types.StringValue("state-id-1"), + }, + Product{ + StateId: types.StringValue("state-id-2"), + }, + platform.ProductUpdate{ + Actions: []platform.ProductUpdateAction{ + platform.ProductTransitionStateAction{ + State: &platform.StateResourceIdentifier{ + ID: utils.StringRef("state-id-2"), + }, + Force: utils.BoolRef(false), + }, + }, + }, + }, + { + "product addToCategory", + Product{ + Categories: []basetypes.StringValue{}, + }, + Product{ + Categories: []basetypes.StringValue{ + types.StringValue("category-1"), + }, + }, + platform.ProductUpdate{ + Actions: []platform.ProductUpdateAction{ + platform.ProductAddToCategoryAction{ + Category: platform.CategoryResourceIdentifier{ + ID: utils.StringRef("category-1"), + }, + Staged: utils.BoolRef(false), + }, + }, + }, + }, + { + "product removeFromCategory", + Product{ + Categories: []basetypes.StringValue{ + types.StringValue("category-1"), + }, + }, + Product{ + Categories: []basetypes.StringValue{}, + }, + platform.ProductUpdate{ + Actions: []platform.ProductUpdateAction{ + platform.ProductRemoveFromCategoryAction{ + Category: platform.CategoryResourceIdentifier{ + ID: utils.StringRef("category-1"), + }, + Staged: utils.BoolRef(false), + }, + }, + }, + }, + { + "product addVariant", + Product{ + MasterVariant: productVariant1, + Variants: []ProductVariant{}, + }, + Product{ + MasterVariant: productVariant1, + Variants: []ProductVariant{ + productVariant2, + }, + }, + platform.ProductUpdate{ + Actions: []platform.ProductUpdateAction{ + platform.ProductAddVariantAction{ + Sku: productVariant2.Sku.ValueStringPointer(), + Key: productVariant2.Key.ValueStringPointer(), + Staged: utils.BoolRef(false), + }, + }, + }, + }, + { + "product changeMasterVariant", + Product{ + MasterVariant: productVariant1, + Variants: []ProductVariant{ + productVariant2, + }, + }, + Product{ + MasterVariant: productVariant2, + Variants: []ProductVariant{ + productVariant1, + }, + }, + platform.ProductUpdate{ + Actions: []platform.ProductUpdateAction{ + platform.ProductChangeMasterVariantAction{ + Sku: productVariant2.Sku.ValueStringPointer(), + Staged: utils.BoolRef(false), + }, + }, + }, + }, + { + "product removeVariant", + Product{ + MasterVariant: productVariant1, + Variants: []ProductVariant{ + productVariant2, + }, + }, + Product{ + MasterVariant: productVariant1, + Variants: []ProductVariant{}, + }, + platform.ProductUpdate{ + Actions: []platform.ProductUpdateAction{ + platform.ProductRemoveVariantAction{ + Sku: productVariant2.Sku.ValueStringPointer(), + Staged: utils.BoolRef(false), + }, + }, + }, + }, + { + "product setAttribute", + Product{ + MasterVariant: productVariantWithAttribute1, + }, + Product{ + MasterVariant: productVariantWithAttribute2, + }, + platform.ProductUpdate{ + Actions: []platform.ProductUpdateAction{ + platform.ProductSetAttributeAction{ + Sku: productVariantWithAttribute2.Sku.ValueStringPointer(), + Name: productVariantWithAttribute2.Attributes[0].Name.ValueString(), + Value: unmarshalAttributeValue(productVariantWithAttribute2.Attributes[0].Value.ValueString()), + Staged: utils.BoolRef(false), + }, + }, + }, + }, + { + "product removeAttribute", + Product{ + MasterVariant: productVariantWithAttribute1, + }, + Product{ + MasterVariant: productVariant1, + }, + platform.ProductUpdate{ + Actions: []platform.ProductUpdateAction{ + platform.ProductSetAttributeAction{ + Sku: productVariantWithAttribute1.Sku.ValueStringPointer(), + Name: productVariantWithAttribute1.Attributes[0].Name.ValueString(), + Staged: utils.BoolRef(false), + }, + }, + }, + }, + { + "product addPrice", + Product{ + MasterVariant: productVariant1, + }, + Product{ + MasterVariant: productVariantWithPrice1, + }, + platform.ProductUpdate{ + Actions: []platform.ProductUpdateAction{ + platform.ProductAddPriceAction{ + Sku: productVariant1.Sku.ValueStringPointer(), + Price: platform.PriceDraft{ + Key: productVariantWithPrice1.Prices[0].Key.ValueStringPointer(), + Value: platform.Money{ + CentAmount: int(productVariantWithPrice1.Prices[0].Value.CentAmount.ValueInt64()), + CurrencyCode: productVariantWithPrice1.Prices[0].Value.CurrencyCode.ValueString(), + }, + }, + Staged: utils.BoolRef(false), + }, + }, + }, + }, + { + "product changePrice", + Product{ + MasterVariant: productVariantWithPrice1, + }, + Product{ + MasterVariant: productVariantWithPrice2, + }, + platform.ProductUpdate{ + Actions: []platform.ProductUpdateAction{ + platform.ProductChangePriceAction{ + PriceId: productVariantWithPrice2.Prices[0].ID.ValueString(), + Price: platform.PriceDraft{ + Key: productVariantWithPrice2.Prices[0].Key.ValueStringPointer(), + Value: platform.Money{ + CentAmount: int(productVariantWithPrice2.Prices[0].Value.CentAmount.ValueInt64()), + CurrencyCode: productVariantWithPrice2.Prices[0].Value.CurrencyCode.ValueString(), + }, + }, + Staged: utils.BoolRef(false), + }, + }, + }, + }, + { + "product removePrice", + Product{ + MasterVariant: productVariantWithPrice1, + }, + Product{ + MasterVariant: productVariant1, + }, + platform.ProductUpdate{ + Actions: []platform.ProductUpdateAction{ + platform.ProductRemovePriceAction{ + PriceId: productVariantWithPrice1.Prices[0].ID.ValueString(), + Sku: productVariantWithPrice1.Sku.ValueStringPointer(), + Staged: utils.BoolRef(false), + }, + }, + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + result := tc.state.updateActions(tc.plan) + assert.EqualValues(t, tc.expected, result) + }) + } +}