fix: restore web widget realtime replies (HH-556) (#136)
* fix: restore web widget realtime replies (HH-556) * fix: make realtime delivery durable (HH-556) * fix: make realtime message outbox atomic (HH-556) --------- Co-authored-by: Rogee <rogee@ipao.vip>
This commit is contained in:
@@ -168,17 +168,20 @@ func (s *MessageHandlerTestSuite) SetupTest() {
|
||||
contact := &model.Contact{AccountID: account.ID, Name: "MsgHandlerTestContact"}
|
||||
s.Require().NoError(s.db.Create(contact).Error)
|
||||
s.testContact = contact
|
||||
contactInbox := &model.ContactInbox{ContactID: contact.ID, InboxID: inbox.ID, SourceID: "message-handler-contact", PubsubToken: "message-handler-token"}
|
||||
s.Require().NoError(s.db.Create(contactInbox).Error)
|
||||
|
||||
displayID := uint(4242)
|
||||
conv := &model.Conversation{
|
||||
AccountID: account.ID,
|
||||
DisplayID: &displayID,
|
||||
InboxID: inbox.ID,
|
||||
ContactID: contact.ID,
|
||||
Status: string(model.ConversationStatusOpen),
|
||||
Priority: string(model.ConversationPriorityMedium),
|
||||
ChannelType: "web_widget",
|
||||
Channel: "web_widget",
|
||||
AccountID: account.ID,
|
||||
DisplayID: &displayID,
|
||||
InboxID: inbox.ID,
|
||||
ContactID: contact.ID,
|
||||
ContactInboxID: &contactInbox.ID,
|
||||
Status: string(model.ConversationStatusOpen),
|
||||
Priority: string(model.ConversationPriorityMedium),
|
||||
ChannelType: "web_widget",
|
||||
Channel: "web_widget",
|
||||
}
|
||||
s.Require().NoError(s.db.Create(conv).Error)
|
||||
s.testConv = conv
|
||||
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/datatypes"
|
||||
@@ -28,6 +29,7 @@ import (
|
||||
|
||||
"github.com/gochat/gochat/internal/automation"
|
||||
"github.com/gochat/gochat/internal/campaign"
|
||||
"github.com/gochat/gochat/internal/channel"
|
||||
"github.com/gochat/gochat/internal/config"
|
||||
apiv1 "github.com/gochat/gochat/internal/handler/api/v1"
|
||||
"github.com/gochat/gochat/internal/middleware"
|
||||
@@ -35,7 +37,9 @@ import (
|
||||
channelmodel "github.com/gochat/gochat/internal/model/channel"
|
||||
"github.com/gochat/gochat/internal/repository"
|
||||
"github.com/gochat/gochat/internal/service"
|
||||
"github.com/gochat/gochat/internal/worker"
|
||||
ws "github.com/gochat/gochat/internal/ws"
|
||||
"github.com/gochat/gochat/internal/wsevent"
|
||||
)
|
||||
|
||||
// noopTypingIndicatorWidget is a stub for handler tests.
|
||||
@@ -2108,6 +2112,51 @@ func TestWidgetHandler_LegacySendMessageUsesWidgetPayload(t *testing.T) {
|
||||
assert.Equal(t, float64(account.ID), attachment["account_id"])
|
||||
}
|
||||
|
||||
func TestWidgetHandler_RealtimeFailureReturnsSuccessWithoutDuplicateMessage(t *testing.T) {
|
||||
db, router, handler := setupWidgetHandlerTest(t)
|
||||
require.NoError(t, db.AutoMigrate(&model.BackgroundJob{}))
|
||||
seedWidgetHandlerData(t, db)
|
||||
|
||||
pool := worker.NewWorkerPoolWithOptions(db, worker.WithBackoff(func(int) time.Duration { return 0 }))
|
||||
handler.widgetService.SetWorkerPool(pool)
|
||||
rdb := redis.NewClient(&redis.Options{Addr: "127.0.0.1:1", MaxRetries: -1, DialTimeout: 10 * time.Millisecond})
|
||||
t.Cleanup(func() { require.NoError(t, rdb.Close()) })
|
||||
publisher := ws.NewEventPublisher(nil, nil, ws.NewBroadcastRelay(rdb, nil))
|
||||
publisher.SetWorkerPool(pool)
|
||||
dispatcher := channel.NewDispatcher()
|
||||
dispatcher.Register(wsevent.New(publisher))
|
||||
handler.widgetService.SetDispatcher(dispatcher)
|
||||
|
||||
initBody, err := json.Marshal(map[string]interface{}{"website_token": "handler_ws_token_123"})
|
||||
require.NoError(t, err)
|
||||
initResponse := httptest.NewRecorder()
|
||||
initRequest := httptest.NewRequest(http.MethodPost, "/widget/init", bytes.NewReader(initBody))
|
||||
initRequest.Header.Set("Content-Type", "application/json")
|
||||
router.ServeHTTP(initResponse, initRequest)
|
||||
require.Equal(t, http.StatusOK, initResponse.Code, initResponse.Body.String())
|
||||
var initPayload map[string]interface{}
|
||||
require.NoError(t, json.Unmarshal(initResponse.Body.Bytes(), &initPayload))
|
||||
|
||||
messageBody, err := json.Marshal(map[string]interface{}{"content": "persist once"})
|
||||
require.NoError(t, err)
|
||||
messageResponse := httptest.NewRecorder()
|
||||
messageRequest := httptest.NewRequest(http.MethodPost, "/widget/messages", bytes.NewReader(messageBody))
|
||||
messageRequest.Header.Set("Content-Type", "application/json")
|
||||
messageRequest.Header.Set("X-Widget-Token", initPayload["widget_token"].(string))
|
||||
router.ServeHTTP(messageResponse, messageRequest)
|
||||
require.Equal(t, http.StatusOK, messageResponse.Code, messageResponse.Body.String())
|
||||
|
||||
processed, err := pool.ProcessOne(context.Background())
|
||||
require.True(t, processed)
|
||||
require.Error(t, err)
|
||||
var messageCount int64
|
||||
require.NoError(t, db.Model(&model.Message{}).Count(&messageCount).Error)
|
||||
require.Equal(t, int64(1), messageCount)
|
||||
var realtimeJob model.BackgroundJob
|
||||
require.NoError(t, db.Where("queue = ? AND last_error != ''", "events").First(&realtimeJob).Error)
|
||||
require.Equal(t, model.BackgroundJobStatusRetrying, realtimeJob.Status)
|
||||
}
|
||||
|
||||
func TestWidgetHandler_SendMessage_NoWidgetToken(t *testing.T) {
|
||||
_, router, _ := setupWidgetHandlerTest(t)
|
||||
|
||||
|
||||
@@ -4,10 +4,12 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -28,10 +30,38 @@ import (
|
||||
"github.com/gochat/gochat/internal/model"
|
||||
"github.com/gochat/gochat/internal/repository"
|
||||
"github.com/gochat/gochat/internal/service"
|
||||
"github.com/gochat/gochat/internal/worker"
|
||||
wspkg "github.com/gochat/gochat/internal/ws"
|
||||
"github.com/gochat/gochat/internal/wsevent"
|
||||
)
|
||||
|
||||
type failTokenPublishOnceHook struct {
|
||||
mu sync.Mutex
|
||||
channel string
|
||||
failed bool
|
||||
}
|
||||
|
||||
func (h *failTokenPublishOnceHook) DialHook(next redis.DialHook) redis.DialHook { return next }
|
||||
|
||||
func (h *failTokenPublishOnceHook) ProcessHook(next redis.ProcessHook) redis.ProcessHook {
|
||||
return func(ctx context.Context, cmd redis.Cmder) error {
|
||||
if cmd.Name() == "publish" && len(cmd.Args()) >= 2 && fmt.Sprint(cmd.Args()[1]) == h.channel {
|
||||
h.mu.Lock()
|
||||
if !h.failed {
|
||||
h.failed = true
|
||||
h.mu.Unlock()
|
||||
return errors.New("token room unavailable")
|
||||
}
|
||||
h.mu.Unlock()
|
||||
}
|
||||
return next(ctx, cmd)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *failTokenPublishOnceHook) ProcessPipelineHook(next redis.ProcessPipelineHook) redis.ProcessPipelineHook {
|
||||
return next
|
||||
}
|
||||
|
||||
// --- Protocol Tests ---
|
||||
|
||||
func TestCommandType_Constants(t *testing.T) {
|
||||
@@ -288,6 +318,79 @@ func TestHub_SendToAccount(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDurableWidgetRetryDoesNotDuplicateRealHubSubscribers(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s-%d?mode=memory&cache=shared", t.Name(), time.Now().UnixNano())), &gorm.Config{})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, db.AutoMigrate(&model.BackgroundJob{}))
|
||||
sqlDB, err := db.DB()
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { require.NoError(t, sqlDB.Close()) })
|
||||
mini := miniredis.RunT(t)
|
||||
rdb := redis.NewClient(&redis.Options{Addr: mini.Addr()})
|
||||
rdb.AddHook(&failTokenPublishOnceHook{channel: wspkg.RedisPrefixRoom + "pubsub_token_visitor"})
|
||||
t.Cleanup(func() { require.NoError(t, rdb.Close()) })
|
||||
|
||||
hub := NewHubSimple()
|
||||
dashboard := NewClient(1, 1, nil, hub)
|
||||
dashboard.Identifier = `{"channel":"AccountChannel","account_id":1}`
|
||||
hub.Register(dashboard)
|
||||
visitor := NewClient(0, 1, nil, hub)
|
||||
visitor.IsContact = true
|
||||
visitor.PubsubToken = "visitor"
|
||||
visitor.Identifier = `{"channel":"RoomChannel","pubsub_token":"visitor"}`
|
||||
hub.Register(visitor)
|
||||
|
||||
relay := wspkg.NewBroadcastRelay(rdb, hub)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
require.NoError(t, relay.Start(ctx))
|
||||
t.Cleanup(func() {
|
||||
cancel()
|
||||
require.NoError(t, relay.Stop())
|
||||
})
|
||||
pool := worker.NewWorkerPoolWithOptions(db, worker.WithBackoff(func(int) time.Duration { return 0 }))
|
||||
publisher := wspkg.NewEventPublisher(hub, nil, relay)
|
||||
publisher.SetWorkerPool(pool)
|
||||
require.NoError(t, publisher.PublishWidgetEvent(1, "visitor", wspkg.EventMessageCreated, map[string]any{"id": 7}))
|
||||
|
||||
processed, err := pool.ProcessOne(context.Background())
|
||||
require.True(t, processed)
|
||||
require.NoError(t, err)
|
||||
select {
|
||||
case <-dashboard.Send:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("account subscriber did not receive message.created")
|
||||
}
|
||||
|
||||
processed, err = pool.ProcessOne(context.Background())
|
||||
require.True(t, processed)
|
||||
require.ErrorContains(t, err, "token room unavailable")
|
||||
select {
|
||||
case <-visitor.Send:
|
||||
t.Fatal("token subscriber received failed delivery")
|
||||
default:
|
||||
}
|
||||
|
||||
processed, err = pool.ProcessOne(context.Background())
|
||||
require.True(t, processed)
|
||||
require.NoError(t, err)
|
||||
select {
|
||||
case <-visitor.Send:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("token subscriber did not receive recovered message.created")
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
select {
|
||||
case <-dashboard.Send:
|
||||
t.Fatal("account subscriber received duplicate message.created")
|
||||
default:
|
||||
}
|
||||
select {
|
||||
case <-visitor.Send:
|
||||
t.Fatal("token subscriber received duplicate message.created")
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
func TestHub_SendToAccountSanitizesVisitorIdentity(t *testing.T) {
|
||||
hub := NewHubSimple()
|
||||
agent := NewClient(1, 10, nil, hub)
|
||||
@@ -721,6 +824,8 @@ func TestDashboardOutgoingReachesDashboardAndReconnectedWidget(t *testing.T) {
|
||||
))
|
||||
account := &model.Account{Name: "Realtime account", Active: true}
|
||||
require.NoError(t, db.Create(account).Error)
|
||||
user := &model.User{Base: model.Base{ID: 7}, Name: "Agent", Email: "agent@example.com", Provider: "local", Active: true}
|
||||
require.NoError(t, db.Create(user).Error)
|
||||
inbox := &model.Inbox{AccountID: account.ID, Name: "Website", ChannelType: "web_widget", Enabled: true}
|
||||
require.NoError(t, db.Create(inbox).Error)
|
||||
contact := &model.Contact{AccountID: account.ID, Name: "Visitor"}
|
||||
@@ -758,7 +863,6 @@ func TestDashboardOutgoingReachesDashboardAndReconnectedWidget(t *testing.T) {
|
||||
server := httptest.NewServer(router)
|
||||
t.Cleanup(server.Close)
|
||||
|
||||
user := &model.User{Base: model.Base{ID: 7}, Provider: "local"}
|
||||
tokenPair, err := jwtService.GenerateTokenPair(user, account.ID, "agent")
|
||||
require.NoError(t, err)
|
||||
dashboard := dialCable(t, server.URL, "?token="+tokenPair.AccessToken)
|
||||
|
||||
Reference in New Issue
Block a user