From df86d0dc4d90e90abf67bc6edd4d3ecd2bbe8f96 Mon Sep 17 00:00:00 2001 From: Nate Meyer <672246+notnmeyer@users.noreply.github.com> Date: Thu, 21 May 2026 13:22:52 -0700 Subject: [PATCH] uploads command --- cmd/uploads.go | 89 +++++++++++++++++++ cmd/uploads_create_test.go | 170 +++++++++++++++++++++++++++++++++++++ go.mod | 2 +- go.sum | 4 +- 4 files changed, 262 insertions(+), 3 deletions(-) create mode 100644 cmd/uploads.go create mode 100644 cmd/uploads_create_test.go diff --git a/cmd/uploads.go b/cmd/uploads.go new file mode 100644 index 0000000..24f9b3c --- /dev/null +++ b/cmd/uploads.go @@ -0,0 +1,89 @@ +package cmd + +import ( + "fmt" + "net/http" + "os" + + "github.com/loops-so/cli/internal/config" + "github.com/loops-so/loops-go" + "github.com/spf13/cobra" +) + +func runUploadsCreate(cfg *config.Config, path, emailMessageID, contentType string) (*loops.CompleteUploadResponse, error) { + info, err := os.Stat(path) + if err != nil { + return nil, fmt.Errorf("open %q: %w", path, err) + } + if info.IsDir() { + return nil, fmt.Errorf("%q is a directory", path) + } + + f, err := os.Open(path) + if err != nil { + return nil, fmt.Errorf("open %q: %w", path, err) + } + defer f.Close() + + if contentType == "" { + var sniff [512]byte + n, err := f.Read(sniff[:]) + if err != nil { + return nil, fmt.Errorf("read %q: %w", path, err) + } + contentType = http.DetectContentType(sniff[:n]) + if _, err := f.Seek(0, 0); err != nil { + return nil, fmt.Errorf("seek %q: %w", path, err) + } + } + + return newAPIClient(cfg).Upload(loops.UploadRequest{ + EmailMessageID: emailMessageID, + ContentType: contentType, + ContentLength: info.Size(), + Body: f, + }) +} + +var uploadsCmd = &cobra.Command{ + Use: "uploads", + Short: "Manage uploaded email assets", +} + +var uploadsCreateCmd = &cobra.Command{ + Use: "create ", + Short: "Upload a file as an email asset", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + emailMessageID, _ := cmd.Flags().GetString("email-message-id") + contentType, _ := cmd.Flags().GetString("content-type") + + cfg, err := loadConfig() + if err != nil { + return err + } + + result, err := runUploadsCreate(cfg, args[0], emailMessageID, contentType) + if err != nil { + return err + } + + if isJSONOutput() { + return printJSON(cmd.OutOrStdout(), result) + } + + t := newStyledTable(cmd.OutOrStdout(), "FIELD", "VALUE") + t.Row("emailAssetId", result.EmailAssetID) + t.Row("finalUrl", result.FinalURL) + return t.Render() + }, +} + +func init() { + uploadsCreateCmd.Flags().StringP("email-message-id", "m", "", "Email message this asset belongs to (required)") + uploadsCreateCmd.Flags().String("content-type", "", "MIME type to use for the upload (default: sniffed from file contents)") + _ = uploadsCreateCmd.MarkFlagRequired("email-message-id") + + uploadsCmd.AddCommand(uploadsCreateCmd) + rootCmd.AddCommand(uploadsCmd) +} diff --git a/cmd/uploads_create_test.go b/cmd/uploads_create_test.go new file mode 100644 index 0000000..02540f6 --- /dev/null +++ b/cmd/uploads_create_test.go @@ -0,0 +1,170 @@ +package cmd + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/zalando/go-keyring" +) + +// pngBytes is a minimal valid PNG header — enough for http.DetectContentType +// to return "image/png". +var pngBytes = []byte{ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, + 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52, +} + +type uploadServer struct { + srv *httptest.Server + createReq map[string]any + putBody []byte + putContentType string + putContentLen int64 + completeCalled bool + completeAssetID string +} + +func serveUpload(t *testing.T) *uploadServer { + t.Helper() + keyring.MockInit() + t.Setenv("LOOPS_CONFIG_DIR", t.TempDir()) + + state := &uploadServer{} + state.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodPost && r.URL.Path == "/uploads": + if err := json.NewDecoder(r.Body).Decode(&state.createReq); err != nil { + t.Errorf("decode create body: %v", err) + } + fmt.Fprintf(w, `{"emailAssetId":"asset_123","presignedUrl":"%s/upload-target","expiresAt":"2026-05-21T12:00:00Z"}`, state.srv.URL) + + case r.Method == http.MethodPut && r.URL.Path == "/upload-target": + state.putContentType = r.Header.Get("Content-Type") + state.putContentLen = r.ContentLength + state.putBody, _ = io.ReadAll(r.Body) + w.WriteHeader(http.StatusOK) + + case r.Method == http.MethodPost && strings.HasPrefix(r.URL.Path, "/uploads/") && strings.HasSuffix(r.URL.Path, "/complete"): + state.completeCalled = true + state.completeAssetID = strings.TrimSuffix(strings.TrimPrefix(r.URL.Path, "/uploads/"), "/complete") + w.Write([]byte(`{"emailAssetId":"asset_123","finalUrl":"https://cdn.loops.so/asset_123.png"}`)) + + default: + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + } + })) + t.Cleanup(state.srv.Close) + t.Setenv("LOOPS_API_KEY", "test-key") + t.Setenv("LOOPS_ENDPOINT_URL", state.srv.URL) + return state +} + +func writeTempPNG(t *testing.T) string { + t.Helper() + dir := t.TempDir() + path := filepath.Join(dir, "image.png") + if err := os.WriteFile(path, pngBytes, 0o600); err != nil { + t.Fatalf("write temp file: %v", err) + } + return path +} + +func TestRunUploadsCreate(t *testing.T) { + t.Run("sniffs content-type when blank and uploads file body", func(t *testing.T) { + state := serveUpload(t) + path := writeTempPNG(t) + + result, err := runUploadsCreate(cfg(t), path, "em_abc123", "") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if result.EmailAssetID != "asset_123" { + t.Errorf("EmailAssetID = %q, want asset_123", result.EmailAssetID) + } + if result.FinalURL != "https://cdn.loops.so/asset_123.png" { + t.Errorf("FinalURL = %q", result.FinalURL) + } + + if state.createReq["emailMessageId"] != "em_abc123" { + t.Errorf("create emailMessageId = %v, want em_abc123", state.createReq["emailMessageId"]) + } + if state.createReq["contentType"] != "image/png" { + t.Errorf("create contentType = %v, want image/png (sniffed)", state.createReq["contentType"]) + } + if got, want := int64(state.createReq["contentLength"].(float64)), int64(len(pngBytes)); got != want { + t.Errorf("create contentLength = %d, want %d", got, want) + } + + if state.putContentType != "image/png" { + t.Errorf("PUT Content-Type = %q, want image/png", state.putContentType) + } + if state.putContentLen != int64(len(pngBytes)) { + t.Errorf("PUT Content-Length = %d, want %d", state.putContentLen, len(pngBytes)) + } + if string(state.putBody) != string(pngBytes) { + t.Errorf("PUT body did not match source file") + } + + if !state.completeCalled { + t.Error("complete endpoint was not called") + } + if state.completeAssetID != "asset_123" { + t.Errorf("complete called for assetID %q, want asset_123", state.completeAssetID) + } + }) + + t.Run("explicit --content-type overrides sniffing", func(t *testing.T) { + state := serveUpload(t) + path := writeTempPNG(t) + + if _, err := runUploadsCreate(cfg(t), path, "em_abc123", "application/octet-stream"); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if state.createReq["contentType"] != "application/octet-stream" { + t.Errorf("create contentType = %v, want application/octet-stream", state.createReq["contentType"]) + } + if state.putContentType != "application/octet-stream" { + t.Errorf("PUT Content-Type = %q, want application/octet-stream", state.putContentType) + } + }) + + t.Run("missing file returns error", func(t *testing.T) { + serveUpload(t) + if _, err := runUploadsCreate(cfg(t), "/does/not/exist.png", "em_abc123", ""); err == nil { + t.Fatal("expected error for missing file, got nil") + } + }) + + t.Run("directory path returns error", func(t *testing.T) { + serveUpload(t) + if _, err := runUploadsCreate(cfg(t), t.TempDir(), "em_abc123", ""); err == nil { + t.Fatal("expected error for directory path, got nil") + } + }) + + t.Run("create failure surfaces error", func(t *testing.T) { + keyring.MockInit() + t.Setenv("LOOPS_CONFIG_DIR", t.TempDir()) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusBadRequest) + w.Write([]byte(`{"success":false,"message":"missing emailMessageId"}`)) + })) + t.Cleanup(srv.Close) + t.Setenv("LOOPS_API_KEY", "test-key") + t.Setenv("LOOPS_ENDPOINT_URL", srv.URL) + + if _, err := runUploadsCreate(cfg(t), writeTempPNG(t), "", ""); err == nil { + t.Fatal("expected error, got nil") + } + }) +} diff --git a/go.mod b/go.mod index 199247c..1f77e3f 100644 --- a/go.mod +++ b/go.mod @@ -9,7 +9,7 @@ require ( github.com/atotto/clipboard v0.1.4 github.com/charmbracelet/colorprofile v0.4.2 github.com/charmbracelet/x/term v0.2.2 - github.com/loops-so/loops-go v0.1.3 + github.com/loops-so/loops-go v0.1.4-0 github.com/spf13/cobra v1.10.2 github.com/spf13/pflag v1.0.10 github.com/zalando/go-keyring v0.2.6 diff --git a/go.sum b/go.sum index 89272db..5c44cee 100644 --- a/go.sum +++ b/go.sum @@ -299,8 +299,8 @@ github.com/kr/pretty v0.2.0/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfn github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= -github.com/loops-so/loops-go v0.1.3 h1:JZqbHBE6T3e6UoJNVydP/I6Ie4mW94IW5uVbwTC+rXM= -github.com/loops-so/loops-go v0.1.3/go.mod h1:BDzBhAn/4e2QSKXrpXufIpSuH8xUPv9oa+hazH01ejE= +github.com/loops-so/loops-go v0.1.4-0 h1:bZt7A2jLGTnaW2uj5b3B9QiRyAXRS9yZ4W17U396PDE= +github.com/loops-so/loops-go v0.1.4-0/go.mod h1:BDzBhAn/4e2QSKXrpXufIpSuH8xUPv9oa+hazH01ejE= github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/lyft/protoc-gen-star v0.5.3/go.mod h1:V0xaHgaf5oCCqmcxYcWiDfTiKsZsRc87/1qhoTACD8w=