From c8e6499b57d357ce74824b6ab200e4decf04767b Mon Sep 17 00:00:00 2001 From: Mikel Lindsaar Date: Sat, 13 Jun 2026 09:14:38 +1000 Subject: [PATCH] Sync theme asset binaries and list content changes theme pull downloads asset files into assets/; theme push hash-diffs them against the server theme, uploads new or changed binaries through the media upload flow, and registers them on the draft content change. sc change list shows the store's content changes so drafts can be resumed from any machine. --- internal/api/content_changes.go | 59 +++++++++-- internal/api/content_changes_test.go | 84 ++++++++++++++- internal/api/media.go | 112 +++++++++++++++++--- internal/api/media_test.go | 143 ++++++++++++++++++++++++++ internal/api/themes.go | 14 ++- internal/commands/change_list.go | 89 ++++++++++++++++ internal/commands/theme_push.go | 63 +++++++++++- internal/testutil/fixtures.go | 5 +- internal/theme/assets.go | 146 ++++++++++++++++++++++++++ internal/theme/assets_test.go | 148 +++++++++++++++++++++++++++ internal/theme/deserializer.go | 11 +- internal/theme/deserializer_test.go | 22 ++-- internal/theme/serializer.go | 81 ++++++++++++++- internal/theme/serializer_test.go | 83 +++++++++++++-- 14 files changed, 1006 insertions(+), 54 deletions(-) create mode 100644 internal/api/media_test.go create mode 100644 internal/commands/change_list.go create mode 100644 internal/theme/assets.go create mode 100644 internal/theme/assets_test.go 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)