-
Notifications
You must be signed in to change notification settings - Fork 31
feat(experiment): add apps.icon.set support for non-hosted app icons #469
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
4d6a19e
2053f5d
febaece
27ba074
462e653
e11c382
e396a02
a126d2a
54387b6
b382d68
5d0ee94
4a06079
73e27f0
0c5b3be
41a63f4
c5d8b54
7b30774
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -35,6 +35,8 @@ import ( | |||||
|
|
||||||
| const ( | ||||||
| appIconMethod = "apps.hosted.icon" | ||||||
| // appIconSetMethod is the API method for setting app icons for non-hosted apps. | ||||||
| appIconSetMethod = "apps.icon.set" | ||||||
| ) | ||||||
|
|
||||||
| // IconResult details to be saved | ||||||
|
|
@@ -46,8 +48,19 @@ type iconResponse struct { | |||||
| IconResult | ||||||
| } | ||||||
|
|
||||||
| // Icon updates a Slack App's icon | ||||||
| // Icon updates a hosted Slack app icon | ||||||
| // DEPRECATED: Prefer "IconSet" instead | ||||||
| func (c *Client) Icon(ctx context.Context, fs afero.Fs, token, appID, iconFilePath string) (IconResult, error) { | ||||||
| return c.uploadIcon(ctx, fs, token, appID, iconFilePath, appIconMethod, "file") | ||||||
| } | ||||||
|
|
||||||
| // IconSet sets a Slack App's icon using the apps.icon.set API method. | ||||||
| func (c *Client) IconSet(ctx context.Context, fs afero.Fs, token, appID, iconFilePath string) (IconResult, error) { | ||||||
| return c.uploadIcon(ctx, fs, token, appID, iconFilePath, appIconSetMethod, "file") | ||||||
| } | ||||||
|
|
||||||
| // uploadIcon uploads an icon to the given API method. | ||||||
| func (c *Client) uploadIcon(ctx context.Context, fs afero.Fs, token, appID, iconFilePath, apiMethod, fileFieldName string) (IconResult, error) { | ||||||
| var ( | ||||||
| iconBytes []byte | ||||||
| err error | ||||||
|
|
@@ -81,7 +94,7 @@ func (c *Client) Icon(ctx context.Context, fs afero.Fs, token, appID, iconFilePa | |||||
|
|
||||||
| var part io.Writer | ||||||
| h := make(textproto.MIMEHeader) | ||||||
| h.Set("Content-Disposition", fmt.Sprintf(`form-data; name="%s"; filename="%s"`, "file", iconStat.Name())) | ||||||
| h.Set("Content-Disposition", fmt.Sprintf(`form-data; name="%s"; filename="%s"`, fileFieldName, iconStat.Name())) | ||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
🌚 suggestion: Let's revert the addition of |
||||||
| h.Set("Content-Type", http.DetectContentType(iconBytes)) | ||||||
| part, err = writer.CreatePart(h) | ||||||
| if err != nil { | ||||||
|
|
@@ -101,7 +114,7 @@ func (c *Client) Icon(ctx context.Context, fs afero.Fs, token, appID, iconFilePa | |||||
| writer.Close() | ||||||
|
|
||||||
| var sURL *url.URL | ||||||
| sURL, err = url.Parse(c.host + "/api/" + appIconMethod) | ||||||
| sURL, err = url.Parse(c.host + "/api/" + apiMethod) | ||||||
| if err != nil { | ||||||
| return IconResult{}, err | ||||||
| } | ||||||
|
|
||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -28,65 +28,221 @@ import ( | |
|
|
||
| var imgFile = ".assets/icon.png" | ||
|
|
||
| func TestClient_IconErrorIfMissingArgs(t *testing.T) { | ||
| ctx := slackcontext.MockContext(t.Context()) | ||
| fs := afero.NewMemMapFs() | ||
| c, teardown := NewFakeClient(t, FakeClientParams{ | ||
| ExpectedMethod: appIconMethod, | ||
| }) | ||
| defer teardown() | ||
| _, err := c.Icon(ctx, fs, "token", "", "") | ||
| require.Error(t, err) | ||
| require.Contains(t, err.Error(), "missing required args") | ||
| } | ||
|
|
||
| func TestClient_IconErrorNoFile(t *testing.T) { | ||
| ctx := slackcontext.MockContext(t.Context()) | ||
| fs := afero.NewMemMapFs() | ||
| c, teardown := NewFakeClient(t, FakeClientParams{ | ||
| ExpectedMethod: appIconMethod, | ||
| }) | ||
| defer teardown() | ||
| _, err := c.Icon(ctx, fs, "token", "12345", imgFile) | ||
| require.Error(t, err) | ||
| require.Contains(t, err.Error(), "file does not exist") | ||
| } | ||
|
|
||
| func TestClient_IconErrorWrongFile(t *testing.T) { | ||
| ctx := slackcontext.MockContext(t.Context()) | ||
| fs := afero.NewMemMapFs() | ||
| err := afero.WriteFile(fs, "test.txt", []byte("this is a text file"), 0666) | ||
| require.NoError(t, err) | ||
| c, teardown := NewFakeClient(t, FakeClientParams{ | ||
| ExpectedMethod: appIconMethod, | ||
| }) | ||
| defer teardown() | ||
| _, err = c.Icon(ctx, fs, "token", "12345", "test.txt") | ||
| require.Error(t, err) | ||
| require.Contains(t, err.Error(), "unknown format") | ||
| } | ||
|
|
||
| func TestClient_IconSuccess(t *testing.T) { | ||
| ctx := slackcontext.MockContext(t.Context()) | ||
| fs := afero.NewMemMapFs() | ||
|
|
||
| func createTestPNG(t *testing.T, fs afero.Fs, path string) { | ||
| t.Helper() | ||
| myimage := image.NewRGBA(image.Rectangle{image.Point{0, 0}, image.Point{100, 100}}) | ||
|
|
||
| // This loop just fills the image with random data | ||
| for x := range 100 { | ||
| for y := range 100 { | ||
| c := color.RGBA{uint8(rand.Intn(255)), uint8(rand.Intn(255)), uint8(rand.Intn(255)), 255} | ||
| myimage.Set(x, y, c) | ||
| } | ||
| } | ||
| myfile, _ := fs.Create(imgFile) | ||
| err := png.Encode(myfile, myimage) | ||
| myfile, err := fs.Create(path) | ||
| require.NoError(t, err) | ||
| c, teardown := NewFakeClient(t, FakeClientParams{ | ||
| ExpectedMethod: appIconMethod, | ||
| Response: `{"ok":true}`, | ||
| }) | ||
| defer teardown() | ||
| _, err = c.Icon(ctx, fs, "token", "12345", imgFile) | ||
| err = png.Encode(myfile, myimage) | ||
| require.NoError(t, err) | ||
| } | ||
|
|
||
| func TestClient_Icon(t *testing.T) { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧪 praise: Thank you for updating existing tests to a familiar format! |
||
| tests := map[string]struct { | ||
| setupFs func(t *testing.T, fs afero.Fs) | ||
| appID string | ||
| filePath string | ||
| response string | ||
| expectErr bool | ||
| errContains string | ||
| }{ | ||
| "returns error when args are missing": { | ||
| appID: "", | ||
| filePath: "", | ||
| expectErr: true, | ||
| errContains: "missing required args", | ||
| }, | ||
| "returns error when file does not exist": { | ||
| appID: "12345", | ||
| filePath: imgFile, | ||
| expectErr: true, | ||
| errContains: "file does not exist", | ||
| }, | ||
| "returns error for non-image file": { | ||
| setupFs: func(t *testing.T, fs afero.Fs) { | ||
| err := afero.WriteFile(fs, "test.txt", []byte("this is a text file"), 0666) | ||
| require.NoError(t, err) | ||
| }, | ||
| appID: "12345", | ||
| filePath: "test.txt", | ||
| expectErr: true, | ||
| errContains: "unknown format", | ||
| }, | ||
| "succeeds with valid PNG": { | ||
| setupFs: func(t *testing.T, fs afero.Fs) { | ||
| createTestPNG(t, fs, imgFile) | ||
| }, | ||
| appID: "12345", | ||
| filePath: imgFile, | ||
| response: `{"ok":true}`, | ||
| }, | ||
| } | ||
| for name, tc := range tests { | ||
| t.Run(name, func(t *testing.T) { | ||
| ctx := slackcontext.MockContext(t.Context()) | ||
| fs := afero.NewMemMapFs() | ||
| if tc.setupFs != nil { | ||
| tc.setupFs(t, fs) | ||
| } | ||
| c, teardown := NewFakeClient(t, FakeClientParams{ | ||
| ExpectedMethod: appIconMethod, | ||
| Response: tc.response, | ||
| }) | ||
| defer teardown() | ||
| _, err := c.Icon(ctx, fs, "token", tc.appID, tc.filePath) | ||
| if tc.expectErr { | ||
| require.Error(t, err) | ||
| if tc.errContains != "" { | ||
| require.Contains(t, err.Error(), tc.errContains) | ||
| } | ||
| } else { | ||
| require.NoError(t, err) | ||
| } | ||
| }) | ||
| } | ||
| } | ||
|
|
||
| func TestClient_IconSet(t *testing.T) { | ||
| tests := map[string]struct { | ||
| setupFs func(t *testing.T, fs afero.Fs) | ||
| appID string | ||
| filePath string | ||
| response string | ||
| expectErr bool | ||
| errContains string | ||
| }{ | ||
| "returns error when args are missing": { | ||
| appID: "", | ||
| filePath: "", | ||
| expectErr: true, | ||
| errContains: "missing required args", | ||
| }, | ||
| "returns error when file does not exist": { | ||
| appID: "12345", | ||
| filePath: imgFile, | ||
| expectErr: true, | ||
| errContains: "file does not exist", | ||
| }, | ||
| "returns error for empty file": { | ||
| setupFs: func(t *testing.T, fs afero.Fs) { | ||
| err := afero.WriteFile(fs, imgFile, []byte{}, 0666) | ||
| require.NoError(t, err) | ||
| }, | ||
| appID: "12345", | ||
| filePath: imgFile, | ||
| expectErr: true, | ||
| }, | ||
| "returns error for unsupported format": { | ||
| setupFs: func(t *testing.T, fs afero.Fs) { | ||
| svgContent := []byte(`<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100"><circle cx="50" cy="50" r="50"/></svg>`) | ||
| err := afero.WriteFile(fs, "icon.svg", svgContent, 0666) | ||
| require.NoError(t, err) | ||
| }, | ||
| appID: "12345", | ||
| filePath: "icon.svg", | ||
| expectErr: true, | ||
| errContains: "unknown format", | ||
| }, | ||
| "returns error for non-image content with .png extension": { | ||
| setupFs: func(t *testing.T, fs afero.Fs) { | ||
| err := afero.WriteFile(fs, "fake.png", []byte("this is not a png"), 0666) | ||
| require.NoError(t, err) | ||
| }, | ||
| appID: "12345", | ||
| filePath: "fake.png", | ||
| expectErr: true, | ||
| errContains: "unknown format", | ||
| }, | ||
| "returns error for truncated PNG": { | ||
| setupFs: func(t *testing.T, fs afero.Fs) { | ||
| // Valid PNG signature followed by incomplete IHDR chunk | ||
| truncatedPNG := []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D} | ||
| err := afero.WriteFile(fs, "truncated.png", truncatedPNG, 0666) | ||
| require.NoError(t, err) | ||
| }, | ||
| appID: "12345", | ||
| filePath: "truncated.png", | ||
| expectErr: true, | ||
| }, | ||
| "returns error from API response": { | ||
| setupFs: func(t *testing.T, fs afero.Fs) { | ||
| createTestPNG(t, fs, imgFile) | ||
| }, | ||
| appID: "12345", | ||
| filePath: imgFile, | ||
| response: `{"ok":false,"error":"invalid_app"}`, | ||
| expectErr: true, | ||
| errContains: "invalid_app", | ||
| }, | ||
| "succeeds with valid PNG": { | ||
| setupFs: func(t *testing.T, fs afero.Fs) { | ||
| createTestPNG(t, fs, imgFile) | ||
| }, | ||
| appID: "12345", | ||
| filePath: imgFile, | ||
| response: `{"ok":true}`, | ||
| }, | ||
| // cutter.Crop with Ratio mode floors a 1x1 image to 0x0, causing png.Encode to fail. | ||
| // May need a follow-up to add a minimum dimension check before cropping. | ||
| "returns error for 1x1 pixel image": { | ||
| setupFs: func(t *testing.T, fs afero.Fs) { | ||
| myimage := image.NewRGBA(image.Rectangle{image.Point{0, 0}, image.Point{1, 1}}) | ||
| myimage.Set(0, 0, color.RGBA{255, 0, 0, 255}) | ||
| myfile, err := fs.Create(imgFile) | ||
| require.NoError(t, err) | ||
| err = png.Encode(myfile, myimage) | ||
| require.NoError(t, err) | ||
| }, | ||
| appID: "12345", | ||
| filePath: imgFile, | ||
| expectErr: true, | ||
| }, | ||
| "succeeds with non-square image": { | ||
| setupFs: func(t *testing.T, fs afero.Fs) { | ||
| myimage := image.NewRGBA(image.Rectangle{image.Point{0, 0}, image.Point{2000, 100}}) | ||
| for x := range 2000 { | ||
| for y := range 100 { | ||
| c := color.RGBA{uint8(rand.Intn(255)), uint8(rand.Intn(255)), uint8(rand.Intn(255)), 255} | ||
| myimage.Set(x, y, c) | ||
| } | ||
| } | ||
| myfile, err := fs.Create(imgFile) | ||
| require.NoError(t, err) | ||
| err = png.Encode(myfile, myimage) | ||
| require.NoError(t, err) | ||
| }, | ||
| appID: "12345", | ||
| filePath: imgFile, | ||
| response: `{"ok":true}`, | ||
| }, | ||
| } | ||
| for name, tc := range tests { | ||
| t.Run(name, func(t *testing.T) { | ||
| ctx := slackcontext.MockContext(t.Context()) | ||
| fs := afero.NewMemMapFs() | ||
| if tc.setupFs != nil { | ||
| tc.setupFs(t, fs) | ||
| } | ||
| c, teardown := NewFakeClient(t, FakeClientParams{ | ||
| ExpectedMethod: appIconSetMethod, | ||
| Response: tc.response, | ||
| }) | ||
| defer teardown() | ||
| _, err := c.IconSet(ctx, fs, "token", tc.appID, tc.filePath) | ||
| if tc.expectErr { | ||
| require.Error(t, err) | ||
| if tc.errContains != "" { | ||
| require.Contains(t, err.Error(), tc.errContains) | ||
| } | ||
| } else { | ||
| require.NoError(t, err) | ||
| } | ||
| }) | ||
| } | ||
| } | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 📚 note: Let's document this too! https://docs.slack.dev/tools/slack-cli/reference/experiments |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🔬 issue: This outputs a warning to verbose logs and it's not clear what I might not be including?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
are you uploading through the manifest or is the image just in the root of the project?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@srtaalej I saved this file to the root of the project 🌲 ✨
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
the "file" fielf for the api request had the wrong name! can you try again ?