package v1 import ( "bytes" "encoding/json" "fmt" "io" "net/http" "net/http/httptest" "strconv" "strings" "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" ) type fakeCaptainToolHTTPDoer func(*http.Request) (*http.Response, error) func (f fakeCaptainToolHTTPDoer) Do(req *http.Request) (*http.Response, error) { return f(req) } 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.CaptainDocument{}, &model.CaptainAssistantResponse{}, &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) toolSvc.SetHTTPClient(fakeCaptainToolHTTPDoer(func(req *http.Request) (*http.Response, error) { return &http.Response{StatusCode: http.StatusCreated, Body: io.NopCloser(strings.NewReader(`{"ok":true}`)), Header: make(http.Header)}, nil })) toolHandler := NewCaptainCustomToolHandler(toolSvc) documentRepo := repository.NewCaptainDocumentRepo(db) documentSvc := service.NewCaptainDocumentService(documentRepo, nil, assistantRepo) documentHandler := NewCaptainDocumentHandler(documentSvc) responseRepo := repository.NewCaptainAssistantResponseRepo(db) responseSvc := service.NewCaptainAssistantResponseService(assistantRepo, responseRepo, nil, nil, nil, nil) responseHandler := NewCaptainAssistantResponseHandler(responseSvc) bulkSvc := service.NewCaptainBulkActionService(nil, nil, assistantRepo, nil, nil, nil, responseSvc) bulkSvc.SetCaptainResourceRepos(responseRepo, documentRepo) bulkHandler := NewCaptainBulkActionHandler(bulkSvc) 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.POST("/test", toolHandler.TestTool) 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) documents := accountGroup.Group("/documents") documents.GET("/", documentHandler.List) documents.POST("/", documentHandler.Create) documents.GET("/:document_id", documentHandler.Get) documents.DELETE("/:document_id", documentHandler.Delete) documents.POST("/:document_id/sync", documentHandler.SyncDocument) assistantResponses := accountGroup.Group("/assistant_responses") assistantResponses.GET("/", responseHandler.List) assistantResponses.POST("/", responseHandler.Create) assistantResponses.GET("/:response_id", responseHandler.Get) assistantResponses.PUT("/:response_id", responseHandler.Update) assistantResponses.DELETE("/:response_id", responseHandler.Delete) bulkActions := accountGroup.Group("/bulk_actions") bulkActions.POST("/", bulkHandler.Execute) 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) } func TestCaptainDocumentHandler_ChatwootDocumentPayloadsAndSync(t *testing.T) { router, _, account, otherAccount, assistant := setupCaptainResourceParityTest(t) basePath := "/api/v1/accounts/" + strconv.FormatUint(uint64(account.ID), 10) + "/captain/documents" otherBasePath := "/api/v1/accounts/" + strconv.FormatUint(uint64(otherAccount.ID), 10) + "/captain/documents" body := map[string]any{"document": map[string]any{ "name": "Help center", "external_link": "https://example.com/help", "assistant_id": assistant.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, "Help center", created["name"]) assert.Equal(t, float64(account.ID), created["account_id"]) assert.Equal(t, "Fin", created["assistant"].(map[string]any)["name"]) documentID := uint(created["id"].(float64)) w = captainResourceJSONRequest(t, router, http.MethodGet, basePath+"/?assistant_id="+strconv.FormatUint(uint64(assistant.ID), 10), 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, documentID), nil) assert.Equal(t, http.StatusNotFound, w.Code) w = captainResourceJSONRequest(t, router, http.MethodPost, fmt.Sprintf("%s/%d/sync", basePath, documentID), nil) assert.Equal(t, http.StatusAccepted, w.Code) w = captainResourceJSONRequest(t, router, http.MethodGet, fmt.Sprintf("%s/%d", basePath, documentID), nil) assert.Equal(t, http.StatusOK, w.Code) var synced map[string]any require.NoError(t, json.Unmarshal(w.Body.Bytes(), &synced)) assert.Equal(t, "syncing", synced["sync_status"]) assert.Equal(t, true, synced["sync_in_progress"]) w = captainResourceJSONRequest(t, router, http.MethodDelete, fmt.Sprintf("%s/%d", basePath, documentID), nil) assert.Equal(t, http.StatusNoContent, w.Code) } func TestCaptainAssistantResponseHandler_ChatwootResponsePayloadsAndFilters(t *testing.T) { router, _, account, otherAccount, assistant := setupCaptainResourceParityTest(t) basePath := "/api/v1/accounts/" + strconv.FormatUint(uint64(account.ID), 10) + "/captain/assistant_responses" otherBasePath := "/api/v1/accounts/" + strconv.FormatUint(uint64(otherAccount.ID), 10) + "/captain/assistant_responses" body := map[string]any{"assistant_response": map[string]any{ "question": "Where is my order?", "answer": "It ships today.", "assistant_id": assistant.ID, "status": "pending", }} 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, "pending", created["status"]) assert.Equal(t, "Fin", created["assistant"].(map[string]any)["name"]) responseID := uint(created["id"].(float64)) w = captainResourceJSONRequest(t, router, http.MethodGet, basePath+"/?status=pending&search=order", 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, responseID), nil) assert.Equal(t, http.StatusNotFound, w.Code) updateBody := map[string]any{"assistant_response": map[string]any{"answer": "It shipped.", "status": "approved"}} w = captainResourceJSONRequest(t, router, http.MethodPut, fmt.Sprintf("%s/%d", basePath, responseID), 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, "approved", updated["status"]) assert.Equal(t, true, updated["edited"]) w = captainResourceJSONRequest(t, router, http.MethodDelete, fmt.Sprintf("%s/%d", basePath, responseID), nil) assert.Equal(t, http.StatusNoContent, w.Code) } func TestCaptainBulkActionHandler_ChatwootResourceActions(t *testing.T) { router, db, account, _, assistant := setupCaptainResourceParityTest(t) basePath := "/api/v1/accounts/" + strconv.FormatUint(uint64(account.ID), 10) + "/captain/bulk_actions" resp := &model.CaptainAssistantResponse{AccountID: account.ID, AssistantID: assistant.ID, Question: "Q", Answer: "A", Status: model.ResponseStatusPending} require.NoError(t, db.Create(resp).Error) w := captainResourceJSONRequest(t, router, http.MethodPost, basePath+"/", map[string]any{ "type": "AssistantResponse", "ids": []uint{resp.ID}, "fields": map[string]any{ "status": "approve", }, }) assert.Equal(t, http.StatusOK, w.Code) var approved []map[string]any require.NoError(t, json.Unmarshal(w.Body.Bytes(), &approved)) require.Len(t, approved, 1) assert.Equal(t, "approved", approved[0]["status"]) doc := &model.CaptainDocument{AccountID: account.ID, AssistantID: assistant.ID, Name: "Doc", ExternalLink: "https://example.com", Status: model.DocumentStatusCompleted, SyncStatus: model.DocumentSyncStatusSynced} require.NoError(t, db.Create(doc).Error) w = captainResourceJSONRequest(t, router, http.MethodPost, basePath+"/", map[string]any{ "type": "AssistantDocument", "ids": []uint{doc.ID}, "fields": map[string]any{ "status": "sync", }, }) assert.Equal(t, http.StatusOK, w.Code) var syncResp map[string]any require.NoError(t, json.Unmarshal(w.Body.Bytes(), &syncResp)) assert.Equal(t, float64(1), syncResp["count"]) w = captainResourceJSONRequest(t, router, http.MethodPost, basePath+"/", map[string]any{"type": "Unknown", "ids": []uint{1}, "fields": map[string]any{"status": "delete"}}) assert.Equal(t, http.StatusUnprocessableEntity, w.Code) var invalid map[string]any require.NoError(t, json.Unmarshal(w.Body.Bytes(), &invalid)) assert.Equal(t, false, invalid["success"]) } func TestCaptainCustomToolHandler_ChatwootTestToolPayload(t *testing.T) { router, _, account, _, _ := setupCaptainResourceParityTest(t) basePath := "/api/v1/accounts/" + strconv.FormatUint(uint64(account.ID), 10) + "/captain/custom_tools/test" w := captainResourceJSONRequest(t, router, http.MethodPost, basePath, map[string]any{"custom_tool": map[string]any{ "title": "Tester", "endpoint_url": "http://tool.test", "http_method": "POST", "auth_type": "none", "request_template": `{"value":"{{.value}}"}`, "params": map[string]any{"value": "ping"}, }}) assert.Equal(t, http.StatusOK, w.Code) var resp map[string]any require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) assert.Equal(t, float64(http.StatusCreated), resp["status"]) assert.Equal(t, `{"ok":true}`, resp["body"]) assert.NotContains(t, resp, "success") }