package router import ( "encoding/json" "net/http" "net/http/httptest" "net/url" "strings" "testing" "github.com/gin-gonic/gin" "github.com/gochat/gochat/internal/config" "github.com/gochat/gochat/internal/middleware" "github.com/gochat/gochat/internal/model" channelmodel "github.com/gochat/gochat/internal/model/channel" "gorm.io/driver/sqlite" "gorm.io/gorm" "gorm.io/gorm/logger" ) func TestRegisterRoutesBootsWithChatwootParityConflictGroups(t *testing.T) { gin.SetMode(gin.TestMode) engine := gin.New() RegisterRoutes( engine, nil, nil, nil, &Handlers{}, nil, nil, &config.JWTConfig{}, middleware.CORSConfig{}, nil, ) routes := map[string]bool{} for _, route := range engine.Routes() { routes[route.Method+" "+route.Path] = true } expected := []string{ "GET /.well-known/assetlinks.json", "GET /.well-known/apple-app-site-association", "GET /.well-known/microsoft-identity-association.json", "GET /.well-known/cf-custom-hostname-challenge/:id", "GET /app", "GET /app/*params", "GET /api/v1/accounts/:account_id/captain/assistants/tools", "GET /api/v1/accounts/:account_id/captain/assistants/:assistant_id", "GET /api/v1/widget/conversations", "GET /api/v1/widget/conversations/toggle_status", "PUT /public/api/v1/inboxes/:inbox_id/contacts/:contact_id/conversations/:conversation_id/messages/:message_id", "GET /hc/:slug", "GET /hc/:slug/sitemap.xml", "GET /hc/:slug/:locale", "GET /hc/:slug/:locale/search", "GET /hc/:slug/:locale/articles.json", "GET /hc/:slug/:locale/categories.json", "GET /hc/:slug/:locale/categories/:category_slug", "GET /hc/:slug/articles/:article_slug", "GET /api/v2/accounts/:account_id/reports/summary", "GET /api/v2/accounts/:account_id/live_reports/grouped_conversation_metrics", "GET /webhooks/twitter", "POST /webhooks/twitter", "POST /webhooks/telegram/:bot_token", "POST /webhooks/line/:line_channel_id", "POST /webhooks/sms/:phone_number", "GET /webhooks/whatsapp/:phone_number", "POST /webhooks/whatsapp/:phone_number", "POST /webhooks/tiktok", "POST /webhooks/shopify", "POST /twilio/voice/call/:phone", "POST /twilio/voice/status/:phone", "POST /twilio/voice/conference_status/:phone", "POST /twilio/voice/recording_status/:phone", } for _, key := range expected { if !routes[key] { t.Fatalf("expected route %s to be registered", key) } } } func TestTwilioVoiceRoutesServeConferenceAndPersistCallbacks(t *testing.T) { gin.SetMode(gin.TestMode) db, call := setupRouterTwilioVoiceDB(t) engine := gin.New() engine.POST("/twilio/voice/call/:phone", twilioVoiceCallTwiML(db)) engine.POST("/twilio/voice/status/:phone", twilioVoiceStatus(db)) engine.POST("/twilio/voice/conference_status/:phone", twilioVoiceConferenceStatus(db)) engine.POST("/twilio/voice/recording_status/:phone", twilioVoiceRecordingStatus(db)) twiml := performFormPost(engine, "/twilio/voice/call/15551234567", url.Values{ "CallSid": {call.ProviderCallID}, "Direction": {"outbound-api"}, "From": {"+15550990000"}, "ParentCallSid": {""}, }) if twiml.Code != http.StatusOK || !strings.Contains(twiml.Body.String(), "`) { t.Fatalf("expected dashboard app mount in response: %s", body) } if !strings.Contains(body, `"hostURL":"https://app.example.test"`) { t.Fatalf("expected frontend URL in chatwoot config: %s", body) } } func TestDashboardIndexRejectsJSONLikeChatwoot(t *testing.T) { gin.SetMode(gin.TestMode) engine := gin.New() engine.GET("/app", dashboardIndex) engine.GET("/app/*params", dashboardIndex) recorder := httptest.NewRecorder() req, _ := http.NewRequest(http.MethodGet, "/app/accounts/1/conversations/42", nil) req.Header.Set("Accept", "application/json") engine.ServeHTTP(recorder, req) if recorder.Code != http.StatusNotAcceptable { t.Fatalf("expected 406, got %d", recorder.Code) } if !strings.Contains(recorder.Body.String(), "Please use API routes instead of dashboard routes for JSON requests") { t.Fatalf("expected Chatwoot dashboard JSON error, got %s", recorder.Body.String()) } } func performGet(engine *gin.Engine, path string) *httptest.ResponseRecorder { recorder := httptest.NewRecorder() req, _ := http.NewRequest(http.MethodGet, path, nil) engine.ServeHTTP(recorder, req) return recorder } func performHostGet(engine *gin.Engine, path string, host string) *httptest.ResponseRecorder { recorder := httptest.NewRecorder() req, _ := http.NewRequest(http.MethodGet, path, nil) req.Host = host engine.ServeHTTP(recorder, req) return recorder } func performFormPost(engine *gin.Engine, path string, form url.Values) *httptest.ResponseRecorder { recorder := httptest.NewRecorder() req, _ := http.NewRequest(http.MethodPost, path, strings.NewReader(form.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") engine.ServeHTTP(recorder, req) return recorder } func setupRouterPortalDB(t *testing.T) *gorm.DB { t.Helper() db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=private"), &gorm.Config{ Logger: logger.Default.LogMode(logger.Silent), }) if err != nil { t.Fatalf("failed to open sqlite: %v", err) } if err := db.AutoMigrate(&model.Portal{}); err != nil { t.Fatalf("failed to migrate portal: %v", err) } return db } func setupRouterTwilioVoiceDB(t *testing.T) (*gorm.DB, *model.Call) { t.Helper() db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=private"), &gorm.Config{ Logger: logger.Default.LogMode(logger.Silent), }) if err != nil { t.Fatalf("failed to open sqlite: %v", err) } if err := db.AutoMigrate(&model.Account{}, &model.Inbox{}, &channelmodel.ChannelTwilioSMS{}, &model.Call{}); err != nil { t.Fatalf("failed to migrate twilio voice models: %v", err) } account := &model.Account{Name: "Voice Account", Locale: "en"} if err := db.Create(account).Error; err != nil { t.Fatalf("failed to create account: %v", err) } inbox := &model.Inbox{AccountID: account.ID, Name: "Voice", ChannelType: "twilio_sms", ChannelID: 1, ChannelConfig: `{"voice_enabled":true}`} if err := db.Create(inbox).Error; err != nil { t.Fatalf("failed to create inbox: %v", err) } channel := &channelmodel.ChannelTwilioSMS{AccountID: account.ID, InboxID: inbox.ID, AccountSID: "AC123", PhoneNumber: "+15551234567"} if err := db.Create(channel).Error; err != nil { t.Fatalf("failed to create twilio channel: %v", err) } call := &model.Call{ AccountID: account.ID, InboxID: inbox.ID, ConversationID: 1, Provider: "twilio", ProviderCallID: "CA123", ConferenceSID: "conf_account_1_call_1", CallerType: "User", CallerID: 1, Status: string(model.CallStatusRinging), CallDirection: "outbound", Direction: "outgoing", AdditionalAttributes: json.RawMessage(`{}`), AcceptedByAgentID: nil, ContactID: 1, MessageID: nil, RecordingURL: "", Duration: 0, } if err := db.Create(call).Error; err != nil { t.Fatalf("failed to create call: %v", err) } return db, call }