package v1 import ( "bytes" "context" "encoding/json" "fmt" "net/http" "net/http/httptest" "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/suite" "gorm.io/driver/sqlite" "gorm.io/gorm" "gorm.io/gorm/logger" ) type DashboardAppHandlerTestSuite struct { suite.Suite db *gorm.DB handler *DashboardAppHandler account *model.Account user *model.User } func (s *DashboardAppHandlerTestSuite) SetupSuite() { gin.SetMode(gin.TestMode) // CRITICAL: use ?cache=shared for SQLite in-memory DB so connection pool // shares the same database instance (otherwise each pooled connection gets // its own separate in-memory DB, making writes invisible to reads). db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{ Logger: logger.Default.LogMode(logger.Silent), }) s.Require().NoError(err) // Configure connection pool to use a single connection to avoid // SQLite "database is locked" errors with shared cache sqlDB, err := db.DB() s.Require().NoError(err) sqlDB.SetMaxOpenConns(1) s.Require().NoError(db.AutoMigrate(&model.Account{}, &model.User{}, &model.DashboardApp{})) s.db = db repo := repository.NewDashboardAppRepo(db) svc := service.NewDashboardAppService(repo) s.handler = NewDashboardAppHandler(svc) s.account = &model.Account{Name: "test-dashboard-app-account"} s.db.Create(s.account) s.user = &model.User{Name: "test-dashboard-app-user", Email: "dashboard@example.com"} s.db.Create(s.user) } func (s *DashboardAppHandlerTestSuite) SetupTest() { // Hard delete all dashboard_apps to avoid GORM soft-delete leaks s.db.Exec("DELETE FROM dashboard_apps") } func (s *DashboardAppHandlerTestSuite) TearDownSuite() { if s.db != nil { sqlDB, _ := s.db.DB() sqlDB.Close() } } func TestDashboardAppHandlerSuite(t *testing.T) { suite.Run(t, new(DashboardAppHandlerTestSuite)) } func (s *DashboardAppHandlerTestSuite) TestList_Empty() { r := gin.New() r.GET("/api/v1/accounts/:account_id/dashboard_apps", func(c *gin.Context) { c.Set("account_id", float64(s.account.ID)) c.Set("user_id", float64(s.user.ID)) c.Next() }, s.handler.List) w := httptest.NewRecorder() req, _ := http.NewRequest("GET", fmt.Sprintf("/api/v1/accounts/%d/dashboard_apps", s.account.ID), nil) r.ServeHTTP(w, req) assert.Equal(s.T(), http.StatusOK, w.Code) // Chatwoot returns pure JSON array (not wrapped in {data, meta}) var result []json.RawMessage err := json.Unmarshal(w.Body.Bytes(), &result) assert.NoError(s.T(), err, "List should return a pure JSON array matching Chatwoot") } func (s *DashboardAppHandlerTestSuite) TestCreate_BadRequest() { r := gin.New() r.POST("/api/v1/accounts/:account_id/dashboard_apps", func(c *gin.Context) { c.Set("account_id", float64(s.account.ID)) c.Set("user_id", float64(s.user.ID)) c.Next() }, s.handler.Create) w := httptest.NewRecorder() req, _ := http.NewRequest("POST", fmt.Sprintf("/api/v1/accounts/%d/dashboard_apps", s.account.ID), bytes.NewBufferString(`{"dashboard_app": {}}`)) req.Header.Set("Content-Type", "application/json") r.ServeHTTP(w, req) assert.Equal(s.T(), http.StatusBadRequest, w.Code) } func (s *DashboardAppHandlerTestSuite) TestCreate_Success() { r := gin.New() r.POST("/api/v1/accounts/:account_id/dashboard_apps", func(c *gin.Context) { c.Set("account_id", float64(s.account.ID)) c.Set("user_id", float64(s.user.ID)) c.Next() }, s.handler.Create) // The reused Chatwoot frontend sends a raw payload; Rails wraps it into // dashboard_app server-side, so GoChat accepts both shapes. body := `{"title": "Test Dashboard App", "content": [{"type": "frame", "url": "https://example.com/widget"}]}` w := httptest.NewRecorder() req, _ := http.NewRequest("POST", fmt.Sprintf("/api/v1/accounts/%d/dashboard_apps", s.account.ID), bytes.NewBufferString(body)) req.Header.Set("Content-Type", "application/json") r.ServeHTTP(w, req) assert.Equal(s.T(), http.StatusOK, w.Code) var payload map[string]any s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &payload)) assert.NotContains(s.T(), payload, "success") assert.NotContains(s.T(), payload, "data") assert.Equal(s.T(), "Test Dashboard App", payload["title"]) assert.NotContains(s.T(), payload, "account_id") assert.NotContains(s.T(), payload, "updated_at") } func (s *DashboardAppHandlerTestSuite) TestGet_Success() { r := gin.New() r.GET("/api/v1/accounts/:account_id/dashboard_apps/:id", s.handler.Get) // Seed via service (same DB path as handler) seedApp, err := s.handler.svc.Create(context.Background(), s.account.ID, &s.user.ID, &service.CreateDashboardAppRequest{ Title: "Seed App", }) s.Require().NoError(err) s.T().Logf("Seeded dashboard app ID=%d", seedApp.ID) w := httptest.NewRecorder() req, _ := http.NewRequest("GET", fmt.Sprintf("/api/v1/accounts/%d/dashboard_apps/%d", s.account.ID, seedApp.ID), nil) r.ServeHTTP(w, req) s.T().Logf("Get response: status=%d, body=%s", w.Code, w.Body.String()) assert.Equal(s.T(), http.StatusOK, w.Code) var payload map[string]any s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &payload)) assert.NotContains(s.T(), payload, "success") assert.NotContains(s.T(), payload, "data") assert.Equal(s.T(), "Seed App", payload["title"]) assert.Contains(s.T(), payload, "created_at") assert.NotContains(s.T(), payload, "account_id") } func (s *DashboardAppHandlerTestSuite) TestPatch_RawPayloadAndAccountScope() { r := gin.New() r.PATCH("/api/v1/accounts/:account_id/dashboard_apps/:id", s.handler.Patch) seedApp, err := s.handler.svc.Create(context.Background(), s.account.ID, &s.user.ID, &service.CreateDashboardAppRequest{ Title: "Before", Content: json.RawMessage(`[{"type":"frame","url":"https://example.com/before"}]`), }) s.Require().NoError(err) body := `{"title":"After","content":[{"type":"frame","url":"https://example.com/after"}]}` w := httptest.NewRecorder() req, _ := http.NewRequest("PATCH", fmt.Sprintf("/api/v1/accounts/%d/dashboard_apps/%d", s.account.ID, seedApp.ID), bytes.NewBufferString(body)) req.Header.Set("Content-Type", "application/json") r.ServeHTTP(w, req) assert.Equal(s.T(), http.StatusOK, w.Code) var payload map[string]any s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &payload)) assert.Equal(s.T(), "After", payload["title"]) assert.NotContains(s.T(), payload, "success") w = httptest.NewRecorder() req, _ = http.NewRequest("PATCH", fmt.Sprintf("/api/v1/accounts/%d/dashboard_apps/%d", s.account.ID+100, seedApp.ID), bytes.NewBufferString(body)) req.Header.Set("Content-Type", "application/json") r.ServeHTTP(w, req) assert.Equal(s.T(), http.StatusNotFound, w.Code) } func (s *DashboardAppHandlerTestSuite) TestDelete_Success() { r := gin.New() r.DELETE("/api/v1/accounts/:account_id/dashboard_apps/:id", s.handler.Delete) // Seed via service seedApp, err := s.handler.svc.Create(context.Background(), s.account.ID, &s.user.ID, &service.CreateDashboardAppRequest{ Title: "Delete App", }) s.Require().NoError(err) w := httptest.NewRecorder() req, _ := http.NewRequest("DELETE", fmt.Sprintf("/api/v1/accounts/%d/dashboard_apps/%d", s.account.ID, seedApp.ID), nil) r.ServeHTTP(w, req) // Chatwoot: head :no_content → 204 assert.Equal(s.T(), http.StatusNoContent, w.Code) }