diff --git a/providers/iterable/README.md b/providers/iterable/README.md new file mode 100644 index 000000000..369e5a8ea --- /dev/null +++ b/providers/iterable/README.md @@ -0,0 +1,95 @@ +# Description + +This is an exhaustive list of API endpoints which are excluded from Write/Delete connector implementation and therefore are available only via proxy. + +# Excluded Endpoints + +**Authentication** +- [/api/auth/jwts/invalidate](https://api.iterable.com/api/docs#users_Invalidate_JWT) - Invalidate all JWTs issued for a user + +**Campaigns** +- [/api/campaigns/abort](https://api.iterable.com/api/docs#campaigns_abort_campaign) - Abort Campaign +- [/api/campaigns/activateTriggered](https://api.iterable.com/api/docs#campaigns_activate_triggered_campaign) - Activate a triggered campaign +- [/api/campaigns/archive](https://api.iterable.com/api/docs#campaigns_archive_campaigns) - Archive campaigns +- [/api/campaigns/cancel](https://api.iterable.com/api/docs#campaigns_cancel_campaign) - Cancel a scheduled or recurring campaign +- [/api/campaigns/deactivateTriggered](https://api.iterable.com/api/docs#campaigns_Deactivate_triggered_campaign) - Deactivate a triggered campaign +- [/api/campaigns/trigger](https://api.iterable.com/api/docs#campaigns_trigger_campaign) - Trigger a campaign + +**Commerce** +- [/api/commerce/trackPurchase](https://api.iterable.com/api/docs#commerce_trackPurchase) - Track a purchase +- [/api/commerce/updateCart](https://api.iterable.com/api/docs#commerce_updateCart) - Update a user's shopping cart items + +**Emails** +- [/api/email/cancel](https://api.iterable.com/api/docs#email_cancel) - Cancel an email to a user +- [/api/email/target](https://api.iterable.com/api/docs#email_target) - Send an email to an email address + +**Message Events** +- [/api/embedded-messaging/events/click](https://api.iterable.com/api/docs#events_embedded_track_click) - Track an embedded message click +- [/api/embedded-messaging/events/received](https://api.iterable.com/api/docs#events_embedded_track_received) - Track an embedded message received event +- [/api/embedded-messaging/events/session](https://api.iterable.com/api/docs#events_embedded_track_impression) - Track an embedded message session and related impressions + +**Events** +- [/api/events/inAppConsume](https://api.iterable.com/api/docs#events_inAppConsume) - Consume or delete an in-app message +- [/api/events/trackBulk](https://api.iterable.com/api/docs#events_trackBulk) - Bulk track events +- [/api/events/trackInAppClick](https://api.iterable.com/api/docs#events_trackInAppClick) - Track an in-app message click +- [/api/events/trackInAppClose](https://api.iterable.com/api/docs#events_trackInAppClose) - Track the closing of an in-app message +- [/api/events/trackInAppDelivery](https://api.iterable.com/api/docs#events_trackInAppDelivery) - Track the delivery of an in-app message +- [/api/events/trackInAppOpen](https://api.iterable.com/api/docs#events_trackInAppOpen) - Track an in-app message open +- [/api/events/trackPushOpen](https://api.iterable.com/api/docs#events_trackPushOpen) - Track a mobile push open +- [/api/events/trackWebPushClick](https://api.iterable.com/api/docs#events_trackWebPushClick) - Track a web push click +- [/api/events/track](https://api.iterable.com/api/docs#events_track) - Track an event +- [/api/export/start](https://api.iterable.com/api/docs#export_startExport) - Start export + +**In-App Notifications** +- [/api/inApp/cancel](https://api.iterable.com/api/docs#In-app_cancel) - Cancel a scheduled in-app message +- [/api/inApp/target](https://api.iterable.com/api/docs#In-app_target) - Send an in-app notification to a user + +**Lists** +- [/api/lists/subscribe](https://api.iterable.com/api/docs#lists_subscribe) - Add subscribers to list +- [/api/lists/unsubscribe](https://api.iterable.com/api/docs#lists_unsubscribe) - Remove users from a list +- [/api/lists](https://api.iterable.com/api/docs#lists_create) - Create a static list + +**Push Notifications** +- [/api/push/cancel](https://api.iterable.com/api/docs#push_cancel) - Cancel a push notification to a user +- [/api/push/target](https://api.iterable.com/api/docs#push_target) - Send push notification to user + +**SMS** +- [/api/sms/cancel](https://api.iterable.com/api/docs#SMS_cancel) - Cancel an SMS to a user +- [/api/sms/target](https://api.iterable.com/api/docs#SMS_target) - Send SMS notification to user + +**Subscription** +- [/api/subscriptions/subscribeToDoubleOptIn](https://api.iterable.com/api/docs#subscriptions_subscribeSingleUserToDoubleOptIn) - Trigger a double opt-in subscription flow + +**Templates** +- [/api/templates/bulkDelete](https://api.iterable.com/api/docs#templates_bulk_delete_templates) - Bulk delete templates + +**Users** +- [/api/users/bulkUpdateSubscriptions](https://api.iterable.com/api/docs#users_bulkUpdateSubscriptions) - Bulk update user subscriptions +- [/api/users/bulkUpdate](https://api.iterable.com/api/docs#users_bulkUpdateUser) - Bulk update user data +- [/api/users/disableDevice](https://api.iterable.com/api/docs#users_disableDevice) - Disable pushes to a mobile device +- [/api/users/forget](https://api.iterable.com/api/docs#users_forget) - Forget a user in compliance with GDPR +- [/api/users/registerBrowserToken](https://api.iterable.com/api/docs#users_registerBrowserToken) - Register a browser token for web push +- [/api/users/registerDeviceToken](https://api.iterable.com/api/docs#users_registerDeviceToken) - Register a device token for push +- [/api/users/unforget](https://api.iterable.com/api/docs#users_unforget) - Unforget a user in compliance with GDPR +- [/api/users/updateEmail](https://api.iterable.com/api/docs#users_updateEmail) - Update user email +- [/api/users/updateSubscriptions](https://api.iterable.com/api/docs#users_updateSubscriptions) - Update user subscriptions + +**Verifications** +- [/api/verify/sms/begin](https://api.iterable.com/api/docs#Verify_beginSmsVerification) - Begin SMS Verification +- [/api/verify/sms/check](https://api.iterable.com/api/docs#Verify_checkSmsVerification) - Check SMS Verification Code + +**Web Push Notification** +- [/api/webPush/cancel](https://api.iterable.com/api/docs#webPush_cancel) - Cancel a web push notification to a user +- [/api/webPush/target](https://api.iterable.com/api/docs#webPush_target) - Send web push notification to user + +**Workflow** +- [/api/workflows/triggerWorkflow](https://api.iterable.com/api/docs#workflows_triggerWorkflow) - Trigger a journey (workflow) + + +Endpoints that are performing alike operations are excluded as well. +In other words they are covered by related endpoint. Excluded similiar endpoints are as follows: +- [/api/templates/email/update](https://api.iterable.com/api/docs#templates_updateEmailTemplate) - Update email template +- [/api/templates/inapp/update](https://api.iterable.com/api/docs#templates_updateInAppTemplate) - Update in-app template +- [/api/templates/push/update](https://api.iterable.com/api/docs#templates_updatePushTemplate) - Update push template +- [/api/templates/sms/update](https://api.iterable.com/api/docs#templates_updateSMSTemplate) - Update SMS template +- [/api/users/{email}](https://api.iterable.com/api/docs#users_delete_0) - Delete a user by email diff --git a/providers/iterable/connector.go b/providers/iterable/connector.go index c6892abee..0033b225d 100644 --- a/providers/iterable/connector.go +++ b/providers/iterable/connector.go @@ -53,6 +53,18 @@ func (c *Connector) getReadURL(objectName string) (*urlbuilder.URL, error) { return urlbuilder.New(c.BaseURL, path) } +func (c *Connector) getWriteURL(objectName string) (*urlbuilder.URL, error) { + path := supportedObjectsByWrite[objectName] + + return urlbuilder.New(c.BaseURL, path) +} + +func (c *Connector) getDeleteURL(objectName, recordID string) (*urlbuilder.URL, error) { + path := supportedObjectsByDelete[objectName] + + return urlbuilder.New(c.BaseURL, path, recordID) +} + func (c *Connector) setBaseURL(newURL string) { c.BaseURL = newURL c.Client.HTTPClient.Base = newURL diff --git a/providers/iterable/delete.go b/providers/iterable/delete.go new file mode 100644 index 000000000..cae48a40d --- /dev/null +++ b/providers/iterable/delete.go @@ -0,0 +1,34 @@ +package iterable + +import ( + "context" + + "github.com/amp-labs/connectors/common" +) + +func (c *Connector) Delete(ctx context.Context, config common.DeleteParams) (*common.DeleteResult, error) { + if err := config.ValidateParams(); err != nil { + return nil, err + } + + if !supportedObjectsByDelete.Has(config.ObjectName) { + // Removing tags is the only to be supported at this time. + // https://developer.instantly.ai/tags/delete-a-tag + return nil, common.ErrOperationNotSupportedForObject + } + + url, err := c.getDeleteURL(config.ObjectName, config.RecordId) + if err != nil { + return nil, err + } + + // 200 OK is expected + _, err = c.Client.Delete(ctx, url.String()) + if err != nil { + return nil, err + } + + return &common.DeleteResult{ + Success: true, + }, nil +} diff --git a/providers/iterable/delete_test.go b/providers/iterable/delete_test.go new file mode 100644 index 000000000..f596733d3 --- /dev/null +++ b/providers/iterable/delete_test.go @@ -0,0 +1,80 @@ +package iterable + +import ( + "errors" + "net/http" + "testing" + + "github.com/amp-labs/connectors" + "github.com/amp-labs/connectors/common" + "github.com/amp-labs/connectors/test/utils/mockutils/mockcond" + "github.com/amp-labs/connectors/test/utils/mockutils/mockserver" + "github.com/amp-labs/connectors/test/utils/testroutines" + "github.com/amp-labs/connectors/test/utils/testutils" +) + +func TestDelete(t *testing.T) { // nolint:funlen,cyclop + t.Parallel() + + responseNotFoundErr := testutils.DataFromFile(t, "delete-tag-missing.json") + responseTag := testutils.DataFromFile(t, "delete-tag.json") + + tests := []testroutines.Delete{ + { + Name: "Write object must be included", + Server: mockserver.Dummy(), + ExpectedErrs: []error{common.ErrMissingObjects}, + }, + { + Name: "Write object and its ID must be included", + Input: common.DeleteParams{ObjectName: "tags"}, + Server: mockserver.Dummy(), + ExpectedErrs: []error{common.ErrMissingRecordID}, + }, + { + Name: "Cannot remove unknown object", + Input: common.DeleteParams{ObjectName: "coupons", RecordId: "132"}, + Server: mockserver.Dummy(), + ExpectedErrs: []error{ + common.ErrOperationNotSupportedForObject, + }, + }, + { + Name: "Cannot remove missing tag", + Input: common.DeleteParams{ObjectName: "tags", RecordId: "5043"}, + Server: mockserver.Fixed{ + Setup: mockserver.ContentJSON(), + Always: mockserver.Response(http.StatusNotFound, responseNotFoundErr), + }.Server(), + ExpectedErrs: []error{ + common.ErrBadRequest, + errors.New(`Not Found`), // nolint:goerr113 + }, + }, + { + Name: "Successful delete", + Input: common.DeleteParams{ObjectName: "tags", RecordId: "5043"}, + Server: mockserver.Conditional{ + Setup: mockserver.ContentJSON(), + If: mockcond.And{ + mockcond.MethodDELETE(), + mockcond.PathSuffix("custom-tag/5043"), + }, + Then: mockserver.Response(http.StatusOK, responseTag), + }.Server(), + Expected: &common.DeleteResult{Success: true}, + ExpectedErrs: nil, + }, + } + + for _, tt := range tests { + // nolint:varnamelen + t.Run(tt.Name, func(t *testing.T) { + t.Parallel() + + tt.Run(t, func() (connectors.DeleteConnector, error) { + return constructTestConnector(tt.Server.URL) + }) + }) + } +} diff --git a/providers/iterable/objectNames.go b/providers/iterable/objectNames.go index 0b272c4f3..f8b88488e 100644 --- a/providers/iterable/objectNames.go +++ b/providers/iterable/objectNames.go @@ -22,3 +22,42 @@ var incrementalReadObjects = datautils.NewSet( //nolint:gochecknoglobals // Supported object names can be found under schemas.json. var supportedObjectsByRead = metadata.Schemas.ObjectNames() //nolint:gochecknoglobals + +var supportedObjectsByWrite = datautils.Map[string, string]{ //nolint:gochecknoglobals + // https://api.iterable.com/api/docs#campaigns_create_campaign + "campaigns": "/api/campaigns/create", + // https://api.iterable.com/api/docs#catalogs_createCatalog + // This endpoint doesn't use payload! Catalog name comes from the path {catalogName}. + objectNameCatalogs: "/api/catalogs", + // https://api.iterable.com/api/docs#lists_create + "lists": "/api/lists", + // https://api.iterable.com/api/docs#users_updateUser + "users": "/api/users/update", // Update or create user. + // https://api.iterable.com/api/docs#webhooks_updateWebhook + "webhooks": "/api/webhooks", // Update webhook + + // + // Template objects. + // + // https://api.iterable.com/api/docs#templates_upsertEmailTemplate + "templatesEmail": "/api/templates/email/upsert", + // https://api.iterable.com/api/docs#templates_upsertInAppTemplate + "templatesInApp": "/api/templates/inapp/upsert", + // https://api.iterable.com/api/docs#templates_upsertPushTemplate + "templatesPush": "/api/templates/push/upsert", + // https://api.iterable.com/api/docs#templates_upsertSMSTemplate + "templatesSMS": "/api/templates/sms/upsert", +} + +var supportedObjectsByDelete = datautils.Map[string, string]{ //nolint:gochecknoglobals + // https://api.iterable.com/api/docs#catalogs_deleteCatalog + objectNameCatalogs: "/api/catalogs", // by catalogName + // https://api.iterable.com/api/docs#export_cancelExport + "exports": "/api/export", // by jobId + // https://api.iterable.com/api/docs#lists_delete + "lists": "/api/lists", // by listId + // https://api.iterable.com/api/docs#metadata_delete + "metadata": "/api/metadata", // by table + // https://api.iterable.com/api/docs#users_delete + "users": "/api/users/byUserId", // by userId +} diff --git a/providers/iterable/test/delete-list.json b/providers/iterable/test/delete-list.json new file mode 100644 index 000000000..57c7360ae --- /dev/null +++ b/providers/iterable/test/delete-list.json @@ -0,0 +1,5 @@ +{ + "msg": "List 5052803 in Project 24382 was successfully deleted.", + "code": "Success", + "params": null +} diff --git a/providers/iterable/test/delete-missing-list-bad-request.json b/providers/iterable/test/delete-missing-list-bad-request.json new file mode 100644 index 000000000..28356515d --- /dev/null +++ b/providers/iterable/test/delete-missing-list-bad-request.json @@ -0,0 +1,5 @@ +{ + "msg": "error.lists.deleteFailed(5052803)", + "code": "BadParams", + "params": null +} diff --git a/providers/iterable/test/write-catalog.json b/providers/iterable/test/write-catalog.json new file mode 100644 index 000000000..6c1681cd8 --- /dev/null +++ b/providers/iterable/test/write-catalog.json @@ -0,0 +1,9 @@ +{ + "msg": "", + "code": "Success", + "params": { + "id": 125254, + "name": "newPostmanCatalog", + "url": "/api/catalogs/newPostmanCatalog" + } +} diff --git a/providers/iterable/test/write-template-bad-request.html b/providers/iterable/test/write-template-bad-request.html new file mode 100644 index 000000000..63ae7b7f1 --- /dev/null +++ b/providers/iterable/test/write-template-bad-request.html @@ -0,0 +1,49 @@ + + + +
++ For request 'POST /api/templates/push/upsert' [Invalid Json: No content to map due to end-of-input] +
+ + + + diff --git a/providers/iterable/test/write-template-invalid-request.json b/providers/iterable/test/write-template-invalid-request.json new file mode 100644 index 000000000..d4ee6ea63 --- /dev/null +++ b/providers/iterable/test/write-template-invalid-request.json @@ -0,0 +1,7 @@ +{ + "msg": "[/api/templates/push/upsert] Invalid JSON body", + "code": "BadJsonBody", + "params": { + "obj.buttons[0].actionIcon.iconType": "String value expected" + } +} \ No newline at end of file diff --git a/providers/iterable/write.go b/providers/iterable/write.go new file mode 100644 index 000000000..f90847669 --- /dev/null +++ b/providers/iterable/write.go @@ -0,0 +1,126 @@ +package iterable + +import ( + "context" + "errors" + "strconv" + + "github.com/amp-labs/connectors/common" + "github.com/amp-labs/connectors/common/jsonquery" + "github.com/amp-labs/connectors/common/urlbuilder" + "github.com/amp-labs/connectors/internal/datautils" + "github.com/spyzhov/ajson" +) + +var ErrCatalogCreate = errors.New("payload must have string 'name' to create a catalog") + +func (c *Connector) Write( + ctx context.Context, config common.WriteParams, +) (*common.WriteResult, error) { + if err := config.ValidateParams(); err != nil { + return nil, err + } + + if !supportedObjectsByWrite.Has(config.ObjectName) { + return nil, common.ErrOperationNotSupportedForObject + } + + // Object name will be used to complete further URL construction below. + url, err := c.getWriteURL(config.ObjectName) + if err != nil { + return nil, err + } + + if config.ObjectName == objectNameCatalogs { + if err = createCatalog(config, url); err != nil { + return nil, err + } + } + + res, err := c.Client.Post(ctx, url.String(), config.RecordData) + if err != nil { + return nil, err + } + + body, ok := res.Body() + if !ok { + // it is unlikely to have no payload + return &common.WriteResult{ + Success: true, + }, nil + } + + recordIdNodePath := recordIdPaths[config.ObjectName] + + // write response was with payload + return constructWriteResult(body, recordIdNodePath) +} + +// This is the only endpoint that doesn't use payload. +// New catalog requires a name which is passed as path parameter in URL. +// However, the write connector will accept payload of shape: +// +// { +// "name": "catalogName" +// } +func createCatalog(config common.WriteParams, url *urlbuilder.URL) error { + payload, isJSON := config.RecordData.(map[string]any) + if !isJSON { + return ErrCatalogCreate + } + + name, found := payload["name"] + if !found { + return ErrCatalogCreate + } + + catalogName, isString := name.(string) + if !isString { + return ErrCatalogCreate + } + + url.AddPath(catalogName) + + return nil +} + +type path struct { + id string + zoom []string +} + +func newPath(id string, zoom ...string) path { + return path{ + id: id, + zoom: zoom, + } +} + +var recordIdPaths = datautils.Map[string, path]{ // nolint:gochecknoglobals + "campaigns": newPath("campaignId"), + "catalogs": newPath("id", "params"), + "lists": newPath("listId"), + "templatesEmail": newPath("TODO", "params"), // TODO template ID + "templatesInApp": newPath("TODO", "params"), // TODO template ID + "templatesPush": newPath("TODO", "params"), // TODO template ID + "templatesSMS": newPath("TODO", "params"), // TODO template ID + "users": newPath("TODO", "params"), // TODO template ID + "webhooks": newPath("id"), +} + +func constructWriteResult(body *ajson.Node, recordIdLocation path) (*common.WriteResult, error) { + // ID is integer that is always stored under different field name. + intIdentifier, err := jsonquery.New(body, recordIdLocation.zoom...).Integer(recordIdLocation.id, false) + if err != nil { + return nil, err + } + + recordID := strconv.FormatInt(*intIdentifier, 10) + + return &common.WriteResult{ + Success: true, + RecordId: recordID, + Errors: nil, + Data: nil, + }, nil +} diff --git a/providers/iterable/write_test.go b/providers/iterable/write_test.go new file mode 100644 index 000000000..4889a3ecc --- /dev/null +++ b/providers/iterable/write_test.go @@ -0,0 +1,167 @@ +package iterable + +import ( + "errors" + "net/http" + "testing" + + "github.com/amp-labs/connectors" + "github.com/amp-labs/connectors/common" + "github.com/amp-labs/connectors/test/utils/mockutils/mockcond" + "github.com/amp-labs/connectors/test/utils/mockutils/mockserver" + "github.com/amp-labs/connectors/test/utils/testroutines" + "github.com/amp-labs/connectors/test/utils/testutils" +) + +func TestWrite(t *testing.T) { // nolint:funlen,cyclop + t.Parallel() + + responseBlocklistEntry := testutils.DataFromFile(t, "write-blocklist-entry.json") + + responseReply := testutils.DataFromFile(t, "write-unibox-reply.json") + + responseLeadErr := testutils.DataFromFile(t, "write-lead-bad-request.json") + responseLead := testutils.DataFromFile(t, "write-lead.json") + + responseTagErr := testutils.DataFromFile(t, "write-tag-bad-request.json") + responseTag := testutils.DataFromFile(t, "write-tag.json") + + tests := []testroutines.Write{ + { + Name: "Write object must be included", + Server: mockserver.Dummy(), + ExpectedErrs: []error{common.ErrMissingObjects}, + }, + { + Name: "Write needs data payload", + Input: common.WriteParams{ObjectName: "notes"}, + Server: mockserver.Dummy(), + ExpectedErrs: []error{common.ErrMissingRecordData}, + }, + { + Name: "Unknown object name is not supported", + Input: common.WriteParams{ObjectName: "orders", RecordData: "dummy"}, + Server: mockserver.Dummy(), + Expected: nil, + ExpectedErrs: []error{ + common.ErrOperationNotSupportedForObject, + }, + }, + { + Name: "Create Blocklist Entry", + Input: common.WriteParams{ObjectName: "blocklist-entries", RecordData: "dummy"}, + Server: mockserver.Conditional{ + Setup: mockserver.ContentJSON(), + If: mockcond.MethodPOST(), + Then: mockserver.Response(http.StatusOK, responseBlocklistEntry), + }.Server(), + Expected: &common.WriteResult{ + Success: true, + RecordId: "cf8fd143-08c3-438e-a396-491aa1ced9d4", + Errors: nil, + Data: nil, + }, + ExpectedErrs: nil, + }, + { + Name: "Create Unibox Reply", + Input: common.WriteParams{ObjectName: "unibox-replies", RecordData: "dummy"}, + Server: mockserver.Conditional{ + Setup: mockserver.ContentJSON(), + If: mockcond.MethodPOST(), + Then: mockserver.Response(http.StatusOK, responseReply), + }.Server(), + Expected: &common.WriteResult{ + Success: true, + RecordId: "19e9d7f9-bd4f-45aa-8d77-9eecc9bd3f9a", + Errors: nil, + Data: nil, + }, + ExpectedErrs: nil, + }, + + { + Name: "Invalid Lead creation", + Input: common.WriteParams{ObjectName: "leads", RecordData: "dummy"}, + Server: mockserver.Fixed{ + Setup: mockserver.ContentJSON(), + Always: mockserver.Response(http.StatusNotFound, responseLeadErr), + }.Server(), + ExpectedErrs: []error{ + common.ErrBadRequest, + errors.New("Leads array is empty"), // nolint:goerr113 + }, + }, + { + Name: "Create Lead", + Input: common.WriteParams{ObjectName: "leads", RecordData: "dummy"}, + Server: mockserver.Conditional{ + Setup: mockserver.ContentJSON(), + If: mockcond.MethodPOST(), + Then: mockserver.Response(http.StatusOK, responseLead), + }.Server(), + Expected: &common.WriteResult{ + Success: true, + RecordId: "", // lead doesn't have ID + Errors: nil, + Data: nil, + }, + ExpectedErrs: nil, + }, + { + Name: "Invalid Tag creation", + Input: common.WriteParams{ObjectName: "tags", RecordData: "dummy"}, + Server: mockserver.Fixed{ + Setup: mockserver.ContentJSON(), + Always: mockserver.Response(http.StatusNotFound, responseTagErr), + }.Server(), + ExpectedErrs: []error{ + common.ErrBadRequest, + errors.New("Bad Request"), // nolint:goerr113 + }, + }, + { + Name: "Create Tag acts as POST", + Input: common.WriteParams{ObjectName: "tags", RecordData: "dummy"}, + Server: mockserver.Conditional{ + Setup: mockserver.ContentJSON(), + If: mockcond.MethodPOST(), + Then: mockserver.Response(http.StatusOK, responseTag), + }.Server(), + Expected: &common.WriteResult{ + Success: true, + RecordId: "f6825fcf-c51b-4724-937b-0814ed02af83", + Errors: nil, + Data: nil, + }, + ExpectedErrs: nil, + }, + { + Name: "Update tag acts as PATCH", + Input: common.WriteParams{ObjectName: "tags", RecordId: "885633", RecordData: "dummy"}, + Server: mockserver.Conditional{ + Setup: mockserver.ContentJSON(), + If: mockcond.MethodPATCH(), + Then: mockserver.Response(http.StatusOK, responseTag), + }.Server(), + Expected: &common.WriteResult{ + Success: true, + RecordId: "f6825fcf-c51b-4724-937b-0814ed02af83", + Errors: nil, + Data: nil, + }, + ExpectedErrs: nil, + }, + } + + for _, tt := range tests { + // nolint:varnamelen + t.Run(tt.Name, func(t *testing.T) { + t.Parallel() + + tt.Run(t, func() (connectors.WriteConnector, error) { + return constructTestConnector(tt.Server.URL) + }) + }) + } +} diff --git a/test/iterable/write-delete/main.go b/test/iterable/write-delete/main.go new file mode 100644 index 000000000..0396c8296 --- /dev/null +++ b/test/iterable/write-delete/main.go @@ -0,0 +1,123 @@ +package main + +import ( + "context" + "log/slog" + "os/signal" + "strconv" + "syscall" + + "github.com/amp-labs/connectors" + "github.com/amp-labs/connectors/common" + "github.com/amp-labs/connectors/providers/iterable" + connTest "github.com/amp-labs/connectors/test/iterable" + "github.com/amp-labs/connectors/test/utils" + "github.com/amp-labs/connectors/test/utils/mockutils" + "github.com/brianvoe/gofakeit/v6" +) + +type ListsPayload struct { + Name string `json:"name"` + Description string `json:"description"` +} + +var objectName = "lists" // nolint: gochecknoglobals + +func main() { + // Handle Ctrl-C gracefully. + ctx, done := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer done() + + // Set up slog logging. + utils.SetupLogging() + + conn := connTest.GetIterableConnector(ctx) + + slog.Info("> TEST Create/Delete lists") + slog.Info("Creating list") + + name := gofakeit.Name() + createLists(ctx, conn, &ListsPayload{ + Name: name, + Description: gofakeit.Name(), // this should be a relatively short field + }) + + slog.Info("Reading segments") + + res := readLists(ctx, conn) + + slog.Info("Finding recently created segment") + + list := searchList(res, "name", name) + listID := listIdentifierAsString(list) + + slog.Info("Removing this list") + removeLists(ctx, conn, listID) + slog.Info("> Successful test completion") +} + +func listIdentifierAsString(list map[string]any) string { + id, ok := list["id"].(float64) + if !ok { + return "" + } + + listIdentifier := int64(id) + + return strconv.FormatInt(listIdentifier, 10) +} + +func searchList(res *common.ReadResult, key, value string) map[string]any { + for _, data := range res.Data { + if mockutils.DoesObjectCorrespondToString(data.Fields[key], value) { + return data.Fields + } + } + + utils.Fail("error finding a list") + + return nil +} + +func readLists(ctx context.Context, conn *iterable.Connector) *common.ReadResult { + res, err := conn.Read(ctx, common.ReadParams{ + ObjectName: objectName, + Fields: connectors.Fields("id", "name", "description"), + }) + if err != nil { + utils.Fail("error reading from Iterable", "error", err) + } + + return res +} + +func createLists(ctx context.Context, conn *iterable.Connector, payload *ListsPayload) *common.WriteResult { + res, err := conn.Write(ctx, common.WriteParams{ + ObjectName: objectName, + RecordId: "", + RecordData: payload, + }) + if err != nil { + utils.Fail("error writing to Iterable", "error", err) + } + + if !res.Success { + utils.Fail("failed to create a list") + } + + return res +} + +func removeLists(ctx context.Context, conn *iterable.Connector, listID string) { + res, err := conn.Delete(ctx, common.DeleteParams{ + ObjectName: objectName, + RecordId: listID, + }) + if err != nil { + utils.Fail("error deleting for Iterable", "error", err) + } + + if !res.Success { + utils.Fail("failed to remove a list") + } +}