379 lines
14 KiB
Go
379 lines
14 KiB
Go
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(), "<Conference") || !strings.Contains(twiml.Body.String(), call.ConferenceSID) {
|
|
t.Fatalf("expected conference TwiML, got %d %q", twiml.Code, twiml.Body.String())
|
|
}
|
|
if !strings.Contains(twiml.Body.String(), `participantLabel="contact"`) {
|
|
t.Fatalf("expected contact participant label, got %s", twiml.Body.String())
|
|
}
|
|
|
|
status := performFormPost(engine, "/twilio/voice/status/15551234567", url.Values{
|
|
"CallSid": {call.ProviderCallID},
|
|
"CallStatus": {"completed"},
|
|
"CallDuration": {"42"},
|
|
})
|
|
if status.Code != http.StatusNoContent {
|
|
t.Fatalf("expected status callback 204, got %d", status.Code)
|
|
}
|
|
|
|
conference := performFormPost(engine, "/twilio/voice/conference_status/15551234567", url.Values{
|
|
"CallSid": {call.ProviderCallID},
|
|
"FriendlyName": {call.ConferenceSID},
|
|
"ConferenceSid": {"CF123"},
|
|
"StatusCallbackEvent": {"participant-join"},
|
|
})
|
|
if conference.Code != http.StatusNoContent {
|
|
t.Fatalf("expected conference callback 204, got %d", conference.Code)
|
|
}
|
|
|
|
recording := performFormPost(engine, "/twilio/voice/recording_status/15551234567", url.Values{
|
|
"CallSid": {call.ProviderCallID},
|
|
"RecordingUrl": {"https://api.twilio.com/recording.mp3"},
|
|
"RecordingDuration": {"43"},
|
|
"RecordingStatus": {"completed"},
|
|
"RecordingSource": {"Conference"},
|
|
"RecordingChannels": {"1"},
|
|
"RecordingStartTime": {"Sat, 06 Jun 2026 09:00:00 +0000"},
|
|
})
|
|
if recording.Code != http.StatusNoContent {
|
|
t.Fatalf("expected recording callback 204, got %d", recording.Code)
|
|
}
|
|
|
|
var updated model.Call
|
|
if err := db.First(&updated, call.ID).Error; err != nil {
|
|
t.Fatalf("failed to reload call: %v", err)
|
|
}
|
|
if updated.Status != string(model.CallStatusOngoing) || updated.Duration != 43 || updated.RecordingURL != "https://api.twilio.com/recording.mp3" {
|
|
t.Fatalf("expected persisted Twilio callback state, got status=%s duration=%d recording=%s", updated.Status, updated.Duration, updated.RecordingURL)
|
|
}
|
|
if !strings.Contains(string(updated.AdditionalAttributes), "twilio_conference_sid") || !strings.Contains(string(updated.AdditionalAttributes), "twilio_recording_payload") {
|
|
t.Fatalf("expected callback payload attrs, got %s", string(updated.AdditionalAttributes))
|
|
}
|
|
}
|
|
|
|
func TestTwilioVoiceCallRejectsUnknownOrDisabledInbox(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
db, call := setupRouterTwilioVoiceDB(t)
|
|
if err := db.Model(&model.Inbox{}).Where("id = ?", call.InboxID).Update("channel_config", `{"voice_enabled":false}`).Error; err != nil {
|
|
t.Fatalf("failed to disable voice inbox: %v", err)
|
|
}
|
|
|
|
engine := gin.New()
|
|
engine.POST("/twilio/voice/call/:phone", twilioVoiceCallTwiML(db))
|
|
|
|
disabled := performFormPost(engine, "/twilio/voice/call/15551234567", url.Values{"CallSid": {call.ProviderCallID}})
|
|
if disabled.Code != http.StatusNotFound {
|
|
t.Fatalf("expected disabled voice inbox 404, got %d", disabled.Code)
|
|
}
|
|
missing := performFormPost(engine, "/twilio/voice/call/15550000000", url.Values{"CallSid": {call.ProviderCallID}})
|
|
if missing.Code != http.StatusNotFound {
|
|
t.Fatalf("expected missing voice inbox 404, got %d", missing.Code)
|
|
}
|
|
}
|
|
|
|
func TestCustomDomainChallengeMatchesChatwootVerification(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
db := setupRouterPortalDB(t)
|
|
portal := &model.Portal{
|
|
AccountID: 1,
|
|
Name: "Help Center",
|
|
Slug: "help-center",
|
|
CustomDomain: "help.example.com",
|
|
SSLSettings: json.RawMessage(`{"cf_verification_id":"challenge-token","cf_verification_body":"cloudflare-body"}`),
|
|
}
|
|
if err := db.Create(portal).Error; err != nil {
|
|
t.Fatalf("failed to create portal: %v", err)
|
|
}
|
|
|
|
engine := gin.New()
|
|
engine.GET("/.well-known/cf-custom-hostname-challenge/:id", customDomainChallenge(db))
|
|
|
|
matched := performHostGet(engine, "/.well-known/cf-custom-hostname-challenge/challenge-token", "help.example.com:3000")
|
|
if matched.Code != http.StatusOK || matched.Body.String() != "cloudflare-body" {
|
|
t.Fatalf("expected matching challenge body, got %d %q", matched.Code, matched.Body.String())
|
|
}
|
|
|
|
missingDomain := performHostGet(engine, "/.well-known/cf-custom-hostname-challenge/challenge-token", "missing.example.com")
|
|
if missingDomain.Code != http.StatusNotFound || missingDomain.Body.String() != "Domain not found" {
|
|
t.Fatalf("expected domain 404, got %d %q", missingDomain.Code, missingDomain.Body.String())
|
|
}
|
|
|
|
wrongChallenge := performHostGet(engine, "/.well-known/cf-custom-hostname-challenge/wrong-token", "help.example.com")
|
|
if wrongChallenge.Code != http.StatusNotFound || wrongChallenge.Body.String() != "Challenge ID not found" {
|
|
t.Fatalf("expected challenge 404, got %d %q", wrongChallenge.Code, wrongChallenge.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestWellKnownRoutesServeMobileAssociationPayloads(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
t.Setenv("ANDROID_BUNDLE_ID", "com.example.gochat")
|
|
t.Setenv("ANDROID_SHA256_CERT_FINGERPRINT", "AA:BB:CC")
|
|
t.Setenv("IOS_APP_ID", "TEAMID.com.example.gochat")
|
|
t.Setenv("AZURE_APP_ID", "azure-application-client-id")
|
|
|
|
engine := gin.New()
|
|
engine.GET("/.well-known/assetlinks.json", androidAssetlinks)
|
|
engine.GET("/.well-known/apple-app-site-association", appleAppSiteAssociation)
|
|
engine.GET("/.well-known/microsoft-identity-association.json", microsoftIdentityAssociation)
|
|
|
|
assetlinks := performGet(engine, "/.well-known/assetlinks.json")
|
|
if assetlinks.Code != http.StatusOK {
|
|
t.Fatalf("expected assetlinks 200, got %d", assetlinks.Code)
|
|
}
|
|
var androidPayload []map[string]any
|
|
if err := json.Unmarshal(assetlinks.Body.Bytes(), &androidPayload); err != nil {
|
|
t.Fatalf("invalid android assetlinks JSON: %v", err)
|
|
}
|
|
if androidPayload[0]["target"].(map[string]any)["package_name"] != "com.example.gochat" {
|
|
t.Fatalf("expected android package name, got %s", assetlinks.Body.String())
|
|
}
|
|
|
|
apple := performGet(engine, "/.well-known/apple-app-site-association")
|
|
if apple.Code != http.StatusOK {
|
|
t.Fatalf("expected apple association 200, got %d", apple.Code)
|
|
}
|
|
if !strings.Contains(apple.Body.String(), `"appID":"TEAMID.com.example.gochat"`) ||
|
|
!strings.Contains(apple.Body.String(), `/app/accounts/*/conversations/*`) {
|
|
t.Fatalf("expected apple app association payload, got %s", apple.Body.String())
|
|
}
|
|
|
|
microsoft := performGet(engine, "/.well-known/microsoft-identity-association.json")
|
|
if microsoft.Code != http.StatusOK {
|
|
t.Fatalf("expected microsoft association 200, got %d", microsoft.Code)
|
|
}
|
|
if !strings.Contains(microsoft.Body.String(), `"applicationId":"azure-application-client-id"`) {
|
|
t.Fatalf("expected microsoft application ID, got %s", microsoft.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestDashboardIndexServesChatwootShell(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
t.Setenv("INSTALLATION_NAME", "GoChat Test")
|
|
t.Setenv("FRONTEND_URL", "https://app.example.test/")
|
|
t.Setenv("HELPCENTER_URL", "https://help.example.test/")
|
|
|
|
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", "text/html")
|
|
engine.ServeHTTP(recorder, req)
|
|
|
|
if recorder.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d", recorder.Code)
|
|
}
|
|
body := recorder.Body.String()
|
|
if !strings.Contains(body, `<div id="app"></div>`) {
|
|
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
|
|
}
|