diff --git a/README.md b/README.md index 60748b6..7eb8d86 100644 --- a/README.md +++ b/README.md @@ -18,12 +18,14 @@ - `url`: **Required** - Mastodon instance URL. - `access-token`: **Required** - Mastodon access token for authentication. Use secrets to protect your access token. -- `message`: **Required** - The content of the toot to be posted. +- `message`: **Required** (optional if `media-paths` is provided) - The content of the toot to be posted. - `visibility`: Optional - Visibility of the toot (`public`, `unlisted`, `private`, `direct`). Defaults to `public`. - `sensitive`: Optional - Mark the toot and attached media as sensitive. Accepts `true` or `false`. Defaults to `false`. - `spoiler-text`: Optional - Text to be shown as a warning before the actual content, used when `sensitive` is `true`. - `language`: Optional - ISO 639 language code for the toot, helping to categorize the post by language. - `scheduled-at`: Optional - ISO 8601 Datetime when the toot should be posted. Must be at least 5 minutes in the future. +- `media-paths`: Optional - Comma-separated list of media file paths to attach to the toot (max 4). Supports images, video, and audio. +- `media-descriptions`: Optional - Comma-separated list of alt text descriptions for the media files. If only one value is provided, it is used for all attachments. ## Outputs @@ -84,6 +86,46 @@ Advanced usage: echo "Scheduled at: ${{ steps.mastodon_toot.outputs.scheduled_at }}" ``` +### Posting with Media Attachments + +Attach a single image with alt text: + +```yaml +- name: Send toot with image + uses: cbrgm/mastodon-github-action@v2 + with: + access-token: ${{ secrets.MASTODON_ACCESS_TOKEN }} + url: ${{ secrets.MASTODON_URL }} + message: "Check out this screenshot!" + media-paths: "screenshot.png" + media-descriptions: "A screenshot of the new dashboard" +``` + +Attach multiple images (up to 4): + +```yaml +- name: Send toot with multiple images + uses: cbrgm/mastodon-github-action@v2 + with: + access-token: ${{ secrets.MASTODON_ACCESS_TOKEN }} + url: ${{ secrets.MASTODON_URL }} + message: "Latest photos from the event" + media-paths: "photos/img1.jpg, photos/img2.jpg, photos/img3.jpg" + media-descriptions: "First image description, Second image description, Third image description" +``` + +Post media without text (media-only toot): + +```yaml +- name: Send media-only toot + uses: cbrgm/mastodon-github-action@v2 + with: + access-token: ${{ secrets.MASTODON_ACCESS_TOKEN }} + url: ${{ secrets.MASTODON_URL }} + media-paths: "artifact.png" + media-descriptions: "Build artifact from CI" +``` + You can find more usage examples in the [./example-workflows](./example-workflows/) subfolder. #### About message `visibility` types diff --git a/action.yml b/action.yml index 660220a..5b44fae 100644 --- a/action.yml +++ b/action.yml @@ -10,8 +10,8 @@ inputs: description: 'Mastodon access token for authentication' required: true message: - description: 'The content of the toot' - required: true + description: 'The content of the toot. Optional if media-paths is provided.' + required: false visibility: description: 'Visibility of the toot (public, unlisted, private, direct)' required: false @@ -29,6 +29,12 @@ inputs: scheduled-at: description: 'ISO 8601 Datetime to schedule the toot. Must be at least 5 minutes in the future' required: false + media-paths: + description: 'Comma-separated list of media file paths to attach (max 4). Supported: images, video, audio.' + required: false + media-descriptions: + description: 'Comma-separated list of alt text descriptions for media files. If one value is provided, it applies to all.' + required: false outputs: id: @@ -57,6 +63,10 @@ runs: - ${{ inputs.language }} - --scheduled-at - ${{ inputs.scheduled-at }} + - --media-paths + - ${{ inputs.media-paths }} + - --media-descriptions + - ${{ inputs.media-descriptions }} branding: icon: edit diff --git a/cmd/mastodon-github-action/main.go b/cmd/mastodon-github-action/main.go index 4e310f1..6c8da81 100644 --- a/cmd/mastodon-github-action/main.go +++ b/cmd/mastodon-github-action/main.go @@ -6,7 +6,10 @@ import ( "fmt" "io" "log" + "mime/multipart" "net/http" + "os" + "path/filepath" "runtime" "strings" "time" @@ -41,24 +44,27 @@ func (vt VisibilityType) IsValid() bool { // MastodonStatus defines the structure for status messages to be sent to Mastodon. type MastodonStatus struct { - Status string `json:"status"` - Visibility string `json:"visibility"` - Sensitive bool `json:"sensitive,omitempty"` - SpoilerText string `json:"spoiler_text,omitempty"` - Language string `json:"language,omitempty"` - ScheduledAt string `json:"scheduled_at,omitempty"` + Status string `json:"status"` + Visibility string `json:"visibility"` + Sensitive bool `json:"sensitive,omitempty"` + SpoilerText string `json:"spoiler_text,omitempty"` + Language string `json:"language,omitempty"` + ScheduledAt string `json:"scheduled_at,omitempty"` + MediaIDs []string `json:"media_ids,omitempty"` } // ActionInputs collects all user inputs required for posting a status. type ActionInputs struct { - URL string `arg:"--url,required, env:MASTODON_URL"` // Mastodon instance URL. - AccessToken string `arg:"--access-token,required, env:MASTODON_ACCESS_TOKEN"` // User access token for authentication. - Message string `arg:"--message,required, env:MASTODON_MESSAGE"` // The status message content. - Visibility string `arg:"--visibility, env:MASTODON_VISIBILITY"` // Visibility of the status. - Sensitive bool `arg:"--sensitive, env:MASTODON_SENSITIVE"` // Flag to mark status as sensitive. - SpoilerText string `arg:"--spoiler-text, env:MASTODON_SPOILER_TEXT"` // Additional content warning text. - Language string `arg:"--language, env:MASTODON_LANGUAGE"` // Language of the status. - ScheduledAt string `arg:"--scheduled-at, env:MASTODON_SCHEDULED_AT"` // Time to schedule the status. + URL string `arg:"--url,required, env:MASTODON_URL"` // Mastodon instance URL. + AccessToken string `arg:"--access-token,required, env:MASTODON_ACCESS_TOKEN"` // User access token for authentication. + Message string `arg:"--message, env:MASTODON_MESSAGE"` // The status message content. + Visibility string `arg:"--visibility, env:MASTODON_VISIBILITY"` // Visibility of the status. + Sensitive bool `arg:"--sensitive, env:MASTODON_SENSITIVE"` // Flag to mark status as sensitive. + SpoilerText string `arg:"--spoiler-text, env:MASTODON_SPOILER_TEXT"` // Additional content warning text. + Language string `arg:"--language, env:MASTODON_LANGUAGE"` // Language of the status. + ScheduledAt string `arg:"--scheduled-at, env:MASTODON_SCHEDULED_AT"` // Time to schedule the status. + MediaPaths string `arg:"--media-paths, env:MASTODON_MEDIA_PATHS"` // Comma-separated media file paths. + MediaDescriptions string `arg:"--media-descriptions, env:MASTODON_MEDIA_DESCRIPTIONS"` // Comma-separated alt text descriptions. } // StatusResponse models the response returned by Mastodon after posting a status. @@ -76,32 +82,50 @@ type ScheduledStatusResponse struct { ScheduledAt time.Time `json:"scheduled_at"` } +const maxMediaAttachments = 4 + func main() { var args ActionInputs - arg.MustParse(&args) // Parses and validates command-line arguments. + arg.MustParse(&args) + + mediaPaths := parseMediaPaths(args.MediaPaths) + hasMedia := len(mediaPaths) > 0 - // Ensures the status message is not empty. - if strings.TrimSpace(args.Message) == "" { - log.Fatal("Status message cannot be empty") + if strings.TrimSpace(args.Message) == "" && !hasMedia { + log.Fatal("Status message cannot be empty when no media is attached") + } + + if len(mediaPaths) > maxMediaAttachments { + log.Fatalf("Too many media attachments: %d (maximum is %d)", len(mediaPaths), maxMediaAttachments) } - // Sets default visibility to "public" if not specified. if args.Visibility == "" { args.Visibility = string(VisibilityPublic) } - // Validates the provided visibility against Mastodon's accepted values. if !VisibilityType(args.Visibility).IsValid() { log.Fatalf("Invalid visibility: %s", args.Visibility) } - // Parse and validate the scheduled time, if provided. scheduledAt, err := parseScheduledAt(args.ScheduledAt) if err != nil { log.Fatalf("Scheduled at error: %v", err) } - // Constructs the status payload and sends it to Mastodon. + var mediaIDs []string + if hasMedia { + descriptions := parseMediaDescriptions(args.MediaDescriptions) + for i, path := range mediaPaths { + desc := resolveDescription(i, descriptions) + id, err := uploadMedia(args.URL, args.AccessToken, path, desc) + if err != nil { + log.Fatalf("Error uploading media %q: %v", path, err) + } + mediaIDs = append(mediaIDs, id) + log.Printf("Uploaded media %q: ID %s", path, id) + } + } + status := MastodonStatus{ Status: args.Message, Visibility: args.Visibility, @@ -109,6 +133,7 @@ func main() { SpoilerText: args.SpoilerText, Language: args.Language, ScheduledAt: scheduledAt, + MediaIDs: mediaIDs, } if err := postStatus(args.URL, args.AccessToken, status); err != nil { @@ -181,7 +206,7 @@ func postStatus(url, accessToken string, status MastodonStatus) error { // validating that it is at least 5 minutes in the future. func parseScheduledAt(input string) (string, error) { if input == "" { - return "", nil // No scheduling requested + return "", nil } t, err := time.Parse("2006-01-02 15:04", input) @@ -195,3 +220,163 @@ func parseScheduledAt(input string) (string, error) { return t.Format(time.RFC3339), nil } + +// parseMediaPaths splits a comma-separated list of file paths into a slice, +// trimming whitespace and discarding empty entries. +func parseMediaPaths(input string) []string { + if strings.TrimSpace(input) == "" { + return nil + } + parts := strings.Split(input, ",") + var paths []string + for _, p := range parts { + trimmed := strings.TrimSpace(p) + if trimmed != "" { + paths = append(paths, trimmed) + } + } + return paths +} + +// parseMediaDescriptions splits a comma-separated list of descriptions. +func parseMediaDescriptions(input string) []string { + if strings.TrimSpace(input) == "" { + return nil + } + parts := strings.Split(input, ",") + var descs []string + for _, p := range parts { + descs = append(descs, strings.TrimSpace(p)) + } + return descs +} + +// resolveDescription returns the alt text for a media item at the given index. +// If a specific description exists for that index, it is used. If only one +// description is provided, it is used for all items. Otherwise returns empty string. +func resolveDescription(index int, descriptions []string) string { + if index < len(descriptions) && descriptions[index] != "" { + return descriptions[index] + } + if len(descriptions) == 1 && descriptions[0] != "" { + return descriptions[0] + } + return "" +} + +// MediaAttachmentResponse represents the response from the Mastodon media upload API. +type MediaAttachmentResponse struct { + ID string `json:"id"` + Type string `json:"type"` + URL *string `json:"url"` +} + +// uploadMedia uploads a media file to the Mastodon instance and returns the attachment ID. +// It uses the v2 media endpoint which may return 202 for async processing. +func uploadMedia(instanceURL, accessToken, filePath, description string) (string, error) { + file, err := os.Open(filePath) + if err != nil { + return "", fmt.Errorf("opening file: %w", err) + } + //nolint: errcheck + defer file.Close() + + body := &bytes.Buffer{} + writer := multipart.NewWriter(body) + + part, err := writer.CreateFormFile("file", filepath.Base(filePath)) + if err != nil { + return "", fmt.Errorf("creating form file: %w", err) + } + + if _, err := io.Copy(part, file); err != nil { + return "", fmt.Errorf("copying file content: %w", err) + } + + if description != "" { + if err := writer.WriteField("description", description); err != nil { + return "", fmt.Errorf("writing description field: %w", err) + } + } + + if err := writer.Close(); err != nil { + return "", fmt.Errorf("closing multipart writer: %w", err) + } + + apiURL := fmt.Sprintf("%s/api/v2/media", instanceURL) + req, err := http.NewRequest("POST", apiURL, body) + if err != nil { + return "", fmt.Errorf("creating request: %w", err) + } + + req.Header.Set("Authorization", "Bearer "+accessToken) + req.Header.Set("Content-Type", writer.FormDataContentType()) + + client := http.Client{Timeout: 60 * time.Second} + resp, err := client.Do(req) + if err != nil { + return "", fmt.Errorf("API call: %w", err) + } + //nolint: errcheck + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return "", fmt.Errorf("reading response: %w", err) + } + + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusAccepted { + return "", fmt.Errorf("API response: %s, Body: %s", resp.Status, string(respBody)) + } + + var attachment MediaAttachmentResponse + if err := json.Unmarshal(respBody, &attachment); err != nil { + return "", fmt.Errorf("unmarshaling response: %w", err) + } + + // 202 means async processing — poll until ready + if resp.StatusCode == http.StatusAccepted { + if err := pollMediaProcessing(instanceURL, accessToken, attachment.ID); err != nil { + return "", err + } + } + + return attachment.ID, nil +} + +// pollMediaProcessing polls GET /api/v1/media/:id until the media is processed. +func pollMediaProcessing(instanceURL, accessToken, mediaID string) error { + apiURL := fmt.Sprintf("%s/api/v1/media/%s", instanceURL, mediaID) + client := http.Client{Timeout: 10 * time.Second} + + for attempts := 0; attempts < 30; attempts++ { + time.Sleep(2 * time.Second) + + req, err := http.NewRequest("GET", apiURL, nil) + if err != nil { + return fmt.Errorf("creating poll request: %w", err) + } + req.Header.Set("Authorization", "Bearer "+accessToken) + + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("polling media status: %w", err) + } + + body, _ := io.ReadAll(resp.Body) + resp.Body.Close() //nolint: errcheck + + if resp.StatusCode == http.StatusOK { + var attachment MediaAttachmentResponse + if err := json.Unmarshal(body, &attachment); err != nil { + return fmt.Errorf("unmarshaling poll response: %w", err) + } + if attachment.URL != nil { + return nil + } + } else if resp.StatusCode != http.StatusPartialContent { + return fmt.Errorf("unexpected poll response: %s, Body: %s", resp.Status, string(body)) + } + } + return fmt.Errorf("media processing timed out for ID %s", mediaID) +} diff --git a/cmd/mastodon-github-action/main_test.go b/cmd/mastodon-github-action/main_test.go index 185f727..768bc29 100644 --- a/cmd/mastodon-github-action/main_test.go +++ b/cmd/mastodon-github-action/main_test.go @@ -1,9 +1,14 @@ package main import ( + "encoding/json" "fmt" + "io" "net/http" "net/http/httptest" + "os" + "path/filepath" + "strings" "testing" ) @@ -168,3 +173,307 @@ func TestPostStatus(t *testing.T) { }) } } + +func TestParseMediaPaths(t *testing.T) { + tests := []struct { + name string + input string + want []string + }{ + {name: "empty string", input: "", want: nil}, + {name: "whitespace only", input: " ", want: nil}, + {name: "single path", input: "/tmp/image.png", want: []string{"/tmp/image.png"}}, + {name: "multiple paths", input: "/tmp/a.png, /tmp/b.jpg, /tmp/c.gif", want: []string{"/tmp/a.png", "/tmp/b.jpg", "/tmp/c.gif"}}, + {name: "paths with extra whitespace", input: " /tmp/a.png , /tmp/b.jpg ", want: []string{"/tmp/a.png", "/tmp/b.jpg"}}, + {name: "trailing comma", input: "/tmp/a.png,", want: []string{"/tmp/a.png"}}, + {name: "empty entries between commas", input: "/tmp/a.png,,/tmp/b.png", want: []string{"/tmp/a.png", "/tmp/b.png"}}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := parseMediaPaths(tc.input) + if len(got) != len(tc.want) { + t.Fatalf("parseMediaPaths(%q) = %v, want %v", tc.input, got, tc.want) + } + for i := range got { + if got[i] != tc.want[i] { + t.Errorf("parseMediaPaths(%q)[%d] = %q, want %q", tc.input, i, got[i], tc.want[i]) + } + } + }) + } +} + +func TestParseMediaDescriptions(t *testing.T) { + tests := []struct { + name string + input string + want []string + }{ + {name: "empty string", input: "", want: nil}, + {name: "whitespace only", input: " ", want: nil}, + {name: "single description", input: "A nice photo", want: []string{"A nice photo"}}, + {name: "multiple descriptions", input: "Photo 1, Photo 2, Photo 3", want: []string{"Photo 1", "Photo 2", "Photo 3"}}, + {name: "descriptions with extra whitespace", input: " Photo 1 , Photo 2 ", want: []string{"Photo 1", "Photo 2"}}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := parseMediaDescriptions(tc.input) + if len(got) != len(tc.want) { + t.Fatalf("parseMediaDescriptions(%q) = %v, want %v", tc.input, got, tc.want) + } + for i := range got { + if got[i] != tc.want[i] { + t.Errorf("parseMediaDescriptions(%q)[%d] = %q, want %q", tc.input, i, got[i], tc.want[i]) + } + } + }) + } +} + +func TestResolveDescription(t *testing.T) { + tests := []struct { + name string + index int + descriptions []string + want string + }{ + {name: "nil descriptions", index: 0, descriptions: nil, want: ""}, + {name: "empty descriptions", index: 0, descriptions: []string{}, want: ""}, + {name: "exact match at index", index: 1, descriptions: []string{"first", "second", "third"}, want: "second"}, + {name: "single description applies to all", index: 2, descriptions: []string{"shared desc"}, want: "shared desc"}, + {name: "index out of range with multiple", index: 5, descriptions: []string{"a", "b"}, want: ""}, + {name: "empty string at index falls back to single", index: 1, descriptions: []string{"fallback", ""}, want: ""}, + {name: "single empty description", index: 0, descriptions: []string{""}, want: ""}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := resolveDescription(tc.index, tc.descriptions) + if got != tc.want { + t.Errorf("resolveDescription(%d, %v) = %q, want %q", tc.index, tc.descriptions, got, tc.want) + } + }) + } +} + +func TestUploadMedia(t *testing.T) { + tests := []struct { + name string + description string + mockStatusCode int + mockResponse string + wantErr bool + wantID string + }{ + { + name: "successful sync upload (200)", + description: "test image", + mockStatusCode: http.StatusOK, + mockResponse: `{"id":"media123","type":"image","url":"https://example.com/media/123.png"}`, + wantErr: false, + wantID: "media123", + }, + { + name: "upload without description", + description: "", + mockStatusCode: http.StatusOK, + mockResponse: `{"id":"media456","type":"image","url":"https://example.com/media/456.png"}`, + wantErr: false, + wantID: "media456", + }, + { + name: "server error", + description: "test", + mockStatusCode: http.StatusInternalServerError, + mockResponse: `{"error":"Internal server error"}`, + wantErr: true, + }, + { + name: "unauthorized", + description: "test", + mockStatusCode: http.StatusUnauthorized, + mockResponse: `{"error":"Unauthorized"}`, + wantErr: true, + }, + { + name: "unprocessable entity", + description: "test", + mockStatusCode: http.StatusUnprocessableEntity, + mockResponse: `{"error":"Validation failed: File content type is invalid"}`, + wantErr: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + tmpFile, err := os.CreateTemp("", "test-media-*.png") + if err != nil { + t.Fatal(err) + } + defer os.Remove(tmpFile.Name()) + tmpFile.Write([]byte("fake image content")) + tmpFile.Close() + + mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" { + t.Errorf("expected POST, got %s", r.Method) + } + if !strings.HasSuffix(r.URL.Path, "/api/v2/media") { + t.Errorf("unexpected path: %s", r.URL.Path) + } + if r.Header.Get("Authorization") != "Bearer fake-token" { + t.Errorf("unexpected auth header: %s", r.Header.Get("Authorization")) + } + if !strings.HasPrefix(r.Header.Get("Content-Type"), "multipart/form-data") { + t.Errorf("expected multipart/form-data content type, got %s", r.Header.Get("Content-Type")) + } + + err := r.ParseMultipartForm(10 << 20) + if err != nil { + t.Errorf("failed to parse multipart form: %v", err) + } + + file, header, err := r.FormFile("file") + if err != nil { + t.Errorf("missing file field: %v", err) + } else { + defer file.Close() + if header.Filename != filepath.Base(tmpFile.Name()) { + t.Errorf("unexpected filename: %s", header.Filename) + } + content, _ := io.ReadAll(file) + if string(content) != "fake image content" { + t.Errorf("unexpected file content: %s", string(content)) + } + } + + if tc.description != "" { + desc := r.FormValue("description") + if desc != tc.description { + t.Errorf("expected description %q, got %q", tc.description, desc) + } + } + + w.WriteHeader(tc.mockStatusCode) + fmt.Fprint(w, tc.mockResponse) + })) + defer mockServer.Close() + + id, err := uploadMedia(mockServer.URL, "fake-token", tmpFile.Name(), tc.description) + if (err != nil) != tc.wantErr { + t.Errorf("uploadMedia() error = %v, wantErr %v", err, tc.wantErr) + } + if !tc.wantErr && id != tc.wantID { + t.Errorf("uploadMedia() id = %q, want %q", id, tc.wantID) + } + }) + } +} + +func TestUploadMediaAsyncProcessing(t *testing.T) { + tmpFile, err := os.CreateTemp("", "test-video-*.mp4") + if err != nil { + t.Fatal(err) + } + defer os.Remove(tmpFile.Name()) + tmpFile.Write([]byte("fake video content")) + tmpFile.Close() + + pollCount := 0 + mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == "POST" && strings.HasSuffix(r.URL.Path, "/api/v2/media") { + w.WriteHeader(http.StatusAccepted) + fmt.Fprint(w, `{"id":"async-media-1","type":"video","url":null}`) + return + } + + if r.Method == "GET" && strings.Contains(r.URL.Path, "/api/v1/media/async-media-1") { + pollCount++ + if pollCount < 3 { + w.WriteHeader(http.StatusPartialContent) + return + } + url := "https://example.com/media/video.mp4" + resp := MediaAttachmentResponse{ID: "async-media-1", Type: "video", URL: &url} + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(resp) + return + } + + w.WriteHeader(http.StatusNotFound) + })) + defer mockServer.Close() + + id, err := uploadMedia(mockServer.URL, "fake-token", tmpFile.Name(), "test video") + if err != nil { + t.Fatalf("uploadMedia() unexpected error: %v", err) + } + if id != "async-media-1" { + t.Errorf("uploadMedia() id = %q, want %q", id, "async-media-1") + } + if pollCount < 3 { + t.Errorf("expected at least 3 poll attempts, got %d", pollCount) + } +} + +func TestUploadMediaFileNotFound(t *testing.T) { + _, err := uploadMedia("http://localhost", "token", "/nonexistent/file.png", "") + if err == nil { + t.Error("uploadMedia() expected error for nonexistent file, got nil") + } +} + +func TestPostStatusWithMediaIDs(t *testing.T) { + var receivedBody map[string]interface{} + + mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + json.Unmarshal(body, &receivedBody) + w.WriteHeader(http.StatusOK) + fmt.Fprint(w, `{"id":"status-1","url":"https://example.com/@user/status-1","content":"Hello","created_at":"2020-01-01T00:00:00Z","visibility":"public"}`) + })) + defer mockServer.Close() + + status := MastodonStatus{ + Status: "Hello with media", + Visibility: string(VisibilityPublic), + MediaIDs: []string{"media-1", "media-2"}, + } + + err := postStatus(mockServer.URL, "fake-token", status) + if err != nil { + t.Fatalf("postStatus() unexpected error: %v", err) + } + + mediaIDs, ok := receivedBody["media_ids"].([]interface{}) + if !ok { + t.Fatal("expected media_ids in request body") + } + if len(mediaIDs) != 2 { + t.Fatalf("expected 2 media_ids, got %d", len(mediaIDs)) + } + if mediaIDs[0] != "media-1" || mediaIDs[1] != "media-2" { + t.Errorf("unexpected media_ids: %v", mediaIDs) + } +} + +func TestPostStatusWithoutMessageButWithMedia(t *testing.T) { + mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + fmt.Fprint(w, `{"id":"status-2","url":"https://example.com/@user/status-2","content":"","created_at":"2020-01-01T00:00:00Z","visibility":"public"}`) + })) + defer mockServer.Close() + + status := MastodonStatus{ + Status: "", + Visibility: string(VisibilityPublic), + MediaIDs: []string{"media-1"}, + } + + err := postStatus(mockServer.URL, "fake-token", status) + if err != nil { + t.Fatalf("postStatus() with empty status but media should succeed, got: %v", err) + } +}