package router import ( "encoding/json" "io" "net/http" "net/http/httptest" "net/url" "strings" "testing" "time" "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" "github.com/golang-jwt/jwt/v5" "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 /linear/callback", "GET /shopify/callback", "GET /notion/callback", "GET /twitter/callback", "GET /google/callback", "GET /microsoft/callback", "GET /instagram/callback", "GET /tiktok/callback", "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/year_in_review", "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/callback", "POST /twilio/delivery_status", "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 setupRouterIntegrationCallbackDB(t *testing.T) (*gorm.DB, *model.Account) { 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.IntegrationHook{}); err != nil { t.Fatalf("failed to migrate integration callback models: %v", err) } account := &model.Account{Name: "Integration Account", Locale: "en"} if err := db.Create(account).Error; err != nil { t.Fatalf("failed to create account: %v", err) } return db, account } func setupRouterChannelCallbackDB(t *testing.T) (*gorm.DB, *model.Account) { 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.ChannelEmail{}, &channelmodel.ChannelInstagram{}, &channelmodel.ChannelTikTok{}, &channelmodel.ChannelTwitter{}, ); err != nil { t.Fatalf("failed to migrate channel callback models: %v", err) } account := &model.Account{Name: "Channel Callback Account", Locale: "en"} if err := db.Create(account).Error; err != nil { t.Fatalf("failed to create account: %v", err) } return db, account } func signedCallbackState(t *testing.T, accountID uint, secret string) string { t.Helper() token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ "sub": accountID, "iat": time.Now().Unix(), }) signed, err := token.SignedString([]byte(secret)) if err != nil { t.Fatalf("failed to sign callback state: %v", err) } return signed } type roundTripFunc func(*http.Request) (*http.Response, error) func (fn roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { return fn(req) } func withFakeOAuthTransport(t *testing.T, payloads map[string]map[string]any) { t.Helper() original := http.DefaultClient http.DefaultClient = &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { if req.Method != http.MethodPost { t.Fatalf("expected token exchange POST, got %s", req.Method) } if err := req.ParseForm(); err != nil { t.Fatalf("failed to parse token exchange form: %v", err) } if req.Form.Get("code") == "" || req.Form.Get("redirect_uri") == "" { t.Fatalf("expected code and redirect_uri in token exchange form: %v", req.Form) } payload, ok := payloads[req.URL.Path] if !ok { t.Fatalf("unexpected OAuth token URL path: %s", req.URL.Path) } encoded, err := json.Marshal(payload) if err != nil { t.Fatalf("failed to encode fake OAuth payload: %v", err) } return &http.Response{ StatusCode: http.StatusOK, Header: http.Header{"Content-Type": []string{"application/json"}}, Body: io.NopCloser(strings.NewReader(string(encoded))), Request: req, }, nil })} t.Cleanup(func() { http.DefaultClient = original }) } func withFakeCallbackTransport(t *testing.T, bodies map[string]string) { t.Helper() original := http.DefaultClient http.DefaultClient = &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { if req.Method != http.MethodPost { t.Fatalf("expected callback token exchange POST, got %s", req.Method) } body, ok := bodies[req.URL.Path] if !ok { t.Fatalf("unexpected callback token URL path: %s", req.URL.Path) } return &http.Response{ StatusCode: http.StatusOK, Header: http.Header{"Content-Type": []string{"application/json"}}, Body: io.NopCloser(strings.NewReader(body)), Request: req, }, nil })} t.Cleanup(func() { http.DefaultClient = original }) } func jsonOAuthBody(t *testing.T, payload map[string]any) string { t.Helper() encoded, err := json.Marshal(payload) if err != nil { t.Fatalf("failed to encode OAuth payload: %v", err) } return string(encoded) } func idToken(t *testing.T, claims map[string]any) string { t.Helper() token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims(claims)) signed, err := token.SignedString([]byte("test-id-token-secret")) if err != nil { t.Fatalf("failed to sign id token: %v", err) } return signed } 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 }