diff --git a/docs/resources/outbound_contact_list.md b/docs/resources/outbound_contact_list.md index bd8e54343..8c9f83bf2 100644 --- a/docs/resources/outbound_contact_list.md +++ b/docs/resources/outbound_contact_list.md @@ -40,7 +40,7 @@ resource "genesyscloud_outbound_contact_list" "contact-list" { ### Required -- `column_names` (List of String) The names of the contact data columns. Changing the column_names attribute will cause the outboundcontact_list object to be dropped and recreated with a new ID +- `column_names` (List of String) The names of the contact data columns. Changing the column_names attribute will cause the outbound_contact_list object to be dropped and recreated with a new ID - `name` (String) The name for the contact list. ### Optional @@ -48,15 +48,20 @@ resource "genesyscloud_outbound_contact_list" "contact-list" { - `attempt_limit_id` (String) Attempt Limit for this ContactList. - `automatic_time_zone_mapping` (Boolean) Indicates if automatic time zone mapping is to be used for this ContactList. Changing the automatic_time_zone_mappings attribute will cause the outboundcontact_list object to be dropped and recreated with a new ID - `column_data_type_specifications` (Block List) The settings of the columns selected for dynamic queueing. If updated, the contact list is dropped and recreated with a new ID (see [below for nested schema](#nestedblock--column_data_type_specifications)) +- `contacts_filepath` (String) The path to a CSV file containing contacts to import into the contact list. When updated, existing contacts will be removed and replaced with contacts from the new file. If not specified, an empty contact list will be created. +- `contacts_id_name` (String) The name of the column in the CSV file that contains the contact's unique contact id. If updated, the contact list is dropped and recreated with a new ID - `division_id` (String) The division this entity belongs to. -- `email_columns` (Block Set) Indicates which columns are email addresses. Changing the email_columns attribute will cause the outboundcontact_list object to be dropped and recreated with a new ID. Required if phone_columns is empty (see [below for nested schema](#nestedblock--email_columns)) -- `phone_columns` (Block Set) Indicates which columns are phone numbers. Changing the phone_columns attribute will cause the outboundcontact_list object to be dropped and recreated with a new ID. Required if email_columns is empty (see [below for nested schema](#nestedblock--phone_columns)) +- `email_columns` (Block Set) Indicates which columns are email addresses. Changing the email_columns attribute will cause the outbound_contact_list object to be dropped and recreated with a new ID. Required if phone_columns is empty (see [below for nested schema](#nestedblock--email_columns)) +- `phone_columns` (Block Set) Indicates which columns are phone numbers. Changing the phone_columns attribute will cause the outbound_contact_list object to be dropped and recreated with a new ID. Required if email_columns is empty (see [below for nested schema](#nestedblock--phone_columns)) - `preview_mode_accepted_values` (List of String) The values in the previewModeColumnName column that indicate a contact should always be dialed in preview mode. - `preview_mode_column_name` (String) A column to check if a contact should always be dialed in preview mode. +- `trim_whitespace` (Boolean) Indicates if leading and trailing whitespace will be trimmed when importing a contactlist CSV file - `zip_code_column_name` (String) The name of contact list column containing the zip code for use with automatic time zone mapping. Only allowed if 'automaticTimeZoneMapping' is set to true. Changing the zip_code_column_name attribute will cause the outboundcontact_list object to be dropped and recreated with a new ID ### Read-Only +- `contacts_file_content_hash` (String) The hash of the contacts file to import. This is retained as a computed value in the state in order to detect when a file's contents have changed. +- `contacts_record_count` (Number) The number of contacts in the contact list. This is a read-only attribute and sanity check - `id` (String) The ID of this resource. diff --git a/docs/resources/outbound_contact_list_contact.md b/docs/resources/outbound_contact_list_contact.md index 8d315175b..b0586db4e 100644 --- a/docs/resources/outbound_contact_list_contact.md +++ b/docs/resources/outbound_contact_list_contact.md @@ -1,12 +1,12 @@ --- page_title: "genesyscloud_outbound_contact_list_contact Resource - terraform-provider-genesyscloud" -subcategory: "" +subcategory: "Deprecated" description: |- - Genesys Cloud Outbound Contact List Contact + [DEPRECATED] Genesys Cloud Outbound Contact List Contact --- # genesyscloud_outbound_contact_list_contact (Resource) -Genesys Cloud Outbound Contact List Contact +[DEPRECATED] Genesys Cloud Outbound Contact List Contact ## API Usage The following Genesys Cloud APIs are used by this resource. Ensure your OAuth Client has been granted the necessary scopes and permissions to perform these operations: @@ -16,6 +16,34 @@ The following Genesys Cloud APIs are used by this resource. Ensure your OAuth Cl - [PUT /api/v2/outbound/contactlists/{contactListId}/contacts/{contactId}](https://developer.genesys.cloud/devapps/api-explorer#put-api-v2-outbound-contactlists--contactListId--contacts--contactId-) - [DELETE /api/v2/outbound/contactlists/{contactListId}/contacts/{contactId}](https://developer.genesys.cloud/devapps/api-explorer#delete-api-v2-outbound-contactlists--contactListId--contacts--contactId-) +## Migrating from genesyscloud_outbound_contact_list_contact + +### Deprecation Notice + +The `genesyscloud_outbound_contact_list_contact` resource is deprecated and will be removed in a future version. Instead, use the `contacts_filepath` and `contacts_id_name` attributes in the `genesyscloud_outbound_contact_list` resource. + +### Note About Exporter + +The exporter functionality has been removed from this resource in favor of the `genesyscloud_outbound_contact_list` resource's built-in bulk handling of contacts via CSV exports. Contacts will now be exported within the CSV file output from the `genesyscloud_outbound_contact_list` and not be exported via this resource due to performance and scalability limitations with this resource. + +### Migration Steps + +1. Remove any `genesyscloud_outbound_contact_list_contact` resources from your Terraform configuration and add them to a `contacts.csv` file +2. Update your `genesyscloud_outbound_contact_list` resource to include: + + ```hcl + resource "genesyscloud_outbound_contact_list" "example" { + name = "Example Contact List" + # ... other existing configuration ... + + contacts_filepath = "path/to/your/contacts.csv" + contacts_id_name = "contact_id_column" + } + ``` +3. Ensure your CSV file contains all required columns defined in `column_names` +4. Run `terraform plan` to verify the changes + + ## Example Usage ```terraform diff --git a/docs/resources/widget_deployment.md b/docs/resources/widget_deployment.md index 8153c0069..659de16b8 100644 --- a/docs/resources/widget_deployment.md +++ b/docs/resources/widget_deployment.md @@ -1,12 +1,12 @@ --- page_title: "genesyscloud_widget_deployment Resource - terraform-provider-genesyscloud" -subcategory: "" +subcategory: "Deprecated" description: |- - Genesys Cloud Widget Deployment + [DEPRECATED] Genesys Cloud Widget Deployment --- # genesyscloud_widget_deployment (Resource) -Genesys Cloud Widget Deployment +[DEPRECATED] Genesys Cloud Widget Deployment ## API Usage The following Genesys Cloud APIs are used by this resource. Ensure your OAuth Client has been granted the necessary scopes and permissions to perform these operations: @@ -17,6 +17,17 @@ The following Genesys Cloud APIs are used by this resource. Ensure your OAuth Cl * [PUT /api/v2/widgets/deployments/{deploymentId}](https://developer.genesys.cloud/api/rest/v2/widgets/#put-api-v2-widgets-deployments--deploymentId-) * [DELETE /api/v2/widgets/deployments/{deploymentId}](https://developer.genesys.cloud/api/rest/v2/widgets/#delete-api-v2-widgets-deployments--deploymentId-) +## Migrating from genesyscloud_widget_deployment + +### Deprecation Notice + +The `genesyscloud_widget_deployment` resource is deprecated and will be removed in a future version due to this functionality being sunset in Genesys Cloud API. + +### Migration Steps + +1. Remove any `genesyscloud_widget_deployment` resources from your Terraform configuration. + + ## Example Usage ```terraform diff --git a/examples/resources/genesyscloud_flow/inboundcall_flow_example.yaml b/examples/resources/genesyscloud_flow/inboundcall_flow_example.yaml index 5d4075b60..f01e264a9 100644 --- a/examples/resources/genesyscloud_flow/inboundcall_flow_example.yaml +++ b/examples/resources/genesyscloud_flow/inboundcall_flow_example.yaml @@ -1,5 +1,5 @@ inboundCall: - name: Terraform Flow Test-258fa494-f633-4c8a-b5da-5f9bfca357aa + name: test_data_flow396854db-7f83-489b-ac81-06f2794e4e54 defaultLanguage: en-us startUpRef: ./menus/menu[mainMenu] initialGreeting: diff --git a/examples/resources/genesyscloud_outbound_contact_list_contact/apis.md b/examples/resources/genesyscloud_outbound_contact_list_contact/apis.md index 239567e26..29dd47c2c 100644 --- a/examples/resources/genesyscloud_outbound_contact_list_contact/apis.md +++ b/examples/resources/genesyscloud_outbound_contact_list_contact/apis.md @@ -1,4 +1,31 @@ - [POST /api/v2/outbound/contactlists/{contactListId}/contacts](https://developer.genesys.cloud/devapps/api-explorer#post-api-v2-outbound-contactlists--contactListId--contacts) - [GET /api/v2/outbound/contactlists/{contactListId}/contacts/{contactId}](https://developer.genesys.cloud/devapps/api-explorer#get-api-v2-outbound-contactlists--contactListId--contacts--contactId-) - [PUT /api/v2/outbound/contactlists/{contactListId}/contacts/{contactId}](https://developer.genesys.cloud/devapps/api-explorer#put-api-v2-outbound-contactlists--contactListId--contacts--contactId-) -- [DELETE /api/v2/outbound/contactlists/{contactListId}/contacts/{contactId}](https://developer.genesys.cloud/devapps/api-explorer#delete-api-v2-outbound-contactlists--contactListId--contacts--contactId-) \ No newline at end of file +- [DELETE /api/v2/outbound/contactlists/{contactListId}/contacts/{contactId}](https://developer.genesys.cloud/devapps/api-explorer#delete-api-v2-outbound-contactlists--contactListId--contacts--contactId-) + +## Migrating from genesyscloud_outbound_contact_list_contact + +### Deprecation Notice + +The `genesyscloud_outbound_contact_list_contact` resource is deprecated and will be removed in a future version. Instead, use the `contacts_filepath` and `contacts_id_name` attributes in the `genesyscloud_outbound_contact_list` resource. + +### Note About Exporter + +The exporter functionality has been removed from this resource in favor of the `genesyscloud_outbound_contact_list` resource's built-in bulk handling of contacts via CSV exports. Contacts will now be exported within the CSV file output from the `genesyscloud_outbound_contact_list` and not be exported via this resource due to performance and scalability limitations with this resource. + +### Migration Steps + +1. Remove any `genesyscloud_outbound_contact_list_contact` resources from your Terraform configuration and add them to a `contacts.csv` file +2. Update your `genesyscloud_outbound_contact_list` resource to include: + + ```hcl + resource "genesyscloud_outbound_contact_list" "example" { + name = "Example Contact List" + # ... other existing configuration ... + + contacts_filepath = "path/to/your/contacts.csv" + contacts_id_name = "contact_id_column" + } + ``` +3. Ensure your CSV file contains all required columns defined in `column_names` +4. Run `terraform plan` to verify the changes diff --git a/examples/resources/genesyscloud_widget_deployment/apis.md b/examples/resources/genesyscloud_widget_deployment/apis.md index 6fb19c5c4..948391291 100644 --- a/examples/resources/genesyscloud_widget_deployment/apis.md +++ b/examples/resources/genesyscloud_widget_deployment/apis.md @@ -2,4 +2,14 @@ * [GET /api/v2/widgets/deployments/{deploymentId}](https://developer.genesys.cloud/api/rest/v2/widgets/#get-api-v2-widgets-deployments--deploymentId-) * [POST /api/v2/widgets/deployments](https://developer.genesys.cloud/api/rest/v2/widgets/#post-api-v2-widgets-deployments) * [PUT /api/v2/widgets/deployments/{deploymentId}](https://developer.genesys.cloud/api/rest/v2/widgets/#put-api-v2-widgets-deployments--deploymentId-) -* [DELETE /api/v2/widgets/deployments/{deploymentId}](https://developer.genesys.cloud/api/rest/v2/widgets/#delete-api-v2-widgets-deployments--deploymentId-) \ No newline at end of file +* [DELETE /api/v2/widgets/deployments/{deploymentId}](https://developer.genesys.cloud/api/rest/v2/widgets/#delete-api-v2-widgets-deployments--deploymentId-) + +## Migrating from genesyscloud_widget_deployment + +### Deprecation Notice + +The `genesyscloud_widget_deployment` resource is deprecated and will be removed in a future version due to this functionality being sunset in Genesys Cloud API. + +### Migration Steps + +1. Remove any `genesyscloud_widget_deployment` resources from your Terraform configuration. diff --git a/genesyscloud/architect_grammar_language/resource_genesyscloud_architect_grammar_language_utils.go b/genesyscloud/architect_grammar_language/resource_genesyscloud_architect_grammar_language_utils.go index 7a0670dff..d7cfdbe06 100644 --- a/genesyscloud/architect_grammar_language/resource_genesyscloud_architect_grammar_language_utils.go +++ b/genesyscloud/architect_grammar_language/resource_genesyscloud_architect_grammar_language_utils.go @@ -177,7 +177,7 @@ func (d *grammarLanguageDownloader) downloadFileData(fileType FileType) error { func (d *grammarLanguageDownloader) downloadLanguageFileAndUpdateConfigMap(url string) error { d.fileUrl = url d.setExportFileName() - if err := files.DownloadExportFile(d.exportFilesFolderPath, d.exportFileName, d.fileUrl); err != nil { + if _, err := files.DownloadExportFile(d.exportFilesFolderPath, d.exportFileName, d.fileUrl); err != nil { return err } d.updatePathsInExportConfigMap() diff --git a/genesyscloud/architect_user_prompt/resource_genesyscloud_architect_user_prompt_utils.go b/genesyscloud/architect_user_prompt/resource_genesyscloud_architect_user_prompt_utils.go index ca1d3df54..1da0f40de 100644 --- a/genesyscloud/architect_user_prompt/resource_genesyscloud_architect_user_prompt_utils.go +++ b/genesyscloud/architect_user_prompt/resource_genesyscloud_architect_user_prompt_utils.go @@ -197,7 +197,7 @@ func ArchitectPromptAudioResolver(promptId, exportDirectory, subDirectory string for _, data := range audioDataList { log.Printf("Downloading file '%s' from mediaUri", filepath.Join(fullPath, data.FileName)) - if err := files.DownloadExportFile(fullPath, data.FileName, data.MediaUri); err != nil { + if _, err := files.DownloadExportFile(fullPath, data.FileName, data.MediaUri); err != nil { return err } log.Println("Successfully downloaded file") diff --git a/genesyscloud/data_source_genesyscloud_journey_segment_test.go b/genesyscloud/data_source_genesyscloud_journey_segment_test.go index 014925f89..e0560b11d 100644 --- a/genesyscloud/data_source_genesyscloud_journey_segment_test.go +++ b/genesyscloud/data_source_genesyscloud_journey_segment_test.go @@ -23,7 +23,7 @@ func runDataJourneySegmentTestCase(t *testing.T, testCaseName string) { resource.Test(t, resource.TestCase{ PreCheck: func() { util.TestAccPreCheck(t) }, ProviderFactories: provider.GetProviderFactories(providerResources, providerDataSources), - Steps: testrunner.GenerateDataSourceTestSteps(resourceType, testCaseName, []resource.TestCheckFunc{ + Steps: testrunner.GenerateDataJourneySourceTestSteps(resourceType, testCaseName, []resource.TestCheckFunc{ resource.ComposeTestCheckFunc( resource.TestCheckResourceAttrPair("data."+testObjectFullName, "id", testObjectFullName, "id"), resource.TestCheckResourceAttr(testObjectFullName, "display_name", testObjectName+"_to_find"), diff --git a/genesyscloud/journey_action_map/data_source_genesyscloud_journey_action_map_test.go b/genesyscloud/journey_action_map/data_source_genesyscloud_journey_action_map_test.go index 8f65ee561..8e0ca3b70 100644 --- a/genesyscloud/journey_action_map/data_source_genesyscloud_journey_action_map_test.go +++ b/genesyscloud/journey_action_map/data_source_genesyscloud_journey_action_map_test.go @@ -22,7 +22,7 @@ func runDataJourneyActionMapTestCase(t *testing.T, testCaseName string) { resource.Test(t, resource.TestCase{ PreCheck: func() { util.TestAccPreCheck(t) }, ProviderFactories: provider.GetProviderFactories(providerResources, providerDataSources), - Steps: testrunner.GenerateDataSourceTestSteps(ResourceType, testCaseName, []resource.TestCheckFunc{ + Steps: testrunner.GenerateDataJourneySourceTestSteps(ResourceType, testCaseName, []resource.TestCheckFunc{ resource.ComposeTestCheckFunc( resource.TestCheckResourceAttrPair("data."+testObjectFullName, "id", testObjectFullName, "id"), resource.TestCheckResourceAttr(testObjectFullName, "display_name", testObjectName+"_to_find"), diff --git a/genesyscloud/journey_action_map/resource_genesyscloud_journey_action_map_test.go b/genesyscloud/journey_action_map/resource_genesyscloud_journey_action_map_test.go index 232c87dd1..5563d16b1 100644 --- a/genesyscloud/journey_action_map/resource_genesyscloud_journey_action_map_test.go +++ b/genesyscloud/journey_action_map/resource_genesyscloud_journey_action_map_test.go @@ -51,7 +51,7 @@ func runJourneyActionMapTestCase(t *testing.T, testCaseName string) { resource.Test(t, resource.TestCase{ PreCheck: func() { util.TestAccPreCheck(t) }, ProviderFactories: provider.GetProviderFactories(providerResources, providerDataSources), - Steps: testrunner.GenerateResourceTestSteps(ResourceType, testCaseName, nil), + Steps: testrunner.GenerateResourceJourneyTestSteps(ResourceType, testCaseName, nil), CheckDestroy: testVerifyJourneyActionMapsDestroyed, }) } diff --git a/genesyscloud/journey_action_template/data_source_genesyscloud_journey_action_template_test.go b/genesyscloud/journey_action_template/data_source_genesyscloud_journey_action_template_test.go index 7a33b48c9..ea8fca489 100644 --- a/genesyscloud/journey_action_template/data_source_genesyscloud_journey_action_template_test.go +++ b/genesyscloud/journey_action_template/data_source_genesyscloud_journey_action_template_test.go @@ -23,7 +23,7 @@ func runDataJourneyActionTemplateTestCase(t *testing.T, testCaseName string) { resource.Test(t, resource.TestCase{ PreCheck: func() { util.TestAccPreCheck(t) }, ProviderFactories: provider.GetProviderFactories(providerResources, providerDataSources), - Steps: testrunner.GenerateDataSourceTestSteps(ResourceType, testCaseName, []resource.TestCheckFunc{ + Steps: testrunner.GenerateDataJourneySourceTestSteps(ResourceType, testCaseName, []resource.TestCheckFunc{ resource.ComposeTestCheckFunc( resource.TestCheckResourceAttrPair("data."+testObjectFullName, "id", testObjectFullName, "id"), resource.TestCheckResourceAttr(testObjectFullName, "name", testObjectName), diff --git a/genesyscloud/journey_action_template/resource_genesyscloud_journey_action_template_test.go b/genesyscloud/journey_action_template/resource_genesyscloud_journey_action_template_test.go index b4ffa48e1..4141b2deb 100644 --- a/genesyscloud/journey_action_template/resource_genesyscloud_journey_action_template_test.go +++ b/genesyscloud/journey_action_template/resource_genesyscloud_journey_action_template_test.go @@ -25,7 +25,7 @@ func runJourneyActionTemplateTestCase(t *testing.T, testCaseName string) { resource.Test(t, resource.TestCase{ PreCheck: func() { util.TestAccPreCheck(t) }, ProviderFactories: provider.GetProviderFactories(providerResources, providerDataSources), - Steps: testrunner.GenerateResourceTestSteps(ResourceType, testCaseName, nil), + Steps: testrunner.GenerateResourceJourneyTestSteps(ResourceType, testCaseName, nil), CheckDestroy: testVerifyJourneyActionTemplatesDestroyed, }) } diff --git a/genesyscloud/journey_outcome/data_source_genesyscloud_journey_outcome_test.go b/genesyscloud/journey_outcome/data_source_genesyscloud_journey_outcome_test.go index 9eedfb6bc..4049f1b81 100644 --- a/genesyscloud/journey_outcome/data_source_genesyscloud_journey_outcome_test.go +++ b/genesyscloud/journey_outcome/data_source_genesyscloud_journey_outcome_test.go @@ -23,7 +23,7 @@ func runDataJourneyOutcomeTestCase(t *testing.T, testCaseName string) { resource.Test(t, resource.TestCase{ PreCheck: func() { util.TestAccPreCheck(t) }, ProviderFactories: provider.GetProviderFactories(providerResources, providerDataSources), - Steps: testrunner.GenerateDataSourceTestSteps(ResourceType, testCaseName, []resource.TestCheckFunc{ + Steps: testrunner.GenerateDataJourneySourceTestSteps(ResourceType, testCaseName, []resource.TestCheckFunc{ resource.ComposeTestCheckFunc( resource.TestCheckResourceAttrPair("data."+testObjectFullName, "id", testObjectFullName, "id"), resource.TestCheckResourceAttr(testObjectFullName, "display_name", testObjectName+"_to_find"), diff --git a/genesyscloud/journey_outcome/resource_genesyscloud_journey_outcome_test.go b/genesyscloud/journey_outcome/resource_genesyscloud_journey_outcome_test.go index a37cd6e9e..07794d81e 100644 --- a/genesyscloud/journey_outcome/resource_genesyscloud_journey_outcome_test.go +++ b/genesyscloud/journey_outcome/resource_genesyscloud_journey_outcome_test.go @@ -25,7 +25,7 @@ func runResourceJourneyOutcomeTestCase(t *testing.T, testCaseName string) { resource.Test(t, resource.TestCase{ PreCheck: func() { util.TestAccPreCheck(t) }, ProviderFactories: provider.GetProviderFactories(providerResources, providerDataSources), - Steps: testrunner.GenerateResourceTestSteps(ResourceType, testCaseName, nil), + Steps: testrunner.GenerateResourceJourneyTestSteps(ResourceType, testCaseName, nil), CheckDestroy: testVerifyJourneyOutcomesDestroyed, }) } diff --git a/genesyscloud/outbound_contact_list/data_source_genesyscloud_outbound_contact_list.go b/genesyscloud/outbound_contact_list/data_source_genesyscloud_outbound_contact_list.go index 3eeca6d97..a4398f700 100644 --- a/genesyscloud/outbound_contact_list/data_source_genesyscloud_outbound_contact_list.go +++ b/genesyscloud/outbound_contact_list/data_source_genesyscloud_outbound_contact_list.go @@ -15,7 +15,7 @@ import ( func dataSourceOutboundContactListRead(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { sdkConfig := m.(*provider.ProviderMeta).ClientConfig - proxy := getOutboundContactlistProxy(sdkConfig) + proxy := GetOutboundContactlistProxy(sdkConfig) name := d.Get("name").(string) return util.WithRetries(ctx, 15*time.Second, func() *retry.RetryError { diff --git a/genesyscloud/outbound_contact_list/genesyscloud_outbound_contact_list_proxy.go b/genesyscloud/outbound_contact_list/genesyscloud_outbound_contact_list_proxy.go index 98417888f..e850ea9da 100644 --- a/genesyscloud/outbound_contact_list/genesyscloud_outbound_contact_list_proxy.go +++ b/genesyscloud/outbound_contact_list/genesyscloud_outbound_contact_list_proxy.go @@ -3,9 +3,13 @@ package outbound_contact_list import ( "context" "fmt" + "io" "log" + "net/http" + "strings" rc "terraform-provider-genesyscloud/genesyscloud/resource_cache" "terraform-provider-genesyscloud/genesyscloud/tfexporter_state" + "terraform-provider-genesyscloud/genesyscloud/util/files" "github.com/mypurecloud/platform-client-sdk-go/v150/platformclientv2" ) @@ -16,86 +20,154 @@ with the Genesys Cloud SDK. We use composition here for each function on the pro out during testing. */ +var internalProxy *OutboundContactlistProxy + var contactListCache = rc.NewResourceCache[platformclientv2.Contactlist]() // Type definitions for each func on our proxy so we can easily mock them out later -type createOutboundContactlistFunc func(ctx context.Context, p *outboundContactlistProxy, contactList *platformclientv2.Contactlist) (*platformclientv2.Contactlist, *platformclientv2.APIResponse, error) -type getAllOutboundContactlistFunc func(ctx context.Context, p *outboundContactlistProxy, name string) (*[]platformclientv2.Contactlist, *platformclientv2.APIResponse, error) -type getOutboundContactlistIdByNameFunc func(ctx context.Context, p *outboundContactlistProxy, name string) (id string, retryable bool, response *platformclientv2.APIResponse, err error) -type getOutboundContactlistByIdFunc func(ctx context.Context, p *outboundContactlistProxy, id string) (contactList *platformclientv2.Contactlist, response *platformclientv2.APIResponse, err error) -type updateOutboundContactlistFunc func(ctx context.Context, p *outboundContactlistProxy, id string, contactList *platformclientv2.Contactlist) (*platformclientv2.Contactlist, *platformclientv2.APIResponse, error) -type deleteOutboundContactlistFunc func(ctx context.Context, p *outboundContactlistProxy, id string) (response *platformclientv2.APIResponse, err error) - -// outboundContactlistProxy contains all of the methods that call genesys cloud APIs. -type outboundContactlistProxy struct { - clientConfig *platformclientv2.Configuration - outboundApi *platformclientv2.OutboundApi - createOutboundContactlistAttr createOutboundContactlistFunc - getAllOutboundContactlistAttr getAllOutboundContactlistFunc - getOutboundContactlistIdByNameAttr getOutboundContactlistIdByNameFunc - getOutboundContactlistByIdAttr getOutboundContactlistByIdFunc - updateOutboundContactlistAttr updateOutboundContactlistFunc - deleteOutboundContactlistAttr deleteOutboundContactlistFunc - contactListCache rc.CacheInterface[platformclientv2.Contactlist] +type createOutboundContactlistFunc func(ctx context.Context, p *OutboundContactlistProxy, contactList *platformclientv2.Contactlist) (*platformclientv2.Contactlist, *platformclientv2.APIResponse, error) +type getAllOutboundContactlistFunc func(ctx context.Context, p *OutboundContactlistProxy, name string) (*[]platformclientv2.Contactlist, *platformclientv2.APIResponse, error) +type getOutboundContactlistIdByNameFunc func(ctx context.Context, p *OutboundContactlistProxy, name string) (id string, retryable bool, response *platformclientv2.APIResponse, err error) +type getOutboundContactlistByIdFunc func(ctx context.Context, p *OutboundContactlistProxy, id string) (contactList *platformclientv2.Contactlist, response *platformclientv2.APIResponse, err error) +type getOutboundContactlistContactRecordLengthFunc func(ctx context.Context, p *OutboundContactlistProxy, contactListId string) (int, *platformclientv2.APIResponse, error) +type updateOutboundContactlistFunc func(ctx context.Context, p *OutboundContactlistProxy, id string, contactList *platformclientv2.Contactlist) (*platformclientv2.Contactlist, *platformclientv2.APIResponse, error) +type deleteOutboundContactlistFunc func(ctx context.Context, p *OutboundContactlistProxy, id string) (response *platformclientv2.APIResponse, err error) +type uploadContactListBulkContactsFunc func(ctx context.Context, p *OutboundContactlistProxy, contactListId, filepath, contactIdName string) (respBytes []byte, err error) +type clearContactListContactsFunc func(ctx context.Context, p *OutboundContactlistProxy, contactListId string) (*platformclientv2.APIResponse, error) +type getContactListContactsExportUrlFunc func(ctx context.Context, p *OutboundContactlistProxy, contactListId string) (exportUrl string, resp *platformclientv2.APIResponse, error error) +type initiateContactListContactsExportFunc func(ctx context.Context, p *OutboundContactlistProxy, contactListId string) (resp *platformclientv2.APIResponse, error error) + +// OutboundContactListProxy defines the interface for outbound contact list operations +type OutboundContactlistProxy struct { + clientConfig *platformclientv2.Configuration + outboundApi *platformclientv2.OutboundApi + createOutboundContactlistAttr createOutboundContactlistFunc + getAllOutboundContactlistAttr getAllOutboundContactlistFunc + getOutboundContactlistIdByNameAttr getOutboundContactlistIdByNameFunc + getOutboundContactlistByIdAttr getOutboundContactlistByIdFunc + getOutboundContactlistContactRecordLengthAttr getOutboundContactlistContactRecordLengthFunc + updateOutboundContactlistAttr updateOutboundContactlistFunc + deleteOutboundContactlistAttr deleteOutboundContactlistFunc + uploadContactListBulkContactsAttr uploadContactListBulkContactsFunc + clearContactListContactsAttr clearContactListContactsFunc + basePath string + accessToken string + getContactListContactsExportUrlAttr getContactListContactsExportUrlFunc + initiateContactListContactsExportAttr initiateContactListContactsExportFunc + contactListCache rc.CacheInterface[platformclientv2.Contactlist] } // newOutboundContactlistProxy initializes the outbound contactlist proxy with all of the data needed to communicate with Genesys Cloud -func newOutboundContactlistProxy(clientConfig *platformclientv2.Configuration) *outboundContactlistProxy { +func newOutboundContactlistProxy(clientConfig *platformclientv2.Configuration) *OutboundContactlistProxy { api := platformclientv2.NewOutboundApiWithConfig(clientConfig) - return &outboundContactlistProxy{ - clientConfig: clientConfig, - outboundApi: api, - createOutboundContactlistAttr: createOutboundContactlistFn, - getAllOutboundContactlistAttr: getAllOutboundContactlistFn, - getOutboundContactlistIdByNameAttr: getOutboundContactlistIdByNameFn, - getOutboundContactlistByIdAttr: getOutboundContactlistByIdFn, - updateOutboundContactlistAttr: updateOutboundContactlistFn, - deleteOutboundContactlistAttr: deleteOutboundContactlistFn, - contactListCache: contactListCache, + return &OutboundContactlistProxy{ + clientConfig: clientConfig, + outboundApi: api, + createOutboundContactlistAttr: createOutboundContactlistFn, + getAllOutboundContactlistAttr: getAllOutboundContactlistFn, + getOutboundContactlistIdByNameAttr: getOutboundContactlistIdByNameFn, + getOutboundContactlistByIdAttr: getOutboundContactlistByIdFn, + getOutboundContactlistContactRecordLengthAttr: getOutboundContactlistContactRecordLengthFn, + updateOutboundContactlistAttr: updateOutboundContactlistFn, + deleteOutboundContactlistAttr: deleteOutboundContactlistFn, + uploadContactListBulkContactsAttr: uploadContactListBulkContactsFn, + clearContactListContactsAttr: clearContactListContactsFn, + basePath: strings.Replace(api.Configuration.BasePath, "api", "apps", -1), + accessToken: api.Configuration.AccessToken, + getContactListContactsExportUrlAttr: getContactListContactsExportUrlFn, + initiateContactListContactsExportAttr: initiateContactListContactsExportFn, + contactListCache: contactListCache, } } -func getOutboundContactlistProxy(clientConfig *platformclientv2.Configuration) *outboundContactlistProxy { - return newOutboundContactlistProxy(clientConfig) +// GetOutboundContactlistProxy returns a proxy struct that implements the outbound contact list operations interface. +// It provides abstraction for the Genesys Cloud outbound contact list API operations. +// +// Parameters: +// - sdkConfig: The Genesys Cloud SDK configuration containing authentication and connection settings +// +// Returns: +// - An implementation of the outbound contact list proxy interface +// +// Example Usage: +// +// sdkConfig := &platformclientv2.Configuration{ +// BasePath: "https://api.mypurecloud.com", +// DefaultHeader: make(map[string]string), +// UserAgent: "terraform-provider-genesyscloud", +// } +// +// proxy := GetOutboundContactlistProxy(sdkConfig) +// contactList, err := proxy.GetOutboundContactList(contactListId) +func GetOutboundContactlistProxy(clientConfig *platformclientv2.Configuration) *OutboundContactlistProxy { + if internalProxy == nil { + internalProxy = newOutboundContactlistProxy(clientConfig) + } + return internalProxy } // createOutboundContactlist creates a Genesys Cloud outbound contactlist -func (p *outboundContactlistProxy) createOutboundContactlist(ctx context.Context, outboundContactlist *platformclientv2.Contactlist) (*platformclientv2.Contactlist, *platformclientv2.APIResponse, error) { +func (p *OutboundContactlistProxy) createOutboundContactlist(ctx context.Context, outboundContactlist *platformclientv2.Contactlist) (*platformclientv2.Contactlist, *platformclientv2.APIResponse, error) { return p.createOutboundContactlistAttr(ctx, p, outboundContactlist) } -// getOutboundContactlist retrieves all Genesys Cloud outbound contactlist -func (p *outboundContactlistProxy) getAllOutboundContactlist(ctx context.Context) (*[]platformclientv2.Contactlist, *platformclientv2.APIResponse, error) { +// GetAllOutboundContactlist retrieves all Genesys Cloud outbound contactlists +func (p *OutboundContactlistProxy) GetAllOutboundContactlist(ctx context.Context) (*[]platformclientv2.Contactlist, *platformclientv2.APIResponse, error) { return p.getAllOutboundContactlistAttr(ctx, p, "") } // getOutboundContactlistIdByName returns a single Genesys Cloud outbound contactlist by a name -func (p *outboundContactlistProxy) getOutboundContactlistIdByName(ctx context.Context, name string) (id string, retryable bool, response *platformclientv2.APIResponse, err error) { +func (p *OutboundContactlistProxy) getOutboundContactlistIdByName(ctx context.Context, name string) (id string, retryable bool, response *platformclientv2.APIResponse, err error) { return p.getOutboundContactlistIdByNameAttr(ctx, p, name) } -// getOutboundContactlistById returns a single Genesys Cloud outbound contactlist by Id -func (p *outboundContactlistProxy) getOutboundContactlistById(ctx context.Context, id string) (outboundContactlist *platformclientv2.Contactlist, response *platformclientv2.APIResponse, err error) { +// GetOutboundContactlistById returns a single Genesys Cloud outbound contactlist by Id +func (p *OutboundContactlistProxy) GetOutboundContactlistById(ctx context.Context, id string) (outboundContactlist *platformclientv2.Contactlist, response *platformclientv2.APIResponse, err error) { return p.getOutboundContactlistByIdAttr(ctx, p, id) } +// getOutboundContactlistContactRecordLength returns the total record count of contacts on a contact list +func (p *OutboundContactlistProxy) getOutboundContactlistContactRecordLength(ctx context.Context, contactListId string) (int, *platformclientv2.APIResponse, error) { + return p.getOutboundContactlistContactRecordLengthAttr(ctx, p, contactListId) +} + // updateOutboundContactlist updates a Genesys Cloud outbound contactlist -func (p *outboundContactlistProxy) updateOutboundContactlist(ctx context.Context, id string, outboundContactlist *platformclientv2.Contactlist) (*platformclientv2.Contactlist, *platformclientv2.APIResponse, error) { +func (p *OutboundContactlistProxy) updateOutboundContactlist(ctx context.Context, id string, outboundContactlist *platformclientv2.Contactlist) (*platformclientv2.Contactlist, *platformclientv2.APIResponse, error) { return p.updateOutboundContactlistAttr(ctx, p, id, outboundContactlist) } // deleteOutboundContactlist deletes a Genesys Cloud outbound contactlist by Id -func (p *outboundContactlistProxy) deleteOutboundContactlist(ctx context.Context, id string) (response *platformclientv2.APIResponse, err error) { +func (p *OutboundContactlistProxy) deleteOutboundContactlist(ctx context.Context, id string) (response *platformclientv2.APIResponse, err error) { return p.deleteOutboundContactlistAttr(ctx, p, id) } +// uploadContactListBulkContacts uploads a Genesys Cloud outbound contactlist +func (p *OutboundContactlistProxy) uploadContactListBulkContacts(ctx context.Context, contactListId, filepath, contactIdName string) (respBytes []byte, err error) { + return p.uploadContactListBulkContactsAttr(ctx, p, contactListId, filepath, contactIdName) +} + +// clearContactListContacts clears all of the contacts in a contact list +func (p *OutboundContactlistProxy) clearContactListContacts(ctx context.Context, contactListId string) (*platformclientv2.APIResponse, error) { + return p.clearContactListContactsAttr(ctx, p, contactListId) +} + +// initiateContactListContactsExport initiates the export for a contact list +func (p *OutboundContactlistProxy) initiateContactListContactsExport(ctx context.Context, contactListId string) (resp *platformclientv2.APIResponse, err error) { + return p.initiateContactListContactsExportAttr(ctx, p, contactListId) +} + +// getContactListContactsExportUrl gets the export url for a contact list (this is just the URL itself, no authorization included) +func (p *OutboundContactlistProxy) getContactListContactsExportUrl(ctx context.Context, contactListId string) (exportUrl string, resp *platformclientv2.APIResponse, err error) { + return p.getContactListContactsExportUrlAttr(ctx, p, contactListId) +} + // createOutboundContactlistFn is an implementation function for creating a Genesys Cloud outbound contactlist -func createOutboundContactlistFn(ctx context.Context, p *outboundContactlistProxy, outboundContactlist *platformclientv2.Contactlist) (*platformclientv2.Contactlist, *platformclientv2.APIResponse, error) { +func createOutboundContactlistFn(ctx context.Context, p *OutboundContactlistProxy, outboundContactlist *platformclientv2.Contactlist) (*platformclientv2.Contactlist, *platformclientv2.APIResponse, error) { return p.outboundApi.PostOutboundContactlists(*outboundContactlist) } // getAllOutboundContactlistFn is the implementation for retrieving all outbound contactlist in Genesys Cloud -func getAllOutboundContactlistFn(ctx context.Context, p *outboundContactlistProxy, name string) (*[]platformclientv2.Contactlist, *platformclientv2.APIResponse, error) { +func getAllOutboundContactlistFn(ctx context.Context, p *OutboundContactlistProxy, name string) (*[]platformclientv2.Contactlist, *platformclientv2.APIResponse, error) { var allContactlists []platformclientv2.Contactlist const pageSize = 100 @@ -131,7 +203,7 @@ func getAllOutboundContactlistFn(ctx context.Context, p *outboundContactlistProx } // getOutboundContactlistIdByNameFn is an implementation of the function to get a Genesys Cloud outbound contactlist by name -func getOutboundContactlistIdByNameFn(ctx context.Context, p *outboundContactlistProxy, name string) (id string, retryable bool, response *platformclientv2.APIResponse, err error) { +func getOutboundContactlistIdByNameFn(ctx context.Context, p *OutboundContactlistProxy, name string) (id string, retryable bool, response *platformclientv2.APIResponse, err error) { contactLists, resp, err := getAllOutboundContactlistFn(ctx, p, name) if err != nil { return "", false, resp, fmt.Errorf("error searching outbound contact list %s: %s", name, err) @@ -148,7 +220,7 @@ func getOutboundContactlistIdByNameFn(ctx context.Context, p *outboundContactlis } // getOutboundContactlistByIdFn is an implementation of the function to get a Genesys Cloud outbound contactlist by Id -func getOutboundContactlistByIdFn(ctx context.Context, p *outboundContactlistProxy, id string) (outboundContactlist *platformclientv2.Contactlist, response *platformclientv2.APIResponse, err error) { +func getOutboundContactlistByIdFn(ctx context.Context, p *OutboundContactlistProxy, id string) (outboundContactlist *platformclientv2.Contactlist, response *platformclientv2.APIResponse, err error) { if contactList := rc.GetCacheItem(p.contactListCache, id); contactList != nil { return contactList, nil, nil } @@ -158,8 +230,18 @@ func getOutboundContactlistByIdFn(ctx context.Context, p *outboundContactlistPro return p.outboundApi.GetOutboundContactlist(id, false, false) } +// getOutboundContactlistContactRecordLengthFn is an implementation of the function to return a total count of contacts in a contact list +func getOutboundContactlistContactRecordLengthFn(ctx context.Context, p *OutboundContactlistProxy, contactListId string) (recordLength int, response *platformclientv2.APIResponse, err error) { + blankReqBody := platformclientv2.Contactlistingrequest{} + contactListing, resp, err := p.outboundApi.PostOutboundContactlistContactsSearch(contactListId, blankReqBody) + if err != nil { + return 0, resp, err + } + return int(*contactListing.ContactsCount), resp, nil +} + // updateOutboundContactlistFn is an implementation of the function to update a Genesys Cloud outbound contactlist -func updateOutboundContactlistFn(ctx context.Context, p *outboundContactlistProxy, id string, outboundContactlist *platformclientv2.Contactlist) (*platformclientv2.Contactlist, *platformclientv2.APIResponse, error) { +func updateOutboundContactlistFn(ctx context.Context, p *OutboundContactlistProxy, id string, outboundContactlist *platformclientv2.Contactlist) (*platformclientv2.Contactlist, *platformclientv2.APIResponse, error) { contactList, resp, err := p.outboundApi.GetOutboundContactlist(id, false, false) if err != nil { return nil, resp, err @@ -170,7 +252,7 @@ func updateOutboundContactlistFn(ctx context.Context, p *outboundContactlistProx } // deleteOutboundContactlistFn is an implementation function for deleting a Genesys Cloud outbound contactlist -func deleteOutboundContactlistFn(ctx context.Context, p *outboundContactlistProxy, id string) (response *platformclientv2.APIResponse, err error) { +func deleteOutboundContactlistFn(ctx context.Context, p *OutboundContactlistProxy, id string) (response *platformclientv2.APIResponse, err error) { resp, err := p.outboundApi.DeleteOutboundContactlist(id) if err != nil { return resp, err @@ -178,3 +260,76 @@ func deleteOutboundContactlistFn(ctx context.Context, p *outboundContactlistProx rc.DeleteCacheItem(p.contactListCache, id) return resp, nil } + +// uploadContactListBulkContactsFn uploads a CSV file to S3 of contacts +func uploadContactListBulkContactsFn(ctx context.Context, p *OutboundContactlistProxy, contactListId, filePath, contactIdColumnName string) ([]byte, error) { + formData, err := createBulkOutboundContactsFormData(filePath, contactListId, contactIdColumnName) + if err != nil { + return nil, err + } + + headers := make(map[string]string) + headers["Authorization"] = "Bearer " + p.accessToken + + s3Uploader := files.NewS3Uploader(nil, formData, nil, headers, "POST", p.basePath+"/uploads/v2/contactlist") + respBytes, err := s3Uploader.Upload() + return respBytes, err +} + +func clearContactListContactsFn(_ context.Context, p *OutboundContactlistProxy, contactListId string) (*platformclientv2.APIResponse, error) { + resp, err := p.outboundApi.PostOutboundContactlistClear(contactListId) + if err != nil { + return resp, err + } + return resp, nil +} + +// initiateContactListContactsExportFn is an implementation function for retrieving the export URL for a contact list's contacts +func initiateContactListContactsExportFn(_ context.Context, p *OutboundContactlistProxy, contactListId string) (resp *platformclientv2.APIResponse, err error) { + var ( + body platformclientv2.Contactsexportrequest + ) + + _, resp, err = p.outboundApi.PostOutboundContactlistExport(contactListId, body) + + if err != nil { + return resp, fmt.Errorf("error calling PostOutboundContactlistExport with error: %v", err) + } + + if resp.StatusCode != http.StatusOK { + return resp, fmt.Errorf("error calling PostOutboundContactlistExport with status: %v", resp.Status) + } + return resp, nil +} + +// getContactListContactsExportUrlFn is an implementation function for retrieving the export URL for a contact list's contacts +func getContactListContactsExportUrlFn(_ context.Context, p *OutboundContactlistProxy, contactListId string) (exportUrl string, resp *platformclientv2.APIResponse, err error) { + + data, resp, err := p.outboundApi.GetOutboundContactlistExport(contactListId, "") + + if err != nil { + return "", resp, fmt.Errorf("error calling GetOutboundContactlistExport with error: %v", err) + } + + if resp.StatusCode != http.StatusOK { + return "", resp, fmt.Errorf("error calling GetOutboundContactlistExport with status: %v", resp.Status) + } + + return *data.Uri, resp, nil +} + +// createBulkOutboundContactsFormData creates the form data attributes to create a bulk upload of contacts in Genesys Cloud +func createBulkOutboundContactsFormData(filePath, contactListId, contactIdColumnName string) (map[string]io.Reader, error) { + fileReader, _, err := files.DownloadOrOpenFile(filePath) + if err != nil { + return nil, err + } + // The form data structure follows the Genesys Cloud API specification for uploading contact lists as CSV files + // See full documentation at: https://developer.genesys.cloud/routing/outbound/uploadcontactlists + formData := make(map[string]io.Reader) + formData["file"] = fileReader + formData["fileType"] = strings.NewReader("contactlist") + formData["id"] = strings.NewReader(contactListId) + formData["contact-id-name"] = strings.NewReader(contactIdColumnName) + return formData, nil +} diff --git a/genesyscloud/outbound_contact_list/resource_genesyscloud_outbound_contact_list.go b/genesyscloud/outbound_contact_list/resource_genesyscloud_outbound_contact_list.go index 142259ccb..1a195e26b 100644 --- a/genesyscloud/outbound_contact_list/resource_genesyscloud_outbound_contact_list.go +++ b/genesyscloud/outbound_contact_list/resource_genesyscloud_outbound_contact_list.go @@ -14,7 +14,8 @@ import ( "terraform-provider-genesyscloud/genesyscloud/consistency_checker" resourceExporter "terraform-provider-genesyscloud/genesyscloud/resource_exporter" - lists "terraform-provider-genesyscloud/genesyscloud/util/lists" + "terraform-provider-genesyscloud/genesyscloud/util/files" + "terraform-provider-genesyscloud/genesyscloud/util/lists" "github.com/hashicorp/terraform-plugin-sdk/v2/diag" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" @@ -23,9 +24,9 @@ import ( func getAllOutboundContactLists(ctx context.Context, clientConfig *platformclientv2.Configuration) (resourceExporter.ResourceIDMetaMap, diag.Diagnostics) { resources := make(resourceExporter.ResourceIDMetaMap) - proxy := getOutboundContactlistProxy(clientConfig) + proxy := GetOutboundContactlistProxy(clientConfig) - contactLists, resp, getErr := proxy.getAllOutboundContactlist(ctx) + contactLists, resp, getErr := proxy.GetAllOutboundContactlist(ctx) if getErr != nil { return nil, util.BuildAPIDiagnosticError(ResourceType, fmt.Sprintf("Failed to get contact lists error: %s", getErr), resp) } @@ -44,9 +45,10 @@ func createOutboundContactList(ctx context.Context, d *schema.ResourceData, meta previewModeAcceptedValues := lists.InterfaceListToStrings(d.Get("preview_mode_accepted_values").([]interface{})) automaticTimeZoneMapping := d.Get("automatic_time_zone_mapping").(bool) zipCodeColumnName := d.Get("zip_code_column_name").(string) + trimWhitespace := d.Get("trim_whitespace").(bool) sdkConfig := meta.(*provider.ProviderMeta).ClientConfig - proxy := getOutboundContactlistProxy(sdkConfig) + proxy := GetOutboundContactlistProxy(sdkConfig) sdkContactList := platformclientv2.Contactlist{ Division: util.BuildSdkDomainEntityRef(d, "division_id"), @@ -57,6 +59,7 @@ func createOutboundContactList(ctx context.Context, d *schema.ResourceData, meta AttemptLimits: util.BuildSdkDomainEntityRef(d, "attempt_limit_id"), AutomaticTimeZoneMapping: &automaticTimeZoneMapping, ColumnDataTypeSpecifications: buildSdkOutboundContactListColumnDataTypeSpecifications(d.Get("column_data_type_specifications").([]interface{})), + TrimWhitespace: &trimWhitespace, } if name != "" { @@ -78,6 +81,12 @@ func createOutboundContactList(ctx context.Context, d *schema.ResourceData, meta d.SetId(*outboundContactList.Id) log.Printf("Created Outbound Contact List %s %s", name, *outboundContactList.Id) + + diagErr := uploadOutboundContactListBulkContacts(ctx, d, meta) + if diagErr != nil { + return diagErr + } + return readOutboundContactList(ctx, d, meta) } @@ -88,9 +97,10 @@ func updateOutboundContactList(ctx context.Context, d *schema.ResourceData, meta previewModeAcceptedValues := lists.InterfaceListToStrings(d.Get("preview_mode_accepted_values").([]interface{})) automaticTimeZoneMapping := d.Get("automatic_time_zone_mapping").(bool) zipCodeColumnName := d.Get("zip_code_column_name").(string) + trimWhitespace := d.Get("trim_whitespace").(bool) sdkConfig := meta.(*provider.ProviderMeta).ClientConfig - proxy := getOutboundContactlistProxy(sdkConfig) + proxy := GetOutboundContactlistProxy(sdkConfig) sdkContactList := platformclientv2.Contactlist{ Division: util.BuildSdkDomainEntityRef(d, "division_id"), @@ -101,6 +111,7 @@ func updateOutboundContactList(ctx context.Context, d *schema.ResourceData, meta AttemptLimits: util.BuildSdkDomainEntityRef(d, "attempt_limit_id"), AutomaticTimeZoneMapping: &automaticTimeZoneMapping, ColumnDataTypeSpecifications: buildSdkOutboundContactListColumnDataTypeSpecifications(d.Get("column_data_type_specifications").([]interface{})), + TrimWhitespace: &trimWhitespace, } if name != "" { @@ -126,19 +137,24 @@ func updateOutboundContactList(ctx context.Context, d *schema.ResourceData, meta return diagErr } + diagErr = uploadOutboundContactListBulkContacts(ctx, d, meta) + if diagErr != nil { + return diagErr + } + log.Printf("Updated Outbound Contact List %s", name) return readOutboundContactList(ctx, d, meta) } func readOutboundContactList(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { sdkConfig := meta.(*provider.ProviderMeta).ClientConfig - proxy := getOutboundContactlistProxy(sdkConfig) + proxy := GetOutboundContactlistProxy(sdkConfig) cc := consistency_checker.NewConsistencyCheck(ctx, d, meta, ResourceOutboundContactList(), constants.ConsistencyChecks(), ResourceType) log.Printf("Reading Outbound Contact List %s", d.Id()) return util.WithRetriesForRead(ctx, d, func() *retry.RetryError { - sdkContactList, resp, getErr := proxy.getOutboundContactlistById(ctx, d.Id()) + sdkContactList, resp, getErr := proxy.GetOutboundContactlistById(ctx, d.Id()) if getErr != nil { if util.IsStatus404(resp) { return retry.RetryableError(util.BuildWithRetriesApiDiagnosticError(ResourceType, fmt.Sprintf("failed to read Outbound Contact List %s | error: %s", d.Id(), getErr), resp)) @@ -179,6 +195,17 @@ func readOutboundContactList(ctx context.Context, d *schema.ResourceData, meta i if sdkContactList.ColumnDataTypeSpecifications != nil { _ = d.Set("column_data_type_specifications", flattenSdkOutboundContactListColumnDataTypeSpecifications(*sdkContactList.ColumnDataTypeSpecifications)) } + if sdkContactList.TrimWhitespace != nil { + _ = d.Set("trim_whitespace", *sdkContactList.TrimWhitespace) + } + + if sdkContactList.Id != nil { + contactListRecordsCount, _, err := proxy.getOutboundContactlistContactRecordLength(ctx, *sdkContactList.Id) + if err != nil { + return retry.NonRetryableError(util.BuildWithRetriesApiDiagnosticError(ResourceType, fmt.Sprintf("failed to read Outbound Contact List's records %s | error: %s", d.Id(), err), resp)) + } + d.Set("contacts_record_count", contactListRecordsCount) + } log.Printf("Read Outbound Contact List %s %s", d.Id(), *sdkContactList.Name) return cc.CheckState(d) @@ -187,7 +214,7 @@ func readOutboundContactList(ctx context.Context, d *schema.ResourceData, meta i func deleteOutboundContactList(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { sdkConfig := meta.(*provider.ProviderMeta).ClientConfig - proxy := getOutboundContactlistProxy(sdkConfig) + proxy := GetOutboundContactlistProxy(sdkConfig) diagErr := util.RetryWhen(util.IsStatus400, func() (*platformclientv2.APIResponse, diag.Diagnostics) { log.Printf("Deleting Outbound Contact List") @@ -202,7 +229,7 @@ func deleteOutboundContactList(ctx context.Context, d *schema.ResourceData, meta } return util.WithRetries(ctx, 30*time.Second, func() *retry.RetryError { - _, resp, err := proxy.getOutboundContactlistById(ctx, d.Id()) + _, resp, err := proxy.GetOutboundContactlistById(ctx, d.Id()) if err != nil { if util.IsStatus404(resp) { // Outbound Contact List deleted @@ -215,3 +242,83 @@ func deleteOutboundContactList(ctx context.Context, d *schema.ResourceData, meta return retry.RetryableError(util.BuildWithRetriesApiDiagnosticError(ResourceType, fmt.Sprintf("Outbound Contact List %s still exists", d.Id()), resp)) }) } + +func uploadOutboundContactListBulkContacts(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics { + filePath := d.Get("contacts_filepath").(string) + if filePath != "" { + + sdkConfig := meta.(*provider.ProviderMeta).ClientConfig + cp := GetOutboundContactlistProxy(sdkConfig) + + filePathHash, err := files.HashFileContent(filePath) + if err != nil { + return diag.Errorf("Failed to read file content hash: %v", err) + } + + contactListId := d.Id() + contactListName := d.Get("name").(string) + contactsIdName := d.Get("contacts_id_name").(string) + + if filePath == "" { + // Shouldn't happen because Terraform should detect this in the schema first + return diag.Errorf("File path is required") + } + + if d.Get("contacts_file_content_hash") != filePathHash { + csvRecordsCount, err := files.GetCSVRecordCount(filePath) + if err != nil { + return diag.Errorf("Failed to get CSV record count: %v", err) + } + + log.Printf("Clearing existing contacts on contact list %s in preparation for updating the latest contacts", contactListName) + resp, err := cp.clearContactListContacts(ctx, d.Id()) + if err != nil { + return util.BuildAPIDiagnosticError(ResourceType, fmt.Sprintf("Failed to clear contacts on contact list %s error: %s", contactListName, err), resp) + } + + _, diagErr := validateContactsRecordCount(ctx, cp, d.Id(), 0) + if diagErr != nil { + return diagErr + } + + log.Printf("Uploading %d contact records to %s contact list", csvRecordsCount, contactListName) + + if contactListId != "" { + _, err := cp.uploadContactListBulkContacts(ctx, contactListId, filePath, contactsIdName) + if err != nil { + return diag.Errorf("Failed to upload contact list bulk contacts: %v", err) + } + } + + contactCount, diagErr := validateContactsRecordCount(ctx, cp, contactListId, csvRecordsCount) + + d.Set("contacts_file_content_hash", filePathHash) + d.Set("contacts_record_count", contactCount) + } + } + return nil +} + +// Validate number of contact records in a contact list +func validateContactsRecordCount(ctx context.Context, cp *OutboundContactlistProxy, contactListId string, expectedRecordCount int) (recordCount int, err diag.Diagnostics) { + contactListContactsCount := 0 + diagErr := util.WithRetries(ctx, 60*time.Second, func() *retry.RetryError { + + // Sleep for 5 seconds before (re)trying as per documentation + // https://developer.genesys.cloud/routing/outbound/contactmanagement#manipulate-contact-list + time.Sleep(5 * time.Second) + + contactListContactsCount, _, err := cp.getOutboundContactlistContactRecordLength(ctx, contactListId) + if err != nil { + return retry.NonRetryableError(err) + } + if expectedRecordCount != contactListContactsCount { + return retry.RetryableError(fmt.Errorf("Number of records in the CSV file (%d) does not match the number of records in the contact list via the API (%d). Retrying.", expectedRecordCount, contactListContactsCount)) + } + return nil + }) + if diagErr != nil { + return contactListContactsCount, diag.Errorf("Failed to validate number of records in the CSV file: %v", diagErr) + } + return contactListContactsCount, nil +} diff --git a/genesyscloud/outbound_contact_list/resource_genesyscloud_outbound_contact_list_schema.go b/genesyscloud/outbound_contact_list/resource_genesyscloud_outbound_contact_list_schema.go index 39d09fe70..431f8039a 100644 --- a/genesyscloud/outbound_contact_list/resource_genesyscloud_outbound_contact_list_schema.go +++ b/genesyscloud/outbound_contact_list/resource_genesyscloud_outbound_contact_list_schema.go @@ -3,7 +3,9 @@ package outbound_contact_list import ( "terraform-provider-genesyscloud/genesyscloud/provider" resourceExporter "terraform-provider-genesyscloud/genesyscloud/resource_exporter" + "terraform-provider-genesyscloud/genesyscloud/validators" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/customdiff" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation" ) @@ -101,7 +103,11 @@ func ResourceOutboundContactList() *schema.Resource { Importer: &schema.ResourceImporter{ StateContext: schema.ImportStatePassthroughContext, }, - SchemaVersion: 1, + SchemaVersion: 2, + CustomizeDiff: customdiff.All( + customdiff.ComputedIf("contacts_file_content_hash", validators.ValidateFileContentHashChanged("contacts_filepath", "contacts_file_content_hash")), + validators.ValidateCSVWithColumns("contacts_filepath", "column_names"), + ), Schema: map[string]*schema.Schema{ `name`: { Description: `The name for the contact list.`, @@ -115,21 +121,21 @@ func ResourceOutboundContactList() *schema.Resource { Type: schema.TypeString, }, `column_names`: { - Description: `The names of the contact data columns. Changing the column_names attribute will cause the outboundcontact_list object to be dropped and recreated with a new ID`, + Description: `The names of the contact data columns. Changing the column_names attribute will cause the outbound_contact_list object to be dropped and recreated with a new ID`, Required: true, ForceNew: true, Type: schema.TypeList, Elem: &schema.Schema{Type: schema.TypeString}, }, `phone_columns`: { - Description: `Indicates which columns are phone numbers. Changing the phone_columns attribute will cause the outboundcontact_list object to be dropped and recreated with a new ID. Required if email_columns is empty`, + Description: `Indicates which columns are phone numbers. Changing the phone_columns attribute will cause the outbound_contact_list object to be dropped and recreated with a new ID. Required if email_columns is empty`, Optional: true, ForceNew: true, Type: schema.TypeSet, Elem: outboundContactListContactPhoneNumberColumnResource, }, `email_columns`: { - Description: `Indicates which columns are email addresses. Changing the email_columns attribute will cause the outboundcontact_list object to be dropped and recreated with a new ID. Required if phone_columns is empty`, + Description: `Indicates which columns are email addresses. Changing the email_columns attribute will cause the outbound_contact_list object to be dropped and recreated with a new ID. Required if phone_columns is empty`, Optional: true, ForceNew: true, Type: schema.TypeSet, @@ -170,6 +176,42 @@ func ResourceOutboundContactList() *schema.Resource { Type: schema.TypeList, Elem: outboundContactListColumnDataTypeSpecification, }, + `trim_whitespace`: { + Description: `Indicates if leading and trailing whitespace will be trimmed when importing a contactlist CSV file`, + Optional: true, + Type: schema.TypeBool, + }, + `contacts_filepath`: { + Description: "The path to a CSV file containing contacts to import into the contact list. When updated, existing contacts will be removed and replaced with contacts from the new file. If not specified, an empty contact list will be created.", + Optional: true, + Computed: false, + ForceNew: false, + Type: schema.TypeString, + ValidateFunc: validators.ValidatePath, + RequiredWith: []string{"contacts_filepath", "contacts_id_name"}, + }, + `contacts_id_name`: { + Description: `The name of the column in the CSV file that contains the contact's unique contact id. If updated, the contact list is dropped and recreated with a new ID`, + Optional: true, + Computed: false, + ForceNew: false, + Type: schema.TypeString, + RequiredWith: []string{"contacts_id_name", "contacts_filepath"}, + }, + `contacts_file_content_hash`: { + Description: `The hash of the contacts file to import. This is retained as a computed value in the state in order to detect when a file's contents have changed.`, + Computed: true, + Optional: false, + Required: false, + Type: schema.TypeString, + }, + `contacts_record_count`: { + Description: `The number of contacts in the contact list. This is a read-only attribute and sanity check`, + Computed: true, + Optional: false, + Required: false, + Type: schema.TypeInt, + }, }, } } @@ -181,6 +223,10 @@ func OutboundContactListExporter() *resourceExporter.ResourceExporter { "attempt_limit_id": {RefType: "genesyscloud_outbound_attempt_limit"}, "division_id": {RefType: "genesyscloud_auth_division"}, }, + CustomFileWriter: resourceExporter.CustomFileWriterSettings{ + RetrieveAndWriteFilesFunc: ContactsExporterResolver, + SubDirectory: "contacts", + }, } } diff --git a/genesyscloud/outbound_contact_list/resource_genesyscloud_outbound_contact_list_test.go b/genesyscloud/outbound_contact_list/resource_genesyscloud_outbound_contact_list_test.go index 61580b150..e4bb0964a 100644 --- a/genesyscloud/outbound_contact_list/resource_genesyscloud_outbound_contact_list_test.go +++ b/genesyscloud/outbound_contact_list/resource_genesyscloud_outbound_contact_list_test.go @@ -2,11 +2,15 @@ package outbound_contact_list import ( "fmt" + "os" + "regexp" "strconv" "terraform-provider-genesyscloud/genesyscloud/provider" "terraform-provider-genesyscloud/genesyscloud/util" "testing" + testrunner "terraform-provider-genesyscloud/genesyscloud/util/testrunner" + obAttemptLimit "terraform-provider-genesyscloud/genesyscloud/outbound_attempt_limit" "github.com/google/uuid" @@ -15,7 +19,7 @@ import ( "github.com/mypurecloud/platform-client-sdk-go/v150/platformclientv2" ) -func TestAccResourceOutboundContactListBasic(t *testing.T) { +func TestAccResourceOutboundContactListBasicWithoutContacts(t *testing.T) { t.Parallel() var ( @@ -99,6 +103,8 @@ func TestAccResourceOutboundContactListBasic(t *testing.T) { resource.TestCheckResourceAttr(ResourceType+"."+resourceLabel, "preview_mode_column_name", previewModeColumnName), resource.TestCheckResourceAttr(ResourceType+"."+resourceLabel, "preview_mode_accepted_values.0", previewModeColumnName), resource.TestCheckResourceAttr(ResourceType+"."+resourceLabel, "automatic_time_zone_mapping", automaticTimeZoneMapping), + resource.TestCheckResourceAttr(ResourceType+"."+resourceLabel, "contacts_record_count", "0"), + resource.TestCheckNoResourceAttr(ResourceType+"."+resourceLabel, "contacts_file_content_hash"), provider.TestDefaultHomeDivision(ResourceType+"."+resourceLabel), ), }, @@ -157,6 +163,8 @@ func TestAccResourceOutboundContactListBasic(t *testing.T) { resource.TestCheckResourceAttr(ResourceType+"."+resourceLabel, "preview_mode_accepted_values.0", previewModeColumnName), resource.TestCheckResourceAttr(ResourceType+"."+resourceLabel, "preview_mode_accepted_values.1", previewModeColumnNameUpdated), resource.TestCheckResourceAttr(ResourceType+"."+resourceLabel, "automatic_time_zone_mapping", automaticTimeZoneMapping), + resource.TestCheckResourceAttr(ResourceType+"."+resourceLabel, "contacts_record_count", "0"), + resource.TestCheckNoResourceAttr(ResourceType+"."+resourceLabel, "contacts_file_content_hash"), provider.TestDefaultHomeDivision(ResourceType+"."+resourceLabel), ), }, @@ -241,6 +249,8 @@ func TestAccResourceOutboundContactListBasic(t *testing.T) { resource.TestCheckResourceAttr(ResourceType+"."+resourceLabel, "preview_mode_accepted_values.0", previewModeColumnName), resource.TestCheckResourceAttr(ResourceType+"."+resourceLabel, "preview_mode_accepted_values.1", previewModeColumnNameUpdated), resource.TestCheckResourceAttr(ResourceType+"."+resourceLabel, "automatic_time_zone_mapping", automaticTimeZoneMapping), + resource.TestCheckResourceAttr(ResourceType+"."+resourceLabel, "contacts_record_count", "0"), + resource.TestCheckNoResourceAttr(ResourceType+"."+resourceLabel, "contacts_file_content_hash"), provider.TestDefaultHomeDivision(ResourceType+"."+resourceLabel), ), }, @@ -327,6 +337,8 @@ func TestAccResourceOutboundContactListBasic(t *testing.T) { resource.TestCheckResourceAttr(ResourceType+"."+resourceLabel, "preview_mode_accepted_values.0", previewModeColumnName), resource.TestCheckResourceAttr(ResourceType+"."+resourceLabel, "preview_mode_accepted_values.1", previewModeColumnNameUpdated), resource.TestCheckResourceAttr(ResourceType+"."+resourceLabel, "automatic_time_zone_mapping", automaticTimeZoneMappingUpdated), + resource.TestCheckResourceAttr(ResourceType+"."+resourceLabel, "contacts_record_count", "0"), + resource.TestCheckNoResourceAttr(ResourceType+"."+resourceLabel, "contacts_file_content_hash"), resource.TestCheckResourceAttrPair("data.genesyscloud_outbound_attempt_limit."+attemptLimitDataSourceLabel, "id", ResourceType+"."+resourceLabel, "attempt_limit_id"), provider.TestDefaultHomeDivision(ResourceType+"."+resourceLabel), @@ -362,3 +374,420 @@ func testVerifyContactListDestroyed(state *terraform.State) error { // Success. All contact lists destroyed return nil } + +func TestAccResourceOutboundContactListWithContacts(t *testing.T) { + t.Parallel() + var ( + resourceLabel = "contact-list-with-contacts" + name = "Test Contact List " + uuid.NewString() + columnNames = []string{ + strconv.Quote("id"), + strconv.Quote("firstName"), + strconv.Quote("lastName"), + strconv.Quote("phone"), + strconv.Quote("email"), + } + // Create mock CSV file contact data + testContactsContentWithTwoRecords = `id,firstName,lastName,phone,email +100,John,Doe,+13175555555,john.doe@example.com +101,Jane,Smith,+13175555556,jane.smith@example.com` + + testContactsContentWithThreeRecords = testContactsContentWithTwoRecords + ` +102,Bob,Johnson,+13175555557,bob.johnson@example.com` + + testContactsContentWithFourRecords = testContactsContentWithThreeRecords + ` +103,Charlie,Brown,000000000000,charlie.brown@example.com` + + testContactsContentWithFiveRecords = testContactsContentWithFourRecords + ` +104,Jenny,Doe,+15558675309,jenny@jenny.com` + ) + // Create a temporary file for the contacts + tmpFile, err := os.CreateTemp(testrunner.GetTestDataPath(), "contacts*.csv") + if err != nil { + t.Fatal(err) + } + defer os.Remove(tmpFile.Name()) + + // Write the test contacts to the temp file + if err := os.WriteFile(tmpFile.Name(), []byte(testContactsContentWithTwoRecords), 0644); err != nil { + t.Fatal(err) + } + + // Create a second temporary file for the contacts + tmpFile2, err := os.CreateTemp(testrunner.GetTestDataPath(), "contacts*.csv") + if err != nil { + t.Fatal(err) + } + defer os.Remove(tmpFile2.Name()) + + // Write the test contacts to the temp file + if err := os.WriteFile(tmpFile2.Name(), []byte(testContactsContentWithFourRecords), 0644); err != nil { + t.Fatal(err) + } + + // Create a third temporary file for the contacts + tmpFile3, err := os.CreateTemp(testrunner.GetTestDataPath(), "contacts*.csv") + if err != nil { + t.Fatal(err) + } + defer os.Remove(tmpFile3.Name()) + + // Write the test contacts to the temp file + if err := os.WriteFile(tmpFile3.Name(), []byte(testContactsContentWithFiveRecords), 0644); err != nil { + t.Fatal(err) + } + + resource.Test(t, resource.TestCase{ + PreCheck: func() { util.TestAccPreCheck(t) }, + ProviderFactories: provider.GetProviderFactories(providerResources, providerDataSources), + Steps: []resource.TestStep{ + { + Config: GenerateOutboundContactList( + resourceLabel, + name, + util.NullValue, // division_id + util.NullValue, // preview_mode_column_name + []string{}, // preview_mode_accepted_values + columnNames, + util.FalseValue, // automatic_time_zone_mapping + util.NullValue, // zipcode_column_names + util.NullValue, // attempt_limit_id + GeneratePhoneColumnsBlock( + "phone", + "phone", + util.NullValue, + ), + GenerateEmailColumnsBlock( + "email", + "email", + util.NullValue, + ), + GenerateContactsFile( + tmpFile.Name(), // contacts_filepath + "id", // contacts_id_name + ), + ), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr(ResourceType+"."+resourceLabel, "name", name), + resource.TestCheckResourceAttr(ResourceType+"."+resourceLabel, "column_names.#", "5"), + util.ValidateStringInArray(ResourceType+"."+resourceLabel, "column_names", "id"), + util.ValidateStringInArray(ResourceType+"."+resourceLabel, "column_names", "firstName"), + util.ValidateStringInArray(ResourceType+"."+resourceLabel, "column_names", "lastName"), + util.ValidateStringInArray(ResourceType+"."+resourceLabel, "column_names", "phone"), + util.ValidateStringInArray(ResourceType+"."+resourceLabel, "column_names", "email"), + resource.TestCheckResourceAttr(ResourceType+"."+resourceLabel, "phone_columns.0.column_name", "phone"), + resource.TestCheckResourceAttr(ResourceType+"."+resourceLabel, "phone_columns.0.type", "phone"), + resource.TestCheckResourceAttr(ResourceType+"."+resourceLabel, "email_columns.0.column_name", "email"), + resource.TestCheckResourceAttr(ResourceType+"."+resourceLabel, "email_columns.0.type", "email"), + resource.TestCheckResourceAttr(ResourceType+"."+resourceLabel, "contacts_record_count", "2"), + ), + }, + // Test updating the contents of the contacts file + { + PreConfig: func() { + // Update CSV file content + err := os.WriteFile(tmpFile.Name(), []byte(testContactsContentWithThreeRecords), 0644) + if err != nil { + t.Fatal(err) + } + }, + Config: GenerateOutboundContactList( + resourceLabel, + name, + util.NullValue, + util.NullValue, + []string{}, + columnNames, + util.FalseValue, + util.NullValue, + util.NullValue, + GeneratePhoneColumnsBlock( + "phone", + "phone", + util.NullValue, + ), + GenerateEmailColumnsBlock( + "email", + "email", + util.NullValue, + ), + GenerateContactsFile( + tmpFile.Name(), // Same file, but in real usage this could be a different file + "id", // contacts_id_name + ), + ), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr(ResourceType+"."+resourceLabel, "name", name), + resource.TestCheckResourceAttr(ResourceType+"."+resourceLabel, "column_names.#", "5"), + resource.TestCheckResourceAttr(ResourceType+"."+resourceLabel, "contacts_record_count", "3"), + ), + }, + // Test when the contacts file path changes + { + Config: GenerateOutboundContactList( + resourceLabel, + name, + util.NullValue, + util.NullValue, + []string{}, + columnNames, + util.FalseValue, + util.NullValue, + util.NullValue, + GeneratePhoneColumnsBlock( + "phone", + "phone", + util.NullValue, + ), + GenerateEmailColumnsBlock( + "email", + "email", + util.NullValue, + ), + GenerateContactsFile( + tmpFile2.Name(), // Same file, but in real usage this could be a different file + "id", // contacts_id_name + ), + ), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr(ResourceType+"."+resourceLabel, "name", name), + resource.TestCheckResourceAttr(ResourceType+"."+resourceLabel, "column_names.#", "5"), + resource.TestCheckResourceAttr(ResourceType+"."+resourceLabel, "contacts_record_count", "4"), + ), + }, + // Test a blank file of contacts + { + PreConfig: func() { + // Remove file content + err := os.WriteFile(tmpFile2.Name(), []byte(""), 0644) + if err != nil { + t.Fatal(err) + } + }, + Config: GenerateOutboundContactList( + resourceLabel, + name, + util.NullValue, + util.NullValue, + []string{}, + columnNames, + util.FalseValue, + util.NullValue, + util.NullValue, + GeneratePhoneColumnsBlock( + "phone", + "phone", + util.NullValue, + ), + GenerateEmailColumnsBlock( + "email", + "email", + util.NullValue, + ), + GenerateContactsFile( + tmpFile2.Name(), // Same file, but in real usage this could be a different file + "id", // contacts_id_name + ), + ), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr(ResourceType+"."+resourceLabel, "name", name), + resource.TestCheckResourceAttr(ResourceType+"."+resourceLabel, "column_names.#", "5"), + resource.TestCheckResourceAttr(ResourceType+"."+resourceLabel, "contacts_record_count", "0"), + ), + }, + // Test that contacts can be re-uploaded + { + Config: GenerateOutboundContactList( + resourceLabel, + name, + util.NullValue, + util.NullValue, + []string{}, + columnNames, + util.FalseValue, + util.NullValue, + util.NullValue, + GeneratePhoneColumnsBlock( + "phone", + "phone", + util.NullValue, + ), + GenerateEmailColumnsBlock( + "email", + "email", + util.NullValue, + ), + GenerateContactsFile( + tmpFile.Name(), // Same file, but in real usage this could be a different file + "id", // contacts_id_name + ), + ), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr(ResourceType+"."+resourceLabel, "name", name), + resource.TestCheckResourceAttr(ResourceType+"."+resourceLabel, "column_names.#", "5"), + resource.TestCheckResourceAttr(ResourceType+"."+resourceLabel, "contacts_record_count", "3"), + ), + }, + // Test when the contacts file is non-existant + { + PreConfig: func() { + // Remove file of contacts + err := os.Remove(tmpFile.Name()) + if err != nil { + t.Fatal(err) + } + }, + Config: GenerateOutboundContactList( + resourceLabel, + name, + util.NullValue, + util.NullValue, + []string{}, + columnNames, + util.FalseValue, + util.NullValue, + util.NullValue, + GeneratePhoneColumnsBlock( + "phone", + "phone", + util.NullValue, + ), + GenerateEmailColumnsBlock( + "email", + "email", + util.NullValue, + ), + GenerateContactsFile( + tmpFile.Name(), // Same file, but in real usage this could be a different file + "id", // contacts_id_name + ), + ), + ExpectError: regexp.MustCompile("could not open.*no such file"), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr(ResourceType+"."+resourceLabel, "name", name), + resource.TestCheckResourceAttr(ResourceType+"."+resourceLabel, "column_names.#", "5"), + resource.TestCheckResourceAttr(ResourceType+"."+resourceLabel, "contacts_record_count", "3"), + ), + }, + // Ensure we can re-upload the file after it was removed + { + Config: GenerateOutboundContactList( + resourceLabel, + name, + util.NullValue, // division_id + util.NullValue, // preview_mode_column_name + []string{}, // preview_mode_accepted_values + columnNames, + util.FalseValue, // automatic_time_zone_mapping + util.NullValue, // zipcode_column_names + util.NullValue, // attempt_limit_id + GeneratePhoneColumnsBlock( + "phone", + "phone", + util.NullValue, + ), + GenerateEmailColumnsBlock( + "email", + "email", + util.NullValue, + ), + GenerateContactsFile( + tmpFile3.Name(), // contacts_filepath + "id", // contacts_id_name + ), + ), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr(ResourceType+"."+resourceLabel, "name", name), + resource.TestCheckResourceAttr(ResourceType+"."+resourceLabel, "column_names.#", "5"), + util.ValidateStringInArray(ResourceType+"."+resourceLabel, "column_names", "id"), + util.ValidateStringInArray(ResourceType+"."+resourceLabel, "column_names", "firstName"), + util.ValidateStringInArray(ResourceType+"."+resourceLabel, "column_names", "lastName"), + util.ValidateStringInArray(ResourceType+"."+resourceLabel, "column_names", "phone"), + util.ValidateStringInArray(ResourceType+"."+resourceLabel, "column_names", "email"), + resource.TestCheckResourceAttr(ResourceType+"."+resourceLabel, "phone_columns.0.column_name", "phone"), + resource.TestCheckResourceAttr(ResourceType+"."+resourceLabel, "phone_columns.0.type", "phone"), + resource.TestCheckResourceAttr(ResourceType+"."+resourceLabel, "email_columns.0.column_name", "email"), + resource.TestCheckResourceAttr(ResourceType+"."+resourceLabel, "email_columns.0.type", "email"), + resource.TestCheckResourceAttr(ResourceType+"."+resourceLabel, "contacts_record_count", "5"), + ), + }, + { + // Import + ResourceName: ResourceType + "." + resourceLabel, + ImportState: true, + ImportStateVerify: true, + ImportStateVerifyIgnore: []string{ + "contacts_file_content_hash", + "contacts_filepath", + "contacts_id_name", + }, + }, + }, + CheckDestroy: testVerifyContactListDestroyed, + }) +} + +// You might also want to add a test for invalid contact data +func TestAccResourceOutboundContactListWithInvalidContacts(t *testing.T) { + t.Parallel() + var ( + resourceLabel = "contact-list-invalid" + name = "Test Contact List " + uuid.NewString() + columnNames = []string{ + strconv.Quote("Id"), + strconv.Quote("Cell"), + strconv.Quote("Home"), + strconv.Quote("Name"), + } + // Create invalid CSV data (missing required columns) + invalidContactsContent = `WrongColumn,Name ++13175551234,John Doe ++13175559876,Jane Smith` + ) + + // Create a temporary file for the invalid contacts + tmpFile, err := os.CreateTemp(testrunner.GetTestDataPath(), "invalid_contacts*.csv") + if err != nil { + t.Fatal(err) + } + defer os.Remove(tmpFile.Name()) + + if err := os.WriteFile(tmpFile.Name(), []byte(invalidContactsContent), 0644); err != nil { + t.Fatal(err) + } + + resource.Test(t, resource.TestCase{ + PreCheck: func() { util.TestAccPreCheck(t) }, + ProviderFactories: provider.GetProviderFactories(providerResources, providerDataSources), + Steps: []resource.TestStep{ + { + Config: GenerateOutboundContactList( + resourceLabel, + name, + util.NullValue, // division_id + util.NullValue, + []string{}, + columnNames, + util.FalseValue, + util.NullValue, + util.NullValue, + GeneratePhoneColumnsBlock( + "Cell", + "cell", + util.NullValue, + ), + GeneratePhoneColumnsBlock( + "Home", + "home", + util.NullValue, + ), + GenerateContactsFile( + tmpFile.Name(), // contacts_filepath + "Id", // contacts_id_name + ), + ), + ExpectError: regexp.MustCompile(`failed to validate contacts file: CSV file is missing required columns`), // Adjust the error message based on your actual implementation + }, + }, + }) +} diff --git a/genesyscloud/outbound_contact_list/resource_genesyscloud_outbound_contact_list_utils.go b/genesyscloud/outbound_contact_list/resource_genesyscloud_outbound_contact_list_utils.go index 7d24af544..9c32f7479 100644 --- a/genesyscloud/outbound_contact_list/resource_genesyscloud_outbound_contact_list_utils.go +++ b/genesyscloud/outbound_contact_list/resource_genesyscloud_outbound_contact_list_utils.go @@ -1,9 +1,22 @@ package outbound_contact_list import ( + "context" "fmt" + "log" + "os" + "path" + "path/filepath" + "strconv" "strings" + "time" + "terraform-provider-genesyscloud/genesyscloud/provider" + resourceExporter "terraform-provider-genesyscloud/genesyscloud/resource_exporter" + "terraform-provider-genesyscloud/genesyscloud/util" + "terraform-provider-genesyscloud/genesyscloud/util/files" + + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" "github.com/mypurecloud/platform-client-sdk-go/v150/platformclientv2" ) @@ -65,17 +78,24 @@ func buildSdkOutboundContactListContactEmailAddressColumnSlice(contactEmailAddre contactEmailAddressColumnList := contactEmailAddressColumn.List() for _, configEmailColumn := range contactEmailAddressColumnList { var sdkContactEmailAddressColumn platformclientv2.Emailcolumn + contactEmailAddressColumnMap, ok := configEmailColumn.(map[string]interface{}) if !ok { continue } - if columnName, _ := contactEmailAddressColumnMap["column_name"].(string); columnName != "" { + + // Safely handle column_name + if columnName, ok := contactEmailAddressColumnMap["column_name"].(string); ok && columnName != "" { sdkContactEmailAddressColumn.ColumnName = &columnName } - if varType, _ := contactEmailAddressColumnMap["type"].(string); varType != "" { + + // Safely handle type + if varType, ok := contactEmailAddressColumnMap["type"].(string); ok && varType != "" { sdkContactEmailAddressColumn.VarType = &varType } - if contactableTimeColumn, _ := contactEmailAddressColumnMap["contactable_time_column"].(string); contactableTimeColumn != "" { + + // Safely handle contactable_time_column + if contactableTimeColumn, ok := contactEmailAddressColumnMap["contactable_time_column"].(string); ok && contactableTimeColumn != "" { sdkContactEmailAddressColumn.ContactableTimeColumn = &contactableTimeColumn } @@ -171,6 +191,85 @@ func flattenSdkOutboundContactListColumnDataTypeSpecifications(columnDataTypeSpe return columnDataTypeSpecificationsSlice } +func ContactsExporterResolver(resourceId, exportDirectory, subDirectory string, configMap map[string]interface{}, meta interface{}, resource resourceExporter.ResourceInfo) error { + sdkConfig := meta.(*provider.ProviderMeta).ClientConfig + cp := GetOutboundContactlistProxy(sdkConfig) + + contactListName := resource.BlockLabel + contactListId := resource.State.Attributes["id"] + exportFileName := fmt.Sprintf("%s.csv", contactListName) + + fullDirectoryPath := path.Join(exportDirectory, subDirectory) + if err := os.MkdirAll(fullDirectoryPath, os.ModePerm); err != nil { + return fmt.Errorf("failed to create directory %s: %w", fullDirectoryPath, err) + } + + ctx := context.Background() + var exportUrl string + diagErr := util.RetryWhen(util.IsStatus404, func() (*platformclientv2.APIResponse, diag.Diagnostics) { + resp, err := cp.initiateContactListContactsExport(ctx, contactListId) + // Sleep one second before attempting to retrieve export url to give the system time to be able to generate the URL + time.Sleep(time.Second) + if err != nil { + return resp, diag.FromErr(err) + } + return resp, nil + }, 400) + if diagErr != nil { + return fmt.Errorf(`Error initiating contact list export: %v`, diagErr) + } + diagErr = util.RetryWhen(util.IsStatus404, func() (*platformclientv2.APIResponse, diag.Diagnostics) { + var err error + var resp *platformclientv2.APIResponse + exportUrl, resp, err = cp.getContactListContactsExportUrl(ctx, contactListId) + if err != nil { + return resp, diag.FromErr(err) + } + return resp, nil + + }, 400) + if diagErr != nil { + return fmt.Errorf(`Error retrieving contact list export url: %v`, diagErr) + } + diagErr = util.RetryWhen(util.IsStatus404, func() (*platformclientv2.APIResponse, diag.Diagnostics) { + resp, err := files.DownloadExportFileWithAccessToken(fullDirectoryPath, exportFileName, exportUrl, sdkConfig.AccessToken) + if err != nil { + return resp, diag.FromErr(err) + } + return resp, nil + }, 400) + if diagErr != nil { + return fmt.Errorf(`Error downloading exported contacts: %v`, diagErr) + } + + fullCurrentPath := filepath.Join(fullDirectoryPath, exportFileName) + fullRelativePath := filepath.Join(subDirectory, exportFileName) + configMap["contacts_filepath"] = fullRelativePath + configMap["contacts_id_name"] = "inin-outbound-id" + + // Remove read only attributes from the config file + delete(configMap, "contacts_file_content_hash") + delete(configMap, "contacts_record_count") + hash, err := files.HashFileContent(fullCurrentPath) + if err != nil { + log.Printf("Error calculating file content hash: %v", err) + return err + } + resource.State.Attributes["contacts_file_content_hash"] = hash + + recordCount, err := files.GetCSVRecordCount(fullCurrentPath) + if err != nil { + log.Printf("Error getting CSV record count: %v", err) + return err + } + resource.State.Attributes["contacts_record_count"] = strconv.Itoa(recordCount) + + resource.State.Attributes["contacts_filepath"] = fullRelativePath + resource.State.Attributes["contacts_id_name"] = "inin-outbound-id" + + return nil +} + func GeneratePhoneColumnsBlock(columnName, columnType, callableTimeColumn string) string { return fmt.Sprintf(` phone_columns { @@ -181,6 +280,13 @@ func GeneratePhoneColumnsBlock(columnName, columnType, callableTimeColumn string `, columnName, columnType, callableTimeColumn) } +func GenerateContactsFile(filepath, contactsIdName string) string { + return fmt.Sprintf(` + contacts_filepath = "%s" + contacts_id_name = "%s" + `, filepath, contactsIdName) +} + func GenerateOutboundContactList( resourceLabel string, name string, diff --git a/genesyscloud/outbound_contact_list/resource_genesyscloud_outbound_contact_list_utils_test.go b/genesyscloud/outbound_contact_list/resource_genesyscloud_outbound_contact_list_utils_test.go index 33c25e636..bcad3de05 100644 --- a/genesyscloud/outbound_contact_list/resource_genesyscloud_outbound_contact_list_utils_test.go +++ b/genesyscloud/outbound_contact_list/resource_genesyscloud_outbound_contact_list_utils_test.go @@ -1,10 +1,20 @@ package outbound_contact_list import ( + "context" + "fmt" + "net/http" + "os" + "path" "reflect" "testing" + "terraform-provider-genesyscloud/genesyscloud/provider" + resourceExporter "terraform-provider-genesyscloud/genesyscloud/resource_exporter" + "terraform-provider-genesyscloud/genesyscloud/util/files" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + "github.com/hashicorp/terraform-plugin-sdk/v2/terraform" "github.com/mypurecloud/platform-client-sdk-go/v150/platformclientv2" ) @@ -539,3 +549,210 @@ func TestContactListFlattenSdkOutboundContactListColumnDataTypeSpecifications(t t.Error("Failed to type assert result to map[string]interface{}") } } + +func TestContactListContactsExporterResolver(t *testing.T) { + // Setup test directory + tempDir := t.TempDir() + subDir := "test_subdir" + + // Mock the config map + configMap := map[string]interface{}{ + "contact_list_id": "test-contact-list", + } + + t.Run("successful export", func(t *testing.T) { + // Create test proxy with our test implementation + testProxy := &OutboundContactlistProxy{ + initiateContactListContactsExportAttr: func(_ context.Context, p *OutboundContactlistProxy, contactListId string) (*platformclientv2.APIResponse, error) { + // Mock the API response + resp := &platformclientv2.APIResponse{ + StatusCode: http.StatusOK, + } + return resp, nil + }, + getContactListContactsExportUrlAttr: func(_ context.Context, p *OutboundContactlistProxy, contactListId string) (string, *platformclientv2.APIResponse, error) { + // Mock the API response + resp := &platformclientv2.APIResponse{ + StatusCode: http.StatusOK, + } + return "http://test-url.com/export", resp, nil + }, + } + + // Set the internal proxy to our test proxy + internalProxy = testProxy + + // Mock the provider meta + mockMeta := &provider.ProviderMeta{ + ClientConfig: &platformclientv2.Configuration{}, + } + + // Mock resource info + mockResource := resourceExporter.ResourceInfo{ + BlockLabel: "contacts_test-contact-list", + State: &terraform.InstanceState{ + Attributes: make(map[string]string), + }, + } + + // Mock the file download function + origDownloadFile := files.DownloadExportFileWithAccessToken + files.DownloadExportFileWithAccessToken = func(directory, filename, url, accessToken string) (*platformclientv2.APIResponse, error) { + fullPath := path.Join(directory, filename) + if err := os.MkdirAll(directory, os.ModePerm); err != nil { + return nil, err + } + os.WriteFile(fullPath, []byte("test content"), 0644) + return nil, nil + } + defer func() { files.DownloadExportFileWithAccessToken = origDownloadFile }() + + // Test the function + err := ContactsExporterResolver("test-id", tempDir, subDir, configMap, mockMeta, mockResource) + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + + // Verify the filepath was set in configMap + expectedPath := path.Join(subDir, "contacts_test-contact-list.csv") + if configMap["contacts_filepath"] != expectedPath { + t.Errorf("Expected filepath %s, got %s", expectedPath, configMap["filepath"]) + } + + if configMap["contacts_id_name"] != "inin-outbound-id" { + t.Errorf("Expected contacts_id_name to be 'inin-outbound-id', got %s", configMap["contacts_id_name"]) + } + + // Verify computed attributes not set on configMap + if _, exists := configMap["contacts_file_content_hash"]; exists { + t.Errorf("Expected contacts_file_content_hash to not be in configMap") + } + if _, exists := configMap["contacts_record_count"]; exists { + t.Errorf("Expected contacts_record_count to not be in configMap") + } + + // Verify state attributes set + if mockResource.State.Attributes["contacts_file_content_hash"] == "" { + t.Error("Expected contacts_file_content_hash to be set") + } + if mockResource.State.Attributes["contacts_record_count"] == "" { + t.Error("Expected contacts_record_count to be set") + } + if mockResource.State.Attributes["contacts_filepath"] == "" { + t.Error("Expected contacts_filepath to be set") + } + if mockResource.State.Attributes["contacts_id_name"] == "" { + t.Error("Expected contacts_id_name to be set") + } + + }) + + t.Run("initiate export url error", func(t *testing.T) { + // Create test proxy with error case + + testProxy := &OutboundContactlistProxy{ + initiateContactListContactsExportAttr: func(_ context.Context, p *OutboundContactlistProxy, contactListId string) (*platformclientv2.APIResponse, error) { + return nil, fmt.Errorf("failed to initiate export") + }, + } + + // Set the internal proxy to our test proxy + internalProxy = testProxy + + mockMeta := &provider.ProviderMeta{ + ClientConfig: &platformclientv2.Configuration{}, + } + + mockResource := resourceExporter.ResourceInfo{ + State: &terraform.InstanceState{ + Attributes: make(map[string]string), + }, + } + + err := ContactsExporterResolver("test-id", tempDir, subDir, configMap, mockMeta, mockResource) + if err == nil { + t.Error("Expected error, got nil") + } + }) + + t.Run("get export url error", func(t *testing.T) { + // Create test proxy with error case + + testProxy := &OutboundContactlistProxy{ + initiateContactListContactsExportAttr: func(_ context.Context, p *OutboundContactlistProxy, contactListId string) (*platformclientv2.APIResponse, error) { + // Mock the API response + resp := &platformclientv2.APIResponse{ + StatusCode: http.StatusOK, + } + return resp, nil + }, + getContactListContactsExportUrlAttr: func(_ context.Context, p *OutboundContactlistProxy, contactListId string) (string, *platformclientv2.APIResponse, error) { + return "", nil, fmt.Errorf("failed to get export URL") + }, + } + + // Set the internal proxy to our test proxy + internalProxy = testProxy + + mockMeta := &provider.ProviderMeta{ + ClientConfig: &platformclientv2.Configuration{}, + } + + mockResource := resourceExporter.ResourceInfo{ + State: &terraform.InstanceState{ + Attributes: make(map[string]string), + }, + } + + err := ContactsExporterResolver("test-id", tempDir, subDir, configMap, mockMeta, mockResource) + if err == nil { + t.Error("Expected error, got nil") + } + }) + + t.Run("download error", func(t *testing.T) { + // Create test proxy + testProxy := &OutboundContactlistProxy{ + initiateContactListContactsExportAttr: func(_ context.Context, p *OutboundContactlistProxy, contactListId string) (*platformclientv2.APIResponse, error) { + // Mock the API response + resp := &platformclientv2.APIResponse{ + StatusCode: http.StatusOK, + } + return resp, nil + }, + getContactListContactsExportUrlAttr: func(_ context.Context, p *OutboundContactlistProxy, contactListId string) (string, *platformclientv2.APIResponse, error) { + // Mock the API response + resp := &platformclientv2.APIResponse{ + StatusCode: http.StatusOK, + } + return "http://test-url.com/export", resp, nil + }, + } + + // Set the internal proxy to our test proxy + internalProxy = testProxy + + mockMeta := &provider.ProviderMeta{ + ClientConfig: &platformclientv2.Configuration{}, + } + + mockResource := resourceExporter.ResourceInfo{ + State: &terraform.InstanceState{ + Attributes: make(map[string]string), + }, + } + + // Mock download failure + files.DownloadExportFileWithAccessToken = func(directory, filename, url, accessToken string) (*platformclientv2.APIResponse, error) { + return nil, fmt.Errorf("download failed") + } + + err := ContactsExporterResolver("test-id", tempDir, subDir, configMap, mockMeta, mockResource) + if err == nil { + t.Error("Expected error, got nil") + } + }) + + // Clean up after all tests + internalProxy = nil +} diff --git a/genesyscloud/outbound_contact_list_contact/genesyscloud_outbound_contact_list_contact_proxy.go b/genesyscloud/outbound_contact_list_contact/genesyscloud_outbound_contact_list_contact_proxy.go index 17da974bc..f41e12f0a 100644 --- a/genesyscloud/outbound_contact_list_contact/genesyscloud_outbound_contact_list_contact_proxy.go +++ b/genesyscloud/outbound_contact_list_contact/genesyscloud_outbound_contact_list_contact_proxy.go @@ -3,6 +3,7 @@ package outbound_contact_list_contact import ( "context" "log" + contactList "terraform-provider-genesyscloud/genesyscloud/outbound_contact_list" rc "terraform-provider-genesyscloud/genesyscloud/resource_cache" "terraform-provider-genesyscloud/genesyscloud/tfexporter_state" @@ -30,10 +31,12 @@ type contactProxy struct { deleteContactAttr deleteContactFunc getAllContactsAttr getAllContactsFunc contactCache rc.CacheInterface[platformclientv2.Dialercontact] + contactListProxy *contactList.OutboundContactlistProxy } func newContactProxy(clientConfig *platformclientv2.Configuration) *contactProxy { api := platformclientv2.NewOutboundApiWithConfig(clientConfig) + contactListProxy := contactList.GetOutboundContactlistProxy(clientConfig) return &contactProxy{ clientConfig: clientConfig, outboundApi: api, @@ -43,6 +46,7 @@ func newContactProxy(clientConfig *platformclientv2.Configuration) *contactProxy deleteContactAttr: deleteContactFn, getAllContactsAttr: getAllContactsFn, contactCache: contactCache, + contactListProxy: contactListProxy, } } @@ -100,12 +104,15 @@ func deleteContactFn(_ context.Context, p *contactProxy, contactListId, contactI func getAllContactsFn(ctx context.Context, p *contactProxy) ([]ContactEntry, *platformclientv2.APIResponse, error) { var allContacts []ContactEntry - contactLists, resp, err := p.getAllContactLists(ctx) + contactLists, resp, err := p.contactListProxy.GetAllOutboundContactlist(ctx) if err != nil { return allContacts, resp, err } - for _, contactList := range contactLists { + for _, contactList := range *contactLists { + if contactList.Id == nil { + continue + } contacts, resp, err := p.getContactsByContactListId(ctx, *contactList.Id) if err != nil { return nil, resp, err @@ -162,35 +169,3 @@ func (p *contactProxy) getContactsByContactListId(_ context.Context, contactList return allContacts, nil, nil } - -func (p *contactProxy) getAllContactLists(_ context.Context) ([]platformclientv2.Contactlist, *platformclientv2.APIResponse, error) { - const pageSize = 100 - var pageNum = 1 - var allContactLists []platformclientv2.Contactlist - - contactListConfigs, resp, getErr := p.outboundApi.GetOutboundContactlists(false, false, pageSize, pageNum, true, "", "", []string{}, []string{}, "", "") - if getErr != nil { - return nil, resp, getErr - } - if contactListConfigs.Entities == nil || len(*contactListConfigs.Entities) == 0 { - return nil, nil, nil - } - for _, cl := range *contactListConfigs.Entities { - allContactLists = append(allContactLists, cl) - } - - for pageNum := 2; pageNum <= *contactListConfigs.PageCount; pageNum++ { - contactListConfigs, resp, getErr := p.outboundApi.GetOutboundContactlists(false, false, pageSize, pageNum, true, "", "", []string{}, []string{}, "", "") - if getErr != nil { - return nil, resp, getErr - } - if contactListConfigs.Entities == nil || len(*contactListConfigs.Entities) == 0 { - break - } - for _, cl := range *contactListConfigs.Entities { - allContactLists = append(allContactLists, cl) - } - } - - return allContactLists, nil, nil -} diff --git a/genesyscloud/outbound_contact_list_contact/resource_genesyscloud_outbound_contact_list_contact.go b/genesyscloud/outbound_contact_list_contact/resource_genesyscloud_outbound_contact_list_contact.go index c063accf4..0e6b2eca3 100644 --- a/genesyscloud/outbound_contact_list_contact/resource_genesyscloud_outbound_contact_list_contact.go +++ b/genesyscloud/outbound_contact_list_contact/resource_genesyscloud_outbound_contact_list_contact.go @@ -6,7 +6,6 @@ import ( "log" "terraform-provider-genesyscloud/genesyscloud/consistency_checker" "terraform-provider-genesyscloud/genesyscloud/provider" - resourceExporter "terraform-provider-genesyscloud/genesyscloud/resource_exporter" "terraform-provider-genesyscloud/genesyscloud/util" "terraform-provider-genesyscloud/genesyscloud/util/constants" "terraform-provider-genesyscloud/genesyscloud/util/resourcedata" @@ -18,30 +17,6 @@ import ( "github.com/mypurecloud/platform-client-sdk-go/v150/platformclientv2" ) -func getAllContacts(ctx context.Context, clientConfig *platformclientv2.Configuration) (resourceExporter.ResourceIDMetaMap, diag.Diagnostics) { - resources := make(resourceExporter.ResourceIDMetaMap) - cp := getContactProxy(clientConfig) - - contactEntries, resp, err := cp.getAllContacts(ctx) - if err != nil { - msg := fmt.Sprintf("Failed to read all contact list contacts. Error: %v", err) - if resp != nil { - return nil, util.BuildAPIDiagnosticError(ResourceType, msg, resp) - } - return nil, util.BuildDiagnosticError(ResourceType, msg, err) - } - - for _, contactEntry := range contactEntries { - for _, contact := range *contactEntry.Contact { - id := buildComplexContactId(*contactEntry.ContactList.Id, *contact.Id) - name := *contactEntry.ContactList.Name + "_" + *contact.Id - resources[id] = &resourceExporter.ResourceMeta{BlockLabel: name} - } - } - - return resources, nil -} - func createOutboundContactListContact(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics { sdkConfig := meta.(*provider.ProviderMeta).ClientConfig cp := getContactProxy(sdkConfig) diff --git a/genesyscloud/outbound_contact_list_contact/resource_genesyscloud_outbound_contact_list_contact_schema.go b/genesyscloud/outbound_contact_list_contact/resource_genesyscloud_outbound_contact_list_contact_schema.go index cd34085e8..1dd677ec4 100644 --- a/genesyscloud/outbound_contact_list_contact/resource_genesyscloud_outbound_contact_list_contact_schema.go +++ b/genesyscloud/outbound_contact_list_contact/resource_genesyscloud_outbound_contact_list_contact_schema.go @@ -2,7 +2,6 @@ package outbound_contact_list_contact import ( "terraform-provider-genesyscloud/genesyscloud/provider" - resourceExporter "terraform-provider-genesyscloud/genesyscloud/resource_exporter" registrar "terraform-provider-genesyscloud/genesyscloud/resource_register" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" @@ -12,7 +11,6 @@ const ResourceType = "genesyscloud_outbound_contact_list_contact" func SetRegistrar(regInstance registrar.Registrar) { regInstance.RegisterResource(ResourceType, ResourceOutboundContactListContact()) - regInstance.RegisterExporter(ResourceType, ContactExporter()) } var ( @@ -66,24 +64,14 @@ var ( } ) -func ContactExporter() *resourceExporter.ResourceExporter { - return &resourceExporter.ResourceExporter{ - GetResourcesFunc: provider.GetAllWithPooledClient(getAllContacts), - RefAttrs: map[string]*resourceExporter.RefAttrSettings{ - "contact_list_id": {RefType: "genesyscloud_outbound_contact_list"}, - }, - AllowZeroValuesInMap: []string{"data"}, - } -} - func ResourceOutboundContactListContact() *schema.Resource { return &schema.Resource{ - Description: `Genesys Cloud Outbound Contact List Contact`, - - CreateContext: provider.CreateWithPooledClient(createOutboundContactListContact), - ReadContext: provider.ReadWithPooledClient(readOutboundContactListContact), - UpdateContext: provider.UpdateWithPooledClient(updateOutboundContactListContact), - DeleteContext: provider.DeleteWithPooledClient(deleteOutboundContactListContact), + Description: `[DEPRECATED] Genesys Cloud Outbound Contact List Contact`, + DeprecationMessage: "This resource is deprecated and will be removed in a future version. The exporter functionality of this resource has been removed. Please use the contacts_* fields within the genesyscloud_outbound_contact_list resource instead. This change consolidates contact management to improve reliability and performance.", + CreateContext: provider.CreateWithPooledClient(createOutboundContactListContact), + ReadContext: provider.ReadWithPooledClient(readOutboundContactListContact), + UpdateContext: provider.UpdateWithPooledClient(updateOutboundContactListContact), + DeleteContext: provider.DeleteWithPooledClient(deleteOutboundContactListContact), Importer: &schema.ResourceImporter{ StateContext: schema.ImportStatePassthroughContext, }, diff --git a/genesyscloud/provider/provider.go b/genesyscloud/provider/provider.go index cb81229e4..544217583 100644 --- a/genesyscloud/provider/provider.go +++ b/genesyscloud/provider/provider.go @@ -21,8 +21,6 @@ import ( "github.com/mypurecloud/platform-client-sdk-go/v150/platformclientv2" ) -var orgDefaultCountryCode string - func init() { // Set descriptions to support markdown syntax, this will be used in document generation // and the language server. @@ -239,12 +237,13 @@ func New(version string, providerResources map[string]*schema.Resource, provider } type ProviderMeta struct { - Version string - Registry string - Platform *platform.Platform - ClientConfig *platformclientv2.Configuration - Domain string - Organization *platformclientv2.Organization + Version string + Registry string + Platform *platform.Platform + ClientConfig *platformclientv2.Configuration + Domain string + Organization *platformclientv2.Organization + DefaultCountryCode string } func configure(version string) schema.ConfigureContextFunc { @@ -269,16 +268,21 @@ func configure(version string) schema.ConfigureContextFunc { if err != nil { return nil, err } - orgDefaultCountryCode = *currentOrg.DefaultCountryCode - - return &ProviderMeta{ - Version: version, - Platform: &platform, - Registry: providerSourceRegistry, - ClientConfig: defaultConfig, - Domain: getRegionDomain(data.Get("aws_region").(string)), - Organization: currentOrg, - }, nil + + meta := &ProviderMeta{ + Version: version, + Platform: &platform, + Registry: providerSourceRegistry, + ClientConfig: defaultConfig, + Domain: getRegionDomain(data.Get("aws_region").(string)), + Organization: currentOrg, + DefaultCountryCode: *currentOrg.DefaultCountryCode, + } + + setProviderMeta(meta) + + return meta, nil + } } diff --git a/genesyscloud/provider/provider_utils.go b/genesyscloud/provider/provider_utils.go index fc400a11e..60f5e144f 100644 --- a/genesyscloud/provider/provider_utils.go +++ b/genesyscloud/provider/provider_utils.go @@ -2,6 +2,7 @@ package provider import ( "fmt" + "sync" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" @@ -44,6 +45,29 @@ func TestDefaultHomeDivision(resource string) resource.TestCheckFunc { } } +// Ensure the Meta (with ClientCredentials) is accessible throughout the provider, especially +// within acceptance testing +var ( + providerMeta *ProviderMeta + mutex sync.RWMutex +) + +func GetProviderMeta() *ProviderMeta { + mutex.RLock() + defer mutex.RUnlock() + return providerMeta +} + +func setProviderMeta(p *ProviderMeta) { + mutex.Lock() + defer mutex.Unlock() + providerMeta = p +} + func GetOrgDefaultCountryCode() string { - return orgDefaultCountryCode + meta := GetProviderMeta() + if meta == nil { + return "" + } + return meta.DefaultCountryCode } diff --git a/genesyscloud/resource_genesyscloud_journey_segment_test.go b/genesyscloud/resource_genesyscloud_journey_segment_test.go index a586cf9bc..2c9dcfc13 100644 --- a/genesyscloud/resource_genesyscloud_journey_segment_test.go +++ b/genesyscloud/resource_genesyscloud_journey_segment_test.go @@ -34,7 +34,7 @@ func runResourceJourneySegmentTestCase(t *testing.T, testCaseName string) { resource.Test(t, resource.TestCase{ PreCheck: func() { util.TestAccPreCheck(t) }, ProviderFactories: provider.GetProviderFactories(providerResources, providerDataSources), - Steps: testrunner.GenerateResourceTestSteps(resourceType, testCaseName, nil), + Steps: testrunner.GenerateResourceJourneyTestSteps(resourceType, testCaseName, nil), CheckDestroy: testVerifyJourneySegmentsDestroyed, }) } diff --git a/genesyscloud/resource_genesyscloud_widget_deployment.go b/genesyscloud/resource_genesyscloud_widget_deployment.go index 7923e2562..261d8bd8c 100644 --- a/genesyscloud/resource_genesyscloud_widget_deployment.go +++ b/genesyscloud/resource_genesyscloud_widget_deployment.go @@ -75,7 +75,7 @@ func WidgetDeploymentExporter() *resourceExporter.ResourceExporter { func ResourceWidgetDeployment() *schema.Resource { return &schema.Resource{ - Description: "Genesys Cloud Widget Deployment", + Description: "[DEPRECATED] Genesys Cloud Widget Deployment", DeprecationMessage: "The CX as Code team will be removing the genesyscloud_widget_deployment resource and data source from the CX as Code Terraform provider in mid-April. If you are using these resources you must upgrade your CX as Code provider version after mid-April and before mid-June, you will experience errors in your CI/CD pipelines and CX as Code exports with the removal of /api/v2/widgets/deployments APIs.", CreateContext: provider.CreateWithPooledClient(createWidgetDeployment), ReadContext: provider.ReadWithPooledClient(readWidgetDeployment), diff --git a/genesyscloud/responsemanagement_responseasset/resource_genesyscloud_responsemanagement_responseasset_utils.go b/genesyscloud/responsemanagement_responseasset/resource_genesyscloud_responsemanagement_responseasset_utils.go index be36f15d0..f04eeb8eb 100644 --- a/genesyscloud/responsemanagement_responseasset/resource_genesyscloud_responsemanagement_responseasset_utils.go +++ b/genesyscloud/responsemanagement_responseasset/resource_genesyscloud_responsemanagement_responseasset_utils.go @@ -32,7 +32,7 @@ func responsemanagementResponseassetResolver(responseAssetId, exportDirectory, s fileName := fmt.Sprintf("%s-%s%s", baseName, responseAssetId, filepath.Ext(*data.Name)) exportFilename := filepath.Join(subDirectory, fileName) - if err := files.DownloadExportFile(fullPath, fileName, *data.ContentLocation); err != nil { + if _, err := files.DownloadExportFile(fullPath, fileName, *data.ContentLocation); err != nil { return err } configMap["filename"] = exportFilename diff --git a/genesyscloud/scripts/genesyscloud_scripts_proxy.go b/genesyscloud/scripts/genesyscloud_scripts_proxy.go index 1caff52e3..c8de2865e 100644 --- a/genesyscloud/scripts/genesyscloud_scripts_proxy.go +++ b/genesyscloud/scripts/genesyscloud_scripts_proxy.go @@ -318,7 +318,7 @@ func verifyScriptUploadSuccessFn(ctx context.Context, p *scriptsProxy, body []by return false, nil } -// getUploadIdFromBody retrieves the upload Id from the json file being uploade +// getUploadIdFromBody retrieves the upload Id from the json file being uploaded func (p *scriptsProxy) getUploadIdFromBody(body []byte) (string, error) { var ( jsonData interface{} diff --git a/genesyscloud/scripts/resource_genesyscloud_script_utils.go b/genesyscloud/scripts/resource_genesyscloud_script_utils.go index f2010f4f0..2a1a561b8 100644 --- a/genesyscloud/scripts/resource_genesyscloud_script_utils.go +++ b/genesyscloud/scripts/resource_genesyscloud_script_utils.go @@ -29,7 +29,7 @@ func ScriptResolver(scriptId, exportDirectory, subDirectory string, configMap ma return err } - if err := files.DownloadExportFile(fullPath, exportFileName, url); err != nil { + if _, err := files.DownloadExportFile(fullPath, exportFileName, url); err != nil { return err } diff --git a/genesyscloud/tfexporter/resource_genesyscloud_tf_export_test.go b/genesyscloud/tfexporter/resource_genesyscloud_tf_export_test.go index 28f30bdf0..e0837a6b7 100644 --- a/genesyscloud/tfexporter/resource_genesyscloud_tf_export_test.go +++ b/genesyscloud/tfexporter/resource_genesyscloud_tf_export_test.go @@ -80,34 +80,33 @@ func TestAccResourceTfExportIncludeFilterResourcesByRegEx(t *testing.T) { var ( exportTestDir = testrunner.GetTestTempPath(".terraformregex" + uuid.NewString()) exportResourceLabel = "test-export3" - - queueResources = []QueueExport{ - {OriginalResourceLabel: "test-queue-prod-1", ExportedLabel: "test-queue-" + uuid.NewString() + "-prod", Description: "This is a test prod queue 1", AcwTimeoutMs: 200000}, - {OriginalResourceLabel: "test-queue-prod-2", ExportedLabel: "test-queue-" + uuid.NewString() + "-prod", Description: "This is a test prod queue 2", AcwTimeoutMs: 200000}, - {OriginalResourceLabel: "test-queue-prod-3", ExportedLabel: "test-queue-" + uuid.NewString() + "-prod", Description: "This is a test prod queue 3", AcwTimeoutMs: 200000}, - {OriginalResourceLabel: "test-queue-test-4", ExportedLabel: "test-queue-" + uuid.NewString() + "-test", Description: "This is a test prod queue 4", AcwTimeoutMs: 200000}, + uniquePostfix = randString(7) + queueResources = []QueueExport{ + {OriginalResourceLabel: "test-queue-prod-1", ExportedLabel: "test-queue-" + uuid.NewString() + "-prod-" + uniquePostfix, Description: "This is a test prod queue 1", AcwTimeoutMs: 200000}, + {OriginalResourceLabel: "test-queue-prod-2", ExportedLabel: "test-queue-" + uuid.NewString() + "-prod-" + uniquePostfix, Description: "This is a test prod queue 2", AcwTimeoutMs: 200000}, + {OriginalResourceLabel: "test-queue-prod-3", ExportedLabel: "test-queue-" + uuid.NewString() + "-prod-" + uniquePostfix, Description: "This is a test prod queue 3", AcwTimeoutMs: 200000}, + {OriginalResourceLabel: "test-queue-test-4", ExportedLabel: "test-queue-" + uuid.NewString() + "-prod-" + uniquePostfix, Description: "This is a test prod queue 4", AcwTimeoutMs: 200000}, } ) defer os.RemoveAll(exportTestDir) queueResourceDef := buildQueueResources(queueResources) - config := queueResourceDef + - generateTfExportByIncludeFilterResources( - exportResourceLabel, - exportTestDir, - util.TrueValue, - []string{ - strconv.Quote("genesyscloud_routing_queue::-prod"), - }, - util.FalseValue, - util.FalseValue, - []string{ - strconv.Quote("genesyscloud_routing_queue." + queueResources[0].OriginalResourceLabel), - strconv.Quote("genesyscloud_routing_queue." + queueResources[1].OriginalResourceLabel), - strconv.Quote("genesyscloud_routing_queue." + queueResources[2].OriginalResourceLabel), - strconv.Quote("genesyscloud_routing_queue." + queueResources[3].OriginalResourceLabel), - }, - ) + baseConfig := queueResourceDef + configWithExporter := baseConfig + generateTfExportByIncludeFilterResources( + exportResourceLabel, + exportTestDir, + util.TrueValue, + []string{ + strconv.Quote("genesyscloud_routing_queue::.*-prod"), + }, + util.FalseValue, + util.FalseValue, + []string{ + strconv.Quote("genesyscloud_routing_queue." + queueResources[0].OriginalResourceLabel), + strconv.Quote("genesyscloud_routing_queue." + queueResources[1].OriginalResourceLabel), + strconv.Quote("genesyscloud_routing_queue." + queueResources[2].OriginalResourceLabel), + }, + ) sanitizer := resourceExporter.NewSanitizerProvider() @@ -116,9 +115,12 @@ func TestAccResourceTfExportIncludeFilterResourcesByRegEx(t *testing.T) { ProviderFactories: provider.GetProviderFactories(providerResources, providerDataSources), Steps: []resource.TestStep{ { - // Generate a queue as well and export it - Config: config, - // Wait for a specified duration to avoid runtime error + // Generate a queue + Config: baseConfig, + }, + { + // Now, export it + Config: configWithExporter, Check: resource.ComposeTestCheckFunc( testQueueExportEqual(exportTestDir+"/"+defaultTfJSONFile, "genesyscloud_routing_queue", sanitizer.S.SanitizeResourceBlockLabel(queueResources[0].ExportedLabel), queueResources[0]), testQueueExportEqual(exportTestDir+"/"+defaultTfJSONFile, "genesyscloud_routing_queue", sanitizer.S.SanitizeResourceBlockLabel(queueResources[1].ExportedLabel), queueResources[1]), @@ -137,34 +139,34 @@ func TestAccResourceTfExportIncludeFilterResourcesByRegExAndSanitizedLabels(t *t var ( exportTestDir = testrunner.GetTestTempPath(".terraformregex" + uuid.NewString()) exportResourceLabel = "test-export3_1" - - queueResources = []QueueExport{ - {OriginalResourceLabel: "test-queue-test-1", ExportedLabel: "include filter test - exclude me", Description: "This is an excluded bar test resource", AcwTimeoutMs: 200000}, - {OriginalResourceLabel: "test-queue-test-2", ExportedLabel: "include filter test - foo - bar me", Description: "This is a foo bar test resource", AcwTimeoutMs: 200000}, - {OriginalResourceLabel: "test-queue-test-3", ExportedLabel: "include filter test - fu - barre you", Description: "This is a foo bar test resource", AcwTimeoutMs: 200000}, + uniquePostfix = randString(7) + queueResources = []QueueExport{ + {OriginalResourceLabel: "test-queue-test-1", ExportedLabel: "include filter test - exclude me" + uuid.NewString() + uniquePostfix, Description: "This is an excluded bar test resource", AcwTimeoutMs: 200000}, + {OriginalResourceLabel: "test-queue-test-2", ExportedLabel: "include filter test - foo - bar me" + uuid.NewString() + uniquePostfix, Description: "This is a foo bar test resource", AcwTimeoutMs: 200000}, + {OriginalResourceLabel: "test-queue-test-3", ExportedLabel: "include filter test - fu - barre you" + uuid.NewString() + uniquePostfix, Description: "This is a foo bar test resource", AcwTimeoutMs: 200000}, } ) defer os.RemoveAll(exportTestDir) queueResourceDef := buildQueueResources(queueResources) - config := queueResourceDef + - generateTfExportByIncludeFilterResources( - exportResourceLabel, - exportTestDir, - util.TrueValue, - []string{ - strconv.Quote("genesyscloud_routing_queue::include filter test - foo - bar me"), // Unsanitized Label Resource - strconv.Quote("genesyscloud_routing_queue::include_filter_test_-_fu_-_barre_you"), // Sanitized Label Resource - }, - util.FalseValue, - util.FalseValue, - []string{ - strconv.Quote("genesyscloud_routing_queue." + queueResources[0].OriginalResourceLabel), - strconv.Quote("genesyscloud_routing_queue." + queueResources[1].OriginalResourceLabel), - strconv.Quote("genesyscloud_routing_queue." + queueResources[2].OriginalResourceLabel), - }, - ) + baseConfig := queueResourceDef + configWithExporter := baseConfig + generateTfExportByIncludeFilterResources( + exportResourceLabel, + exportTestDir, + util.TrueValue, + []string{ + strconv.Quote("genesyscloud_routing_queue::include filter test - foo - bar me"), // Unsanitized Label Resource + strconv.Quote("genesyscloud_routing_queue::include_filter_test_-_fu_-_barre_you"), // Sanitized Label Resource + }, + util.FalseValue, + util.FalseValue, + []string{ + strconv.Quote("genesyscloud_routing_queue." + queueResources[0].OriginalResourceLabel), + strconv.Quote("genesyscloud_routing_queue." + queueResources[1].OriginalResourceLabel), + strconv.Quote("genesyscloud_routing_queue." + queueResources[2].OriginalResourceLabel), + }, + ) sanitizer := resourceExporter.NewSanitizerProvider() @@ -173,9 +175,12 @@ func TestAccResourceTfExportIncludeFilterResourcesByRegExAndSanitizedLabels(t *t ProviderFactories: provider.GetProviderFactories(providerResources, providerDataSources), Steps: []resource.TestStep{ { - // Generate a queue as well and export it - Config: config, - // Wait for a specified duration to avoid runtime error + // Generate a queue + Config: baseConfig, + }, + { + // Export the queue + Config: configWithExporter, Check: resource.ComposeTestCheckFunc( testQueueExportEqual(exportTestDir+"/"+defaultTfJSONFile, "genesyscloud_routing_queue", sanitizer.S.SanitizeResourceBlockLabel(queueResources[1].ExportedLabel), queueResources[1]), testQueueExportEqual(exportTestDir+"/"+defaultTfJSONFile, "genesyscloud_routing_queue", sanitizer.S.SanitizeResourceBlockLabel(queueResources[2].ExportedLabel), queueResources[2]), @@ -198,15 +203,16 @@ func TestAccResourceTfExportIncludeFilterResourcesByRegExExclusiveToResource(t * var ( exportTestDir = testrunner.GetTestTempPath(".terraformInclude" + uuid.NewString()) exportResourceLabel = "test-export4" + uniquePostfix = randString(7) queueResources = []QueueExport{ - {OriginalResourceLabel: "test-queue-prod", ExportedLabel: "test-queue-" + uuid.NewString() + "-prod", Description: "This is the prod queue", AcwTimeoutMs: 200000}, - {OriginalResourceLabel: "test-queue-test", ExportedLabel: "test-queue-" + uuid.NewString() + "-test", Description: "This is the test queue", AcwTimeoutMs: 200000}, + {OriginalResourceLabel: "test-queue-prod", ExportedLabel: "test-queue-" + uuid.NewString() + "-prod-" + uniquePostfix, Description: "This is the prod queue", AcwTimeoutMs: 200000}, + {OriginalResourceLabel: "test-queue-test", ExportedLabel: "test-queue-" + uuid.NewString() + "-test-" + uniquePostfix, Description: "This is the test queue", AcwTimeoutMs: 200000}, } wrapupCodeResources = []WrapupcodeExport{ - {OriginalResourceLabel: "test-wrapupcode-prod", Name: "test-wrapupcode-" + uuid.NewString() + "-prod"}, - {OriginalResourceLabel: "test-wrapupcode-test", Name: "test-wrapupcode-" + uuid.NewString() + "-test"}, + {OriginalResourceLabel: "test-wrapupcode-prod", Name: "test-wrapupcode-" + uuid.NewString() + "-prod-" + uniquePostfix}, + {OriginalResourceLabel: "test-wrapupcode-test", Name: "test-wrapupcode-" + uuid.NewString() + "-test-" + uniquePostfix}, } divResourceLabel = "test-division" divName = "terraform-" + uuid.NewString() @@ -215,24 +221,24 @@ func TestAccResourceTfExportIncludeFilterResourcesByRegExExclusiveToResource(t * queueResourceDef := buildQueueResources(queueResources) wrapupcodeResourceDef := buildWrapupcodeResources(wrapupCodeResources, "genesyscloud_auth_division."+divResourceLabel+".id") - config := queueResourceDef + authDivision.GenerateAuthDivisionBasic(divResourceLabel, divName) + wrapupcodeResourceDef + - generateTfExportByIncludeFilterResources( - exportResourceLabel, - exportTestDir, - util.TrueValue, - []string{ - strconv.Quote("genesyscloud_routing_queue::-prod$"), - strconv.Quote("genesyscloud_routing_wrapupcode"), - }, - util.FalseValue, - util.FalseValue, - []string{ - strconv.Quote("genesyscloud_routing_queue." + queueResources[0].OriginalResourceLabel), - strconv.Quote("genesyscloud_routing_queue." + queueResources[1].OriginalResourceLabel), - strconv.Quote("genesyscloud_routing_wrapupcode." + wrapupCodeResources[0].OriginalResourceLabel), - strconv.Quote("genesyscloud_routing_wrapupcode." + wrapupCodeResources[1].OriginalResourceLabel), - }, - ) + baseConfig := queueResourceDef + authDivision.GenerateAuthDivisionBasic(divResourceLabel, divName) + wrapupcodeResourceDef + configWithExporter := baseConfig + generateTfExportByIncludeFilterResources( + exportResourceLabel, + exportTestDir, + util.TrueValue, + []string{ + strconv.Quote("genesyscloud_routing_queue::.*-prod"), + strconv.Quote("genesyscloud_routing_wrapupcode"), + }, + util.FalseValue, + util.FalseValue, + []string{ + strconv.Quote("genesyscloud_routing_queue." + queueResources[0].OriginalResourceLabel), + strconv.Quote("genesyscloud_routing_queue." + queueResources[1].OriginalResourceLabel), + strconv.Quote("genesyscloud_routing_wrapupcode." + wrapupCodeResources[0].OriginalResourceLabel), + strconv.Quote("genesyscloud_routing_wrapupcode." + wrapupCodeResources[1].OriginalResourceLabel), + }, + ) sanitizer := resourceExporter.NewSanitizerProvider() @@ -241,13 +247,17 @@ func TestAccResourceTfExportIncludeFilterResourcesByRegExExclusiveToResource(t * ProviderFactories: provider.GetProviderFactories(providerResources, providerDataSources), Steps: []resource.TestStep{ { - // Generate a queue as well and export it - Config: config, + // Generate a queue + Config: baseConfig, + }, + { + // Export the queue + Config: configWithExporter, Check: resource.ComposeTestCheckFunc( testQueueExportEqual(exportTestDir+"/"+defaultTfJSONFile, "genesyscloud_routing_queue", sanitizer.S.SanitizeResourceBlockLabel(queueResources[0].ExportedLabel), queueResources[0]), testWrapupcodeExportEqual(exportTestDir+"/"+defaultTfJSONFile, "genesyscloud_routing_wrapupcode", sanitizer.S.SanitizeResourceBlockLabel(wrapupCodeResources[0].Name), wrapupCodeResources[0]), testWrapupcodeExportEqual(exportTestDir+"/"+defaultTfJSONFile, "genesyscloud_routing_wrapupcode", sanitizer.S.SanitizeResourceBlockLabel(wrapupCodeResources[1].Name), wrapupCodeResources[1]), - testQueueExportMatchesRegEx(exportTestDir+"/"+defaultTfJSONFile, "genesyscloud_routing_queue", "-prod$"), //We should not find any "test" queues here because we only wanted to include queues that ended with a -prod + testQueueExportMatchesRegEx(exportTestDir+"/"+defaultTfJSONFile, "genesyscloud_routing_queue", ".*-prod"), //We should not find any "test" queues here because we only wanted to include queues that ended with a -prod ), }, }, @@ -263,17 +273,17 @@ func TestAccResourceTfExportExcludeFilterResourcesByRegExExclusiveToResource(t * var ( exportTestDir = testrunner.GetTestTempPath(".terraformExclude" + uuid.NewString()) exportResourceLabel = "test-export6" - - queueResources = []QueueExport{ - {OriginalResourceLabel: "test-queue-prod", ExportedLabel: "test-queue-" + uuid.NewString() + "-prod", Description: "This is a test prod queue", AcwTimeoutMs: 200000}, - {OriginalResourceLabel: "test-queue-test", ExportedLabel: "test-queue-" + uuid.NewString() + "-test", Description: "This is a test queue", AcwTimeoutMs: 200000}, - {OriginalResourceLabel: "test-queue-dev", ExportedLabel: "test-queue-" + uuid.NewString() + "-dev", Description: "This is a dev queue", AcwTimeoutMs: 200000}, + uniquePostfix = randString(7) + queueResources = []QueueExport{ + {OriginalResourceLabel: "test-queue-prod", ExportedLabel: "test-queue-" + uuid.NewString() + "-prod-" + uniquePostfix, Description: "This is a test prod queue", AcwTimeoutMs: 200000}, + {OriginalResourceLabel: "test-queue-test", ExportedLabel: "test-queue-" + uuid.NewString() + "-test-" + uniquePostfix, Description: "This is a test queue", AcwTimeoutMs: 200000}, + {OriginalResourceLabel: "test-queue-dev", ExportedLabel: "test-queue-" + uuid.NewString() + "-dev-" + uniquePostfix, Description: "This is a dev queue", AcwTimeoutMs: 200000}, } wrapupCodeResources = []WrapupcodeExport{ - {OriginalResourceLabel: "test-wrapupcode-prod", Name: "test-wrapupcode-" + uuid.NewString() + "-prod"}, - {OriginalResourceLabel: "test-wrapupcode-test", Name: "test-wrapupcode-" + uuid.NewString() + "-test"}, - {OriginalResourceLabel: "test-wrapupcode-dev", Name: "test-wrapupcode-" + uuid.NewString() + "-dev"}, + {OriginalResourceLabel: "test-wrapupcode-prod", Name: "test-wrapupcode-" + uuid.NewString() + "-prod-" + uniquePostfix}, + {OriginalResourceLabel: "test-wrapupcode-test", Name: "test-wrapupcode-" + uuid.NewString() + "-test-" + uniquePostfix}, + {OriginalResourceLabel: "test-wrapupcode-dev", Name: "test-wrapupcode-" + uuid.NewString() + "-dev-" + uniquePostfix}, } divResourceLabel = "test-division" divName = "terraform-" + uuid.NewString() @@ -282,29 +292,29 @@ func TestAccResourceTfExportExcludeFilterResourcesByRegExExclusiveToResource(t * queueResourceDef := buildQueueResources(queueResources) wrapupcodeResourceDef := buildWrapupcodeResources(wrapupCodeResources, "genesyscloud_auth_division."+divResourceLabel+".id") - config := queueResourceDef + authDivision.GenerateAuthDivisionBasic(divResourceLabel, divName) + wrapupcodeResourceDef + - generateTfExportByExcludeFilterResources( - exportResourceLabel, - exportTestDir, - util.TrueValue, - []string{ - strconv.Quote("genesyscloud_routing_queue::-(dev|test)$"), - strconv.Quote("genesyscloud_outbound_ruleset"), - strconv.Quote("genesyscloud_user"), - strconv.Quote("genesyscloud_user_roles"), - strconv.Quote("genesyscloud_flow"), - }, - util.FalseValue, - util.FalseValue, - []string{ - strconv.Quote("genesyscloud_routing_queue." + queueResources[0].OriginalResourceLabel), - strconv.Quote("genesyscloud_routing_queue." + queueResources[1].OriginalResourceLabel), - strconv.Quote("genesyscloud_routing_queue." + queueResources[2].OriginalResourceLabel), - strconv.Quote("genesyscloud_routing_wrapupcode." + wrapupCodeResources[0].OriginalResourceLabel), - strconv.Quote("genesyscloud_routing_wrapupcode." + wrapupCodeResources[1].OriginalResourceLabel), - strconv.Quote("genesyscloud_routing_wrapupcode." + wrapupCodeResources[2].OriginalResourceLabel), - }, - ) + baseConfig := queueResourceDef + authDivision.GenerateAuthDivisionBasic(divResourceLabel, divName) + wrapupcodeResourceDef + configWithExporter := baseConfig + generateTfExportByExcludeFilterResources( + exportResourceLabel, + exportTestDir, + util.TrueValue, + []string{ + strconv.Quote("genesyscloud_routing_queue::.*-(dev|test)"), + strconv.Quote("genesyscloud_outbound_ruleset"), + strconv.Quote("genesyscloud_user"), + strconv.Quote("genesyscloud_user_roles"), + strconv.Quote("genesyscloud_flow"), + }, + util.FalseValue, + util.FalseValue, + []string{ + strconv.Quote("genesyscloud_routing_queue." + queueResources[0].OriginalResourceLabel), + strconv.Quote("genesyscloud_routing_queue." + queueResources[1].OriginalResourceLabel), + strconv.Quote("genesyscloud_routing_queue." + queueResources[2].OriginalResourceLabel), + strconv.Quote("genesyscloud_routing_wrapupcode." + wrapupCodeResources[0].OriginalResourceLabel), + strconv.Quote("genesyscloud_routing_wrapupcode." + wrapupCodeResources[1].OriginalResourceLabel), + strconv.Quote("genesyscloud_routing_wrapupcode." + wrapupCodeResources[2].OriginalResourceLabel), + }, + ) sanitizer := resourceExporter.NewSanitizerProvider() resource.Test(t, resource.TestCase{ @@ -312,14 +322,18 @@ func TestAccResourceTfExportExcludeFilterResourcesByRegExExclusiveToResource(t * ProviderFactories: provider.GetProviderFactories(providerResources, providerDataSources), Steps: []resource.TestStep{ { - // Generate a queue as well and export it - Config: config, + // Generate a queue with wrapup codes + Config: baseConfig, + }, + { + // Generate a queue with wrapup codes and exporter + Config: configWithExporter, Check: resource.ComposeTestCheckFunc( testQueueExportEqual(exportTestDir+"/"+defaultTfJSONFile, "genesyscloud_routing_queue", sanitizer.S.SanitizeResourceBlockLabel(queueResources[0].ExportedLabel), queueResources[0]), testWrapupcodeExportEqual(exportTestDir+"/"+defaultTfJSONFile, "genesyscloud_routing_wrapupcode", sanitizer.S.SanitizeResourceBlockLabel(wrapupCodeResources[0].Name), wrapupCodeResources[0]), testWrapupcodeExportEqual(exportTestDir+"/"+defaultTfJSONFile, "genesyscloud_routing_wrapupcode", sanitizer.S.SanitizeResourceBlockLabel(wrapupCodeResources[1].Name), wrapupCodeResources[1]), testWrapupcodeExportEqual(exportTestDir+"/"+defaultTfJSONFile, "genesyscloud_routing_wrapupcode", sanitizer.S.SanitizeResourceBlockLabel(wrapupCodeResources[2].Name), wrapupCodeResources[2]), - testQueueExportExcludesRegEx(exportTestDir+"/"+defaultTfJSONFile, "genesyscloud_routing_queue", "-(dev|test)$"), + testQueueExportExcludesRegEx(exportTestDir+"/"+defaultTfJSONFile, "genesyscloud_routing_queue", ".*-(dev|test)"), ), }, }, @@ -347,8 +361,8 @@ func TestAccResourceTfExportSplitFilesAsJSON(t *testing.T) { } userResources = []UserExport{ - {OriginalResourceLabel: "test-user-1", ExportedLabel: "test-user-1", Email: "test-user-1" + uuid.NewString() + "@test.com" + uniquePostfix, State: "active"}, - {OriginalResourceLabel: "test-user-2", ExportedLabel: "test-user-2", Email: "test-user-2" + uuid.NewString() + "@test.com" + uniquePostfix, State: "active"}, + {OriginalResourceLabel: "test-user-1", ExportedLabel: "test-user-1" + uuid.NewString() + uniquePostfix, Email: "test-user-1" + uuid.NewString() + "@test.com" + uniquePostfix, State: "active"}, + {OriginalResourceLabel: "test-user-2", ExportedLabel: "test-user-2" + uuid.NewString() + uniquePostfix, Email: "test-user-2" + uuid.NewString() + "@test.com" + uniquePostfix, State: "active"}, } wrapupCodeResources = []WrapupcodeExport{ @@ -364,34 +378,37 @@ func TestAccResourceTfExportSplitFilesAsJSON(t *testing.T) { queueResourceDef := buildQueueResources(queueResources) userResourcesDef := buildUserResources(userResources) wrapupcodeResourceDef := buildWrapupcodeResources(wrapupCodeResources, "genesyscloud_auth_division."+divResourceLabel+".id") - config := queueResourceDef + authDivision.GenerateAuthDivisionBasic(divResourceLabel, divName) + wrapupcodeResourceDef + userResourcesDef + - generateTfExportByIncludeFilterResources( - exportResourceLabel, - exportTestDir, - util.TrueValue, - []string{ - strconv.Quote("genesyscloud_routing_queue::" + uniquePostfix + "$"), - strconv.Quote("genesyscloud_user::" + uniquePostfix + "$"), - strconv.Quote("genesyscloud_routing_wrapupcode::" + uniquePostfix + "$"), - }, - util.FalseValue, - util.TrueValue, - []string{ - strconv.Quote("genesyscloud_routing_queue." + queueResources[0].OriginalResourceLabel), - strconv.Quote("genesyscloud_routing_queue." + queueResources[1].OriginalResourceLabel), - strconv.Quote("genesyscloud_user." + userResources[0].OriginalResourceLabel), - strconv.Quote("genesyscloud_user." + userResources[1].OriginalResourceLabel), - strconv.Quote("genesyscloud_routing_wrapupcode." + wrapupCodeResources[0].OriginalResourceLabel), - strconv.Quote("genesyscloud_routing_wrapupcode." + wrapupCodeResources[1].OriginalResourceLabel), - }, - ) + baseConfig := queueResourceDef + authDivision.GenerateAuthDivisionBasic(divResourceLabel, divName) + wrapupcodeResourceDef + userResourcesDef + configWithExporter := baseConfig + generateTfExportByIncludeFilterResources( + exportResourceLabel, + exportTestDir, + util.TrueValue, + []string{ + strconv.Quote("genesyscloud_routing_queue::" + uniquePostfix + "$"), + strconv.Quote("genesyscloud_user::" + uniquePostfix + "$"), + strconv.Quote("genesyscloud_routing_wrapupcode::" + uniquePostfix + "$"), + }, + util.FalseValue, + util.TrueValue, + []string{ + strconv.Quote("genesyscloud_routing_queue." + queueResources[0].OriginalResourceLabel), + strconv.Quote("genesyscloud_routing_queue." + queueResources[1].OriginalResourceLabel), + strconv.Quote("genesyscloud_user." + userResources[0].OriginalResourceLabel), + strconv.Quote("genesyscloud_user." + userResources[1].OriginalResourceLabel), + strconv.Quote("genesyscloud_routing_wrapupcode." + wrapupCodeResources[0].OriginalResourceLabel), + strconv.Quote("genesyscloud_routing_wrapupcode." + wrapupCodeResources[1].OriginalResourceLabel), + }, + ) resource.Test(t, resource.TestCase{ PreCheck: func() { util.TestAccPreCheck(t) }, ProviderFactories: provider.GetProviderFactories(providerResources, providerDataSources), Steps: []resource.TestStep{ { - Config: config, + Config: baseConfig, + }, + { + Config: configWithExporter, Check: resource.ComposeTestCheckFunc( validateFileCreated(expectedFilesPath[0]), validateFileCreated(expectedFilesPath[1]), @@ -426,34 +443,39 @@ func TestAccResourceTfExportExcludeFilterResourcesByRegExExclusiveToResourceAndS divResourceLabel = "test-division" divName = "terraform-" + uuid.NewString() ) - defer os.RemoveAll(exportTestDir) + cleanupFunc := func() { + if err := os.RemoveAll(exportTestDir); err != nil { + t.Logf("Error while cleaning up %v", err) + } + } + t.Cleanup(cleanupFunc) queueResourceDef := buildQueueResources(queueResources) wrapupcodeResourceDef := buildWrapupcodeResources(wrapupCodeResources, "genesyscloud_auth_division."+divResourceLabel+".id") - config := queueResourceDef + authDivision.GenerateAuthDivisionBasic(divResourceLabel, divName) + wrapupcodeResourceDef + - generateTfExportByExcludeFilterResources( - exportResourceLabel, - exportTestDir, - util.TrueValue, - []string{ - strconv.Quote("genesyscloud_routing_queue::exclude filter - foo - bar me"), - strconv.Quote("genesyscloud_routing_queue::exclude_filter_-_fu_-_barre_you"), - strconv.Quote("genesyscloud_outbound_ruleset"), - strconv.Quote("genesyscloud_user"), - strconv.Quote("genesyscloud_user_roles"), - strconv.Quote("genesyscloud_flow"), - }, - util.FalseValue, - util.FalseValue, - []string{ - strconv.Quote("genesyscloud_routing_queue." + queueResources[0].OriginalResourceLabel), - strconv.Quote("genesyscloud_routing_queue." + queueResources[1].OriginalResourceLabel), - strconv.Quote("genesyscloud_routing_queue." + queueResources[2].OriginalResourceLabel), - strconv.Quote("genesyscloud_routing_wrapupcode." + wrapupCodeResources[0].OriginalResourceLabel), - strconv.Quote("genesyscloud_routing_wrapupcode." + wrapupCodeResources[1].OriginalResourceLabel), - strconv.Quote("genesyscloud_routing_wrapupcode." + wrapupCodeResources[2].OriginalResourceLabel), - }, - ) + baseConfig := queueResourceDef + authDivision.GenerateAuthDivisionBasic(divResourceLabel, divName) + wrapupcodeResourceDef + configWithExporter := baseConfig + generateTfExportByExcludeFilterResources( + exportResourceLabel, + exportTestDir, + util.TrueValue, + []string{ + strconv.Quote("genesyscloud_routing_queue::exclude filter - foo - bar me"), + strconv.Quote("genesyscloud_routing_queue::exclude_filter_-_fu_-_barre_you"), + strconv.Quote("genesyscloud_outbound_ruleset"), + strconv.Quote("genesyscloud_user"), + strconv.Quote("genesyscloud_user_roles"), + strconv.Quote("genesyscloud_flow"), + }, + util.FalseValue, + util.FalseValue, + []string{ + strconv.Quote("genesyscloud_routing_queue." + queueResources[0].OriginalResourceLabel), + strconv.Quote("genesyscloud_routing_queue." + queueResources[1].OriginalResourceLabel), + strconv.Quote("genesyscloud_routing_queue." + queueResources[2].OriginalResourceLabel), + strconv.Quote("genesyscloud_routing_wrapupcode." + wrapupCodeResources[0].OriginalResourceLabel), + strconv.Quote("genesyscloud_routing_wrapupcode." + wrapupCodeResources[1].OriginalResourceLabel), + strconv.Quote("genesyscloud_routing_wrapupcode." + wrapupCodeResources[2].OriginalResourceLabel), + }, + ) sanitizer := resourceExporter.NewSanitizerProvider() resource.Test(t, resource.TestCase{ @@ -461,8 +483,12 @@ func TestAccResourceTfExportExcludeFilterResourcesByRegExExclusiveToResourceAndS ProviderFactories: provider.GetProviderFactories(providerResources, providerDataSources), Steps: []resource.TestStep{ { - // Generate a queue as well and export it - Config: config, + // Generate a queue + Config: baseConfig, + }, + { + // Now export it + Config: configWithExporter, Check: resource.ComposeTestCheckFunc( testQueueExportEqual(exportTestDir+"/"+defaultTfJSONFile, "genesyscloud_routing_queue", sanitizer.S.SanitizeResourceBlockLabel(queueResources[0].ExportedLabel), queueResources[0]), testWrapupcodeExportEqual(exportTestDir+"/"+defaultTfJSONFile, "genesyscloud_routing_wrapupcode", sanitizer.S.SanitizeResourceBlockLabel(wrapupCodeResources[0].Name), wrapupCodeResources[0]), @@ -482,21 +508,31 @@ func TestAccResourceTfExportForCompress(t *testing.T) { exportTestDir = testrunner.GetTestTempPath(".terraform" + uuid.NewString()) exportResourceLabel1 = "test-export1" zipFileName = "../archive_genesyscloud_tf_export*" + divResourceLabel = "test-division" + divName = "terraform-" + uuid.NewString() ) + baseConfig := authDivision.GenerateAuthDivisionBasic(divResourceLabel, divName) defer os.RemoveAll(exportTestDir) resource.Test(t, resource.TestCase{ PreCheck: func() { util.TestAccPreCheck(t) }, ProviderFactories: provider.GetProviderFactories(providerResources, providerDataSources), Steps: []resource.TestStep{ + // Create basic object + { + Config: baseConfig, + }, { // Run export without state file - Config: generateTfExportResourceForCompress( + Config: baseConfig + generateTfExportResourceForCompress( exportResourceLabel1, exportTestDir, util.TrueValue, util.TrueValue, + []string{ + strconv.Quote(authDivision.ResourceType), + }, "", ), Check: resource.ComposeTestCheckFunc( @@ -616,7 +652,7 @@ func TestAccResourceTfExportByLabel(t *testing.T) { ProviderFactories: provider.GetProviderFactories(providerResources, providerDataSources), Steps: []resource.TestStep{ { - // Generate a user and export it + // Generate a user Config: user.GenerateBasicUserResource( userResourceLabel1, userEmail1, @@ -624,7 +660,7 @@ func TestAccResourceTfExportByLabel(t *testing.T) { ), }, { - // Generate a user and export it + // Now export it Config: user.GenerateBasicUserResource( userResourceLabel1, userEmail1, @@ -817,35 +853,36 @@ func TestAccResourceTfExportIncludeFilterResourcesByType(t *testing.T) { var ( exportTestDir = testrunner.GetTestTempPath(".terraform" + uuid.NewString()) exportResourceLabel = "test-export2" + uniquePostfix = randString(7) ) queueResources := []QueueExport{ - {OriginalResourceLabel: "test-queue-prod-1", ExportedLabel: "test-queue-" + uuid.NewString() + "-prod", Description: "This is a test prod queue 1", AcwTimeoutMs: 200000}, - {OriginalResourceLabel: "test-queue-prod-2", ExportedLabel: "test-queue-" + uuid.NewString() + "-prod", Description: "This is a test prod queue 2", AcwTimeoutMs: 200000}, - {OriginalResourceLabel: "test-queue-prod-3", ExportedLabel: "test-queue-" + uuid.NewString() + "-prod", Description: "This is a test prod queue 3", AcwTimeoutMs: 200000}, - {OriginalResourceLabel: "test-queue-test-1", ExportedLabel: "test-queue-" + uuid.NewString() + "-test", Description: "This is a test prod queue 4", AcwTimeoutMs: 200000}, + {OriginalResourceLabel: "test-queue-prod-1", ExportedLabel: "test-queue-" + uuid.NewString() + "-prod" + uniquePostfix, Description: "This is a test prod queue 1", AcwTimeoutMs: 200000}, + {OriginalResourceLabel: "test-queue-prod-2", ExportedLabel: "test-queue-" + uuid.NewString() + "-prod" + uniquePostfix, Description: "This is a test prod queue 2", AcwTimeoutMs: 200000}, + {OriginalResourceLabel: "test-queue-prod-3", ExportedLabel: "test-queue-" + uuid.NewString() + "-prod" + uniquePostfix, Description: "This is a test prod queue 3", AcwTimeoutMs: 200000}, + {OriginalResourceLabel: "test-queue-test-1", ExportedLabel: "test-queue-" + uuid.NewString() + "-test" + uniquePostfix, Description: "This is a test prod queue 4", AcwTimeoutMs: 200000}, } defer os.RemoveAll(exportTestDir) queueResourceDef := buildQueueResources(queueResources) - config := queueResourceDef + - generateTfExportByIncludeFilterResources( - exportResourceLabel, - exportTestDir, - util.TrueValue, - []string{ - strconv.Quote("genesyscloud_routing_queue"), - }, - util.FalseValue, - util.FalseValue, - []string{ - strconv.Quote("genesyscloud_routing_queue." + queueResources[0].OriginalResourceLabel), - strconv.Quote("genesyscloud_routing_queue." + queueResources[1].OriginalResourceLabel), - strconv.Quote("genesyscloud_routing_queue." + queueResources[2].OriginalResourceLabel), - strconv.Quote("genesyscloud_routing_queue." + queueResources[3].OriginalResourceLabel), - }, - ) + baseConfig := queueResourceDef + configWithExporter := baseConfig + generateTfExportByIncludeFilterResources( + exportResourceLabel, + exportTestDir, + util.TrueValue, + []string{ + strconv.Quote("genesyscloud_routing_queue"), + }, + util.FalseValue, + util.FalseValue, + []string{ + strconv.Quote("genesyscloud_routing_queue." + queueResources[0].OriginalResourceLabel), + strconv.Quote("genesyscloud_routing_queue." + queueResources[1].OriginalResourceLabel), + strconv.Quote("genesyscloud_routing_queue." + queueResources[2].OriginalResourceLabel), + strconv.Quote("genesyscloud_routing_queue." + queueResources[3].OriginalResourceLabel), + }, + ) sanitizer := resourceExporter.NewSanitizerProvider() resource.Test(t, resource.TestCase{ @@ -853,12 +890,12 @@ func TestAccResourceTfExportIncludeFilterResourcesByType(t *testing.T) { ProviderFactories: provider.GetProviderFactories(providerResources, providerDataSources), Steps: []resource.TestStep{ { - // Generate a queue as well and export it - Config: config, - PreConfig: func() { - // Wait for a specified duration to avoid runtime error - time.Sleep(30 * time.Second) - }, + // Generate a queues + Config: baseConfig, + }, + { + // Now export them + Config: configWithExporter, Check: resource.ComposeTestCheckFunc( testQueueExportEqual(exportTestDir+"/"+defaultTfJSONFile, "genesyscloud_routing_queue", sanitizer.S.SanitizeResourceBlockLabel(queueResources[0].ExportedLabel), queueResources[0]), testQueueExportEqual(exportTestDir+"/"+defaultTfJSONFile, "genesyscloud_routing_queue", sanitizer.S.SanitizeResourceBlockLabel(queueResources[1].ExportedLabel), queueResources[1]), @@ -877,40 +914,41 @@ func TestAccResourceTfExportExcludeFilterResourcesByRegEx(t *testing.T) { var ( exportTestDir = testrunner.GetTestTempPath(".terraform" + uuid.NewString()) exportResourceLabel = "test-export5" + uniquePostfix = randString(7) queueResources = []QueueExport{ - {OriginalResourceLabel: "test-queue-prod-1", ExportedLabel: "test-queue-" + uuid.NewString() + "-prod", Description: "This is a test prod queue 1", AcwTimeoutMs: 200000}, - {OriginalResourceLabel: "test-queue-prod-2", ExportedLabel: "test-queue-" + uuid.NewString() + "-prod", Description: "This is a test prod queue 2", AcwTimeoutMs: 200000}, - {OriginalResourceLabel: "test-queue-prod-3", ExportedLabel: "test-queue-" + uuid.NewString() + "-prod", Description: "This is a test prod queue 3", AcwTimeoutMs: 200000}, - {OriginalResourceLabel: "test-queue-test-4", ExportedLabel: "test-queue-" + uuid.NewString() + "-test", Description: "This is a test queue 4", AcwTimeoutMs: 200000}, - {OriginalResourceLabel: "test-queue-dev-1", ExportedLabel: "test-queue-" + uuid.NewString() + "-dev", Description: "This is a dev queue 5", AcwTimeoutMs: 200000}, + {OriginalResourceLabel: "test-queue-prod-1", ExportedLabel: "test-queue-" + uuid.NewString() + "-prod-" + uniquePostfix, Description: "This is a test prod queue 1", AcwTimeoutMs: 200000}, + {OriginalResourceLabel: "test-queue-prod-2", ExportedLabel: "test-queue-" + uuid.NewString() + "-prod-" + uniquePostfix, Description: "This is a test prod queue 2", AcwTimeoutMs: 200000}, + {OriginalResourceLabel: "test-queue-prod-3", ExportedLabel: "test-queue-" + uuid.NewString() + "-prod-" + uniquePostfix, Description: "This is a test prod queue 3", AcwTimeoutMs: 200000}, + {OriginalResourceLabel: "test-queue-test-4", ExportedLabel: "test-queue-" + uuid.NewString() + "-test-" + uniquePostfix, Description: "This is a test queue 4", AcwTimeoutMs: 200000}, + {OriginalResourceLabel: "test-queue-dev-1", ExportedLabel: "test-queue-" + uuid.NewString() + "-dev-" + uniquePostfix, Description: "This is a dev queue 5", AcwTimeoutMs: 200000}, } ) defer os.RemoveAll(exportTestDir) queueResourceDef := buildQueueResources(queueResources) - config := queueResourceDef + - generateTfExportByExcludeFilterResources( - exportResourceLabel, - exportTestDir, - util.TrueValue, - []string{ - strconv.Quote("genesyscloud_routing_queue::-(dev|test)$"), - strconv.Quote("genesyscloud_outbound_ruleset"), - strconv.Quote("genesyscloud_user"), - strconv.Quote("genesyscloud_user_roles"), - strconv.Quote("genesyscloud_flow"), - }, - util.FalseValue, - util.FalseValue, - []string{ - strconv.Quote("genesyscloud_routing_queue." + queueResources[0].OriginalResourceLabel), - strconv.Quote("genesyscloud_routing_queue." + queueResources[1].OriginalResourceLabel), - strconv.Quote("genesyscloud_routing_queue." + queueResources[2].OriginalResourceLabel), - strconv.Quote("genesyscloud_routing_queue." + queueResources[3].OriginalResourceLabel), - strconv.Quote("genesyscloud_routing_queue." + queueResources[4].OriginalResourceLabel), - }, - ) + baseConfig := queueResourceDef + configWithExporter := baseConfig + generateTfExportByExcludeFilterResources( + exportResourceLabel, + exportTestDir, + util.TrueValue, + []string{ + strconv.Quote("genesyscloud_routing_queue::.*-(dev|test)"), + strconv.Quote("genesyscloud_outbound_ruleset"), + strconv.Quote("genesyscloud_user"), + strconv.Quote("genesyscloud_user_roles"), + strconv.Quote("genesyscloud_flow"), + }, + util.FalseValue, + util.FalseValue, + []string{ + strconv.Quote("genesyscloud_routing_queue." + queueResources[0].OriginalResourceLabel), + strconv.Quote("genesyscloud_routing_queue." + queueResources[1].OriginalResourceLabel), + strconv.Quote("genesyscloud_routing_queue." + queueResources[2].OriginalResourceLabel), + strconv.Quote("genesyscloud_routing_queue." + queueResources[3].OriginalResourceLabel), + strconv.Quote("genesyscloud_routing_queue." + queueResources[4].OriginalResourceLabel), + }, + ) sanitizer := resourceExporter.NewSanitizerProvider() @@ -919,17 +957,17 @@ func TestAccResourceTfExportExcludeFilterResourcesByRegEx(t *testing.T) { ProviderFactories: provider.GetProviderFactories(providerResources, providerDataSources), Steps: []resource.TestStep{ { - // Generate a queue as well and export it - Config: config, - PreConfig: func() { - // Wait for a specified duration to avoid runtime error - time.Sleep(30 * time.Second) - }, + // Generate a queue + Config: baseConfig, + }, + { + // Now export them all + Config: configWithExporter, Check: resource.ComposeTestCheckFunc( testQueueExportEqual(exportTestDir+"/"+defaultTfJSONFile, "genesyscloud_routing_queue", sanitizer.S.SanitizeResourceBlockLabel(queueResources[0].ExportedLabel), queueResources[0]), //Want to make sure the prod queues are queue is there testQueueExportEqual(exportTestDir+"/"+defaultTfJSONFile, "genesyscloud_routing_queue", sanitizer.S.SanitizeResourceBlockLabel(queueResources[1].ExportedLabel), queueResources[1]), //Want to make sure the prod queues are queue is there testQueueExportEqual(exportTestDir+"/"+defaultTfJSONFile, "genesyscloud_routing_queue", sanitizer.S.SanitizeResourceBlockLabel(queueResources[2].ExportedLabel), queueResources[2]), //Want to make sure the prod queues are queue is there - testQueueExportExcludesRegEx(exportTestDir+"/"+defaultTfJSONFile, "genesyscloud_routing_queue", "-(dev|test)$"), //We should not find any "dev or test" queues here because we only wanted to include queues that ended with a -prod + testQueueExportExcludesRegEx(exportTestDir+"/"+defaultTfJSONFile, "genesyscloud_routing_queue", ".*-(dev|test)"), //We should not find any "dev or test" queues here because we only wanted to include queues that ended with a -prod ), }, }, @@ -1485,34 +1523,37 @@ func TestAccResourceTfExportSplitFilesAsHCL(t *testing.T) { queueResourceDef := buildQueueResources(queueResources) userResourcesDef := buildUserResources(userResources) wrapupcodeResourceDef := buildWrapupcodeResources(wrapupCodeResources, "genesyscloud_auth_division."+divResourceLabel+".id") - config := queueResourceDef + authDivision.GenerateAuthDivisionBasic(divResourceLabel, divName) + wrapupcodeResourceDef + userResourcesDef + - generateTfExportByIncludeFilterResources( - exportResourceLabel, - exportTestDir, - util.TrueValue, - []string{ - strconv.Quote("genesyscloud_routing_queue::" + uniquePostfix + "$"), - strconv.Quote("genesyscloud_user::" + uniquePostfix + "$"), - strconv.Quote("genesyscloud_routing_wrapupcode::" + uniquePostfix + "$"), - }, - util.TrueValue, - util.TrueValue, - []string{ - strconv.Quote("genesyscloud_routing_queue." + queueResources[0].OriginalResourceLabel), - strconv.Quote("genesyscloud_routing_queue." + queueResources[1].OriginalResourceLabel), - strconv.Quote("genesyscloud_user." + userResources[0].OriginalResourceLabel), - strconv.Quote("genesyscloud_user." + userResources[1].OriginalResourceLabel), - strconv.Quote("genesyscloud_routing_wrapupcode." + wrapupCodeResources[0].OriginalResourceLabel), - strconv.Quote("genesyscloud_routing_wrapupcode." + wrapupCodeResources[1].OriginalResourceLabel), - }, - ) + baseConfig := queueResourceDef + authDivision.GenerateAuthDivisionBasic(divResourceLabel, divName) + wrapupcodeResourceDef + userResourcesDef + configWithExporter := baseConfig + generateTfExportByIncludeFilterResources( + exportResourceLabel, + exportTestDir, + util.TrueValue, + []string{ + strconv.Quote("genesyscloud_routing_queue::" + uniquePostfix + "$"), + strconv.Quote("genesyscloud_user::" + uniquePostfix + "$"), + strconv.Quote("genesyscloud_routing_wrapupcode::" + uniquePostfix + "$"), + }, + util.TrueValue, + util.TrueValue, + []string{ + strconv.Quote("genesyscloud_routing_queue." + queueResources[0].OriginalResourceLabel), + strconv.Quote("genesyscloud_routing_queue." + queueResources[1].OriginalResourceLabel), + strconv.Quote("genesyscloud_user." + userResources[0].OriginalResourceLabel), + strconv.Quote("genesyscloud_user." + userResources[1].OriginalResourceLabel), + strconv.Quote("genesyscloud_routing_wrapupcode." + wrapupCodeResources[0].OriginalResourceLabel), + strconv.Quote("genesyscloud_routing_wrapupcode." + wrapupCodeResources[1].OriginalResourceLabel), + }, + ) resource.Test(t, resource.TestCase{ PreCheck: func() { util.TestAccPreCheck(t) }, ProviderFactories: provider.GetProviderFactories(providerResources, providerDataSources), Steps: []resource.TestStep{ { - Config: config, + Config: baseConfig, + }, + { + Config: configWithExporter, Check: resource.ComposeTestCheckFunc( validateFileCreated(expectedFilesPath[0]), validateFileCreated(expectedFilesPath[1]), @@ -2660,17 +2701,16 @@ func generateTfExportResourceForCompress( directory string, includeState string, compressFlag string, + includeResourcesFilter []string, excludedAttributes string) string { return fmt.Sprintf(`resource "genesyscloud_tf_export" "%s" { directory = "%s" include_state_file = %s compress=%s - resource_types = [ - "genesyscloud_user", - ] + include_filter_resources = [%s] exclude_attributes = [%s] } - `, resourceLabel, directory, includeState, compressFlag, excludedAttributes) + `, resourceLabel, directory, includeState, compressFlag, strings.Join(includeResourcesFilter, ","), excludedAttributes) } func generateTfExportResourceMin( diff --git a/genesyscloud/tfexporter/tf_exporter_resource_test.go b/genesyscloud/tfexporter/tf_exporter_resource_test.go index 99ad15389..a0fb7001d 100644 --- a/genesyscloud/tfexporter/tf_exporter_resource_test.go +++ b/genesyscloud/tfexporter/tf_exporter_resource_test.go @@ -285,7 +285,6 @@ func (r *registerTestInstance) registerTestExporters() { RegisterExporter("genesyscloud_outbound_campaign", obCampaign.OutboundCampaignExporter()) RegisterExporter("genesyscloud_outbound_contact_list", outboundContactList.OutboundContactListExporter()) RegisterExporter("genesyscloud_outbound_contact_list_template", outboundContactListTemplate.OutboundContactListTemplateExporter()) - RegisterExporter("genesyscloud_outbound_contact_list_contact", outboundContactListContact.ContactExporter()) RegisterExporter("genesyscloud_outbound_contactlistfilter", obContactListFilter.OutboundContactlistfilterExporter()) RegisterExporter("genesyscloud_outbound_messagingcampaign", ob.OutboundMessagingcampaignExporter()) RegisterExporter("genesyscloud_outbound_sequence", obSequence.OutboundSequenceExporter()) diff --git a/genesyscloud/util/files/util_files.go b/genesyscloud/util/files/util_files.go index 23a5e6177..42384408b 100644 --- a/genesyscloud/util/files/util_files.go +++ b/genesyscloud/util/files/util_files.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "crypto/sha256" + "encoding/csv" "encoding/hex" "fmt" "io" @@ -13,13 +14,15 @@ import ( "net/http" "net/url" "os" - "path" + "path/filepath" "strings" - "terraform-provider-genesyscloud/genesyscloud/util" "time" + "terraform-provider-genesyscloud/genesyscloud/util" + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/retry" + "github.com/mypurecloud/platform-client-sdk-go/v150/platformclientv2" ) type S3Uploader struct { @@ -179,25 +182,23 @@ func DownloadOrOpenFile(path string) (io.Reader, *os.File, error) { var reader io.Reader var file *os.File - _, err := os.Stat(path) - if err != nil { - _, err = url.ParseRequestURI(path) - if err == nil { - resp, err := http.Get(path) - if err != nil { - return nil, nil, err - } - if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { - return nil, nil, fmt.Errorf("HTTP Error downloading file: %v", resp.StatusCode) - } - reader = resp.Body - } else { - return nil, nil, fmt.Errorf("invalid file path or URL: %v", path) + // Check if the path has a protocol scheme to call as an HTTP request + if u, err := url.ParseRequestURI(path); err == nil && u.Scheme != "" { + resp, err := http.Get(path) + if err != nil { + return nil, nil, err + } + if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { + return nil, nil, fmt.Errorf("HTTP Error downloading file: %v", resp.StatusCode) } + reader = resp.Body } else { file, err = os.Open(path) if err != nil { - return nil, nil, err + if os.IsNotExist(err) { + return nil, nil, fmt.Errorf("could not %w", err) + } + return nil, nil, fmt.Errorf("error opening local file \"%s\": %v", path, err) } reader = file } @@ -205,23 +206,52 @@ func DownloadOrOpenFile(path string) (io.Reader, *os.File, error) { return reader, file, nil } -// DownloadExportFile Download file from uri to directory/fileName -func DownloadExportFile(directory, fileName, uri string) error { - resp, err := http.Get(uri) +// DownloadExportFile is a variable that holds the function for downloading export files. +// By default it points to downloadExportFile, but can be replaced with a mock implementation +// during testing. This pattern enables unit testing of code that depends on file downloads +// without actually downloading files. +var DownloadExportFile = downloadExportFile + +func downloadExportFile(directory, fileName, uri string) (*platformclientv2.APIResponse, error) { + return downloadExportFileWithAccessToken(directory, fileName, uri, "") +} + +var DownloadExportFileWithAccessToken = downloadExportFileWithAccessToken + +func downloadExportFileWithAccessToken(directory, fileName, uri, accessToken string) (*platformclientv2.APIResponse, error) { + client := &http.Client{} + + req, err := http.NewRequest("GET", uri, nil) if err != nil { - return err + return nil, err } + if accessToken != "" { + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", accessToken)) + } + + resp, err := client.Do(req) + apiResp, apiErr := platformclientv2.NewAPIResponse(resp, nil) + if err != nil { + return apiResp, err + } + if apiErr != nil { + return apiResp, apiErr + } defer resp.Body.Close() - out, err := os.Create(path.Join(directory, fileName)) + if err := os.MkdirAll(directory, 0755); err != nil { + return apiResp, fmt.Errorf("failed to create directory: %w", err) + } + + out, err := os.Create(filepath.Join(directory, fileName)) if err != nil { - return err + return apiResp, err } defer out.Close() _, err = io.Copy(out, resp.Body) - return err + return apiResp, err } // Hash file content, used in stateFunc for "filepath" type attributes @@ -307,3 +337,37 @@ func WriteToFile(bytes []byte, path string) diag.Diagnostics { } return nil } + +// getCSVRecordCount retrieves the number of records in a CSV file (i.e., number of lines in a file minus the header) +func GetCSVRecordCount(filepath string) (int, error) { + // Open file up and read the record count + reader, file, err := DownloadOrOpenFile(filepath) + if err != nil { + return 0, err + } + defer file.Close() + + // Count the number of records in the CSV file + csvReader := csv.NewReader(reader) + csvReader.LazyQuotes = true + csvReader.TrimLeadingSpace = true + csvReader.FieldsPerRecord = 0 + recordCount := 0 + for { + _, err := csvReader.Read() + if err == io.EOF { + break + } + if err != nil { + return 0, err + } + recordCount++ + } + + // Subtract 1 to account for header row + if recordCount > 0 { + recordCount-- + } + + return recordCount, nil +} diff --git a/genesyscloud/util/files/util_files_test.go b/genesyscloud/util/files/util_files_test.go index 5cd5fb6d7..1747eec31 100644 --- a/genesyscloud/util/files/util_files_test.go +++ b/genesyscloud/util/files/util_files_test.go @@ -6,9 +6,13 @@ import ( "io" "net/http" "net/http/httptest" + "os" + "path/filepath" "strings" "testing" + testrunner "terraform-provider-genesyscloud/genesyscloud/util/testrunner" + "github.com/stretchr/testify/assert" ) @@ -201,3 +205,207 @@ func TestScriptUploadSuccess(t *testing.T) { t.Errorf(`expected %s got %s`, scriptFile, resultsStr) } } + +func TestDownloadOrOpenFile(t *testing.T) { + // Test HTTP download + t.Run("successful HTTP download", func(t *testing.T) { + // Setup test server + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte("test content")) + })) + defer server.Close() + + reader, file, err := DownloadOrOpenFile(server.URL) + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + if file != nil { + t.Error("Expected file to be nil for HTTP downloads") + } + + // Read content + content, err := io.ReadAll(reader) + if err != nil { + t.Errorf("Failed to read content: %v", err) + } + if string(content) != "test content" { + t.Errorf("Expected 'test content', got '%s'", string(content)) + } + }) + + t.Run("HTTP download failure", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + })) + defer server.Close() + + reader, file, err := DownloadOrOpenFile(server.URL) + if err == nil { + t.Error("Expected error for 404 response, got nil") + } + if reader != nil || file != nil { + t.Error("Expected nil reader and file for failed request") + } + }) + + // Test local file operations + t.Run("successful local file read", func(t *testing.T) { + // Create temporary test file + tmpfile, err := os.CreateTemp("", "test") + if err != nil { + t.Fatal(err) + } + defer os.Remove(tmpfile.Name()) + + content := []byte("local file content") + if _, err := tmpfile.Write(content); err != nil { + t.Fatal(err) + } + tmpfile.Close() + + reader, file, err := DownloadOrOpenFile(tmpfile.Name()) + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + if file == nil { + t.Error("Expected file to not be nil for local files") + } + defer file.Close() + + // Read content + readContent, err := io.ReadAll(reader) + if err != nil { + t.Errorf("Failed to read content: %v", err) + } + if string(readContent) != "local file content" { + t.Errorf("Expected 'local file content', got '%s'", string(readContent)) + } + }) + + t.Run("non-existent local file", func(t *testing.T) { + path := filepath.Join(os.TempDir(), "nonexistent-file") + reader, file, err := DownloadOrOpenFile(path) + if err == nil { + t.Error("Expected error for non-existent file, got nil") + } + if !strings.Contains(err.Error(), fmt.Sprintf("could not open %s: no such file", path)) { + t.Error("Expected 'no such file or directory' error") + } + if reader != nil || file != nil { + t.Error("Expected nil reader and file for non-existent file") + } + }) +} + +func TestHashFileContent(t *testing.T) { + // Create a temporary test file + tempContent := []byte("test content") + tempFile, err := os.CreateTemp(testrunner.GetTestDataPath(), "test_file_*.txt") + if err != nil { + t.Fatalf("Failed to create temp file: %v", err) + } + defer os.Remove(tempFile.Name()) // Clean up after test + + // Write content to temp file + if err := os.WriteFile(tempFile.Name(), tempContent, 0644); err != nil { + t.Fatalf("Failed to write to temp file: %v", err) + } + + // Test successful case + t.Run("successful hash", func(t *testing.T) { + hash, err := HashFileContent(tempFile.Name()) + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + if hash == "" { + t.Error("Expected non-empty hash") + } + // Known hash for "test content" + expectedHash := "6ae8a75555209fd6c44157c0aed8016e763ff435a19cf186f76863140143ff72" + if hash != expectedHash { + t.Errorf("Expected hash %s, got %s", expectedHash, hash) + } + }) + + // Test non-existent file + t.Run("non-existent file", func(t *testing.T) { + hash, err := HashFileContent("non_existent_file.txt") + if err == nil { + t.Error("Expected error for non-existent file, got nil") + } + if hash != "" { + t.Errorf("Expected empty hash for error case, got %s", hash) + } + }) +} + +func TestGetCSVRecordCount(t *testing.T) { + tests := []struct { + name string + fileContent string + expectedCount int + expectedError bool + }{ + { + name: "Valid CSV with multiple records", + fileContent: "header1,header2\nvalue1,value2\nvalue3,value4", + expectedCount: 2, + expectedError: false, + }, + { + name: "CSV with only header", + fileContent: "header1,header2", + expectedCount: 0, + expectedError: false, + }, + { + name: "Empty file", + fileContent: "", + expectedCount: 0, + expectedError: false, + }, + { + name: "Malformed CSV", + fileContent: "header1,header2\nvalue1,value2,extra", + expectedCount: 0, + expectedError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create temporary test file + tmpDir := t.TempDir() + tmpFile := filepath.Join(tmpDir, "test.csv") + + err := os.WriteFile(tmpFile, []byte(tt.fileContent), 0644) + if err != nil { + t.Fatalf("Failed to create test file: %v", err) + } + + // Run the function + count, err := GetCSVRecordCount(tmpFile) + + // Check error + if tt.expectedError && err == nil { + t.Error("Expected an error but got none") + } + if !tt.expectedError && err != nil { + t.Errorf("Unexpected error: %v", err) + } + + // Check count + if !tt.expectedError && count != tt.expectedCount { + t.Errorf("Expected count %d, got %d", tt.expectedCount, count) + } + }) + } +} + +func TestGetCSVRecordCount_NonexistentFile(t *testing.T) { + _, err := GetCSVRecordCount("nonexistent.csv") + if err == nil { + t.Error("Expected error for nonexistent file, got none") + } +} diff --git a/genesyscloud/util/testrunner/testrunner.go b/genesyscloud/util/testrunner/testrunner.go index 71e3fa80d..853bb3886 100644 --- a/genesyscloud/util/testrunner/testrunner.go +++ b/genesyscloud/util/testrunner/testrunner.go @@ -1,13 +1,18 @@ package testrunner import ( + "context" + "fmt" "log" "os" "path/filepath" "runtime" + "strconv" "strings" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + "github.com/hashicorp/terraform-plugin-sdk/v2/terraform" ) const ( @@ -62,21 +67,18 @@ func GetTestTempPath(elem ...string) string { return filepath.Join(basePath, subPath) } -func GenerateDataSourceTestSteps(resourceType string, testCaseName string, checkFuncs []resource.TestCheckFunc) []resource.TestStep { - return GenerateTestSteps(DataSourceTestType, resourceType, testCaseName, checkFuncs) +func GenerateDataJourneySourceTestSteps(resourceType string, testCaseName string, checkFuncs []resource.TestCheckFunc) []resource.TestStep { + return GenerateJourneyTestSteps(DataSourceTestType, resourceType, testCaseName, checkFuncs) } -func GenerateResourceTestSteps(resourceType string, testCaseName string, checkFuncs []resource.TestCheckFunc) []resource.TestStep { - return GenerateTestSteps(ResourceTestType, resourceType, testCaseName, checkFuncs) +func GenerateResourceJourneyTestSteps(resourceType string, testCaseName string, checkFuncs []resource.TestCheckFunc) []resource.TestStep { + return GenerateJourneyTestSteps(ResourceTestType, resourceType, testCaseName, checkFuncs) } -func GenerateTestSteps(testType string, resourceType string, testCaseName string, checkFuncs []resource.TestCheckFunc) []resource.TestStep { +func GenerateJourneyTestSteps(testType string, resourceType string, testCaseName string, checkFuncs []resource.TestCheckFunc) []resource.TestStep { var testSteps []resource.TestStep var testCasePath string testCasePath = GetTestDataPath(testType, resourceType, testCaseName) - if resourceType == "genesyscloud_journey_action_map" || resourceType == "genesyscloud_journey_action_template" || resourceType == "genesyscloud_journey_outcome" { - testCasePath = filepath.Join("../", testCasePath) - } testCaseDirEntries, _ := os.ReadDir(testCasePath) checkFuncIndex := 0 for _, testCaseDirEntry := range testCaseDirEntries { @@ -110,3 +112,69 @@ func GenerateTestSteps(testType string, resourceType string, testCaseName string func GenerateFullPathId(resourceType string, resourceLabel string) string { return resourceType + "." + resourceLabel + "." + "id" } + +// Helper function to create test provider +func GenerateTestProvider(resourceName string, schemas map[string]*schema.Schema, diff schema.CustomizeDiffFunc) *schema.Provider { + return &schema.Provider{ + Schema: schemas, + ResourcesMap: map[string]*schema.Resource{ + resourceName: { + Schema: schemas, + CustomizeDiff: diff, + }, + }, + } +} +func GenerateTestDiff(provider *schema.Provider, resourceName string, oldValue, newValue map[string]string) (*terraform.InstanceDiff, error) { + // Convert newValue map[string]string to map[string]interface{} and handle list attributes + newI := make(map[string]interface{}) + for k, v := range newValue { + if strings.Contains(k, ".#") { + // This is a list length indicator - skip it as we'll handle the list elements + continue + } + + // Check if this is a list element (e.g., "list_attr.0", "list_attr.1") + if idx := strings.LastIndex(k, "."); idx != -1 { + listName := k[:idx] + if _, err := strconv.Atoi(k[idx+1:]); err == nil { + // This is a list element + // Initialize the list if it doesn't exist + if _, exists := newI[listName]; !exists { + // Find the length of the list from the ".#" attribute + if lenStr, ok := newValue[listName+".#"]; ok { + length, _ := strconv.Atoi(lenStr) + newI[listName] = make([]interface{}, length) + } + } + + // Get the list and ensure it's the correct type + if list, ok := newI[listName].([]interface{}); ok { + index, _ := strconv.Atoi(k[idx+1:]) + if index < len(list) { + list[index] = v + } + } + continue + } + } + + // Regular (non-list) attribute + newI[k] = v + } + + if resource, ok := provider.ResourcesMap[resourceName]; ok { + return resource.Diff( + context.Background(), + &terraform.InstanceState{ + Attributes: oldValue, + }, + &terraform.ResourceConfig{ + Config: newI, + }, + provider.Meta(), + ) + } else { + return nil, fmt.Errorf("Resource %s not found in provider", resourceName) + } +} diff --git a/genesyscloud/util/testrunner/testrunner_test.go b/genesyscloud/util/testrunner/testrunner_test.go index 2f4aca68b..58bafd870 100644 --- a/genesyscloud/util/testrunner/testrunner_test.go +++ b/genesyscloud/util/testrunner/testrunner_test.go @@ -5,6 +5,7 @@ import ( "path/filepath" "testing" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" "github.com/stretchr/testify/assert" ) @@ -92,3 +93,128 @@ func TestGenerateFullPathId(t *testing.T) { }) } } + +func TestGenerateTestProvider(t *testing.T) { + schemas := map[string]*schema.Schema{ + "test_field": { + Type: schema.TypeString, + Required: true, + }, + } + + provider := GenerateTestProvider("test_resource", schemas, nil) + + assert.NotNil(t, provider) + assert.NotNil(t, provider.ResourcesMap["test_resource"]) + assert.Equal(t, schemas, provider.ResourcesMap["test_resource"].Schema) +} + +func TestGenerateTestDiff(t *testing.T) { + // Create a mock provider + provider := &schema.Provider{ + ResourcesMap: map[string]*schema.Resource{ + "test_resource": &schema.Resource{ + Schema: map[string]*schema.Schema{ + "simple_attr": &schema.Schema{ + Type: schema.TypeString, + Optional: true, + }, + "list_attr": &schema.Schema{ + Type: schema.TypeList, + Optional: true, + Elem: &schema.Schema{ + Type: schema.TypeString, + }, + }, + }, + }, + }, + } + + tests := []struct { + name string + resourceName string + oldValue map[string]string + newValue map[string]string + wantErr bool + }{ + { + name: "Simple attribute change", + resourceName: "test_resource", + oldValue: map[string]string{ + "simple_attr": "old", + }, + newValue: map[string]string{ + "simple_attr": "new", + }, + wantErr: false, + }, + { + name: "List attribute change", + resourceName: "test_resource", + oldValue: map[string]string{ + "list_attr.#": "1", + "list_attr.0": "old_item", + }, + newValue: map[string]string{ + "list_attr.#": "2", + "list_attr.0": "new_item1", + "list_attr.1": "new_item2", + }, + wantErr: false, + }, + { + name: "Invalid resource name", + resourceName: "invalid_resource", + oldValue: map[string]string{}, + newValue: map[string]string{}, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + diff, err := GenerateTestDiff(provider, tt.resourceName, tt.oldValue, tt.newValue) + + if tt.wantErr { + if err == nil { + t.Errorf("GenerateTestDiff() expected error but got none") + } + return + } + + if err != nil { + t.Errorf("GenerateTestDiff() error = %v", err) + return + } + + // Verify the diff was generated + if diff == nil { + t.Error("GenerateTestDiff() returned nil diff") + return + } + + // For simple attribute change, verify the diff contains the change + if tt.name == "Simple attribute change" { + if attr, ok := diff.Attributes["simple_attr"]; !ok { + t.Error("Expected diff for simple_attr but found none") + } else { + if attr.Old != "old" || attr.New != "new" { + t.Errorf("Unexpected diff values for simple_attr: got old=%v, new=%v", attr.Old, attr.New) + } + } + } + + // For list attribute change, verify the diff contains the changes + if tt.name == "List attribute change" { + if attr, ok := diff.Attributes["list_attr.#"]; !ok { + t.Error("Expected diff for list_attr.# but found none") + } else { + if attr.Old != "1" || attr.New != "2" { + t.Errorf("Unexpected diff values for list_attr.#: got old=%v, new=%v", attr.Old, attr.New) + } + } + } + }) + } +} diff --git a/genesyscloud/validators/validators.go b/genesyscloud/validators/validators.go index 569b6ca7e..ae6b6c34c 100644 --- a/genesyscloud/validators/validators.go +++ b/genesyscloud/validators/validators.go @@ -1,7 +1,12 @@ package validators import ( + "context" + "encoding/csv" "fmt" + "io" + "log" + "math" "regexp" "strconv" "time" @@ -11,11 +16,12 @@ import ( "terraform-provider-genesyscloud/genesyscloud/util" "terraform-provider-genesyscloud/genesyscloud/util/resourcedata" - files "terraform-provider-genesyscloud/genesyscloud/util/files" + "terraform-provider-genesyscloud/genesyscloud/util/files" lists "terraform-provider-genesyscloud/genesyscloud/util/lists" "github.com/hashicorp/go-cty/cty" "github.com/hashicorp/terraform-plugin-sdk/v2/diag" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/customdiff" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" ) @@ -136,6 +142,7 @@ func ValidateDateTime(date interface{}, _ cty.Path) diag.Diagnostics { // ValidateCountryCode validates a country code is in format ISO 3166-1 alpha-2 func ValidateCountryCode(code interface{}, _ cty.Path) diag.Diagnostics { countryCode := code.(string) + // amazonq-ignore-next-line if len(countryCode) == 2 { return nil } else if countryCode == "country-code-1" { @@ -198,7 +205,7 @@ func ValidatePath(i interface{}, k string) (warnings []string, errors []error) { _, file, err := files.DownloadOrOpenFile(v) if err != nil { - errors = append(errors, err) + return warnings, append(errors, err) } if file != nil { defer file.Close() @@ -207,6 +214,114 @@ func ValidatePath(i interface{}, k string) (warnings []string, errors []error) { return warnings, errors } +type ValidateCSVOptions struct { + RequiredColumns []string + SampleSize int + MaxRowCount int64 + SkipInterval int // How often to sample after initial sampling +} + +func ValidateCSVFormatWithConfig(filepath string, opts ValidateCSVOptions) error { + + const ( + maxSkipInterval = 1000000 // Maximum allowed skip interval + defaultSkipInterval = 1000 // Default skip interval + maxSampleSize = 100000 // Maximum allowed sample size + ) + // Validate configuration + if opts.SkipInterval < 0 { + return fmt.Errorf("skip interval must be non-negative, got %d", opts.SkipInterval) + } + if opts.SkipInterval > maxSkipInterval { + return fmt.Errorf("skip interval too large, maximum allowed is %d", maxSkipInterval) + } + if opts.SampleSize < 0 { + return fmt.Errorf("sample size must be non-negative, got %d", opts.SampleSize) + } + if opts.SampleSize > maxSampleSize { + return fmt.Errorf("sample size too large, maximum allowed is %d", maxSampleSize) + } + + // Open the file + _, fileHandler, err := files.DownloadOrOpenFile(filepath) + if err != nil { + return fmt.Errorf("failed to open file: %w", err) + } + defer fileHandler.Close() + + reader := csv.NewReader(fileHandler) + reader.LazyQuotes = true + reader.TrimLeadingSpace = true + reader.FieldsPerRecord = 0 + + // Read header row + headers, err := reader.Read() + if err != nil { + return fmt.Errorf("failed to read CSV headers: %w", err) + } + + // Validate required columns if specified + if len(opts.RequiredColumns) > 0 { + headerMap := make(map[string]bool) + for _, header := range headers { + headerMap[header] = true + } + + requiredColumnsNotFound := []string{} + for _, required := range opts.RequiredColumns { + if !headerMap[required] { + requiredColumnsNotFound = append(requiredColumnsNotFound, required) + } + } + + if len(requiredColumnsNotFound) > 0 { + return fmt.Errorf("CSV file is missing required columns: %v", requiredColumnsNotFound) + } + } + + expectedFields := len(headers) + + skipInterval := opts.SkipInterval + if skipInterval == 0 { + skipInterval = defaultSkipInterval + } + + var rowCount uint64 = 1 // Start at 1 since we already read header + skipIntervalU64 := uint64(skipInterval) + + for { + // Check for uint64 overflow + if rowCount == math.MaxUint64 { + return fmt.Errorf("file exceeds maximum supported row count") + } + + row, err := reader.Read() + if err == io.EOF { + break + } + if err != nil { + return fmt.Errorf("error reading line %d: %w", rowCount, err) + } + + if opts.MaxRowCount > 0 && rowCount > uint64(opts.MaxRowCount) { + return fmt.Errorf("CSV file exceeds maximum allowed rows of %d", opts.MaxRowCount) + } + + // Validate sampled rows + if rowCount <= uint64(opts.SampleSize) || rowCount%skipIntervalU64 == 0 { + if len(row) != expectedFields { + return fmt.Errorf("line %d has %d fields, expected %d", rowCount, len(row), expectedFields) + } + } else { + reader.FieldsPerRecord = -1 + } + + rowCount++ + } + + return nil +} + // ValidateResponseAssetName validate a response asset filename matches the criteria outlined in the description func ValidateResponseAssetName(name interface{}, _ cty.Path) diag.Diagnostics { if nameStr, ok := name.(string); ok { @@ -276,3 +391,56 @@ func ValidateLanguageCode(lang interface{}, _ cty.Path) diag.Diagnostics { } return diag.Errorf("Language code %v is not a string", lang) } + +// Function factory that returns a custom diff function +func ValidateFileContentHashChanged(filepathAttr, hashAttr string) customdiff.ResourceConditionFunc { + return func(ctx context.Context, d *schema.ResourceDiff, meta interface{}) bool { + filepath := d.Get(filepathAttr).(string) + + newHash, err := files.HashFileContent(filepath) + if err != nil { + log.Printf("Error calculating file content hash: %v", err) + return false + } + + // Get the current hash value + oldHash := d.Get(hashAttr).(string) + + // Return true if the hashes are different + return oldHash != newHash + } +} + +// ValidateCSVColumns returns a CustomizeDiffFunction that validates if a CSV file +// contains the required columns. It takes the names of the attributes that contain +// the file path and the column names. +func ValidateCSVWithColumns(filePathAttr string, columnNamesAttr string) schema.CustomizeDiffFunc { + + // This function ensures that the contacts file is a CSV file and that it includes the columns defined on the resource + return func(ctx context.Context, d *schema.ResourceDiff, _ interface{}) error { + if !d.HasChange(filePathAttr) || !d.HasChange(columnNamesAttr) { + return nil + } + + filepath := d.Get(filePathAttr).(string) + if filepath == "" { + return nil + } + + columnNamesRaw := d.Get(columnNamesAttr).([]interface{}) + requiredColumns := make([]string, len(columnNamesRaw)) + for i, v := range columnNamesRaw { + requiredColumns[i] = v.(string) + } + + validatorOpts := ValidateCSVOptions{ + RequiredColumns: requiredColumns, + } + + err := ValidateCSVFormatWithConfig(filepath, validatorOpts) + if err != nil { + return fmt.Errorf("failed to validate contacts file: %s", err) + } + return nil + } +} diff --git a/genesyscloud/validators/validators_test.go b/genesyscloud/validators/validators_test.go new file mode 100644 index 000000000..0be16e78d --- /dev/null +++ b/genesyscloud/validators/validators_test.go @@ -0,0 +1,539 @@ +package validators + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "testing" + + "terraform-provider-genesyscloud/genesyscloud/util/files" + testrunner "terraform-provider-genesyscloud/genesyscloud/util/testrunner" + + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/customdiff" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" +) + +func TestValidateCSVFormatWithConfig(t *testing.T) { + // Create a temporary directory for test files + tmpDir, err := os.MkdirTemp("", "csv-tests") + if err != nil { + t.Fatalf("failed to create temp directory: %v", err) + } + defer os.RemoveAll(tmpDir) // Clean up after tests + + tests := []struct { + name string + csvContent string + opts ValidateCSVOptions + expectedError bool + errorMessage string + }{ + { + name: "Valid CSV with required columns", + csvContent: `id,name,value +1,test1,val1 +2,test2,val2 +3,test3,val3`, + opts: ValidateCSVOptions{ + RequiredColumns: []string{"id", "name"}, + SampleSize: 10, + }, + expectedError: false, + }, + { + name: "Missing required column", + csvContent: `id,value +1,val1 +2,val2`, + opts: ValidateCSVOptions{ + RequiredColumns: []string{"id", "name"}, + SampleSize: 10, + }, + expectedError: true, + errorMessage: "CSV file is missing required columns: [name]", + }, + { + name: "Inconsistent number of fields", + csvContent: `id,name,value +1,test1 +2,test2,val2`, + opts: ValidateCSVOptions{ + SampleSize: 10, + }, + expectedError: true, + errorMessage: "error reading line 1: record on line 2: wrong number of fields", + }, + { + name: "Empty CSV", + csvContent: "", + opts: ValidateCSVOptions{ + SampleSize: 10, + }, + expectedError: true, + errorMessage: "failed to read CSV headers", + }, + { + name: "CSV exceeds max row count", + csvContent: `id,name +1,test1 +2,test2 +3,test3`, + opts: ValidateCSVOptions{ + MaxRowCount: 2, + SampleSize: 10, + }, + expectedError: true, + errorMessage: "CSV file exceeds maximum allowed rows of 2", + }, + { + name: "Valid CSV with sampling", + csvContent: `id,name +1,test1 +2,test2 +3,test3 +4,test4 +5,test5`, + opts: ValidateCSVOptions{ + SampleSize: 2, + SkipInterval: 2, + }, + expectedError: false, + }, + { + name: "Invalid CSV format", + csvContent: `field1,'field2',"field3" +value1,"mixed'quotes",value3 +value4,value5,value6,value7,, +`, + opts: ValidateCSVOptions{ + SampleSize: 10, + }, + expectedError: true, + errorMessage: "wrong number of fields", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create a temporary file for this test + tmpFile := filepath.Join(tmpDir, fmt.Sprintf("test-%s.csv", tt.name)) + err := os.WriteFile(tmpFile, []byte(tt.csvContent), 0644) + if err != nil { + t.Fatalf("failed to create test file: %v", err) + } + + err = ValidateCSVFormatWithConfig(tmpFile, tt.opts) + + if tt.expectedError { + if err == nil { + t.Error("expected error but got none") + } else if tt.errorMessage != "" && !strings.Contains(err.Error(), tt.errorMessage) { + t.Errorf("expected error message containing '%s', got: %v", + tt.errorMessage, err) + } + } else if err != nil { + t.Errorf("unexpected error: %v", err) + } + }) + } +} + +// Helper function to generate large CSV for testing +func generateLargeCSV(rows int) string { + var builder strings.Builder + builder.WriteString("id,name,value\n") + + for i := 1; i <= rows; i++ { + builder.WriteString(fmt.Sprintf("%d,test%d,value%d\n", i, i, i)) + } + + return builder.String() +} + +// Test specific edge cases +func TestValidateCSVFormatWithConfigEdgeCases(t *testing.T) { + // Create a temporary directory for test files + tmpDir, err := os.MkdirTemp("", "csv-edge-cases") + if err != nil { + t.Fatalf("failed to create temp directory: %v", err) + } + defer os.RemoveAll(tmpDir) + + t.Run("Zero skip interval defaults to 1000", func(t *testing.T) { + tmpFile := filepath.Join(tmpDir, "large.csv") + err := os.WriteFile(tmpFile, []byte(generateLargeCSV(2000)), 0644) + if err != nil { + t.Fatalf("failed to create test file: %v", err) + } + + opts := ValidateCSVOptions{ + SampleSize: 10, + SkipInterval: 0, // Should default to 1000 + } + + err = ValidateCSVFormatWithConfig(tmpFile, opts) + if err != nil { + t.Errorf("unexpected validation failure: %v", err) + } + }) + + t.Run("CSV with quoted fields", func(t *testing.T) { + csvContent := `id,name,description +1,"Smith, John","Description, with comma" +2,"Jones, Bob","Another, description"` + + tmpFile := filepath.Join(tmpDir, "quoted.csv") + err := os.WriteFile(tmpFile, []byte(csvContent), 0644) + if err != nil { + t.Fatalf("failed to create test file: %v", err) + } + + opts := ValidateCSVOptions{ + SampleSize: 10, + } + + err = ValidateCSVFormatWithConfig(tmpFile, opts) + if err != nil { + t.Errorf("unexpected validation failure: %v", err) + } + }) +} + +func BenchmarkValidateCSVFormatWithConfig(b *testing.B) { + // Create a temporary directory for benchmark files + tmpDir, err := os.MkdirTemp("", "csv-benchmarks") + if err != nil { + b.Fatalf("failed to create temp directory: %v", err) + } + defer os.RemoveAll(tmpDir) + + // Create test files of different sizes + sizes := map[string]int{ + "Small": 100, + "Medium": 10_000, + "Large": 100_000, + } + + files := make(map[string]string) + for name, size := range sizes { + tmpFile := filepath.Join(tmpDir, fmt.Sprintf("%s.csv", name)) + err := os.WriteFile(tmpFile, []byte(generateLargeCSV(size)), 0644) + if err != nil { + b.Fatalf("failed to create benchmark file: %v", err) + } + files[name] = tmpFile + } + + opts := ValidateCSVOptions{ + RequiredColumns: []string{"id", "name", "value"}, + SampleSize: 100, + SkipInterval: 1000, + } + + for name, file := range files { + b.Run(fmt.Sprintf("%s CSV", name), func(b *testing.B) { + for i := 0; i < b.N; i++ { + ValidateCSVFormatWithConfig(file, opts) + } + }) + } +} + +func TestFileContentHashChanged(t *testing.T) { + // Create a temporary test file + tmpFile, err := os.CreateTemp(testrunner.GetTestDataPath(), "test-content-*.txt") + if err != nil { + t.Fatalf("Failed to create temp file: %v", err) + } + defer os.Remove(tmpFile.Name()) + + // Write initial content + initialContent := []byte("initial content") + if err := os.WriteFile(tmpFile.Name(), initialContent, 0644); err != nil { + t.Fatalf("Failed to write initial content: %v", err) + } + + tests := []struct { + name string + setupFunc func() error + expectedDiff bool + }{ + { + name: "content_unchanged", + setupFunc: func() error { + // No changes to file + return nil + }, + expectedDiff: false, + }, + { + name: "content_changed", + setupFunc: func() error { + return os.WriteFile(tmpFile.Name(), []byte("changed content"), 0644) + }, + expectedDiff: true, + }, + { + name: "content_unchanged_again", + setupFunc: func() error { + // No changes to file + return nil + }, + expectedDiff: false, + }, + { + name: "content_changed_again", + setupFunc: func() error { + return os.WriteFile(tmpFile.Name(), []byte("changed content again"), 0644) + }, + expectedDiff: true, + }, + { + name: "final_content_changed", + setupFunc: func() error { + return os.WriteFile(tmpFile.Name(), []byte("final changed content"), 0644) + }, + expectedDiff: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + provider := testrunner.GenerateTestProvider("test_resource", + map[string]*schema.Schema{ + "filepath": { + Type: schema.TypeString, + Required: true, + }, + "file_content_hash": { + Type: schema.TypeString, + Computed: true, + }, + }, + customdiff.ComputedIf("file_content_hash", ValidateFileContentHashChanged("filepath", "file_content_hash")), + ) + + // Pre calculate hash + priorHash, err := files.HashFileContent(tmpFile.Name()) + if err != nil { + t.Fatalf("Failed to calculate hash: %v", err) + } + + // Run setup for this test case + if err := tt.setupFunc(); err != nil { + t.Fatalf("Setup failed: %v", err) + } + + diff, err := testrunner.GenerateTestDiff( + provider, + "test_resource", + map[string]string{ + "filepath": tmpFile.Name(), + "file_content_hash": priorHash, + }, + map[string]string{ + "filepath": tmpFile.Name(), + }, + ) + + if err != nil { + t.Fatalf("Diff failed with error: %s", err) + } + + if tt.expectedDiff { + if diff == nil { + t.Error("Expected a diff when file content changes, got nil") + } else if !diff.Attributes["file_content_hash"].NewComputed { + t.Error("file_content_hash is not marked as NewComputed when file content changes") + } + } else { + if diff != nil && diff.Attributes["file_content_hash"].NewComputed { + t.Error("Expected no diff when file content unchanged, but file_content_hash was marked as NewComputed") + } + } + }) + } +} + +func TestValidateCSVWithColumns(t *testing.T) { + // Create a temporary test file + tmpFile, err := os.CreateTemp(testrunner.GetTestDataPath(), "test-csv-*.csv") + if err != nil { + t.Fatalf("Failed to create temp file: %v", err) + } + defer os.Remove(tmpFile.Name()) + + tests := []struct { + name string + setupFunc func() error + oldValues map[string]string + newValues map[string]string + expectedDiff bool + expectedError bool + errorMessage string + }{ + { + name: "valid_csv_with_columns", + setupFunc: func() error { + content := "header1,header2,header3\nvalue1,value2,value3" + return os.WriteFile(tmpFile.Name(), []byte(content), 0644) + }, + oldValues: map[string]string{ + "filepath": tmpFile.Name() + ".old", + "column_names.#": "2", + "column_names.0": "old_header1", + "column_names.1": "old_header2", + }, + newValues: map[string]string{ + "filepath": tmpFile.Name(), + "column_names.#": "3", + "column_names.0": "header1", + "column_names.1": "header2", + "column_names.2": "header3", + }, + expectedDiff: true, + expectedError: false, + }, + { + name: "missing_required_column", + setupFunc: func() error { + content := "header1,header3\nvalue1,value3" + return os.WriteFile(tmpFile.Name(), []byte(content), 0644) + }, + oldValues: map[string]string{ + "filepath": tmpFile.Name() + ".old", + "column_names.#": "2", + "column_names.0": "old_header1", + "column_names.1": "old_header2", + }, + newValues: map[string]string{ + "filepath": tmpFile.Name(), + "column_names.#": "3", + "column_names.0": "header1", + "column_names.1": "header2", + "column_names.2": "header3", + }, + expectedDiff: false, + expectedError: true, + errorMessage: "missing required columns: [header2]", + }, + { + name: "empty_file", + setupFunc: func() error { + return os.WriteFile(tmpFile.Name(), []byte(""), 0644) + }, + oldValues: map[string]string{ + "filepath": tmpFile.Name() + ".old", + "column_names.#": "1", + "column_names.0": "old_header", + }, + newValues: map[string]string{ + "filepath": tmpFile.Name(), + "column_names.#": "2", + "column_names.0": "header1", + "column_names.1": "header2", + }, + expectedDiff: false, + expectedError: true, + errorMessage: "failed to read CSV headers", + }, + { + name: "file_with_only_headers", + setupFunc: func() error { + content := "header1,header2\n" + return os.WriteFile(tmpFile.Name(), []byte(content), 0644) + }, + oldValues: map[string]string{ + "filepath": tmpFile.Name() + ".old", + "column_names.#": "1", + "column_names.0": "old_header", + }, + newValues: map[string]string{ + "filepath": tmpFile.Name(), + "column_names.#": "2", + "column_names.0": "header1", + "column_names.1": "header2", + }, + expectedDiff: true, + expectedError: false, + }, + { + name: "case_sensitive_headers", + setupFunc: func() error { + content := "Header1,HEADER2\nvalue1,value2" + return os.WriteFile(tmpFile.Name(), []byte(content), 0644) + }, + oldValues: map[string]string{ + "filepath": tmpFile.Name() + ".old", + "column_names.#": "1", + "column_names.0": "old_header", + }, + newValues: map[string]string{ + "filepath": tmpFile.Name(), + "column_names.#": "2", + "column_names.0": "header1", + "column_names.1": "header2", + }, + expectedDiff: false, + expectedError: true, + errorMessage: "missing required column", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + resource := &schema.Resource{ + Schema: map[string]*schema.Schema{ + "filepath": { + Type: schema.TypeString, + Required: true, + }, + "column_names": { + Type: schema.TypeList, + Required: true, + Elem: &schema.Schema{ + Type: schema.TypeString, + }, + }, + }, + } + + provider := testrunner.GenerateTestProvider("test_resource", resource.Schema, ValidateCSVWithColumns("filepath", "column_names")) + + // Run setup for this test case + if err := tt.setupFunc(); err != nil { + t.Fatalf("Setup failed: %v", err) + } + + diff, err := testrunner.GenerateTestDiff( + provider, + "test_resource", + tt.oldValues, + tt.newValues, + ) + + // Check for expected error + if tt.expectedError { + if err == nil { + t.Errorf("Expected an error for '%s' check but got none", tt.name) + } else if tt.errorMessage != "" && !strings.Contains(err.Error(), tt.errorMessage) { + t.Errorf("Expected error message containing '%s', got: %v for '%s' check", tt.errorMessage, err, tt.name) + } + } else if err != nil { + t.Errorf("Unexpected error for '%s' check: %v", tt.name, err) + } + + // Check for expected diff + if tt.expectedDiff { + if diff == nil { + t.Errorf("Expected a diff for '%s' check but got nil", tt.name) + } + } else { + if diff != nil { + t.Errorf("Expected no diff for '%s' check but got one", tt.name) + } + } + }) + } +} diff --git a/templates/resources.md.tmpl b/templates/resources.md.tmpl index 0dd65f031..a7e2c3323 100644 --- a/templates/resources.md.tmpl +++ b/templates/resources.md.tmpl @@ -1,6 +1,10 @@ --- page_title: "{{.Name}} {{.Type}} - {{.ProviderName}}" +{{ if gt (len (split .Description "[DEPRECATED]")) 1 -}} +subcategory: "Deprecated" +{{- else -}} subcategory: "" +{{- end }} description: |- {{ .Description | plainmarkdown | trimspace | prefixlines " " }} ---