Repository: amp-labs/connectors
Pull request #44 (2024-01-03 19:10:38)

[ENG-459] feat: Add hubspot write tests
I forgot to add these tests to the write PR. These tests actually validate that hubspot writes work.

See https://github.com/amp-labs/connectors/pull/41 for an example run.

codesee-maps[bot]: <details open>
<summary><b>👇 <a href="https://app.codesee.io/r/reviews?pr=44&src=https%3A%2F%2Fgithub.com%2Famp-labs%2Fconnectors">Click on the image</a> for a new way to code review</b>
</summary>

####

[![Review these changes using an interactive CodeSee Map](https://s3.us-east-2.amazonaws.com/maps.codesee.io/images/github/amp-labs/connectors/44/66393c1d/f124b0cd743d41b64ec502e92d564f690e533cde.svg)](https://app.codesee.io/r/reviews?pr=44&src=https%3A%2F%2Fgithub.com%2Famp-labs%2Fconnectors)

#### Legend
<img src="https://app.codesee.io/Legend_v3.png" alt="CodeSee Map legend" width="220" height="156">
</details>

Patch:
diff --git a/go.mod b/go.mod
index 765beabb..8208646d 100644
--- a/go.mod
+++ b/go.mod
@@ -10,6 +10,7 @@ require (
 )
 
 require (
+	github.com/brianvoe/gofakeit/v6 v6.26.3 // indirect
 	github.com/gabriel-vasile/mimetype v1.4.2 // indirect
 	github.com/go-playground/locales v0.14.1 // indirect
 	github.com/go-playground/universal-translator v0.18.1 // indirect
diff --git a/go.sum b/go.sum
index bce8eb3d..7224e8df 100644
--- a/go.sum
+++ b/go.sum
@@ -1,3 +1,5 @@
+github.com/brianvoe/gofakeit/v6 v6.26.3 h1:3ljYrjPwsUNAUFdUIr2jVg5EhKdcke/ZLop7uVg1Er8=
+github.com/brianvoe/gofakeit/v6 v6.26.3/go.mod h1:Xj58BMSnFqcn/fAQeSK+/PLtC5kSb7FJIq4JyGa8vEs=
 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=


Test patch:
diff --git a/test/hubspot/main.go b/test/hubspot/main.go
new file mode 100644
index 00000000..60312a35
--- /dev/null
+++ b/test/hubspot/main.go
@@ -0,0 +1,76 @@
+package main
+
+import (
+	"context"
+	"os"
+	"os/signal"
+	"syscall"
+
+	"github.com/amp-labs/connectors/common"
+	"github.com/amp-labs/connectors/test/utils"
+	"github.com/brianvoe/gofakeit/v6"
+)
+
+// Contact is a basic Hubspot contact.
+type Contact struct {
+	Email     string `json:"email"`
+	Phone     string `json:"phone"`
+	Company   string `json:"company"`
+	Website   string `json:"website"`
+	Lastname  string `json:"lastname"`
+	Firstname string `json:"firstname"`
+}
+
+// Running instructions
+//
+// Step 1: prepare creds.json file
+//
+//	e.g. {
+//	  "CLIENT_ID": "<client id goes here>",
+//	  "CLIENT_SECRET": "<client secret goes here>",
+//	  "ACCESS_TOKEN": "<access token goes here>",
+//	  "REFRESH_TOKEN": "<refresh token goes here>"
+//	}
+//
+// In 1password, you can find a Hubspot creds.json file in the "Shared" vault.
+// Look for the title "Hubspot Sample OAuth Credentials".
+// The 1password item has an attached file called "creds.json" that contains the JSON.
+//
+// Step 2: run the following commands
+//
+//	$> export CREDENTIALS_FILE=creds.json
+//	$> go run test/hubspot/main.go
+func main() {
+	// Handle Ctrl-C gracefully.
+	ctx, done := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
+	defer done()
+
+	// Set up slog logging.
+	utils.SetupLogging()
+
+	// Get the Hubspot connector.
+	hsConn := utils.GetHubspotConnector(ctx, "creds.json")
+	defer utils.Close(hsConn)
+
+	// Write an artificial contact to Hubspot.
+	result, err := hsConn.Write(ctx, common.WriteParams{
+		ObjectName: "contacts",
+		ObjectId:   "",
+		ObjectData: map[string]interface{}{
+			"properties": &Contact{
+				Email:     gofakeit.Email(),
+				Phone:     gofakeit.Phone(),
+				Company:   gofakeit.Company(),
+				Website:   gofakeit.URL(),
+				Lastname:  gofakeit.LastName(),
+				Firstname: gofakeit.FirstName(),
+			},
+		},
+	})
+	if err != nil {
+		utils.Fail("error writing to hubspot", "error", err)
+	}
+
+	// Dump the result.
+	utils.DumpJSON(result, os.Stdout)
+}
diff --git a/test/utils/close.go b/test/utils/close.go
new file mode 100644
index 00000000..3729ab53
--- /dev/null
+++ b/test/utils/close.go
@@ -0,0 +1,10 @@
+package utils
+
+import "io"
+
+// Close closes the given Closer and fails if there is an error.
+func Close(c io.Closer) {
+	if err := c.Close(); err != nil {
+		Fail("error closing: %w", err)
+	}
+}
diff --git a/test/utils/creds.go b/test/utils/creds.go
new file mode 100644
index 00000000..4f3a3341
--- /dev/null
+++ b/test/utils/creds.go
@@ -0,0 +1,23 @@
+package utils
+
+import (
+	"os"
+
+	"github.com/spyzhov/ajson"
+)
+
+// Credentials returns the credentials from the creds.json file.
+func Credentials(defaultFileName string) (*ajson.Node, error) {
+	fileName := defaultFileName
+
+	if fn, ok := os.LookupEnv("CREDENTIALS_FILE"); ok {
+		fileName = fn
+	}
+
+	byteValue, err := os.ReadFile(fileName)
+	if err != nil {
+		return nil, err
+	}
+
+	return ajson.Unmarshal(byteValue)
+}
diff --git a/test/utils/dump.go b/test/utils/dump.go
new file mode 100644
index 00000000..b1778cf5
--- /dev/null
+++ b/test/utils/dump.go
@@ -0,0 +1,19 @@
+package utils
+
+import (
+	"encoding/json"
+	"io"
+)
+
+// DumpJSON dumps the given value as JSON to the given writer.
+func DumpJSON(v any, w io.Writer) {
+	bts, err := json.MarshalIndent(v, "", "  ")
+	if err != nil {
+		Fail("error marshaling to JSON: %w", "error", err)
+	}
+
+	_, err = w.Write(bts)
+	if err != nil {
+		Fail("error writing to writer: %w", "error", err)
+	}
+}
diff --git a/test/utils/fail.go b/test/utils/fail.go
new file mode 100644
index 00000000..f825cd65
--- /dev/null
+++ b/test/utils/fail.go
@@ -0,0 +1,12 @@
+package utils
+
+import (
+	"log/slog"
+	"os"
+)
+
+// Fail logs the message and exits with code 1.
+func Fail(msg string, args ...any) {
+	slog.Error(msg, args...)
+	os.Exit(1)
+}
diff --git a/test/utils/hubspot.go b/test/utils/hubspot.go
new file mode 100644
index 00000000..b069ba9c
--- /dev/null
+++ b/test/utils/hubspot.go
@@ -0,0 +1,85 @@
+package utils
+
+import (
+	"context"
+	"net/http"
+	"time"
+
+	"github.com/amp-labs/connectors"
+	"github.com/amp-labs/connectors/hubspot"
+	"github.com/spyzhov/ajson"
+	"golang.org/x/oauth2"
+)
+
+// GetHubspotConnector returns a Hubspot connector.
+func GetHubspotConnector(ctx context.Context, defaultFileName string) *hubspot.Connector {
+	creds, err := Credentials(defaultFileName)
+	if err != nil {
+		Fail("error getting credentials", "error", err)
+	}
+
+	cfg, tok := GetOAuthInfo(creds)
+
+	conn, err := connectors.Hubspot(
+		hubspot.WithClient(ctx, http.DefaultClient, cfg, tok),
+		hubspot.WithModule(hubspot.ModuleCRM))
+	if err != nil {
+		Fail("error creating hubspot connector", "error", err)
+	}
+
+	return conn
+}
+
+// GetOAuthInfo returns the OAuth2 config and token from the creds.json file.
+func GetOAuthInfo(creds *ajson.Node) (*oauth2.Config, *oauth2.Token) {
+	config := creds.MustObject()
+
+	clientId, found := config["CLIENT_ID"]
+	if !found {
+		Fail("CLIENT_ID not found in creds")
+	}
+
+	clientSecret, found := config["CLIENT_SECRET"]
+
+	if !found {
+		Fail("CLIENT_SECRET not found in creds")
+	}
+
+	// Treat this as optional, since we can get a new one if needed.
+	accessToken, _ := config["ACCESS_TOKEN"]
+
+	refreshToken, found := config["REFRESH_TOKEN"]
+
+	if !found {
+		Fail("REFRESH_TOKEN not found in creds")
+	}
+
+	cfg := &oauth2.Config{
+		ClientID:     clientId.MustString(),
+		ClientSecret: clientSecret.MustString(),
+		RedirectURL:  "http://localhost:8080/callbacks/v1/oauth",
+		Endpoint: oauth2.Endpoint{
+			AuthURL:   "https://app.hubspot.com/oauth/authorize",
+			TokenURL:  "https://api.hubapi.com/oauth/v1/token",
+			AuthStyle: oauth2.AuthStyleInParams,
+		},
+		Scopes: []string{
+			"crm.objects.contacts.read",
+			"crm.objects.contacts.write",
+			"crm.objects.deals.read",
+			"crm.objects.line_items.read",
+			"oauth",
+			"crm.objects.companies.read",
+			"tickets",
+		},
+	}
+
+	tok := &oauth2.Token{
+		AccessToken:  accessToken.MustString(),
+		RefreshToken: refreshToken.MustString(),
+		TokenType:    "bearer",
+		Expiry:       time.Now().Add(-1 * time.Hour), // just pretend it's expired already, whatever, it'll fetch a new one.
+	}
+
+	return cfg, tok
+}
diff --git a/test/utils/logging.go b/test/utils/logging.go
new file mode 100644
index 00000000..c648b0b3
--- /dev/null
+++ b/test/utils/logging.go
@@ -0,0 +1,15 @@
+package utils
+
+import (
+	"log/slog"
+	"os"
+)
+
+// SetupLogging sets up logging for the test suite.
+func SetupLogging() {
+	handler := slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
+		Level: slog.LevelDebug,
+	})
+	logger := slog.New(handler)
+	slog.SetDefault(logger)
+}