package v1 import ( "bytes" "encoding/json" "fmt" "net/http" "net/http/httptest" "strconv" "testing" "github.com/gin-gonic/gin" "github.com/gochat/gochat/internal/model" "github.com/gochat/gochat/internal/repository" "github.com/gochat/gochat/internal/service" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "gorm.io/driver/sqlite" "gorm.io/gorm" ) func setupCaptainResourceParityTest(t *testing.T) (*gin.Engine, *gorm.DB, *model.Account, *model.Account, *model.CaptainAssistant) { t.Helper() gin.SetMode(gin.TestMode) dbName := fmt.Sprintf("file:%s?mode=memory&cache=private", t.Name()) db, err := gorm.Open(sqlite.Open(dbName), &gorm.Config{}) require.NoError(t, err) require.NoError(t, db.AutoMigrate( &model.Account{}, &model.CaptainAssistant{}, &model.CaptainScenario{}, &model.CaptainCustomTool{}, )) t.Cleanup(func() { sqlDB, _ := db.DB() sqlDB.Close() }) account := &model.Account{Name: "Captain Account", Locale: "en", Active: true} otherAccount := &model.Account{Name: "Other Account", Locale: "en", Active: true} require.NoError(t, db.Create(account).Error) require.NoError(t, db.Create(otherAccount).Error) assistant := &model.CaptainAssistant{AccountID: account.ID, Name: "Fin", Description: "Support", Config: json.RawMessage(`{}`), Status: model.AssistantStatusActive} require.NoError(t, db.Create(assistant).Error) assistantRepo := repository.NewCaptainAssistantRepo(db) scenarioRepo := repository.NewCaptainScenarioRepo(db) scenarioSvc := service.NewCaptainScenarioService(scenarioRepo, assistantRepo) scenarioHandler := NewCaptainScenarioHandler(scenarioSvc) toolRepo := repository.NewCaptainCustomToolRepo(db) toolSvc := service.NewCaptainCustomToolService(toolRepo) toolHandler := NewCaptainCustomToolHandler(toolSvc) router := gin.New() accountGroup := router.Group("/api/v1/accounts/:account_id/captain") assistantScenarios := accountGroup.Group("/assistants/:assistant_id/scenarios") assistantScenarios.GET("/", scenarioHandler.List) assistantScenarios.POST("/", scenarioHandler.Create) assistantScenarios.GET("/:scenario_id", scenarioHandler.Get) assistantScenarios.PUT("/:scenario_id", scenarioHandler.Update) assistantScenarios.DELETE("/:scenario_id", scenarioHandler.Delete) customTools := accountGroup.Group("/custom_tools") customTools.GET("/", toolHandler.List) customTools.POST("/", toolHandler.Create) customTools.GET("/:tool_id", toolHandler.Get) customTools.PUT("/:tool_id", toolHandler.Update) customTools.DELETE("/:tool_id", toolHandler.Delete) return router, db, account, otherAccount, assistant } func captainResourceJSONRequest(t *testing.T, router *gin.Engine, method, path string, body any) *httptest.ResponseRecorder { t.Helper() var payload []byte if body != nil { data, err := json.Marshal(body) require.NoError(t, err) payload = data } w := httptest.NewRecorder() req := httptest.NewRequest(method, path, bytes.NewReader(payload)) if body != nil { req.Header.Set("Content-Type", "application/json") } router.ServeHTTP(w, req) return w } func TestCaptainScenarioHandler_ChatwootScenarioPayloadsAndScope(t *testing.T) { router, db, account, otherAccount, assistant := setupCaptainResourceParityTest(t) basePath := "/api/v1/accounts/" + strconv.FormatUint(uint64(account.ID), 10) + "/captain/assistants/" + strconv.FormatUint(uint64(assistant.ID), 10) + "/scenarios" otherBasePath := "/api/v1/accounts/" + strconv.FormatUint(uint64(otherAccount.ID), 10) + "/captain/assistants/" + strconv.FormatUint(uint64(assistant.ID), 10) + "/scenarios" body := map[string]any{"scenario": map[string]any{ "title": "Escalate billing", "description": "Billing handoff", "instruction": "Ask for invoice ID", "tools": []string{"handoff"}, }} w := captainResourceJSONRequest(t, router, http.MethodPost, basePath+"/", body) assert.Equal(t, http.StatusOK, w.Code) var created map[string]any require.NoError(t, json.Unmarshal(w.Body.Bytes(), &created)) assert.NotContains(t, created, "success") assert.Equal(t, "Escalate billing", created["title"]) assert.Equal(t, float64(account.ID), created["account_id"]) assert.Equal(t, float64(assistant.ID), created["assistant_id"]) assert.Equal(t, "Fin", created["assistant"].(map[string]any)["name"]) scenarioID := uint(created["id"].(float64)) disabled := &model.CaptainScenario{AccountID: account.ID, AssistantID: assistant.ID, Title: "Disabled", Enabled: false} require.NoError(t, db.Create(disabled).Error) require.NoError(t, db.Model(disabled).Update("enabled", false).Error) w = captainResourceJSONRequest(t, router, http.MethodGet, basePath+"/", nil) assert.Equal(t, http.StatusOK, w.Code) var listResp map[string]any require.NoError(t, json.Unmarshal(w.Body.Bytes(), &listResp)) assert.Len(t, listResp["payload"], 1) assert.Equal(t, float64(1), listResp["meta"].(map[string]any)["total_count"]) w = captainResourceJSONRequest(t, router, http.MethodGet, fmt.Sprintf("%s/%d", otherBasePath, scenarioID), nil) assert.Equal(t, http.StatusNotFound, w.Code) updateBody := map[string]any{"scenario": map[string]any{"enabled": false, "instruction": "Updated"}} w = captainResourceJSONRequest(t, router, http.MethodPut, fmt.Sprintf("%s/%d", basePath, scenarioID), updateBody) assert.Equal(t, http.StatusOK, w.Code) var updated map[string]any require.NoError(t, json.Unmarshal(w.Body.Bytes(), &updated)) assert.Equal(t, false, updated["enabled"]) assert.Equal(t, "Updated", updated["instruction"]) w = captainResourceJSONRequest(t, router, http.MethodDelete, fmt.Sprintf("%s/%d", basePath, scenarioID), nil) assert.Equal(t, http.StatusNoContent, w.Code) } func TestCaptainCustomToolHandler_ChatwootToolPayloadsAndScope(t *testing.T) { router, _, account, otherAccount, _ := setupCaptainResourceParityTest(t) basePath := "/api/v1/accounts/" + strconv.FormatUint(uint64(account.ID), 10) + "/captain/custom_tools" otherBasePath := "/api/v1/accounts/" + strconv.FormatUint(uint64(otherAccount.ID), 10) + "/captain/custom_tools" body := map[string]any{"custom_tool": map[string]any{ "title": "Lookup Order", "description": "Fetch order status", "endpoint_url": "https://example.com/orders", "http_method": "POST", "auth_type": "none", "param_schema": []map[string]any{{"name": "order_id", "type": "string", "required": true}}, "request_template": "{\"id\":\"{{.order_id}}\"}", }} w := captainResourceJSONRequest(t, router, http.MethodPost, basePath+"/", body) assert.Equal(t, http.StatusOK, w.Code) var created map[string]any require.NoError(t, json.Unmarshal(w.Body.Bytes(), &created)) assert.NotContains(t, created, "success") assert.Equal(t, "lookup-order", created["slug"]) assert.Equal(t, "POST", created["http_method"]) toolID := uint(created["id"].(float64)) w = captainResourceJSONRequest(t, router, http.MethodGet, basePath+"/", nil) assert.Equal(t, http.StatusOK, w.Code) var listResp map[string]any require.NoError(t, json.Unmarshal(w.Body.Bytes(), &listResp)) assert.Len(t, listResp["payload"], 1) assert.Equal(t, float64(1), listResp["meta"].(map[string]any)["page"]) w = captainResourceJSONRequest(t, router, http.MethodGet, fmt.Sprintf("%s/%d", otherBasePath, toolID), nil) assert.Equal(t, http.StatusNotFound, w.Code) updateBody := map[string]any{"custom_tool": map[string]any{"enabled": false, "title": "Lookup Order V2"}} w = captainResourceJSONRequest(t, router, http.MethodPut, fmt.Sprintf("%s/%d", basePath, toolID), updateBody) assert.Equal(t, http.StatusOK, w.Code) var updated map[string]any require.NoError(t, json.Unmarshal(w.Body.Bytes(), &updated)) assert.Equal(t, false, updated["enabled"]) assert.Equal(t, "Lookup Order V2", updated["title"]) w = captainResourceJSONRequest(t, router, http.MethodDelete, fmt.Sprintf("%s/%d", basePath, toolID), nil) assert.Equal(t, http.StatusNoContent, w.Code) }