Restructure the monorepo into clear top-level directories: - backend/: Go module root (cmd, internal, pkg, configs, migrations, docs/swagger, scripts, tests, go.mod, Makefile, .air.toml) - deploy/: Docker (Dockerfile, docker-compose*), quickstart, fluentd - docs/: project documentation + reports/ (moved from repo root) - AGENTS.md: new AI coding-agent guide at repo root Update all references to the new layout: - Dockerfile: COPY backend/go.mod, COPY backend/ (context = repo root) - docker-compose files: context ../.., dockerfile deploy/docker/Dockerfile, env_file ../../.env, volume mounts ../../backend:/app - deploy/quickstart/compose.yaml: dockerfile deploy/docker/Dockerfile - CI: working-directory: backend for go commands, file deploy/docker/Dockerfile, coverage path backend/coverage.out, health_check backend/scripts/ - backend/Makefile: docker target uses -f ../deploy/docker/Dockerfile ../ - README: architecture tree, quickstart, config paths updated Move root stray scripts (rename_models.*, run_m11_tests.sh, verify_build.sh, gorm_bool_main.go) to backend/scripts/legacy/. All moves via git mv to preserve history. Build, vet, SQLite tests, and docker compose config verified.
335 lines
13 KiB
Go
335 lines
13 KiB
Go
package v1
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/gochat/gochat/internal/automation"
|
|
"github.com/gochat/gochat/internal/model"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/suite"
|
|
"gorm.io/driver/sqlite"
|
|
"gorm.io/gorm"
|
|
"gorm.io/gorm/logger"
|
|
)
|
|
|
|
type testMacroDBProvider struct {
|
|
db *gorm.DB
|
|
}
|
|
|
|
func (p *testMacroDBProvider) DB() *gorm.DB { return p.db }
|
|
|
|
type MacroHandlerTestSuite struct {
|
|
suite.Suite
|
|
db *gorm.DB
|
|
handler *MacroHandler
|
|
|
|
account *model.Account
|
|
user *model.User
|
|
}
|
|
|
|
func (s *MacroHandlerTestSuite) SetupSuite() {
|
|
gin.SetMode(gin.TestMode)
|
|
db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{
|
|
Logger: logger.Default.LogMode(logger.Silent),
|
|
})
|
|
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
|
|
|
|
macroSvc := automation.NewMacroService(&testMacroDBProvider{db: db})
|
|
s.handler = NewMacroHandler(macroSvc)
|
|
|
|
s.account = &model.Account{Name: "test-macro-account"}
|
|
s.db.Create(s.account)
|
|
s.user = &model.User{Name: "test-macro-user", Email: "macro@example.com"}
|
|
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()
|
|
sqlDB.Close()
|
|
}
|
|
}
|
|
|
|
func TestMacroHandlerSuite(t *testing.T) {
|
|
suite.Run(t, new(MacroHandlerTestSuite))
|
|
}
|
|
|
|
func (s *MacroHandlerTestSuite) TestList_Empty() {
|
|
r := gin.New()
|
|
r.GET("/api/v1/accounts/:account_id/macros", s.handler.List)
|
|
|
|
w := httptest.NewRecorder()
|
|
req, _ := http.NewRequest("GET", fmt.Sprintf("/api/v1/accounts/%d/macros", s.account.ID), nil)
|
|
r.ServeHTTP(w, req)
|
|
|
|
assert.Equal(s.T(), http.StatusOK, w.Code)
|
|
}
|
|
|
|
func (s *MacroHandlerTestSuite) TestCreate_Success() {
|
|
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 := map[string]interface{}{
|
|
"name": "test-macro",
|
|
"visibility": "global",
|
|
"actions": []map[string]interface{}{{"action_name": "assign_agent", "action_params": []interface{}{"self"}}},
|
|
}
|
|
b, _ := json.Marshal(body)
|
|
|
|
w := httptest.NewRecorder()
|
|
req, _ := http.NewRequest("POST", fmt.Sprintf("/api/v1/accounts/%d/macros", s.account.ID), bytes.NewBuffer(b))
|
|
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{})
|
|
assert.Equal(s.T(), "test-macro", payload["name"])
|
|
assert.Equal(s.T(), "global", payload["visibility"])
|
|
createdBy := payload["created_by"].(map[string]interface{})
|
|
assert.Equal(s.T(), float64(s.user.ID), createdBy["id"])
|
|
actions := payload["actions"].([]interface{})
|
|
action := actions[0].(map[string]interface{})
|
|
assert.Equal(s.T(), []interface{}{"self"}, action["action_params"])
|
|
}
|
|
|
|
func (s *MacroHandlerTestSuite) TestCreate_BadRequest() {
|
|
r := gin.New()
|
|
r.POST("/api/v1/accounts/:account_id/macros", s.handler.Create)
|
|
|
|
w := httptest.NewRecorder()
|
|
req, _ := http.NewRequest("POST", fmt.Sprintf("/api/v1/accounts/%d/macros", s.account.ID), nil)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
r.ServeHTTP(w, req)
|
|
|
|
assert.Equal(s.T(), http.StatusBadRequest, w.Code)
|
|
}
|
|
|
|
func (s *MacroHandlerTestSuite) TestUpdateDeleteAuthorizationAndPayload() {
|
|
admin := &model.User{Name: "Admin", Email: "macro-admin@example.com", Role: "administrator"}
|
|
s.Require().NoError(s.db.Create(admin).Error)
|
|
agent := &model.User{Name: "Agent", Email: "macro-agent@example.com", Role: "agent"}
|
|
s.Require().NoError(s.db.Create(agent).Error)
|
|
macro := &automation.Macro{
|
|
AccountID: s.account.ID,
|
|
Name: "public macro",
|
|
Actions: automation.Actions{},
|
|
Visibility: automation.MacroVisibilityGlobal,
|
|
Active: true,
|
|
CreatedByID: admin.ID,
|
|
UpdatedByID: admin.ID,
|
|
}
|
|
s.Require().NoError(s.db.Create(macro).Error)
|
|
|
|
r := gin.New()
|
|
r.PUT("/api/v1/accounts/:account_id/macros/:macro_id", func(c *gin.Context) {
|
|
c.Set("user_id", agent.ID)
|
|
c.Set("role", "agent")
|
|
c.Next()
|
|
}, s.handler.Update)
|
|
|
|
body := []byte(`{"name":"agent edit","visibility":"global","actions":[]}`)
|
|
w := httptest.NewRecorder()
|
|
req, _ := http.NewRequest("PUT", fmt.Sprintf("/api/v1/accounts/%d/macros/%d", s.account.ID, macro.ID), bytes.NewBuffer(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
r.ServeHTTP(w, req)
|
|
assert.Equal(s.T(), http.StatusUnauthorized, w.Code)
|
|
|
|
r = gin.New()
|
|
r.PUT("/api/v1/accounts/:account_id/macros/:macro_id", func(c *gin.Context) {
|
|
c.Set("user_id", admin.ID)
|
|
c.Set("role", "administrator")
|
|
c.Next()
|
|
}, s.handler.Update)
|
|
w = httptest.NewRecorder()
|
|
req, _ = http.NewRequest("PUT", fmt.Sprintf("/api/v1/accounts/%d/macros/%d", s.account.ID, macro.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{})
|
|
assert.Equal(s.T(), "agent edit", payload["name"])
|
|
assert.Equal(s.T(), "global", payload["visibility"])
|
|
}
|
|
|
|
func (s *MacroHandlerTestSuite) TestExecute_UsesConversationDisplayIDsAndMutatesConversation() {
|
|
admin := &model.User{Name: "Exec Admin", Email: "macro-exec-admin@example.com", Role: "administrator"}
|
|
s.Require().NoError(s.db.Create(admin).Error)
|
|
inbox := &model.Inbox{AccountID: s.account.ID, Name: "Macro Inbox", ChannelType: "web"}
|
|
s.Require().NoError(s.db.Create(inbox).Error)
|
|
contact := &model.Contact{AccountID: s.account.ID, Name: "Macro Contact", Email: "macro-contact@example.com"}
|
|
s.Require().NoError(s.db.Create(contact).Error)
|
|
displayID := uint(444)
|
|
conversation := &model.Conversation{AccountID: s.account.ID, InboxID: inbox.ID, ContactID: contact.ID, DisplayID: &displayID, Status: "open", Priority: "low", ChannelType: "web", Channel: "web_widget"}
|
|
s.Require().NoError(s.db.Create(conversation).Error)
|
|
macro := &automation.Macro{
|
|
AccountID: s.account.ID,
|
|
Name: "execute macro",
|
|
Actions: automation.Actions{
|
|
{ActionName: "add_label", ActionParams: map[string]interface{}{"labels": []string{"vip"}}},
|
|
{ActionName: "change_status", ActionParams: map[string]interface{}{"status": "resolved"}},
|
|
{ActionName: "send_message", ActionParams: map[string]interface{}{"content": "Hello from macro"}},
|
|
{ActionName: "add_private_note", ActionParams: map[string]interface{}{"content": "Internal macro note"}},
|
|
},
|
|
Visibility: automation.MacroVisibilityGlobal,
|
|
Active: true,
|
|
CreatedByID: admin.ID,
|
|
UpdatedByID: admin.ID,
|
|
}
|
|
s.Require().NoError(s.db.Create(macro).Error)
|
|
|
|
r := gin.New()
|
|
r.POST("/api/v1/accounts/:account_id/macros/:macro_id/execute", func(c *gin.Context) {
|
|
c.Set("user_id", admin.ID)
|
|
c.Set("role", "administrator")
|
|
c.Next()
|
|
}, s.handler.Execute)
|
|
|
|
w := httptest.NewRecorder()
|
|
req, _ := http.NewRequest("POST", fmt.Sprintf("/api/v1/accounts/%d/macros/%d/execute", s.account.ID, macro.ID), bytes.NewBufferString(`{"conversation_ids":[444]}`))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
r.ServeHTTP(w, req)
|
|
assert.Equal(s.T(), http.StatusOK, w.Code)
|
|
assert.Empty(s.T(), w.Body.String())
|
|
|
|
var reloaded model.Conversation
|
|
s.Require().NoError(s.db.First(&reloaded, conversation.ID).Error)
|
|
assert.Equal(s.T(), "resolved", reloaded.Status)
|
|
var label automation.ConversationLabel
|
|
s.Require().NoError(s.db.Where("conversation_id = ? AND label = ?", conversation.ID, "vip").First(&label).Error)
|
|
var messages []model.Message
|
|
s.Require().NoError(s.db.Where("conversation_id = ?", conversation.ID).Order("id ASC").Find(&messages).Error)
|
|
s.Require().Len(messages, 2)
|
|
assert.Equal(s.T(), "Hello from macro", messages[0].Content)
|
|
assert.Equal(s.T(), admin.ID, *messages[0].SenderID)
|
|
assert.False(s.T(), messages[0].Private)
|
|
assert.Equal(s.T(), "Internal macro note", messages[1].Content)
|
|
assert.True(s.T(), messages[1].Private)
|
|
}
|
|
|
|
func (s *MacroHandlerTestSuite) TestExecute_ChatwootFrontendAwaitsEmptyOKAndSupportsSingleConversationID() {
|
|
admin := &model.User{Name: "Exec Single Admin", Email: "macro-exec-single@example.com", Role: "administrator"}
|
|
s.Require().NoError(s.db.Create(admin).Error)
|
|
inbox := &model.Inbox{AccountID: s.account.ID, Name: "Macro Single Inbox", ChannelType: "web"}
|
|
s.Require().NoError(s.db.Create(inbox).Error)
|
|
contact := &model.Contact{AccountID: s.account.ID, Name: "Macro Single Contact", Email: "macro-single@example.com"}
|
|
s.Require().NoError(s.db.Create(contact).Error)
|
|
displayID := uint(555)
|
|
conversation := &model.Conversation{AccountID: s.account.ID, InboxID: inbox.ID, ContactID: contact.ID, DisplayID: &displayID, Status: "open", Priority: "low", ChannelType: "web", Channel: "web_widget"}
|
|
s.Require().NoError(s.db.Create(conversation).Error)
|
|
macro := &automation.Macro{
|
|
AccountID: s.account.ID,
|
|
Name: "single execute macro",
|
|
Actions: automation.Actions{
|
|
{ActionName: "change_status", ActionParams: map[string]interface{}{"status": "resolved"}},
|
|
},
|
|
Visibility: automation.MacroVisibilityGlobal,
|
|
Active: true,
|
|
CreatedByID: admin.ID,
|
|
UpdatedByID: admin.ID,
|
|
}
|
|
s.Require().NoError(s.db.Create(macro).Error)
|
|
|
|
r := gin.New()
|
|
r.POST("/api/v1/accounts/:account_id/macros/:macro_id/execute", func(c *gin.Context) {
|
|
c.Set("user_id", admin.ID)
|
|
c.Set("role", "administrator")
|
|
c.Next()
|
|
}, s.handler.Execute)
|
|
|
|
w := httptest.NewRecorder()
|
|
req, _ := http.NewRequest("POST", fmt.Sprintf("/api/v1/accounts/%d/macros/%d/execute", s.account.ID, macro.ID), bytes.NewBufferString(`{"conversation_id":555}`))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
r.ServeHTTP(w, req)
|
|
assert.Equal(s.T(), http.StatusOK, w.Code)
|
|
assert.Empty(s.T(), w.Body.String())
|
|
|
|
var reloaded model.Conversation
|
|
s.Require().NoError(s.db.First(&reloaded, conversation.ID).Error)
|
|
assert.Equal(s.T(), "resolved", reloaded.Status)
|
|
|
|
w = httptest.NewRecorder()
|
|
req, _ = http.NewRequest("POST", fmt.Sprintf("/api/v1/accounts/%d/macros/%d/execute", s.account.ID, macro.ID), nil)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
r.ServeHTTP(w, req)
|
|
assert.Equal(s.T(), http.StatusBadRequest, w.Code)
|
|
var errorResp map[string]interface{}
|
|
s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &errorResp))
|
|
validationError := errorResp["error"].(map[string]interface{})
|
|
assert.Equal(s.T(), "VALIDATION_ERROR", validationError["code"])
|
|
assert.Equal(s.T(), "conversation_ids is required", validationError["message"])
|
|
}
|