diff --git a/internal/api/content_changes.go b/internal/api/content_changes.go index c8732c9..1871882 100644 --- a/internal/api/content_changes.go +++ b/internal/api/content_changes.go @@ -4,16 +4,20 @@ import "errors" // ContentChange represents a draft change set type ContentChange struct { - SCID string `json:"sc_id"` - SFID string `json:"sfid,omitempty"` - Status string `json:"status"` - CustomData map[string]interface{} `json:"custom_data,omitempty"` + SCID string `json:"sc_id"` + SFID string `json:"sfid,omitempty"` + Status string `json:"status"` + Summary string `json:"summary,omitempty"` + RecordsCount int `json:"records_count,omitempty"` + CreatedAt string `json:"created_at,omitempty"` + CustomData map[string]interface{} `json:"custom_data,omitempty"` } // ContentChangeRequest represents a request to create/update content changes type ContentChangeRequest struct { ThemeID string `json:"theme_id,omitempty"` Templates []ContentChangeTemplate `json:"templates,omitempty"` + Assets []ContentChangeAsset `json:"assets,omitempty"` } // ContentChangeTemplate represents a template change @@ -23,6 +27,17 @@ type ContentChangeTemplate struct { Action string `json:"action"` // "create", "update", or "delete" } +// ContentChangeAsset represents an uploaded or changed binary asset that +// should be registered on the draft. URL is the hosted location returned by +// the media upload flow; ContentHash lets the server skip re-processing +// unchanged binaries. +type ContentChangeAsset struct { + Key string `json:"key"` + URL string `json:"url"` + ContentType string `json:"content_type,omitempty"` + ContentHash string `json:"content_hash,omitempty"` +} + // PreviewURLResponse represents the preview URL response type PreviewURLResponse struct { PreviewURL string `json:"preview_url"` @@ -62,17 +77,43 @@ func (cc *ContentChanges) Create(themeID string) (*ContentChange, error) { return &result, nil } -// Update adds template changes to an existing content change -func (cc *ContentChanges) Update(id string, themeID string, templates []ContentChangeTemplate) error { - body := map[string]interface{}{ - "theme_id": themeID, - "templates": templates, +// Update adds template and asset changes to an existing content change. +// +// Assets are the binaries already uploaded through the media flow; passing an +// empty slice simply omits them from the request body. +func (cc *ContentChanges) Update(id string, themeID string, templates []ContentChangeTemplate, assets []ContentChangeAsset) error { + body := ContentChangeRequest{ + ThemeID: themeID, + Templates: templates, + Assets: assets, } var result ContentChange return cc.client.Patch("/api/v1/content_changes/"+id, body, &result) } +// List returns the store's content changes, optionally filtered by status. +// +// Drafts created from one machine can be discovered and resumed from another +// by listing them here. +func (cc *ContentChanges) List(status string) ([]ContentChange, error) { + var result struct { + Data []ContentChange `json:"data"` + } + + var params map[string]string + if status != "" { + params = map[string]string{"status": status} + } + + err := cc.client.Get("/api/v1/content_changes", &result, params) + if err != nil { + return nil, err + } + + return result.Data, nil +} + // GetPreviewURL gets the preview URL for a content change func (cc *ContentChanges) GetPreviewURL(id string) (string, error) { var result PreviewURLResponse diff --git a/internal/api/content_changes_test.go b/internal/api/content_changes_test.go index e81f463..79bcf83 100644 --- a/internal/api/content_changes_test.go +++ b/internal/api/content_changes_test.go @@ -95,10 +95,92 @@ func TestContentChangesUpdate(t *testing.T) { client := NewClient(server.URL, "test-store", "test-key") ccService := NewContentChanges(client) - err := ccService.Update("cc-123", "theme-123", templates) + err := ccService.Update("cc-123", "theme-123", templates, nil) require.NoError(t, err) } +func TestContentChangesUpdateIncludesAssets(t *testing.T) { + templates := []ContentChangeTemplate{ + {Key: "pages/home", Content: "

Home

", Action: "update"}, + } + assets := []ContentChangeAsset{ + {Key: "images/logo.png", URL: "https://cdn.example.com/logo.png", ContentType: "image/png", ContentHash: "hash1"}, + } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/api/v1/content_changes/cc-123", r.URL.Path) + assert.Equal(t, http.MethodPatch, r.Method) + + var body map[string]interface{} + require.NoError(t, json.NewDecoder(r.Body).Decode(&body)) + + require.Contains(t, body, "assets") + rawAssets, ok := body["assets"].([]interface{}) + require.True(t, ok) + require.Len(t, rawAssets, 1) + + asset, ok := rawAssets[0].(map[string]interface{}) + require.True(t, ok) + assert.Equal(t, "images/logo.png", asset["key"]) + assert.Equal(t, "https://cdn.example.com/logo.png", asset["url"]) + assert.Equal(t, "image/png", asset["content_type"]) + assert.Equal(t, "hash1", asset["content_hash"]) + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(ContentChange{SCID: "cc-123", Status: "draft"}) + })) + defer server.Close() + + client := NewClient(server.URL, "test-store", "test-key") + ccService := NewContentChanges(client) + + err := ccService.Update("cc-123", "theme-123", templates, assets) + require.NoError(t, err) +} + +func TestContentChangesList(t *testing.T) { + tests := []struct { + name string + status string + wantStatus string + wantLen int + }{ + {name: "no status filter", status: "", wantStatus: "", wantLen: 2}, + {name: "filtered by status", status: "draft", wantStatus: "draft", wantLen: 2}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/api/v1/content_changes", r.URL.Path) + assert.Equal(t, http.MethodGet, r.Method) + assert.Equal(t, tt.wantStatus, r.URL.Query().Get("status")) + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]interface{}{ + "data": []ContentChange{ + {SCID: "cc-1", Status: "draft", Summary: "First", RecordsCount: 3, CreatedAt: "2026-06-12T00:00:00Z"}, + {SCID: "cc-2", Status: "draft", Summary: "Second", RecordsCount: 1}, + }, + }) + })) + defer server.Close() + + client := NewClient(server.URL, "test-store", "test-key") + ccService := NewContentChanges(client) + + changes, err := ccService.List(tt.status) + require.NoError(t, err) + require.Len(t, changes, tt.wantLen) + assert.Equal(t, "cc-1", changes[0].SCID) + assert.Equal(t, "First", changes[0].Summary) + assert.Equal(t, 3, changes[0].RecordsCount) + }) + } +} + func TestContentChangesGetPreviewURL(t *testing.T) { tests := []struct { name string diff --git a/internal/api/media.go b/internal/api/media.go index f04e272..1f8d175 100644 --- a/internal/api/media.go +++ b/internal/api/media.go @@ -1,5 +1,15 @@ package api +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "mime/multipart" + "net/http" + "time" +) + // MediaAsset represents a media asset type MediaAsset struct { SCID string `json:"sc_id"` @@ -10,20 +20,46 @@ type MediaAsset struct { Size int64 `json:"size,omitempty"` } -// UploadURLResponse represents the upload URL response +// UploadURLResponse represents the response from POST /api/v1/media/upload_url. +// +// The server hands back a target URL plus a set of form parameters that must +// accompany the binary in the follow-up multipart POST. type UploadURLResponse struct { - UploadURL string `json:"upload_url"` - AssetID string `json:"asset_id"` + UploadURL string `json:"upload_url"` + UploadParams map[string]string `json:"upload_params"` + AssetID string `json:"asset_id,omitempty"` +} + +// UploadResult is the parsed response from the upload host. The hosted URL is +// "secure_url" when present, falling back to "url". +type UploadResult struct { + SecureURL string `json:"secure_url"` + URL string `json:"url"` +} + +// HostedURL returns the canonical hosted location, preferring the secure URL. +func (r UploadResult) HostedURL() string { + if r.SecureURL != "" { + return r.SecureURL + } + return r.URL } // Media handles media-related endpoints type Media struct { client *Client + + // uploader performs the multipart POST to the (external) upload host. + // It is overridable in tests; production uses the default HTTP client. + uploader *http.Client } // NewMedia creates a new Media service func NewMedia(client *Client) *Media { - return &Media{client: client} + return &Media{ + client: client, + uploader: &http.Client{Timeout: 60 * time.Second}, + } } // List returns all media assets @@ -40,9 +76,13 @@ func (m *Media) List() ([]MediaAsset, error) { return result.Media, nil } -// GetUploadURL gets a presigned upload URL -func (m *Media) GetUploadURL(filename, contentType string) (*UploadURLResponse, error) { +// UploadURL requests a signed upload target for a single file. +// +// fileType is the StoreConnect media kind ("image" or "document"), filename is +// the asset key/name, and contentType is the MIME type inferred from the file. +func (m *Media) UploadURL(fileType, filename, contentType string) (*UploadURLResponse, error) { body := map[string]interface{}{ + "file_type": fileType, "filename": filename, "content_type": contentType, } @@ -56,17 +96,61 @@ func (m *Media) GetUploadURL(filename, contentType string) (*UploadURLResponse, return &result, nil } -// ConfirmUpload confirms a completed upload -func (m *Media) ConfirmUpload(assetID string) (*MediaAsset, error) { - body := map[string]interface{}{ - "asset_id": assetID, +// UploadFile streams the binary to the upload host as a multipart POST, +// sending every upload param as a form field plus the bytes as the "file" +// field. It returns the hosted URL the server assigned to the upload. +func (m *Media) UploadFile(upload *UploadURLResponse, filename string, content []byte) (string, error) { + var buf bytes.Buffer + writer := multipart.NewWriter(&buf) + + for key, value := range upload.UploadParams { + if err := writer.WriteField(key, value); err != nil { + return "", fmt.Errorf("failed to write upload param %q: %w", key, err) + } } - var result MediaAsset - err := m.client.Post("/api/v1/media/confirm_upload", body, &result) + part, err := writer.CreateFormFile("file", filename) if err != nil { - return nil, err + return "", fmt.Errorf("failed to create file field: %w", err) + } + if _, err := part.Write(content); err != nil { + return "", fmt.Errorf("failed to write file contents: %w", err) } - return &result, nil + if err := writer.Close(); err != nil { + return "", fmt.Errorf("failed to finalize upload body: %w", err) + } + + req, err := http.NewRequest(http.MethodPost, upload.UploadURL, &buf) + if err != nil { + return "", fmt.Errorf("failed to build upload request: %w", err) + } + req.Header.Set("Content-Type", writer.FormDataContentType()) + + resp, err := m.uploader.Do(req) + if err != nil { + return "", fmt.Errorf("failed to upload file: %w", err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return "", fmt.Errorf("failed to read upload response: %w", err) + } + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return "", fmt.Errorf("upload failed with status %d: %s", resp.StatusCode, string(respBody)) + } + + var result UploadResult + if err := json.Unmarshal(respBody, &result); err != nil { + return "", fmt.Errorf("failed to parse upload response: %w", err) + } + + hosted := result.HostedURL() + if hosted == "" { + return "", fmt.Errorf("upload response missing hosted URL") + } + + return hosted, nil } diff --git a/internal/api/media_test.go b/internal/api/media_test.go new file mode 100644 index 0000000..def285b --- /dev/null +++ b/internal/api/media_test.go @@ -0,0 +1,143 @@ +package api + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestMediaUploadURL(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/api/v1/media/upload_url", r.URL.Path) + assert.Equal(t, http.MethodPost, r.Method) + + var body map[string]interface{} + require.NoError(t, json.NewDecoder(r.Body).Decode(&body)) + assert.Equal(t, "image", body["file_type"]) + assert.Equal(t, "logo.png", body["filename"]) + assert.Equal(t, "image/png", body["content_type"]) + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]interface{}{ + "upload_url": "https://uploads.example.com/signed", + "upload_params": map[string]string{ + "signature": "abc", + "timestamp": "123", + }, + "asset_id": "asset-1", + }) + })) + defer server.Close() + + client := NewClient(server.URL, "test-store", "test-key") + media := NewMedia(client) + + resp, err := media.UploadURL("image", "logo.png", "image/png") + require.NoError(t, err) + assert.Equal(t, "https://uploads.example.com/signed", resp.UploadURL) + assert.Equal(t, "abc", resp.UploadParams["signature"]) + assert.Equal(t, "123", resp.UploadParams["timestamp"]) + assert.Equal(t, "asset-1", resp.AssetID) +} + +func TestMediaUploadFile(t *testing.T) { + tests := []struct { + name string + response map[string]interface{} + wantURL string + wantErr bool + errContains string + }{ + { + name: "prefers secure_url", + response: map[string]interface{}{"secure_url": "https://cdn.example.com/secure.png", "url": "http://cdn.example.com/plain.png"}, + wantURL: "https://cdn.example.com/secure.png", + }, + { + name: "falls back to url", + response: map[string]interface{}{"url": "http://cdn.example.com/plain.png"}, + wantURL: "http://cdn.example.com/plain.png", + }, + { + name: "missing hosted url", + response: map[string]interface{}{"public_id": "x"}, + wantErr: true, + errContains: "missing hosted URL", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method) + + // Parse the multipart body and assert the params + file arrive. + require.NoError(t, r.ParseMultipartForm(10<<20)) + + assert.Equal(t, "sig-value", r.FormValue("signature")) + assert.Equal(t, "1700000000", r.FormValue("timestamp")) + + file, header, err := r.FormFile("file") + require.NoError(t, err) + defer file.Close() + // Go's multipart reader returns the base name (path components in + // the Content-Disposition filename are stripped by the stdlib). + assert.Equal(t, "logo.png", header.Filename) + contents, err := io.ReadAll(file) + require.NoError(t, err) + assert.Equal(t, []byte("binary-bytes"), contents) + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(tt.response) + })) + defer server.Close() + + client := NewClient(server.URL, "test-store", "test-key") + media := NewMedia(client) + + upload := &UploadURLResponse{ + UploadURL: server.URL, + UploadParams: map[string]string{ + "signature": "sig-value", + "timestamp": "1700000000", + }, + } + + hosted, err := media.UploadFile(upload, "images/logo.png", []byte("binary-bytes")) + + if tt.wantErr { + require.Error(t, err) + if tt.errContains != "" { + assert.Contains(t, err.Error(), tt.errContains) + } + } else { + require.NoError(t, err) + assert.Equal(t, tt.wantURL, hosted) + } + }) + } +} + +func TestMediaUploadFileServerError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte("boom")) + })) + defer server.Close() + + client := NewClient(server.URL, "test-store", "test-key") + media := NewMedia(client) + + upload := &UploadURLResponse{UploadURL: server.URL, UploadParams: map[string]string{}} + _, err := media.UploadFile(upload, "logo.png", []byte("x")) + + require.Error(t, err) + assert.Contains(t, err.Error(), "status 500") +} diff --git a/internal/api/themes.go b/internal/api/themes.go index 68e059c..2473c62 100644 --- a/internal/api/themes.go +++ b/internal/api/themes.go @@ -16,11 +16,17 @@ type ThemeTemplate struct { Content string `json:"content"` } -// ThemeAsset represents an asset in a theme +// ThemeAsset represents an asset in a theme. +// +// The server contract for GET /api/v1/themes/:id returns assets as +// [{key, url, content_type, content_hash}]. Key is the asset's path within +// the theme (it may contain slashes), content_hash is the SHA-256 hex digest +// of the binary, used to skip uploads of unchanged files. type ThemeAsset struct { - Filename string `json:"filename"` - ContentType string `json:"content_type"` - URL string `json:"url"` + Key string `json:"key"` + URL string `json:"url,omitempty"` + ContentType string `json:"content_type,omitempty"` + ContentHash string `json:"content_hash,omitempty"` } // Themes handles theme-related endpoints diff --git a/internal/commands/change_list.go b/internal/commands/change_list.go new file mode 100644 index 0000000..7db01ee --- /dev/null +++ b/internal/commands/change_list.go @@ -0,0 +1,89 @@ +package commands + +import ( + "fmt" + + "github.com/GetStoreConnect/storeconnect-cli/internal/api" + "github.com/GetStoreConnect/storeconnect-cli/internal/ui" + "github.com/spf13/cobra" +) + +var changeCmd = &cobra.Command{ + Use: "change", + Short: "Content change management commands", + Long: `Commands for working with StoreConnect content changes (drafts) so they can be resumed from any machine.`, +} + +var changeListCmd = &cobra.Command{ + Use: "list", + Short: "List content changes", + Long: `List the store's content changes (drafts). + +Use --status to filter by status (e.g. draft, review, published). Drafts +created on one machine can be discovered and resumed from another.`, + RunE: runChangeList, +} + +func init() { + rootCmd.AddCommand(changeCmd) + changeCmd.AddCommand(changeListCmd) + + changeListCmd.Flags().String("status", "", "filter content changes by status (e.g. draft, review, published)") +} + +func runChangeList(cmd *cobra.Command, args []string) error { + formatter := ui.NewFormatter() + status, _ := cmd.Flags().GetString("status") + + client, serverAlias, err := getAPIClient(cmd) + if err != nil { + if !jsonOutput { + formatter.Error(fmt.Sprintf("Failed to get API client: %v", err)) + } + return outputError(err) + } + + changesService := api.NewContentChanges(client) + + var spinner *ui.Spinner + if !jsonOutput { + spinner = ui.NewSpinner("Fetching content changes") + spinner.Start() + } + + changes, err := changesService.List(status) + if err != nil { + if spinner != nil { + spinner.Error(fmt.Sprintf("Failed to fetch content changes: %v", err)) + } + return outputError(err) + } + + if spinner != nil { + spinner.Stop() + } + + // JSON output + if jsonOutput { + return outputResponse(changes, nil) + } + + // Human-friendly table + if len(changes) == 0 { + formatter.Warning("No content changes found") + return nil + } + + formatter.Info(fmt.Sprintf("Content changes on %s:", serverAlias)) + fmt.Println() + + fmt.Printf("%-22s %-12s %-8s %s\n", "SC ID", "STATUS", "RECORDS", "SUMMARY") + for _, change := range changes { + fmt.Printf("%-22s %-12s %-8d %s\n", change.SCID, change.Status, change.RecordsCount, change.Summary) + } + + fmt.Println() + formatter.Dim(fmt.Sprintf("Total: %d content changes", len(changes))) + + return nil +} diff --git a/internal/commands/theme_push.go b/internal/commands/theme_push.go index 97a1cb6..7f57552 100644 --- a/internal/commands/theme_push.go +++ b/internal/commands/theme_push.go @@ -112,6 +112,30 @@ func runThemePush(cmd *cobra.Command, args []string) error { // Create API client client := api.NewClient(cred.URL, cred.StoreSFID, cred.APIKey, api.WithOrgID(cred.OrgID)) contentChangesService := api.NewContentChanges(client) + themesService := api.NewThemes(client) + mediaService := api.NewMedia(client) + + // Determine which local assets need uploading by hash-diffing against the + // server's copy of the theme. Unchanged binaries are skipped entirely. + localAssets, err := theme.ReadLocalAssets(fmt.Sprintf("themes/%s", themeName)) + if err != nil { + if spinner != nil { + spinner.Error(fmt.Sprintf("Failed to read assets: %v", err)) + } + return outputError(err) + } + + var changedAssets []theme.LocalAsset + if len(localAssets) > 0 { + serverTheme, err := themesService.Get(themeID) + if err != nil { + if spinner != nil { + spinner.Error(fmt.Sprintf("Failed to fetch server theme: %v", err)) + } + return outputError(err) + } + changedAssets = theme.SelectChangedAssets(localAssets, serverTheme.Assets) + } // Create content change (draft) contentChange, err := contentChangesService.Create(themeID) @@ -122,6 +146,39 @@ func runThemePush(cmd *cobra.Command, args []string) error { return outputError(err) } + // Upload each changed asset binary through the media flow and collect the + // hosted URLs so they can be registered on the draft. + assets := make([]api.ContentChangeAsset, 0, len(changedAssets)) + for _, asset := range changedAssets { + if spinner != nil { + spinner.UpdateMessage(fmt.Sprintf("Uploading asset %s", asset.Key)) + } + + fileType := theme.InferFileType(asset.Key) + upload, err := mediaService.UploadURL(fileType, asset.Key, asset.ContentType) + if err != nil { + if spinner != nil { + spinner.Error(fmt.Sprintf("Failed to get upload URL for %s: %v", asset.Key, err)) + } + return outputError(err) + } + + hostedURL, err := mediaService.UploadFile(upload, asset.Key, asset.Content) + if err != nil { + if spinner != nil { + spinner.Error(fmt.Sprintf("Failed to upload asset %s: %v", asset.Key, err)) + } + return outputError(err) + } + + assets = append(assets, api.ContentChangeAsset{ + Key: asset.Key, + URL: hostedURL, + ContentType: asset.ContentType, + ContentHash: asset.ContentHash, + }) + } + if spinner != nil { spinner.UpdateMessage("Uploading templates") } @@ -136,9 +193,9 @@ func runThemePush(cmd *cobra.Command, args []string) error { } } - // Update content change with templates - if len(templates) > 0 { - if err := contentChangesService.Update(contentChange.SCID, themeID, templates); err != nil { + // Update content change with templates and any uploaded assets + if len(templates) > 0 || len(assets) > 0 { + if err := contentChangesService.Update(contentChange.SCID, themeID, templates, assets); err != nil { if spinner != nil { spinner.Error(fmt.Sprintf("Failed to upload templates: %v", err)) } diff --git a/internal/testutil/fixtures.go b/internal/testutil/fixtures.go index 40b55b9..22f2951 100644 --- a/internal/testutil/fixtures.go +++ b/internal/testutil/fixtures.go @@ -20,9 +20,10 @@ func TestTheme() *api.Theme { }, Assets: []api.ThemeAsset{ { - Filename: "logo.png", - ContentType: "image/png", + Key: "logo.png", URL: "https://example.com/logo.png", + ContentType: "image/png", + ContentHash: "abc123", }, }, Variables: map[string]interface{}{ diff --git a/internal/theme/assets.go b/internal/theme/assets.go new file mode 100644 index 0000000..5e1868e --- /dev/null +++ b/internal/theme/assets.go @@ -0,0 +1,146 @@ +package theme + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/GetStoreConnect/storeconnect-cli/internal/api" +) + +// AssetsDirName is the directory inside a theme that holds binary assets. +const AssetsDirName = "assets" + +// LocalAsset is a binary asset read from the local theme's assets/ directory. +// Key is the path relative to assets/ (forward-slashed, may contain slashes), +// matching the server's asset key contract. +type LocalAsset struct { + Key string + Path string + ContentType string + ContentHash string + Content []byte +} + +// ReadLocalAssets walks the assets/ directory inside themeDir and returns every +// file as a LocalAsset with its SHA-256 hex digest computed. A missing assets/ +// directory is not an error - it simply yields no assets. +func ReadLocalAssets(themeDir string) ([]LocalAsset, error) { + assetsDir := filepath.Join(themeDir, AssetsDirName) + + if _, err := os.Stat(assetsDir); os.IsNotExist(err) { + return nil, nil + } + + var assets []LocalAsset + + err := filepath.Walk(assetsDir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + if info.IsDir() { + return nil + } + + content, err := os.ReadFile(path) + if err != nil { + return err + } + + relPath, err := filepath.Rel(assetsDir, path) + if err != nil { + return err + } + key := filepath.ToSlash(relPath) + + assets = append(assets, LocalAsset{ + Key: key, + Path: path, + ContentType: InferContentType(key), + ContentHash: HashContent(content), + Content: content, + }) + + return nil + }) + + if err != nil { + return nil, fmt.Errorf("failed to read assets: %w", err) + } + + return assets, nil +} + +// HashContent returns the SHA-256 hex digest of the given bytes. +func HashContent(content []byte) string { + sum := sha256.Sum256(content) + return hex.EncodeToString(sum[:]) +} + +// SelectChangedAssets returns the local assets that need uploading: those whose +// key is absent on the server, or whose content hash differs from the server's. +// Assets whose hash matches the server are skipped. This is a pure function so +// the selection rule is trivially unit-testable. +func SelectChangedAssets(local []LocalAsset, server []api.ThemeAsset) []LocalAsset { + serverHashes := make(map[string]string, len(server)) + for _, a := range server { + serverHashes[a.Key] = a.ContentHash + } + + var changed []LocalAsset + for _, a := range local { + serverHash, exists := serverHashes[a.Key] + if !exists || serverHash != a.ContentHash { + changed = append(changed, a) + } + } + + return changed +} + +// imageExtensions are the file extensions treated as images by the media flow. +var imageExtensions = map[string]bool{ + ".png": true, + ".jpg": true, + ".jpeg": true, + ".gif": true, + ".webp": true, + ".svg": true, +} + +// contentTypes maps known image extensions to their MIME type. +var contentTypes = map[string]string{ + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".gif": "image/gif", + ".webp": "image/webp", + ".svg": "image/svg+xml", + ".css": "text/css", + ".js": "application/javascript", + ".json": "application/json", + ".pdf": "application/pdf", +} + +// InferContentType guesses the MIME type from a filename's extension, defaulting +// to application/octet-stream for unknown extensions. +func InferContentType(filename string) string { + ext := strings.ToLower(filepath.Ext(filename)) + if ct, ok := contentTypes[ext]; ok { + return ct + } + return "application/octet-stream" +} + +// InferFileType returns the StoreConnect media file_type for a filename: +// "image" for known image extensions, "document" otherwise. +func InferFileType(filename string) string { + ext := strings.ToLower(filepath.Ext(filename)) + if imageExtensions[ext] { + return "image" + } + return "document" +} diff --git a/internal/theme/assets_test.go b/internal/theme/assets_test.go new file mode 100644 index 0000000..7b8d068 --- /dev/null +++ b/internal/theme/assets_test.go @@ -0,0 +1,148 @@ +package theme + +import ( + "path/filepath" + "testing" + + "github.com/GetStoreConnect/storeconnect-cli/internal/api" + "github.com/GetStoreConnect/storeconnect-cli/internal/testutil" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSelectChangedAssets(t *testing.T) { + tests := []struct { + name string + local []LocalAsset + server []api.ThemeAsset + wantKeys []string + }{ + { + name: "new asset absent on server is selected", + local: []LocalAsset{ + {Key: "images/logo.png", ContentHash: "hashA"}, + }, + server: nil, + wantKeys: []string{"images/logo.png"}, + }, + { + name: "changed hash is selected", + local: []LocalAsset{ + {Key: "images/logo.png", ContentHash: "hashNEW"}, + }, + server: []api.ThemeAsset{ + {Key: "images/logo.png", ContentHash: "hashOLD"}, + }, + wantKeys: []string{"images/logo.png"}, + }, + { + name: "unchanged hash is skipped", + local: []LocalAsset{ + {Key: "images/logo.png", ContentHash: "same"}, + }, + server: []api.ThemeAsset{ + {Key: "images/logo.png", ContentHash: "same"}, + }, + wantKeys: nil, + }, + { + name: "mixed selects only new and changed", + local: []LocalAsset{ + {Key: "a.png", ContentHash: "1"}, // unchanged + {Key: "b.png", ContentHash: "2-new"}, // changed + {Key: "c.png", ContentHash: "3"}, // new + }, + server: []api.ThemeAsset{ + {Key: "a.png", ContentHash: "1"}, + {Key: "b.png", ContentHash: "2-old"}, + }, + wantKeys: []string{"b.png", "c.png"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + changed := SelectChangedAssets(tt.local, tt.server) + + var gotKeys []string + for _, a := range changed { + gotKeys = append(gotKeys, a.Key) + } + assert.Equal(t, tt.wantKeys, gotKeys) + }) + } +} + +func TestReadLocalAssets(t *testing.T) { + basePath, cleanup := testutil.CreateTempProject(t) + defer cleanup() + + themeDir := filepath.Join(basePath, "themes", "Test") + assetsDir := filepath.Join(themeDir, AssetsDirName) + testutil.CreateTestDir(t, filepath.Join(assetsDir, "images")) + + testutil.WriteTestFile(t, filepath.Join(assetsDir, "style.css"), "body{}") + testutil.WriteTestFile(t, filepath.Join(assetsDir, "images", "logo.png"), "PNGDATA") + + assets, err := ReadLocalAssets(themeDir) + require.NoError(t, err) + require.Len(t, assets, 2) + + byKey := map[string]LocalAsset{} + for _, a := range assets { + byKey[a.Key] = a + } + + // Nested key uses forward slashes regardless of platform. + logo, ok := byKey["images/logo.png"] + require.True(t, ok) + assert.Equal(t, "image/png", logo.ContentType) + assert.Equal(t, HashContent([]byte("PNGDATA")), logo.ContentHash) + assert.Equal(t, []byte("PNGDATA"), logo.Content) + + css, ok := byKey["style.css"] + require.True(t, ok) + assert.Equal(t, "text/css", css.ContentType) +} + +func TestReadLocalAssetsMissingDir(t *testing.T) { + basePath, cleanup := testutil.CreateTempProject(t) + defer cleanup() + + themeDir := filepath.Join(basePath, "themes", "NoAssets") + testutil.CreateTestDir(t, themeDir) + + assets, err := ReadLocalAssets(themeDir) + require.NoError(t, err) + assert.Empty(t, assets) +} + +func TestInferContentType(t *testing.T) { + cases := map[string]string{ + "logo.png": "image/png", + "photo.JPG": "image/jpeg", + "icon.svg": "image/svg+xml", + "style.css": "text/css", + "app.js": "application/javascript", + "data.bin": "application/octet-stream", + "noext": "application/octet-stream", + "manual.pdf": "application/pdf", + } + for name, want := range cases { + assert.Equal(t, want, InferContentType(name), name) + } +} + +func TestInferFileType(t *testing.T) { + cases := map[string]string{ + "logo.png": "image", + "photo.jpeg": "image", + "icon.SVG": "image", + "style.css": "document", + "manual.pdf": "document", + "noext": "document", + } + for name, want := range cases { + assert.Equal(t, want, InferFileType(name), name) + } +} diff --git a/internal/theme/deserializer.go b/internal/theme/deserializer.go index 3d23714..1438697 100644 --- a/internal/theme/deserializer.go +++ b/internal/theme/deserializer.go @@ -61,14 +61,17 @@ func (d *Deserializer) Deserialize(themeName string) (*api.Theme, error) { for _, a := range assetsList { if assetMap, ok := a.(map[string]interface{}); ok { asset := api.ThemeAsset{} - if filename, ok := assetMap["filename"].(string); ok { - asset.Filename = filename + if key, ok := assetMap["key"].(string); ok { + asset.Key = key + } + if url, ok := assetMap["url"].(string); ok { + asset.URL = url } if contentType, ok := assetMap["content_type"].(string); ok { asset.ContentType = contentType } - if url, ok := assetMap["url"].(string); ok { - asset.URL = url + if contentHash, ok := assetMap["content_hash"].(string); ok { + asset.ContentHash = contentHash } theme.Assets = append(theme.Assets, asset) } diff --git a/internal/theme/deserializer_test.go b/internal/theme/deserializer_test.go index 535c6b7..eac53b4 100644 --- a/internal/theme/deserializer_test.go +++ b/internal/theme/deserializer_test.go @@ -33,7 +33,8 @@ func TestDeserializer_Deserialize(t *testing.T) { setup: func(t *testing.T, basePath string) string { // Serialize a complete theme first theme := testutil.TestTheme() - serializer := NewSerializer(basePath) + serializer := NewSerializer(basePath). + WithDownloader(func(url string) ([]byte, error) { return []byte("stub-asset"), nil }) err := serializer.Serialize(theme) require.NoError(t, err) return theme.Name @@ -490,10 +491,10 @@ font_family: Arial`, }, { name: "array data", - content: `- filename: logo.png + content: `- key: logo.png content_type: image/png url: https://example.com/logo.png -- filename: style.css +- key: style.css content_type: text/css url: https://example.com/style.css`, wantErr: false, @@ -504,7 +505,7 @@ font_family: Arial`, first, ok := arr[0].(map[string]interface{}) require.True(t, ok) - assert.Equal(t, "logo.png", first["filename"]) + assert.Equal(t, "logo.png", first["key"]) }, }, { @@ -612,12 +613,14 @@ func TestDeserializer_AssetsTypeAssertion(t *testing.T) { testutil.WriteTestFile(t, filepath.Join(themePath, "theme.yml"), string(data)) // Write assets.json - assets := `- filename: logo.png + assets := `- key: logo.png content_type: image/png url: https://example.com/logo.png -- filename: style.css + content_hash: hash1 +- key: style.css content_type: text/css - url: https://example.com/style.css` + url: https://example.com/style.css + content_hash: hash2` testutil.WriteTestFile(t, filepath.Join(themePath, "assets.json"), assets) deserializer := NewDeserializer(basePath) @@ -625,10 +628,11 @@ func TestDeserializer_AssetsTypeAssertion(t *testing.T) { require.NoError(t, err) require.Len(t, theme.Assets, 2) - assert.Equal(t, "logo.png", theme.Assets[0].Filename) + assert.Equal(t, "logo.png", theme.Assets[0].Key) assert.Equal(t, "image/png", theme.Assets[0].ContentType) assert.Equal(t, "https://example.com/logo.png", theme.Assets[0].URL) - assert.Equal(t, "style.css", theme.Assets[1].Filename) + assert.Equal(t, "hash1", theme.Assets[0].ContentHash) + assert.Equal(t, "style.css", theme.Assets[1].Key) } func TestDeserializer_InvalidAssetsFormat(t *testing.T) { diff --git a/internal/theme/serializer.go b/internal/theme/serializer.go index c5014a3..f035588 100644 --- a/internal/theme/serializer.go +++ b/internal/theme/serializer.go @@ -2,21 +2,65 @@ package theme import ( "fmt" + "io" + "net/http" "os" "path/filepath" + "time" "github.com/GetStoreConnect/storeconnect-cli/internal/api" "gopkg.in/yaml.v3" ) +// Downloader fetches the bytes at a URL. It is a seam so asset downloading can +// be driven by httptest (or stubbed) without reaching the network. +type Downloader func(url string) ([]byte, error) + +// defaultDownloader fetches a URL over HTTP with a sane timeout. +func defaultDownloader(url string) ([]byte, error) { + client := &http.Client{Timeout: 60 * time.Second} + resp, err := client.Get(url) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, fmt.Errorf("download failed with status %d", resp.StatusCode) + } + + return io.ReadAll(resp.Body) +} + // Serializer converts API theme JSON to local file structure type Serializer struct { basePath string + + // download fetches asset binaries; overridable for tests. + download Downloader + // warn reports a non-fatal problem (e.g. a failed asset download). + warn func(string) } // NewSerializer creates a new theme serializer func NewSerializer(basePath string) *Serializer { - return &Serializer{basePath: basePath} + return &Serializer{ + basePath: basePath, + download: defaultDownloader, + warn: func(msg string) { fmt.Fprintln(os.Stderr, msg) }, + } +} + +// WithDownloader overrides how asset binaries are fetched (used in tests). +func (s *Serializer) WithDownloader(d Downloader) *Serializer { + s.download = d + return s +} + +// WithWarner overrides where non-fatal warnings are reported (used in tests). +func (s *Serializer) WithWarner(w func(string)) *Serializer { + s.warn = w + return s } // Serialize writes a theme to the local filesystem @@ -43,11 +87,15 @@ func (s *Serializer) Serialize(theme *api.Theme) error { return fmt.Errorf("failed to write variables: %w", err) } - // Write assets.json + // Write assets.json metadata if err := s.writeJSON(filepath.Join(themePath, "assets.json"), theme.Assets); err != nil { return fmt.Errorf("failed to write assets: %w", err) } + // Download asset binaries into assets/. A failed download is a warning, + // not a fatal error - the rest of the theme is still usable. + s.downloadAssets(themePath, theme.Assets) + return nil } @@ -93,6 +141,35 @@ func (s *Serializer) writeTemplates(themePath string, templates []api.ThemeTempl return nil } +// downloadAssets fetches each asset's URL into assets/, creating any +// subdirectories the key implies. Download failures are reported via warn and +// skipped so a single broken asset never fails the whole pull. +func (s *Serializer) downloadAssets(themePath string, assets []api.ThemeAsset) { + for _, asset := range assets { + if asset.Key == "" || asset.URL == "" { + continue + } + + content, err := s.download(asset.URL) + if err != nil { + s.warn(fmt.Sprintf("warning: failed to download asset %q: %v", asset.Key, err)) + continue + } + + // Key may contain slashes - build the nested path and ensure dirs exist. + destPath := filepath.Join(themePath, AssetsDirName, filepath.FromSlash(asset.Key)) + if err := os.MkdirAll(filepath.Dir(destPath), 0755); err != nil { + s.warn(fmt.Sprintf("warning: failed to create directory for asset %q: %v", asset.Key, err)) + continue + } + + if err := os.WriteFile(destPath, content, 0644); err != nil { + s.warn(fmt.Sprintf("warning: failed to write asset %q: %v", asset.Key, err)) + continue + } + } +} + func (s *Serializer) writeJSON(path string, data interface{}) error { if data == nil { return nil diff --git a/internal/theme/serializer_test.go b/internal/theme/serializer_test.go index 1a333e1..a471e9d 100644 --- a/internal/theme/serializer_test.go +++ b/internal/theme/serializer_test.go @@ -20,6 +20,75 @@ func TestNewSerializer(t *testing.T) { assert.Equal(t, basePath, serializer.basePath) } +func TestSerializer_DownloadsAssets(t *testing.T) { + basePath, cleanup := testutil.CreateTempProject(t) + defer cleanup() + + theme := &api.Theme{ + SCID: "with-assets", + Name: "With Assets", + Assets: []api.ThemeAsset{ + {Key: "images/logo.png", URL: "https://example.com/logo.png"}, + {Key: "style.css", URL: "https://example.com/style.css"}, + }, + } + + requested := map[string]bool{} + serializer := NewSerializer(basePath).WithDownloader(func(url string) ([]byte, error) { + requested[url] = true + return []byte("downloaded:" + url), nil + }) + + require.NoError(t, serializer.Serialize(theme)) + + themePath := filepath.Join(basePath, "themes", theme.Name) + + // Nested key creates subdirectories under assets/. + logoPath := filepath.Join(themePath, "assets", "images", "logo.png") + assert.True(t, testutil.FileExists(logoPath)) + assert.Equal(t, "downloaded:https://example.com/logo.png", testutil.ReadTestFile(t, logoPath)) + + cssPath := filepath.Join(themePath, "assets", "style.css") + assert.True(t, testutil.FileExists(cssPath)) + + assert.True(t, requested["https://example.com/logo.png"]) + assert.True(t, requested["https://example.com/style.css"]) +} + +func TestSerializer_DownloadErrorIsWarnedNotFatal(t *testing.T) { + basePath, cleanup := testutil.CreateTempProject(t) + defer cleanup() + + theme := &api.Theme{ + SCID: "broken-asset", + Name: "Broken Asset", + Assets: []api.ThemeAsset{ + {Key: "ok.png", URL: "https://example.com/ok.png"}, + {Key: "bad.png", URL: "https://example.com/bad.png"}, + }, + } + + var warnings []string + serializer := NewSerializer(basePath). + WithWarner(func(msg string) { warnings = append(warnings, msg) }). + WithDownloader(func(url string) ([]byte, error) { + if url == "https://example.com/bad.png" { + return nil, assert.AnError + } + return []byte("ok"), nil + }) + + // A failed download must not fail the whole serialize. + require.NoError(t, serializer.Serialize(theme)) + + themePath := filepath.Join(basePath, "themes", theme.Name) + assert.True(t, testutil.FileExists(filepath.Join(themePath, "assets", "ok.png"))) + assert.False(t, testutil.FileExists(filepath.Join(themePath, "assets", "bad.png"))) + + require.Len(t, warnings, 1) + assert.Contains(t, warnings[0], "bad.png") +} + func TestSerializer_Serialize(t *testing.T) { tests := []struct { name string @@ -155,7 +224,8 @@ func TestSerializer_Serialize(t *testing.T) { basePath, cleanup := testutil.CreateTempProject(t) defer cleanup() - serializer := NewSerializer(basePath) + serializer := NewSerializer(basePath). + WithDownloader(func(url string) ([]byte, error) { return []byte("stub-asset"), nil }) err := serializer.Serialize(tt.theme) if tt.wantErr { @@ -364,8 +434,8 @@ func TestSerializer_writeJSON(t *testing.T) { { name: "array data", data: []api.ThemeAsset{ - {Filename: "logo.png", ContentType: "image/png", URL: "https://example.com/logo.png"}, - {Filename: "style.css", ContentType: "text/css", URL: "https://example.com/style.css"}, + {Key: "logo.png", ContentType: "image/png", URL: "https://example.com/logo.png", ContentHash: "hash1"}, + {Key: "style.css", ContentType: "text/css", URL: "https://example.com/style.css", ContentHash: "hash2"}, }, wantErr: false, validate: func(t *testing.T, path string, data interface{}) { @@ -378,8 +448,8 @@ func TestSerializer_writeJSON(t *testing.T) { require.NoError(t, err) assert.Len(t, result, 2) - assert.Equal(t, "logo.png", result[0]["filename"]) - assert.Equal(t, "style.css", result[1]["filename"]) + assert.Equal(t, "logo.png", result[0]["key"]) + assert.Equal(t, "style.css", result[1]["key"]) }, }, { @@ -487,7 +557,8 @@ func TestSerializer_RoundTrip(t *testing.T) { original := testutil.TestTheme() // Serialize - serializer := NewSerializer(basePath) + serializer := NewSerializer(basePath). + WithDownloader(func(url string) ([]byte, error) { return []byte("stub-asset"), nil }) err := serializer.Serialize(original) require.NoError(t, err)