feat(macros): align attachment uploads
This commit is contained in:
@@ -105,6 +105,10 @@ func (h *MacroHandler) Create(c *gin.Context) {
|
||||
}
|
||||
|
||||
if svcErr := h.svc.Create(c.Request.Context(), macro); svcErr != nil {
|
||||
if strings.Contains(svcErr.Error(), "invalid attachment") {
|
||||
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "invalid attachment"})
|
||||
return
|
||||
}
|
||||
handleServiceError(c, svcErr)
|
||||
return
|
||||
}
|
||||
@@ -154,6 +158,10 @@ func (h *MacroHandler) Update(c *gin.Context) {
|
||||
|
||||
updated, svcErr := h.svc.UpdateForAccount(c.Request.Context(), accountID, macro)
|
||||
if svcErr != nil {
|
||||
if strings.Contains(svcErr.Error(), "invalid attachment") {
|
||||
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "invalid attachment"})
|
||||
return
|
||||
}
|
||||
handleServiceError(c, svcErr)
|
||||
return
|
||||
}
|
||||
@@ -312,9 +320,9 @@ type macroActionRequest struct {
|
||||
}
|
||||
|
||||
type macroRequest struct {
|
||||
Name string `json:"name"`
|
||||
Visibility interface{} `json:"visibility"`
|
||||
Actions []macroActionRequest `json:"actions"`
|
||||
Name string `json:"name"`
|
||||
Visibility interface{} `json:"visibility"`
|
||||
Actions *[]macroActionRequest `json:"actions"`
|
||||
}
|
||||
|
||||
func bindMacroRequest(c *gin.Context) (*automation.Macro, error) {
|
||||
@@ -322,13 +330,16 @@ func bindMacroRequest(c *gin.Context) (*automation.Macro, error) {
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
actions := make(automation.Actions, 0, len(req.Actions))
|
||||
for _, actionReq := range req.Actions {
|
||||
params, err := normalizeAutomationActionParams(actionReq.ActionName, actionReq.ActionParams)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
var actions automation.Actions
|
||||
if req.Actions != nil {
|
||||
actions = make(automation.Actions, 0, len(*req.Actions))
|
||||
for _, actionReq := range *req.Actions {
|
||||
params, err := normalizeAutomationActionParams(actionReq.ActionName, actionReq.ActionParams)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
actions = append(actions, automation.Action{ActionName: actionReq.ActionName, ActionParams: params})
|
||||
}
|
||||
actions = append(actions, automation.Action{ActionName: actionReq.ActionName, ActionParams: params})
|
||||
}
|
||||
return &automation.Macro{
|
||||
Name: strings.TrimSpace(req.Name),
|
||||
@@ -392,6 +403,21 @@ func serializeMacro(macro *automation.Macro) gin.H {
|
||||
if macro.UpdatedBy != nil && macro.UpdatedBy.ID != 0 {
|
||||
item["updated_by"] = serializeMacroAgent(macro.UpdatedBy)
|
||||
}
|
||||
if len(macro.Files) > 0 {
|
||||
files := make([]gin.H, 0, len(macro.Files))
|
||||
for _, file := range macro.Files {
|
||||
files = append(files, gin.H{
|
||||
"id": file.ID,
|
||||
"macro_id": file.MacroID,
|
||||
"file_type": file.FileType,
|
||||
"account_id": file.AccountID,
|
||||
"file_url": file.FileURL,
|
||||
"blob_id": file.BlobID,
|
||||
"filename": file.Filename,
|
||||
})
|
||||
}
|
||||
item["files"] = files
|
||||
}
|
||||
return item
|
||||
}
|
||||
|
||||
|
||||
@@ -41,6 +41,7 @@ func (s *MacroHandlerTestSuite) SetupSuite() {
|
||||
s.Require().NoError(err)
|
||||
s.Require().NoError(db.AutoMigrate(
|
||||
&model.Account{}, &model.User{}, &model.AccountUser{}, &model.Inbox{}, &model.Contact{}, &model.Conversation{}, &model.Message{},
|
||||
&model.Attachment{}, &model.DirectUpload{},
|
||||
&automation.Macro{}, &automation.MacroExecution{}, &automation.ConversationLabel{}, &automation.ConversationMute{},
|
||||
))
|
||||
s.db = db
|
||||
@@ -54,6 +55,62 @@ func (s *MacroHandlerTestSuite) SetupSuite() {
|
||||
s.db.Create(s.user)
|
||||
}
|
||||
|
||||
func (s *MacroHandlerTestSuite) TestCreate_AttachmentActionAcceptsSignedUploadAndSerializesFiles() {
|
||||
upload := &model.DirectUpload{
|
||||
UploadUUID: "macro-upload-signed-id",
|
||||
AccountID: s.account.ID,
|
||||
Status: model.DirectUploadStatusPending,
|
||||
Source: model.DirectUploadSourceAccount,
|
||||
OriginalName: "avatar.png",
|
||||
FileType: "image",
|
||||
MimeType: "image/png",
|
||||
FileSize: 123,
|
||||
FileURL: "/uploads/account/1/avatar.png",
|
||||
ThumbURL: "/uploads/account/1/avatar.png",
|
||||
}
|
||||
s.Require().NoError(s.db.Create(upload).Error)
|
||||
r := gin.New()
|
||||
r.POST("/api/v1/accounts/:account_id/macros", func(c *gin.Context) {
|
||||
c.Set("user_id", float64(s.user.ID))
|
||||
c.Next()
|
||||
}, s.handler.Create)
|
||||
body := []byte(`{"name":"send file","visibility":"global","actions":[{"action_name":"send_attachment","action_params":["macro-upload-signed-id"]}]}`)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("POST", fmt.Sprintf("/api/v1/accounts/%d/macros", s.account.ID), bytes.NewBuffer(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(s.T(), http.StatusOK, w.Code)
|
||||
var resp map[string]interface{}
|
||||
s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &resp))
|
||||
payload := resp["payload"].(map[string]interface{})
|
||||
actions := payload["actions"].([]interface{})
|
||||
action := actions[0].(map[string]interface{})
|
||||
assert.Equal(s.T(), []interface{}{float64(upload.ID)}, action["action_params"])
|
||||
files := payload["files"].([]interface{})
|
||||
s.Require().Len(files, 1)
|
||||
file := files[0].(map[string]interface{})
|
||||
assert.Equal(s.T(), float64(upload.ID), file["blob_id"])
|
||||
assert.Equal(s.T(), "avatar.png", file["filename"])
|
||||
}
|
||||
|
||||
func (s *MacroHandlerTestSuite) TestCreate_AttachmentActionRejectsInvalidBlob() {
|
||||
r := gin.New()
|
||||
r.POST("/api/v1/accounts/:account_id/macros", func(c *gin.Context) {
|
||||
c.Set("user_id", float64(s.user.ID))
|
||||
c.Next()
|
||||
}, s.handler.Create)
|
||||
body := []byte(`{"name":"bad file","visibility":"global","actions":[{"action_name":"send_attachment","action_params":["missing-upload"]}]}`)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("POST", fmt.Sprintf("/api/v1/accounts/%d/macros", s.account.ID), bytes.NewBuffer(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(s.T(), http.StatusUnprocessableEntity, w.Code)
|
||||
}
|
||||
|
||||
func (s *MacroHandlerTestSuite) TearDownSuite() {
|
||||
if s.db != nil {
|
||||
sqlDB, _ := s.db.DB()
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
package v1
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/gochat/gochat/internal/service"
|
||||
"github.com/gochat/gochat/pkg/response"
|
||||
@@ -29,21 +31,44 @@ func (h *UploadHandler) Upload(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
fileHeader, err := c.FormFile("file")
|
||||
if err != nil {
|
||||
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, "file is required")
|
||||
return
|
||||
var result *service.UploadResponse
|
||||
var svcErr error
|
||||
if strings.Contains(c.GetHeader("Content-Type"), "application/json") {
|
||||
var req struct {
|
||||
ExternalURL string `json:"external_url"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil || strings.TrimSpace(req.ExternalURL) == "" {
|
||||
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "missing input"})
|
||||
return
|
||||
}
|
||||
result, svcErr = h.svc.AccountUploadFromURL(c.Request.Context(), accountID, strings.TrimSpace(req.ExternalURL))
|
||||
} else {
|
||||
fileHeader, err := c.FormFile("attachment")
|
||||
if err != nil {
|
||||
fileHeader, err = c.FormFile("file")
|
||||
}
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "missing input"})
|
||||
return
|
||||
}
|
||||
result, svcErr = h.svc.AccountUpload(c.Request.Context(), accountID, service.AccountUploadRequest{
|
||||
FileHeader: fileHeader,
|
||||
})
|
||||
}
|
||||
|
||||
result, svcErr := h.svc.AccountUpload(c.Request.Context(), accountID, service.AccountUploadRequest{
|
||||
FileHeader: fileHeader,
|
||||
})
|
||||
if svcErr != nil {
|
||||
handleServiceError(c, svcErr)
|
||||
status := http.StatusUnprocessableEntity
|
||||
if errors.Is(svcErr, gorm.ErrRecordNotFound) {
|
||||
status = http.StatusNotFound
|
||||
}
|
||||
c.JSON(status, gin.H{"error": svcErr.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
response.OK(c, result)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"file_url": result.FileURL,
|
||||
"blob_id": result.UploadUUID,
|
||||
"blob_key": result.UploadUUID,
|
||||
})
|
||||
}
|
||||
|
||||
// DirectUpload handles POST /api/v1/widget/direct_uploads — widget direct file upload.
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -55,9 +56,13 @@ func setupUploadHandlerRouter(h *UploadHandler) *gin.Engine {
|
||||
|
||||
// makeMultipartUploadBody creates a multipart form body with a file field.
|
||||
func makeMultipartUploadBody(filename string, content []byte) (body *bytes.Buffer, contentType string, err error) {
|
||||
return makeMultipartUploadBodyWithField("file", filename, content)
|
||||
}
|
||||
|
||||
func makeMultipartUploadBodyWithField(fieldName, filename string, content []byte) (body *bytes.Buffer, contentType string, err error) {
|
||||
body = &bytes.Buffer{}
|
||||
writer := multipart.NewWriter(body)
|
||||
part, err := writer.CreateFormFile("file", filename)
|
||||
part, err := writer.CreateFormFile(fieldName, filename)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
@@ -79,7 +84,60 @@ func TestUploadHandler_Upload_NoFile(t *testing.T) {
|
||||
// Set account_id in context (simulating middleware)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, w.Code)
|
||||
assert.Equal(t, http.StatusUnprocessableEntity, w.Code)
|
||||
}
|
||||
|
||||
func TestUploadHandler_Upload_ChatwootAttachmentFieldRawPayload(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, db.AutoMigrate(&model.DirectUpload{}))
|
||||
uploadSvc := service.NewUploadService(repository.NewDirectUploadRepo(db), &config.Config{Storage: config.StorageConfig{LocalPath: tmpDir, MaxFileSize: 50 << 20}})
|
||||
router := setupUploadHandlerRouter(NewUploadHandler(uploadSvc))
|
||||
|
||||
body, contentType, err := makeMultipartUploadBodyWithField("attachment", "macro.png", []byte("fake png"))
|
||||
require.NoError(t, err)
|
||||
req, _ := http.NewRequest("POST", "/api/v1/accounts/1/upload", body)
|
||||
req.Header.Set("Content-Type", contentType)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
var payload map[string]any
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &payload))
|
||||
assert.NotContains(t, payload, "success")
|
||||
assert.NotEmpty(t, payload["file_url"])
|
||||
assert.NotEmpty(t, payload["blob_id"])
|
||||
assert.Equal(t, payload["blob_id"], payload["blob_key"])
|
||||
}
|
||||
|
||||
func TestUploadHandler_Upload_ChatwootExternalURLRawPayload(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, db.AutoMigrate(&model.DirectUpload{}))
|
||||
uploadSvc := service.NewUploadService(repository.NewDirectUploadRepo(db), &config.Config{Storage: config.StorageConfig{LocalPath: tmpDir, MaxFileSize: 50 << 20}})
|
||||
router := setupUploadHandlerRouter(NewUploadHandler(uploadSvc))
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "image/png")
|
||||
_, _ = w.Write([]byte("external image"))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
body := strings.NewReader(`{"external_url":"` + server.URL + `/image.png"}`)
|
||||
req, _ := http.NewRequest("POST", "/api/v1/accounts/1/upload", body)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
var payload map[string]any
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &payload))
|
||||
assert.NotContains(t, payload, "success")
|
||||
assert.NotEmpty(t, payload["file_url"])
|
||||
assert.NotEmpty(t, payload["blob_id"])
|
||||
}
|
||||
|
||||
func TestUploadHandler_DirectUpload_NoFile(t *testing.T) {
|
||||
|
||||
Reference in New Issue
Block a user