Files
gochat/internal/handler/api/v1/captain_resource_parity_handler_test.go
T

368 lines
17 KiB
Go

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")
}