package v1 import ( "context" "encoding/json" "net/http" "net/http/httptest" "strconv" "testing" "github.com/gin-gonic/gin" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/suite" "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" ws "github.com/gochat/gochat/internal/ws" ) // --- WebWidgetOfflineHandler Test Suite --- // Uses real SQLite DB + real repo + real service (integration-style). type WebWidgetOfflineHandlerTestSuite struct { suite.Suite router *gin.Engine handler *WebWidgetOfflineHandler db *gorm.DB } func (s *WebWidgetOfflineHandlerTestSuite) SetupSuite() { gin.SetMode(gin.TestMode) db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{ Logger: logger.Default.LogMode(logger.Silent), }) s.Require().NoError(err) s.db = db err = db.AutoMigrate( &model.Account{}, &model.User{}, &model.Inbox{}, &model.WidgetOfflineMessage{}, ) s.Require().NoError(err) // Create real repos inboxRepo := repository.NewInboxRepo(db) contactRepo := repository.NewContactRepo(db) contactInboxRepo := repository.NewContactInboxRepo(db) conversationRepo := repository.NewConversationRepo(db) messageRepo := repository.NewMessageRepo(db) themeConfigRepo := repository.NewWidgetThemeConfigRepo(db) preChatFormRepo := repository.NewPreChatFormRepo(db) fileUploadRepo := repository.NewWidgetFileUploadRepo(db) offlineMessageRepo := repository.NewWidgetOfflineMessageRepo(db) // Create real services — WidgetService needs a TypingIndicator. // For handler tests we only exercise offline-message paths which never call typing, // so we pass a nil-safe stub. However WidgetService stores it as interface; // nil would panic if accidentally called. Use a no-op stub. widgetSvc := service.NewWidgetService( inboxRepo, contactRepo, contactInboxRepo, conversationRepo, messageRepo, &noopTypingIndicator{}, themeConfigRepo, preChatFormRepo, fileUploadRepo, offlineMessageRepo, ) inboxSvc := service.NewInboxService(inboxRepo) // Create handler s.handler = NewWebWidgetOfflineHandler(widgetSvc, inboxSvc) // Setup router s.router = gin.New() accountsGroup := s.router.Group("/api/v1/accounts/:id") { accountsGroup.GET("/web_widgets/offline_messages", s.handler.ListOfflineMessages) accountsGroup.GET("/inboxes/:inbox_id/web_widget/offline_messages", s.handler.ListOfflineMessagesByInbox) accountsGroup.PUT("/web_widgets/offline_messages/:offline_message_id/dismiss", s.handler.DismissOfflineMessage) } } func (s *WebWidgetOfflineHandlerTestSuite) TearDownSuite() { sqlDB, err := s.db.DB() s.Require().NoError(err) sqlDB.Close() } // Helper: create prerequisite Account + Inbox func (s *WebWidgetOfflineHandlerTestSuite) createTestAccountAndInbox() (*model.Account, *model.Inbox) { account := &model.Account{Name: "OfflineTestOrg", Locale: "en"} s.Require().NoError(s.db.Create(account).Error) inbox := &model.Inbox{ AccountID: account.ID, Name: "Widget Inbox", ChannelType: "web_widget", ChannelID: 0, Enabled: true, } s.Require().NoError(s.db.Create(inbox).Error) return account, inbox } // Helper: seed offline messages func (s *WebWidgetOfflineHandlerTestSuite) createOfflineMessage(accountID, inboxID uint, content string) *model.WidgetOfflineMessage { msg := &model.WidgetOfflineMessage{ InboxID: inboxID, AccountID: accountID, ContactName: "Visitor", Content: content, Status: model.OfflineStatusPending, } s.Require().NoError(s.db.Create(msg).Error) return msg } // ========== ListOfflineMessages ========== func (s *WebWidgetOfflineHandlerTestSuite) TestListOfflineMessages_Success() { account, inbox := s.createTestAccountAndInbox() _ = s.createOfflineMessage(account.ID, inbox.ID, "Hello, anyone there?") _ = s.createOfflineMessage(account.ID, inbox.ID, "I need help") w := httptest.NewRecorder() url := "/api/v1/accounts/" + strconv.FormatUint(uint64(account.ID), 10) + "/web_widgets/offline_messages?page=1&page_size=25" req, _ := http.NewRequest("GET", url, nil) s.router.ServeHTTP(w, req) assert.Equal(s.T(), http.StatusOK, w.Code) var resp map[string]interface{} err := json.Unmarshal(w.Body.Bytes(), &resp) assert.NoError(s.T(), err) msgs := resp["offline_messages"].([]interface{}) assert.Equal(s.T(), 2, len(msgs)) meta := resp["meta"].(map[string]interface{}) assert.Equal(s.T(), float64(1), meta["page"]) assert.Equal(s.T(), float64(25), meta["page_size"]) assert.Equal(s.T(), float64(2), meta["total_count"]) } func (s *WebWidgetOfflineHandlerTestSuite) TestListOfflineMessages_InvalidAccountID() { w := httptest.NewRecorder() url := "/api/v1/accounts/invalid/web_widgets/offline_messages" req, _ := http.NewRequest("GET", url, nil) s.router.ServeHTTP(w, req) assert.Equal(s.T(), http.StatusBadRequest, w.Code) var resp map[string]interface{} err := json.Unmarshal(w.Body.Bytes(), &resp) assert.NoError(s.T(), err) assert.Equal(s.T(), "invalid account_id", resp["error"]) } func (s *WebWidgetOfflineHandlerTestSuite) TestListOfflineMessages_EmptyResult() { account, _ := s.createTestAccountAndInbox() w := httptest.NewRecorder() url := "/api/v1/accounts/" + strconv.FormatUint(uint64(account.ID), 10) + "/web_widgets/offline_messages" req, _ := http.NewRequest("GET", url, nil) s.router.ServeHTTP(w, req) assert.Equal(s.T(), http.StatusOK, w.Code) var resp map[string]interface{} err := json.Unmarshal(w.Body.Bytes(), &resp) assert.NoError(s.T(), err) msgs := resp["offline_messages"].([]interface{}) assert.Equal(s.T(), 0, len(msgs)) } // ========== ListOfflineMessagesByInbox ========== func (s *WebWidgetOfflineHandlerTestSuite) TestListOfflineMessagesByInbox_Success() { account, inbox := s.createTestAccountAndInbox() _ = s.createOfflineMessage(account.ID, inbox.ID, "Offline msg A") _ = s.createOfflineMessage(account.ID, inbox.ID, "Offline msg B") w := httptest.NewRecorder() url := "/api/v1/accounts/" + strconv.FormatUint(uint64(account.ID), 10) + "/inboxes/" + strconv.FormatUint(uint64(inbox.ID), 10) + "/web_widget/offline_messages" req, _ := http.NewRequest("GET", url, nil) s.router.ServeHTTP(w, req) assert.Equal(s.T(), http.StatusOK, w.Code) var resp map[string]interface{} err := json.Unmarshal(w.Body.Bytes(), &resp) assert.NoError(s.T(), err) msgs := resp["offline_messages"].([]interface{}) assert.Equal(s.T(), 2, len(msgs)) } func (s *WebWidgetOfflineHandlerTestSuite) TestListOfflineMessagesByInbox_InvalidInboxID() { account, _ := s.createTestAccountAndInbox() w := httptest.NewRecorder() url := "/api/v1/accounts/" + strconv.FormatUint(uint64(account.ID), 10) + "/inboxes/invalid/web_widget/offline_messages" req, _ := http.NewRequest("GET", url, nil) s.router.ServeHTTP(w, req) assert.Equal(s.T(), http.StatusBadRequest, w.Code) var resp map[string]interface{} err := json.Unmarshal(w.Body.Bytes(), &resp) assert.NoError(s.T(), err) assert.Equal(s.T(), "invalid inbox_id", resp["error"]) } func (s *WebWidgetOfflineHandlerTestSuite) TestListOfflineMessagesByInbox_InvalidAccountID() { w := httptest.NewRecorder() url := "/api/v1/accounts/invalid/inboxes/1/web_widget/offline_messages" req, _ := http.NewRequest("GET", url, nil) s.router.ServeHTTP(w, req) assert.Equal(s.T(), http.StatusBadRequest, w.Code) var resp map[string]interface{} err := json.Unmarshal(w.Body.Bytes(), &resp) assert.NoError(s.T(), err) assert.Equal(s.T(), "invalid account_id", resp["error"]) } func (s *WebWidgetOfflineHandlerTestSuite) TestListOfflineMessagesByInbox_InboxNotFound() { account, _ := s.createTestAccountAndInbox() w := httptest.NewRecorder() // Use a non-existent inbox ID under a valid account url := "/api/v1/accounts/" + strconv.FormatUint(uint64(account.ID), 10) + "/inboxes/99999/web_widget/offline_messages" req, _ := http.NewRequest("GET", url, nil) s.router.ServeHTTP(w, req) assert.Equal(s.T(), http.StatusNotFound, w.Code) } func (s *WebWidgetOfflineHandlerTestSuite) TestListOfflineMessagesByInbox_NonWebWidgetInbox() { account := &model.Account{Name: "NonWidgetOrg", Locale: "en"} s.Require().NoError(s.db.Create(account).Error) // Create a non-web_widget inbox (e.g. facebook) inbox := &model.Inbox{ AccountID: account.ID, Name: "FB Inbox", ChannelType: "facebook", ChannelID: 0, Enabled: true, } s.Require().NoError(s.db.Create(inbox).Error) w := httptest.NewRecorder() url := "/api/v1/accounts/" + strconv.FormatUint(uint64(account.ID), 10) + "/inboxes/" + strconv.FormatUint(uint64(inbox.ID), 10) + "/web_widget/offline_messages" req, _ := http.NewRequest("GET", url, nil) s.router.ServeHTTP(w, req) assert.Equal(s.T(), http.StatusBadRequest, w.Code) var resp map[string]interface{} err := json.Unmarshal(w.Body.Bytes(), &resp) assert.NoError(s.T(), err) assert.Equal(s.T(), "offline messages only supported for web_widget channel", resp["error"]) } // ========== DismissOfflineMessage ========== func (s *WebWidgetOfflineHandlerTestSuite) TestDismissOfflineMessage_Success() { account, inbox := s.createTestAccountAndInbox() msg := s.createOfflineMessage(account.ID, inbox.ID, "Please dismiss me") w := httptest.NewRecorder() url := "/api/v1/accounts/" + strconv.FormatUint(uint64(account.ID), 10) + "/web_widgets/offline_messages/" + strconv.FormatUint(uint64(msg.ID), 10) + "/dismiss" req, _ := http.NewRequest("PUT", url, nil) s.router.ServeHTTP(w, req) assert.Equal(s.T(), http.StatusOK, w.Code) var resp map[string]interface{} err := json.Unmarshal(w.Body.Bytes(), &resp) assert.NoError(s.T(), err) assert.Equal(s.T(), "offline message dismissed", resp["message"]) // Verify DB status changed to dismissed var updated model.WidgetOfflineMessage s.Require().NoError(s.db.First(&updated, msg.ID).Error) assert.Equal(s.T(), model.OfflineStatusDismissed, updated.Status) } func (s *WebWidgetOfflineHandlerTestSuite) TestDismissOfflineMessage_InvalidMessageID() { account, _ := s.createTestAccountAndInbox() w := httptest.NewRecorder() url := "/api/v1/accounts/" + strconv.FormatUint(uint64(account.ID), 10) + "/web_widgets/offline_messages/invalid/dismiss" req, _ := http.NewRequest("PUT", url, nil) s.router.ServeHTTP(w, req) assert.Equal(s.T(), http.StatusBadRequest, w.Code) var resp map[string]interface{} err := json.Unmarshal(w.Body.Bytes(), &resp) assert.NoError(s.T(), err) assert.Equal(s.T(), "invalid offline_message_id", resp["error"]) } func (s *WebWidgetOfflineHandlerTestSuite) TestDismissOfflineMessage_NonExistentMessage() { account, _ := s.createTestAccountAndInbox() w := httptest.NewRecorder() url := "/api/v1/accounts/" + strconv.FormatUint(uint64(account.ID), 10) + "/web_widgets/offline_messages/99999/dismiss" req, _ := http.NewRequest("PUT", url, nil) s.router.ServeHTTP(w, req) // Service will return error for non-existent record → 500 assert.Equal(s.T(), http.StatusInternalServerError, w.Code) } // noopTypingIndicator is a stub that satisfies service.TypingIndicator // without doing anything. Used in tests where typing paths are never exercised. type noopTypingIndicator struct{} func (n *noopTypingIndicator) SetTypingOn(_ context.Context, _ uint, _ uint, _ *ws.Performer) error { return nil } func (n *noopTypingIndicator) SetTypingOff(_ context.Context, _ uint, _ uint, _ *ws.Performer) error { return nil } func TestWebWidgetOfflineHandlerTestSuite(t *testing.T) { suite.Run(t, new(WebWidgetOfflineHandlerTestSuite)) }