package v1 import ( "bytes" "encoding/json" "net/http" "net/http/httptest" "strconv" "testing" "time" "github.com/gin-gonic/gin" "github.com/stretchr/testify/require" "gorm.io/driver/sqlite" "gorm.io/gorm" "gorm.io/gorm/logger" "github.com/gochat/gochat/internal/model" "github.com/gochat/gochat/internal/repository" "github.com/gochat/gochat/internal/service" ) type copilotParityFixture struct { db *gorm.DB router *gin.Engine otherUserRouter *gin.Engine account *model.Account otherAccount *model.Account user *model.User otherUser *model.User assistant *model.CaptainAssistant otherAssistant *model.CaptainAssistant } func newCopilotParityFixture(t *testing.T) *copilotParityFixture { t.Helper() gin.SetMode(gin.TestMode) db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)}) require.NoError(t, err) require.NoError(t, db.AutoMigrate( &model.Account{}, &model.User{}, &model.CaptainAssistant{}, &model.CopilotThread{}, &model.CopilotMessage{}, &model.CopilotSuggestionMessage{}, )) account := &model.Account{Name: "Copilot Org", Locale: "en", Active: true} otherAccount := &model.Account{Name: "Other Org", Locale: "en", Active: true} require.NoError(t, db.Create(account).Error) require.NoError(t, db.Create(otherAccount).Error) user := &model.User{AccountID: account.ID, Name: "Agent One", DisplayName: "Agent", Email: "agent@example.com", Password: "secret", Active: true, Available: true} otherUser := &model.User{AccountID: account.ID, Name: "Agent Two", Email: "agent2@example.com", Password: "secret", Active: true} require.NoError(t, db.Create(user).Error) require.NoError(t, db.Create(otherUser).Error) assistant := &model.CaptainAssistant{AccountID: account.ID, Name: "Helper", Description: "Primary assistant", Config: json.RawMessage(`{}`), Status: model.AssistantStatusActive} otherAssistant := &model.CaptainAssistant{AccountID: otherAccount.ID, Name: "Other", Config: json.RawMessage(`{}`), Status: model.AssistantStatusActive} require.NoError(t, db.Create(assistant).Error) require.NoError(t, db.Create(otherAssistant).Error) threadRepo := repository.NewCopilotThreadRepo(db) messageRepo := repository.NewCopilotMessageRepo(db) suggestionRepo := repository.NewCopilotSuggestionRepo(db) assistantRepo := repository.NewCaptainAssistantRepo(db) handler := NewCopilotHandler(service.NewCopilotService(threadRepo, messageRepo, suggestionRepo, nil, assistantRepo)) fixture := &copilotParityFixture{ db: db, account: account, otherAccount: otherAccount, user: user, otherUser: otherUser, assistant: assistant, otherAssistant: otherAssistant, } fixture.router = copilotRouterForUser(handler, user.ID) fixture.otherUserRouter = copilotRouterForUser(handler, otherUser.ID) t.Cleanup(func() { sqlDB, dbErr := db.DB() require.NoError(t, dbErr) require.NoError(t, sqlDB.Close()) }) return fixture } func copilotRouterForUser(handler *CopilotHandler, userID uint) *gin.Engine { router := gin.New() router.Use(func(c *gin.Context) { c.Set("user_id", userID) c.Next() }) accounts := router.Group("/api/v1/accounts/:account_id") captain := accounts.Group("/captain") threads := captain.Group("/copilot_threads") threads.GET("/", handler.ListThreads) threads.POST("/", handler.CreateThread) threads.GET("/:thread_id", handler.GetThread) threads.DELETE("/:thread_id", handler.DeleteThread) messages := threads.Group("/:thread_id/copilot_messages") messages.GET("/", handler.ListSuggestionMessages) messages.POST("/", handler.SendMessage) return router } func (f *copilotParityFixture) request(router *gin.Engine, method, path string, body any) *httptest.ResponseRecorder { var raw []byte if body != nil { raw, _ = json.Marshal(body) } recorder := httptest.NewRecorder() req, _ := http.NewRequest(method, path, bytes.NewReader(raw)) if body != nil { req.Header.Set("Content-Type", "application/json") } router.ServeHTTP(recorder, req) return recorder } func (f *copilotParityFixture) captainPath(path string) string { return "/api/v1/accounts/" + uintString(f.account.ID) + "/captain" + path } func (f *copilotParityFixture) createThread(t *testing.T, message string) map[string]any { t.Helper() w := f.request(f.router, http.MethodPost, f.captainPath("/copilot_threads/"), map[string]any{ "message": message, "assistant_id": f.assistant.ID, "conversation_id": 123, }) require.Equal(t, http.StatusOK, w.Code, w.Body.String()) return decodeMap(t, w) } func decodeMap(t *testing.T, recorder *httptest.ResponseRecorder) map[string]any { t.Helper() var payload map[string]any require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &payload)) return payload } func uintString(id uint) string { return strconv.FormatUint(uint64(id), 10) } func TestCopilotThreadCreateReturnsChatwootPayload(t *testing.T) { f := newCopilotParityFixture(t) payload := f.createThread(t, "Need help") require.Nil(t, payload["success"]) require.Equal(t, "Need help", payload["title"]) require.Equal(t, float64(f.account.ID), payload["account_id"]) user := payload["user"].(map[string]any) require.Equal(t, float64(f.user.ID), user["id"]) require.Equal(t, "user", user["type"]) require.Equal(t, "online", user["availability_status"]) assistant := payload["assistant"].(map[string]any) require.Equal(t, float64(f.assistant.ID), assistant["id"]) require.Equal(t, "captain_assistant", assistant["type"]) var count int64 require.NoError(t, f.db.Model(&model.CopilotMessage{}).Count(&count).Error) require.Equal(t, int64(2), count) } func TestCopilotThreadCreateValidationAndAssistantScope(t *testing.T) { f := newCopilotParityFixture(t) w := f.request(f.router, http.MethodPost, f.captainPath("/copilot_threads/"), map[string]any{"assistant_id": f.assistant.ID}) require.Equal(t, http.StatusUnprocessableEntity, w.Code, w.Body.String()) require.Equal(t, "Message is required", decodeMap(t, w)["error"]) w = f.request(f.router, http.MethodPost, f.captainPath("/copilot_threads/"), map[string]any{"message": "hello"}) require.Equal(t, http.StatusUnprocessableEntity, w.Code, w.Body.String()) require.Equal(t, "assistant_id is required", decodeMap(t, w)["error"]) w = f.request(f.router, http.MethodPost, f.captainPath("/copilot_threads/"), map[string]any{"message": "hello", "assistant_id": f.otherAssistant.ID}) require.Equal(t, http.StatusUnprocessableEntity, w.Code, w.Body.String()) var count int64 require.NoError(t, f.db.Model(&model.CopilotThread{}).Count(&count).Error) require.Equal(t, int64(0), count) } func TestCopilotThreadListIsUserScopedAndOrdered(t *testing.T) { f := newCopilotParityFixture(t) first := f.createThread(t, "First") second := f.createThread(t, "Second") firstID := uint(first["id"].(float64)) secondID := uint(second["id"].(float64)) require.NoError(t, f.db.Model(&model.CopilotThread{}).Where("id = ?", firstID).Update("created_at", time.Now().Add(-time.Hour)).Error) require.NoError(t, f.db.Model(&model.CopilotThread{}).Where("id = ?", secondID).Update("created_at", time.Now()).Error) w := f.request(f.otherUserRouter, http.MethodPost, f.captainPath("/copilot_threads/"), map[string]any{ "message": "Other user", "assistant_id": f.assistant.ID, }) require.Equal(t, http.StatusOK, w.Code, w.Body.String()) w = f.request(f.router, http.MethodGet, f.captainPath("/copilot_threads/?page=1"), nil) require.Equal(t, http.StatusOK, w.Code, w.Body.String()) payload := decodeMap(t, w)["payload"].([]any) require.Len(t, payload, 2) require.Equal(t, second["id"], payload[0].(map[string]any)["id"]) require.Equal(t, first["id"], payload[1].(map[string]any)["id"]) } func TestCopilotThreadMessagesListAndCreateUseNestedPayloads(t *testing.T) { f := newCopilotParityFixture(t) thread := f.createThread(t, "Need help") threadID := uintString(uint(thread["id"].(float64))) path := f.captainPath("/copilot_threads/" + threadID + "/copilot_messages/") w := f.request(f.router, http.MethodGet, path, nil) require.Equal(t, http.StatusOK, w.Code, w.Body.String()) messages := decodeMap(t, w)["payload"].([]any) require.Len(t, messages, 2) require.Equal(t, "user", messages[0].(map[string]any)["message_type"]) require.Equal(t, "assistant", messages[1].(map[string]any)["message_type"]) require.NotNil(t, messages[0].(map[string]any)["copilot_thread"]) w = f.request(f.router, http.MethodPost, path, map[string]any{"message": "Follow up", "conversation_id": 123}) require.Equal(t, http.StatusOK, w.Code, w.Body.String()) created := decodeMap(t, w) require.Nil(t, created["success"]) require.Equal(t, "user", created["message_type"]) require.Equal(t, "Follow up", created["message"].(map[string]any)["content"]) w = f.request(f.router, http.MethodGet, path, nil) require.Equal(t, http.StatusOK, w.Code, w.Body.String()) messages = decodeMap(t, w)["payload"].([]any) require.Len(t, messages, 4) } func TestCopilotMessagePayloadUsesThreadPushShape(t *testing.T) { f := newCopilotParityFixture(t) thread := f.createThread(t, "Need help") threadID := uintString(uint(thread["id"].(float64))) path := f.captainPath("/copilot_threads/" + threadID + "/copilot_messages/") w := f.request(f.router, http.MethodGet, path, nil) require.Equal(t, http.StatusOK, w.Code, w.Body.String()) messages := decodeMap(t, w)["payload"].([]any) message := messages[0].(map[string]any) nestedThread := message["copilot_thread"].(map[string]any) require.Equal(t, thread["id"], nestedThread["id"]) require.NotNil(t, nestedThread["user"]) require.Equal(t, thread["account_id"], nestedThread["account_id"]) require.Nil(t, nestedThread["assistant"]) require.NotNil(t, message["account_id"]) } func TestCopilotMessagePushPayloadMatchesChatwootEventData(t *testing.T) { f := newCopilotParityFixture(t) thread := f.createThread(t, "Need help") threadID := uintString(uint(thread["id"].(float64))) path := f.captainPath("/copilot_threads/" + threadID + "/copilot_messages/") w := f.request(f.router, http.MethodPost, path, map[string]any{"message": "Follow up", "conversation_id": 123}) require.Equal(t, http.StatusOK, w.Code, w.Body.String()) created := decodeMap(t, w) var stored model.CopilotMessage require.NoError(t, f.db.Preload("CopilotThread.User").Preload("CopilotThread.Assistant").First(&stored, uint(created["id"].(float64))).Error) push := copilotMessagePushPayload(&stored) nestedThread := push["copilot_thread"].(gin.H) require.Equal(t, created["id"], float64(push["id"].(uint))) require.Equal(t, "user", string(push["message_type"].(model.CopilotMessageType))) require.Equal(t, "Follow up", push["message"].(map[string]any)["content"]) require.Nil(t, push["account_id"]) require.Equal(t, stored.CopilotThreadID, nestedThread["id"]) require.Nil(t, nestedThread["assistant"]) require.NotNil(t, nestedThread["user"]) } func TestCopilotThreadMessagesAreAccountAndUserScoped(t *testing.T) { f := newCopilotParityFixture(t) thread := f.createThread(t, "Private thread") threadID := uintString(uint(thread["id"].(float64))) path := f.captainPath("/copilot_threads/" + threadID + "/copilot_messages/") w := f.request(f.otherUserRouter, http.MethodGet, path, nil) require.Equal(t, http.StatusNotFound, w.Code, w.Body.String()) otherAccountPath := "/api/v1/accounts/" + uintString(f.otherAccount.ID) + "/captain/copilot_threads/" + threadID + "/copilot_messages/" w = f.request(f.router, http.MethodGet, otherAccountPath, nil) require.Equal(t, http.StatusNotFound, w.Code, w.Body.String()) } func TestCopilotThreadGetAndDeleteAreScoped(t *testing.T) { f := newCopilotParityFixture(t) thread := f.createThread(t, "Delete me") threadID := uintString(uint(thread["id"].(float64))) path := f.captainPath("/copilot_threads/" + threadID) w := f.request(f.router, http.MethodGet, path, nil) require.Equal(t, http.StatusOK, w.Code, w.Body.String()) require.Equal(t, "Delete me", decodeMap(t, w)["title"]) w = f.request(f.otherUserRouter, http.MethodDelete, path, nil) require.Equal(t, http.StatusNotFound, w.Code, w.Body.String()) w = f.request(f.router, http.MethodDelete, path, nil) require.Equal(t, http.StatusNoContent, w.Code, w.Body.String()) w = f.request(f.router, http.MethodGet, path, nil) require.Equal(t, http.StatusNotFound, w.Code, w.Body.String()) }