Files
gochat/internal/router/router_test.go
T

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
}