Restructure the monorepo into clear top-level directories: - backend/: Go module root (cmd, internal, pkg, configs, migrations, docs/swagger, scripts, tests, go.mod, Makefile, .air.toml) - deploy/: Docker (Dockerfile, docker-compose*), quickstart, fluentd - docs/: project documentation + reports/ (moved from repo root) - AGENTS.md: new AI coding-agent guide at repo root Update all references to the new layout: - Dockerfile: COPY backend/go.mod, COPY backend/ (context = repo root) - docker-compose files: context ../.., dockerfile deploy/docker/Dockerfile, env_file ../../.env, volume mounts ../../backend:/app - deploy/quickstart/compose.yaml: dockerfile deploy/docker/Dockerfile - CI: working-directory: backend for go commands, file deploy/docker/Dockerfile, coverage path backend/coverage.out, health_check backend/scripts/ - backend/Makefile: docker target uses -f ../deploy/docker/Dockerfile ../ - README: architecture tree, quickstart, config paths updated Move root stray scripts (rename_models.*, run_m11_tests.sh, verify_build.sh, gorm_bool_main.go) to backend/scripts/legacy/. All moves via git mv to preserve history. Build, vet, SQLite tests, and docker compose config verified.
1536 lines
45 KiB
Go
1536 lines
45 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"crypto/hmac"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"gorm.io/driver/sqlite"
|
|
"gorm.io/gorm"
|
|
"gorm.io/gorm/logger"
|
|
|
|
"github.com/gochat/gochat/internal/model"
|
|
"github.com/gochat/gochat/internal/repository"
|
|
"github.com/gochat/gochat/internal/worker"
|
|
)
|
|
|
|
// ========== Test Helpers ==========
|
|
|
|
// setupWidgetServiceTest creates an in-memory SQLite DB, migrates all models,
|
|
// constructs repos + service, and returns them for use in service-level tests.
|
|
// mockTypingIndicator is defined in widget_offline_message_test.go (same package).
|
|
func setupWidgetServiceTest(t *testing.T) (*gorm.DB, *WidgetService) {
|
|
t.Helper()
|
|
|
|
db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{
|
|
Logger: logger.Default.LogMode(logger.Silent),
|
|
})
|
|
require.NoError(t, err, "failed to open test DB")
|
|
|
|
require.NoError(t, db.AutoMigrate(
|
|
&model.Account{},
|
|
&model.Inbox{},
|
|
&model.Contact{},
|
|
&model.ContactInbox{},
|
|
&model.Conversation{},
|
|
&model.Message{},
|
|
&model.WidgetThemeConfig{},
|
|
&model.PreChatForm{},
|
|
&model.WidgetFileUpload{},
|
|
&model.WidgetOfflineMessage{},
|
|
&model.BackgroundJob{},
|
|
), "failed to auto-migrate models")
|
|
|
|
t.Cleanup(func() {
|
|
sqlDB, _ := db.DB()
|
|
sqlDB.Close()
|
|
})
|
|
|
|
inboxRepo := repository.NewInboxRepo(db)
|
|
contactRepo := repository.NewContactRepo(db)
|
|
contactInboxRepo := repository.NewContactInboxRepo(db)
|
|
conversationRepo := repository.NewConversationRepo(db)
|
|
messageRepo := repository.NewMessageRepo(db)
|
|
themeConfigRepo := repository.NewWidgetThemeConfigRepo(db)
|
|
preChatFormRepo := repository.NewPreChatFormRepo(db)
|
|
fileUploadRepo := repository.NewWidgetFileUploadRepo(db)
|
|
offlineMsgRepo := repository.NewWidgetOfflineMessageRepo(db)
|
|
|
|
svc := NewWidgetService(
|
|
inboxRepo,
|
|
contactRepo,
|
|
contactInboxRepo,
|
|
conversationRepo,
|
|
messageRepo,
|
|
&mockTypingIndicator{},
|
|
themeConfigRepo,
|
|
preChatFormRepo,
|
|
fileUploadRepo,
|
|
offlineMsgRepo,
|
|
nil,
|
|
nil,
|
|
nil,
|
|
)
|
|
|
|
return db, svc
|
|
}
|
|
|
|
// seedWidgetInbox creates an Account + a web_widget Inbox with a known website_token
|
|
// in its ChannelConfig JSON, suitable for Init/SendMessage tests.
|
|
func seedWidgetInbox(t *testing.T, db *gorm.DB) (*model.Account, *model.Inbox) {
|
|
t.Helper()
|
|
|
|
account := &model.Account{Name: "Widget Test Account", Locale: "en", Status: "active"}
|
|
require.NoError(t, db.Create(account).Error)
|
|
|
|
widgetConfig := WebWidgetConfig{
|
|
WebsiteToken: "test_ws_token_123",
|
|
HMACToken: "test_hmac_secret",
|
|
WidgetColor: "#1f93ff",
|
|
WelcomeTitle: "Hello!",
|
|
WelcomeTagline: "We are here to help",
|
|
PreChatFieldsEnabled: true,
|
|
OfflineMessageEnabled: true,
|
|
}
|
|
configJSON, err := json.Marshal(widgetConfig)
|
|
require.NoError(t, err)
|
|
|
|
inbox := &model.Inbox{
|
|
AccountID: account.ID,
|
|
Name: "Widget Inbox",
|
|
ChannelType: "web_widget",
|
|
ChannelID: 1,
|
|
Enabled: true,
|
|
ChannelConfig: string(configJSON),
|
|
}
|
|
require.NoError(t, db.Create(inbox).Error)
|
|
|
|
return account, inbox
|
|
}
|
|
|
|
// ========== Init Tests ==========
|
|
|
|
func TestWidgetService_Init_Success(t *testing.T) {
|
|
db, svc := setupWidgetServiceTest(t)
|
|
ctx := context.Background()
|
|
|
|
account, inbox := seedWidgetInbox(t, db)
|
|
|
|
resp, err := svc.Init(ctx, WidgetInitRequest{
|
|
WebsiteToken: "test_ws_token_123",
|
|
ContactName: "Alice Visitor",
|
|
ContactEmail: "alice@example.com",
|
|
})
|
|
require.NoError(t, err)
|
|
assert.NotNil(t, resp)
|
|
assert.NotEmpty(t, resp.WidgetToken, "widget_token (pubsub_token) must be generated")
|
|
assert.Equal(t, inbox.ID, resp.InboxID)
|
|
assert.Equal(t, account.ID, resp.AccountID)
|
|
assert.NotZero(t, resp.ContactID)
|
|
assert.NotZero(t, resp.ContactInboxID)
|
|
assert.Equal(t, "test_ws_token_123", resp.WidgetConfig.WebsiteToken)
|
|
assert.Equal(t, "#1f93ff", resp.WidgetConfig.WidgetColor)
|
|
}
|
|
|
|
func TestWidgetService_Init_AnonymousVisitor(t *testing.T) {
|
|
db, svc := setupWidgetServiceTest(t)
|
|
ctx := context.Background()
|
|
|
|
_, inbox := seedWidgetInbox(t, db)
|
|
|
|
resp, err := svc.Init(ctx, WidgetInitRequest{
|
|
WebsiteToken: "test_ws_token_123",
|
|
})
|
|
require.NoError(t, err)
|
|
assert.NotNil(t, resp)
|
|
assert.Equal(t, inbox.ID, resp.InboxID)
|
|
assert.NotZero(t, resp.ContactID)
|
|
}
|
|
|
|
func TestWidgetService_Init_ExistingContactByEmail(t *testing.T) {
|
|
db, svc := setupWidgetServiceTest(t)
|
|
ctx := context.Background()
|
|
|
|
account, _ := seedWidgetInbox(t, db)
|
|
|
|
// Create an existing contact
|
|
existingContact := &model.Contact{
|
|
AccountID: account.ID,
|
|
Name: "Bob Returning",
|
|
Email: "bob@example.com",
|
|
ContactType: "visitor",
|
|
}
|
|
require.NoError(t, db.Create(existingContact).Error)
|
|
|
|
// Init with same email should find the existing contact
|
|
resp, err := svc.Init(ctx, WidgetInitRequest{
|
|
WebsiteToken: "test_ws_token_123",
|
|
ContactName: "Bob Updated",
|
|
ContactEmail: "bob@example.com",
|
|
})
|
|
require.NoError(t, err)
|
|
assert.NotNil(t, resp)
|
|
assert.Equal(t, existingContact.ID, resp.ContactID, "should reuse existing contact")
|
|
}
|
|
|
|
func TestWidgetService_Init_EmptyWebsiteToken(t *testing.T) {
|
|
_, svc := setupWidgetServiceTest(t)
|
|
ctx := context.Background()
|
|
|
|
resp, err := svc.Init(ctx, WidgetInitRequest{
|
|
WebsiteToken: "",
|
|
})
|
|
assert.Nil(t, resp)
|
|
assert.Error(t, err)
|
|
assert.Contains(t, err.Error(), "website_token is required")
|
|
}
|
|
|
|
func TestWidgetService_Init_InvalidWebsiteToken(t *testing.T) {
|
|
_, svc := setupWidgetServiceTest(t)
|
|
ctx := context.Background()
|
|
|
|
resp, err := svc.Init(ctx, WidgetInitRequest{
|
|
WebsiteToken: "nonexistent_token",
|
|
})
|
|
assert.Nil(t, resp)
|
|
assert.Error(t, err)
|
|
assert.Contains(t, err.Error(), "invalid website_token")
|
|
}
|
|
|
|
func TestWidgetService_Init_DisabledInbox(t *testing.T) {
|
|
db, svc := setupWidgetServiceTest(t)
|
|
ctx := context.Background()
|
|
|
|
account := &model.Account{Name: "Disabled Test", Locale: "en", Status: "active"}
|
|
require.NoError(t, db.Create(account).Error)
|
|
|
|
widgetConfig := WebWidgetConfig{WebsiteToken: "disabled_ws_token"}
|
|
configJSON, _ := json.Marshal(widgetConfig)
|
|
|
|
// Create inbox with Enabled=true (GORM default:true overrides false on Create),
|
|
// then explicitly update to Enabled=false.
|
|
inbox := &model.Inbox{
|
|
AccountID: account.ID,
|
|
Name: "Disabled Inbox",
|
|
ChannelType: "web_widget",
|
|
ChannelID: 1,
|
|
Enabled: true,
|
|
ChannelConfig: string(configJSON),
|
|
}
|
|
require.NoError(t, db.Create(inbox).Error)
|
|
require.NoError(t, db.Model(inbox).Update("enabled", false).Error)
|
|
|
|
resp, err := svc.Init(ctx, WidgetInitRequest{
|
|
WebsiteToken: "disabled_ws_token",
|
|
})
|
|
assert.Nil(t, resp)
|
|
assert.Error(t, err)
|
|
assert.Contains(t, err.Error(), "disabled")
|
|
}
|
|
|
|
func TestWidgetService_Init_ReInitSameContact(t *testing.T) {
|
|
db, svc := setupWidgetServiceTest(t)
|
|
ctx := context.Background()
|
|
|
|
seedWidgetInbox(t, db)
|
|
|
|
// First init
|
|
resp1, err := svc.Init(ctx, WidgetInitRequest{
|
|
WebsiteToken: "test_ws_token_123",
|
|
ContactEmail: "reinit@example.com",
|
|
})
|
|
require.NoError(t, err)
|
|
require.NotNil(t, resp1)
|
|
|
|
// Second init with same email — should find existing contact and reuse ContactInbox
|
|
resp2, err := svc.Init(ctx, WidgetInitRequest{
|
|
WebsiteToken: "test_ws_token_123",
|
|
ContactEmail: "reinit@example.com",
|
|
})
|
|
require.NoError(t, err)
|
|
assert.NotNil(t, resp2)
|
|
assert.Equal(t, resp1.ContactID, resp2.ContactID, "should reuse same contact")
|
|
assert.Equal(t, resp1.WidgetToken, resp2.WidgetToken, "should reuse same pubsub_token")
|
|
}
|
|
|
|
// ========== SendMessage Tests ==========
|
|
|
|
func TestWidgetService_SendMessage_NewConversation(t *testing.T) {
|
|
db, svc := setupWidgetServiceTest(t)
|
|
ctx := context.Background()
|
|
|
|
seedWidgetInbox(t, db)
|
|
|
|
// Init first to get widget_token
|
|
initResp, err := svc.Init(ctx, WidgetInitRequest{
|
|
WebsiteToken: "test_ws_token_123",
|
|
ContactName: "Sender Visitor",
|
|
})
|
|
require.NoError(t, err)
|
|
require.NotNil(t, initResp)
|
|
|
|
// Send message without conversation_id → creates new conversation
|
|
sendResp, err := svc.SendMessage(ctx, WidgetSendMessageRequest{
|
|
WidgetToken: initResp.WidgetToken,
|
|
Content: "Hello from widget!",
|
|
})
|
|
require.NoError(t, err)
|
|
assert.NotNil(t, sendResp)
|
|
assert.NotZero(t, sendResp.ConversationID)
|
|
assert.Equal(t, "Hello from widget!", sendResp.Message.Content)
|
|
assert.Equal(t, "text", sendResp.Message.ContentType)
|
|
assert.Equal(t, "incoming", sendResp.Message.MessageType)
|
|
}
|
|
|
|
func TestWidgetService_SendMessage_ExistingConversation(t *testing.T) {
|
|
db, svc := setupWidgetServiceTest(t)
|
|
ctx := context.Background()
|
|
|
|
seedWidgetInbox(t, db)
|
|
|
|
initResp, err := svc.Init(ctx, WidgetInitRequest{
|
|
WebsiteToken: "test_ws_token_123",
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
// Send first message → creates conversation
|
|
firstResp, err := svc.SendMessage(ctx, WidgetSendMessageRequest{
|
|
WidgetToken: initResp.WidgetToken,
|
|
Content: "First message",
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
// Send second message to same conversation
|
|
convID := firstResp.ConversationID
|
|
secondResp, err := svc.SendMessage(ctx, WidgetSendMessageRequest{
|
|
WidgetToken: initResp.WidgetToken,
|
|
Content: "Second message in same conversation",
|
|
ConversationID: &convID,
|
|
})
|
|
require.NoError(t, err)
|
|
assert.Equal(t, convID, secondResp.ConversationID)
|
|
assert.Equal(t, "Second message in same conversation", secondResp.Message.Content)
|
|
}
|
|
|
|
func TestWidgetService_SendMessage_EmptyWidgetToken(t *testing.T) {
|
|
_, svc := setupWidgetServiceTest(t)
|
|
ctx := context.Background()
|
|
|
|
resp, err := svc.SendMessage(ctx, WidgetSendMessageRequest{
|
|
WidgetToken: "",
|
|
Content: "test",
|
|
})
|
|
assert.Nil(t, resp)
|
|
assert.Error(t, err)
|
|
assert.Contains(t, err.Error(), "widget_token is required")
|
|
}
|
|
|
|
func TestWidgetService_SendMessage_EmptyContent(t *testing.T) {
|
|
_, svc := setupWidgetServiceTest(t)
|
|
ctx := context.Background()
|
|
|
|
resp, err := svc.SendMessage(ctx, WidgetSendMessageRequest{
|
|
WidgetToken: "some_token",
|
|
Content: "",
|
|
})
|
|
assert.Nil(t, resp)
|
|
assert.Error(t, err)
|
|
assert.Contains(t, err.Error(), "content is required")
|
|
}
|
|
|
|
func TestWidgetService_SendMessage_InvalidWidgetToken(t *testing.T) {
|
|
_, svc := setupWidgetServiceTest(t)
|
|
ctx := context.Background()
|
|
|
|
resp, err := svc.SendMessage(ctx, WidgetSendMessageRequest{
|
|
WidgetToken: "invalid_token_xyz",
|
|
Content: "test message",
|
|
})
|
|
assert.Nil(t, resp)
|
|
assert.Error(t, err)
|
|
assert.Contains(t, err.Error(), "invalid widget_token")
|
|
}
|
|
|
|
func TestWidgetService_SendMessage_WrongConversationOwner(t *testing.T) {
|
|
db, svc := setupWidgetServiceTest(t)
|
|
ctx := context.Background()
|
|
|
|
seedWidgetInbox(t, db)
|
|
|
|
// Init contact A
|
|
initA, err := svc.Init(ctx, WidgetInitRequest{
|
|
WebsiteToken: "test_ws_token_123",
|
|
ContactName: "Contact A",
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
// Init contact B with different email
|
|
initB, err := svc.Init(ctx, WidgetInitRequest{
|
|
WebsiteToken: "test_ws_token_123",
|
|
ContactName: "Contact B",
|
|
ContactEmail: "contact_b@example.com",
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
// Contact A sends message → creates conversation
|
|
msgA, err := svc.SendMessage(ctx, WidgetSendMessageRequest{
|
|
WidgetToken: initA.WidgetToken,
|
|
Content: "Message from A",
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
// Contact B tries to send to Contact A's conversation → should fail
|
|
convID := msgA.ConversationID
|
|
resp, err := svc.SendMessage(ctx, WidgetSendMessageRequest{
|
|
WidgetToken: initB.WidgetToken,
|
|
Content: "Trying A's conversation",
|
|
ConversationID: &convID,
|
|
})
|
|
assert.Nil(t, resp)
|
|
assert.Error(t, err)
|
|
assert.Contains(t, err.Error(), "does not belong to this contact")
|
|
}
|
|
|
|
// ========== GetConversations Tests ==========
|
|
|
|
func TestWidgetService_GetConversations(t *testing.T) {
|
|
db, svc := setupWidgetServiceTest(t)
|
|
ctx := context.Background()
|
|
|
|
seedWidgetInbox(t, db)
|
|
|
|
initResp, err := svc.Init(ctx, WidgetInitRequest{
|
|
WebsiteToken: "test_ws_token_123",
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
// Sending without a conversation_id reuses the latest widget conversation,
|
|
// matching Chatwoot widget message behavior.
|
|
firstResp, err := svc.SendMessage(ctx, WidgetSendMessageRequest{
|
|
WidgetToken: initResp.WidgetToken,
|
|
Content: "Msg 1",
|
|
})
|
|
require.NoError(t, err)
|
|
secondResp, err := svc.SendMessage(ctx, WidgetSendMessageRequest{
|
|
WidgetToken: initResp.WidgetToken,
|
|
Content: "Msg 2",
|
|
})
|
|
require.NoError(t, err)
|
|
assert.Equal(t, firstResp.ConversationID, secondResp.ConversationID)
|
|
|
|
convs, err := svc.GetConversations(ctx, initResp.WidgetToken)
|
|
require.NoError(t, err)
|
|
assert.Len(t, convs, 1)
|
|
}
|
|
|
|
func TestWidgetService_GetConversations_InvalidToken(t *testing.T) {
|
|
_, svc := setupWidgetServiceTest(t)
|
|
ctx := context.Background()
|
|
|
|
convs, err := svc.GetConversations(ctx, "invalid_token")
|
|
assert.Nil(t, convs)
|
|
assert.Error(t, err)
|
|
}
|
|
|
|
func TestWidgetService_UpdateLastSeenQueuesMessageStatusJob(t *testing.T) {
|
|
db, svc := setupWidgetServiceTest(t)
|
|
ctx := context.Background()
|
|
|
|
seedWidgetInbox(t, db)
|
|
initResp, err := svc.Init(ctx, WidgetInitRequest{WebsiteToken: "test_ws_token_123"})
|
|
require.NoError(t, err)
|
|
|
|
incoming, err := svc.SendMessage(ctx, WidgetSendMessageRequest{WidgetToken: initResp.WidgetToken, Content: "hello"})
|
|
require.NoError(t, err)
|
|
outgoing := &model.Message{
|
|
AccountID: incoming.Message.AccountID,
|
|
InboxID: incoming.Message.InboxID,
|
|
ConversationID: incoming.ConversationID,
|
|
Content: "reply before last seen",
|
|
MessageType: "outgoing",
|
|
Status: string(model.MessageStatusSent),
|
|
}
|
|
outgoing.CreatedAt = time.Now().Add(-time.Minute)
|
|
outgoing.UpdatedAt = outgoing.CreatedAt
|
|
require.NoError(t, db.Create(outgoing).Error)
|
|
|
|
wp := worker.NewWorkerPool(db)
|
|
RegisterConversationMaintenanceJobs(wp, db)
|
|
svc.SetWorkerPool(wp)
|
|
|
|
conversation, err := svc.UpdateLastSeen(ctx, initResp.WidgetToken)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, conversation.ContactLastSeenAt)
|
|
|
|
var job model.BackgroundJob
|
|
require.NoError(t, db.Where("job_type = ? AND queue = ? AND status = ?", TaskTypeConversationUpdateMessageStatus, "deferred", model.BackgroundJobStatusQueued).First(&job).Error)
|
|
assert.Contains(t, string(job.Payload), fmt.Sprintf(`"conversation_id":%d`, outgoing.ConversationID))
|
|
}
|
|
|
|
// ========== GetMessages Tests ==========
|
|
|
|
func TestWidgetService_GetMessages(t *testing.T) {
|
|
db, svc := setupWidgetServiceTest(t)
|
|
ctx := context.Background()
|
|
|
|
seedWidgetInbox(t, db)
|
|
|
|
initResp, err := svc.Init(ctx, WidgetInitRequest{
|
|
WebsiteToken: "test_ws_token_123",
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
// Send a message to create a conversation
|
|
sendResp, err := svc.SendMessage(ctx, WidgetSendMessageRequest{
|
|
WidgetToken: initResp.WidgetToken,
|
|
Content: "Test message for retrieval",
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
// Retrieve messages for that conversation
|
|
msgs, total, err := svc.GetMessages(ctx, initResp.WidgetToken, sendResp.ConversationID, 0, 50)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, int64(1), total)
|
|
assert.Len(t, msgs, 1)
|
|
assert.Equal(t, "Test message for retrieval", msgs[0].Content)
|
|
}
|
|
|
|
func TestWidgetService_GetMessages_InvalidToken(t *testing.T) {
|
|
_, svc := setupWidgetServiceTest(t)
|
|
ctx := context.Background()
|
|
|
|
msgs, total, err := svc.GetMessages(ctx, "invalid_token", 1, 0, 50)
|
|
assert.Nil(t, msgs)
|
|
assert.Zero(t, total)
|
|
assert.Error(t, err)
|
|
}
|
|
|
|
func TestWidgetService_GetMessages_WrongConversationOwner(t *testing.T) {
|
|
db, svc := setupWidgetServiceTest(t)
|
|
ctx := context.Background()
|
|
|
|
seedWidgetInbox(t, db)
|
|
|
|
initA, err := svc.Init(ctx, WidgetInitRequest{
|
|
WebsiteToken: "test_ws_token_123",
|
|
ContactName: "Contact A",
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
initB, err := svc.Init(ctx, WidgetInitRequest{
|
|
WebsiteToken: "test_ws_token_123",
|
|
ContactEmail: "contact_b@example.com",
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
msgA, err := svc.SendMessage(ctx, WidgetSendMessageRequest{
|
|
WidgetToken: initA.WidgetToken,
|
|
Content: "A's message",
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
// Contact B tries to read Contact A's conversation
|
|
msgs, total, err := svc.GetMessages(ctx, initB.WidgetToken, msgA.ConversationID, 0, 50)
|
|
assert.Nil(t, msgs)
|
|
assert.Zero(t, total)
|
|
assert.Error(t, err)
|
|
assert.Contains(t, err.Error(), "does not belong to this contact")
|
|
}
|
|
|
|
// ========== GetCableToken Tests ==========
|
|
|
|
func TestWidgetService_GetCableToken(t *testing.T) {
|
|
db, svc := setupWidgetServiceTest(t)
|
|
ctx := context.Background()
|
|
|
|
seedWidgetInbox(t, db)
|
|
|
|
initResp, err := svc.Init(ctx, WidgetInitRequest{
|
|
WebsiteToken: "test_ws_token_123",
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
cableResp, err := svc.GetCableToken(ctx, initResp.WidgetToken)
|
|
require.NoError(t, err)
|
|
assert.NotNil(t, cableResp)
|
|
assert.Equal(t, initResp.WidgetToken, cableResp.PubsubToken)
|
|
assert.Equal(t, initResp.ContactID, cableResp.ContactID)
|
|
assert.Equal(t, initResp.InboxID, cableResp.InboxID)
|
|
assert.Equal(t, initResp.AccountID, cableResp.AccountID)
|
|
}
|
|
|
|
func TestWidgetService_GetCableToken_InvalidToken(t *testing.T) {
|
|
_, svc := setupWidgetServiceTest(t)
|
|
ctx := context.Background()
|
|
|
|
resp, err := svc.GetCableToken(ctx, "invalid_token")
|
|
assert.Nil(t, resp)
|
|
assert.Error(t, err)
|
|
}
|
|
|
|
// ========== UpdateContact Tests ==========
|
|
|
|
func TestWidgetService_UpdateContact(t *testing.T) {
|
|
db, svc := setupWidgetServiceTest(t)
|
|
ctx := context.Background()
|
|
|
|
seedWidgetInbox(t, db)
|
|
|
|
initResp, err := svc.Init(ctx, WidgetInitRequest{
|
|
WebsiteToken: "test_ws_token_123",
|
|
ContactName: "Original Name",
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
// Update contact name and email
|
|
contact, err := svc.UpdateContact(ctx, initResp.WidgetToken, "Updated Name", "updated@example.com")
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "Updated Name", contact.Name)
|
|
assert.Equal(t, "updated@example.com", contact.Email)
|
|
}
|
|
|
|
func TestWidgetService_UpdateContact_PartialUpdate(t *testing.T) {
|
|
db, svc := setupWidgetServiceTest(t)
|
|
ctx := context.Background()
|
|
|
|
seedWidgetInbox(t, db)
|
|
|
|
initResp, err := svc.Init(ctx, WidgetInitRequest{
|
|
WebsiteToken: "test_ws_token_123",
|
|
ContactName: "Name Only",
|
|
ContactEmail: "nameonly@example.com",
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
// Update only the name — email should remain unchanged
|
|
contact, err := svc.UpdateContact(ctx, initResp.WidgetToken, "New Name Only", "")
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "New Name Only", contact.Name)
|
|
assert.Equal(t, "nameonly@example.com", contact.Email, "email should remain unchanged")
|
|
}
|
|
|
|
func TestWidgetService_UpdateContact_InvalidToken(t *testing.T) {
|
|
_, svc := setupWidgetServiceTest(t)
|
|
ctx := context.Background()
|
|
|
|
contact, err := svc.UpdateContact(ctx, "invalid_token", "name", "email")
|
|
assert.Nil(t, contact)
|
|
assert.Error(t, err)
|
|
}
|
|
|
|
func TestWidgetService_SetUserRequiresHMACWhenMandatory(t *testing.T) {
|
|
db, svc := setupWidgetServiceTest(t)
|
|
ctx := context.Background()
|
|
|
|
_, inbox := seedWidgetInbox(t, db)
|
|
config, err := ParseWebWidgetConfig(inbox.ChannelConfig)
|
|
require.NoError(t, err)
|
|
config.HMACMandatory = true
|
|
configJSON, err := json.Marshal(config)
|
|
require.NoError(t, err)
|
|
require.NoError(t, db.Model(inbox).Update("channel_config", string(configJSON)).Error)
|
|
|
|
initResp, err := svc.Init(ctx, WidgetInitRequest{WebsiteToken: "test_ws_token_123"})
|
|
require.NoError(t, err)
|
|
|
|
resp, err := svc.SetUser(ctx, WidgetSetUserRequest{
|
|
WebsiteToken: "test_ws_token_123",
|
|
WidgetToken: initResp.WidgetToken,
|
|
Identifier: "external-123",
|
|
})
|
|
assert.Nil(t, resp)
|
|
require.Error(t, err)
|
|
assert.Contains(t, err.Error(), "HMAC failed")
|
|
}
|
|
|
|
func TestWidgetService_SetUserCustomAttributesWithoutIdentifierSkipsHMAC(t *testing.T) {
|
|
db, svc := setupWidgetServiceTest(t)
|
|
ctx := context.Background()
|
|
|
|
_, inbox := seedWidgetInbox(t, db)
|
|
config, err := ParseWebWidgetConfig(inbox.ChannelConfig)
|
|
require.NoError(t, err)
|
|
config.HMACMandatory = true
|
|
configJSON, err := json.Marshal(config)
|
|
require.NoError(t, err)
|
|
require.NoError(t, db.Model(inbox).Update("channel_config", string(configJSON)).Error)
|
|
|
|
initResp, err := svc.Init(ctx, WidgetInitRequest{WebsiteToken: "test_ws_token_123"})
|
|
require.NoError(t, err)
|
|
|
|
resp, err := svc.SetUser(ctx, WidgetSetUserRequest{
|
|
WebsiteToken: "test_ws_token_123",
|
|
WidgetToken: initResp.WidgetToken,
|
|
CustomAttributes: map[string]any{"order_id": "12345"},
|
|
})
|
|
require.NoError(t, err)
|
|
require.NotNil(t, resp)
|
|
attrs := jsonMap(resp.Contact.CustomAttributes)
|
|
assert.Equal(t, "12345", attrs["order_id"])
|
|
}
|
|
|
|
// ========== ToggleTyping Tests ==========
|
|
|
|
func TestWidgetService_ToggleTyping_On(t *testing.T) {
|
|
db, svc := setupWidgetServiceTest(t)
|
|
ctx := context.Background()
|
|
|
|
seedWidgetInbox(t, db)
|
|
|
|
initResp, err := svc.Init(ctx, WidgetInitRequest{
|
|
WebsiteToken: "test_ws_token_123",
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
// Create a conversation to toggle typing on
|
|
sendResp, err := svc.SendMessage(ctx, WidgetSendMessageRequest{
|
|
WidgetToken: initResp.WidgetToken,
|
|
Content: "Starting conversation",
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
err = svc.ToggleTyping(ctx, initResp.WidgetToken, sendResp.ConversationID, true)
|
|
assert.NoError(t, err)
|
|
}
|
|
|
|
func TestWidgetService_ToggleTyping_Off(t *testing.T) {
|
|
db, svc := setupWidgetServiceTest(t)
|
|
ctx := context.Background()
|
|
|
|
seedWidgetInbox(t, db)
|
|
|
|
initResp, err := svc.Init(ctx, WidgetInitRequest{
|
|
WebsiteToken: "test_ws_token_123",
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
sendResp, err := svc.SendMessage(ctx, WidgetSendMessageRequest{
|
|
WidgetToken: initResp.WidgetToken,
|
|
Content: "Starting conversation",
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
err = svc.ToggleTyping(ctx, initResp.WidgetToken, sendResp.ConversationID, false)
|
|
assert.NoError(t, err)
|
|
}
|
|
|
|
func TestWidgetService_ToggleTyping_InvalidToken(t *testing.T) {
|
|
_, svc := setupWidgetServiceTest(t)
|
|
ctx := context.Background()
|
|
|
|
err := svc.ToggleTyping(ctx, "invalid_token", 1, true)
|
|
assert.Error(t, err)
|
|
}
|
|
|
|
func TestWidgetService_ToggleTyping_WrongConversationOwner(t *testing.T) {
|
|
db, svc := setupWidgetServiceTest(t)
|
|
ctx := context.Background()
|
|
|
|
seedWidgetInbox(t, db)
|
|
|
|
initA, err := svc.Init(ctx, WidgetInitRequest{
|
|
WebsiteToken: "test_ws_token_123",
|
|
ContactName: "Contact A",
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
initB, err := svc.Init(ctx, WidgetInitRequest{
|
|
WebsiteToken: "test_ws_token_123",
|
|
ContactEmail: "contact_b@example.com",
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
msgA, err := svc.SendMessage(ctx, WidgetSendMessageRequest{
|
|
WidgetToken: initA.WidgetToken,
|
|
Content: "A's message",
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
// Contact B tries to toggle typing on Contact A's conversation
|
|
err = svc.ToggleTyping(ctx, initB.WidgetToken, msgA.ConversationID, true)
|
|
assert.Error(t, err)
|
|
assert.Contains(t, err.Error(), "does not belong to this contact")
|
|
}
|
|
|
|
// ========== Theme Config Tests ==========
|
|
|
|
func TestWidgetService_GetThemeConfig(t *testing.T) {
|
|
db, svc := setupWidgetServiceTest(t)
|
|
ctx := context.Background()
|
|
|
|
_, inbox := seedWidgetInbox(t, db)
|
|
|
|
// No theme config exists yet → should return nil
|
|
theme, err := svc.GetThemeConfig(ctx, "test_ws_token_123")
|
|
require.NoError(t, err)
|
|
assert.Nil(t, theme, "no custom theme configured → nil")
|
|
|
|
// Create a theme config
|
|
newTheme := &model.WidgetThemeConfig{
|
|
InboxID: inbox.ID,
|
|
PrimaryColor: "#ff0000",
|
|
BackgroundColor: "#000000",
|
|
TextColor: "#ffffff",
|
|
FontFamily: "Arial, sans-serif",
|
|
WidgetPosition: "left",
|
|
}
|
|
created, err := svc.UpdateThemeConfig(ctx, inbox.ID, newTheme)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, created)
|
|
|
|
// Now GetThemeConfig should return it
|
|
theme, err = svc.GetThemeConfig(ctx, "test_ws_token_123")
|
|
require.NoError(t, err)
|
|
assert.NotNil(t, theme)
|
|
assert.Equal(t, "#ff0000", theme.PrimaryColor)
|
|
assert.Equal(t, inbox.ID, theme.InboxID)
|
|
}
|
|
|
|
func TestWidgetService_GetThemeConfigByInboxID(t *testing.T) {
|
|
db, svc := setupWidgetServiceTest(t)
|
|
ctx := context.Background()
|
|
|
|
_, inbox := seedWidgetInbox(t, db)
|
|
|
|
// No config → nil
|
|
theme, err := svc.GetThemeConfigByInboxID(ctx, inbox.ID)
|
|
require.NoError(t, err)
|
|
assert.Nil(t, theme)
|
|
|
|
// Create config
|
|
newTheme := &model.WidgetThemeConfig{
|
|
InboxID: inbox.ID,
|
|
PrimaryColor: "#00ff00",
|
|
}
|
|
_, err = svc.UpdateThemeConfig(ctx, inbox.ID, newTheme)
|
|
require.NoError(t, err)
|
|
|
|
// Now should find it
|
|
theme, err = svc.GetThemeConfigByInboxID(ctx, inbox.ID)
|
|
require.NoError(t, err)
|
|
assert.NotNil(t, theme)
|
|
assert.Equal(t, "#00ff00", theme.PrimaryColor)
|
|
}
|
|
|
|
func TestWidgetService_UpdateThemeConfig_Create(t *testing.T) {
|
|
db, svc := setupWidgetServiceTest(t)
|
|
ctx := context.Background()
|
|
|
|
_, inbox := seedWidgetInbox(t, db)
|
|
|
|
config := &model.WidgetThemeConfig{
|
|
InboxID: inbox.ID,
|
|
PrimaryColor: "#336699",
|
|
SecondaryColor: "#996633",
|
|
BackgroundColor: "#f5f5f5",
|
|
TextColor: "#333333",
|
|
FontFamily: "Georgia, serif",
|
|
FontSize: "16px",
|
|
WidgetPosition: "right",
|
|
WidgetAlignment: "bottom",
|
|
AvatarRadius: "40%",
|
|
ButtonRadius: "8px",
|
|
CustomCSS: "body { margin: 0; }",
|
|
}
|
|
|
|
result, err := svc.UpdateThemeConfig(ctx, inbox.ID, config)
|
|
require.NoError(t, err)
|
|
assert.NotNil(t, result)
|
|
assert.Equal(t, inbox.ID, result.InboxID)
|
|
assert.Equal(t, "#336699", result.PrimaryColor)
|
|
assert.NotZero(t, result.ID)
|
|
}
|
|
|
|
func TestWidgetService_UpdateThemeConfig_UpdateExisting(t *testing.T) {
|
|
db, svc := setupWidgetServiceTest(t)
|
|
ctx := context.Background()
|
|
|
|
_, inbox := seedWidgetInbox(t, db)
|
|
|
|
// Create initial config
|
|
config1 := &model.WidgetThemeConfig{
|
|
InboxID: inbox.ID,
|
|
PrimaryColor: "#111111",
|
|
}
|
|
created1, err := svc.UpdateThemeConfig(ctx, inbox.ID, config1)
|
|
require.NoError(t, err)
|
|
|
|
// Update with different values
|
|
config2 := &model.WidgetThemeConfig{
|
|
InboxID: inbox.ID,
|
|
PrimaryColor: "#222222",
|
|
FontSize: "18px",
|
|
}
|
|
updated, err := svc.UpdateThemeConfig(ctx, inbox.ID, config2)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, created1.ID, updated.ID, "same record should be updated")
|
|
assert.Equal(t, "#222222", updated.PrimaryColor)
|
|
}
|
|
|
|
func TestWidgetService_UpdateThemeConfig_InvalidInboxID(t *testing.T) {
|
|
_, svc := setupWidgetServiceTest(t)
|
|
ctx := context.Background()
|
|
|
|
result, err := svc.UpdateThemeConfig(ctx, 0, &model.WidgetThemeConfig{})
|
|
assert.Nil(t, result)
|
|
assert.Error(t, err)
|
|
assert.Contains(t, err.Error(), "inbox_id is required")
|
|
}
|
|
|
|
func TestWidgetService_UpdateThemeConfig_NonWebWidgetInbox(t *testing.T) {
|
|
db, svc := setupWidgetServiceTest(t)
|
|
ctx := context.Background()
|
|
|
|
account := &model.Account{Name: "Non Widget Account", Locale: "en", Status: "active"}
|
|
require.NoError(t, db.Create(account).Error)
|
|
|
|
// Create a non-web_widget inbox
|
|
nonWidgetInbox := &model.Inbox{
|
|
AccountID: account.ID,
|
|
Name: "Telegram Inbox",
|
|
ChannelType: "telegram",
|
|
ChannelID: 1,
|
|
Enabled: true,
|
|
}
|
|
require.NoError(t, db.Create(nonWidgetInbox).Error)
|
|
|
|
result, err := svc.UpdateThemeConfig(ctx, nonWidgetInbox.ID, &model.WidgetThemeConfig{})
|
|
assert.Nil(t, result)
|
|
assert.Error(t, err)
|
|
assert.Contains(t, err.Error(), "web_widget")
|
|
}
|
|
|
|
func TestWidgetService_DeleteThemeConfig(t *testing.T) {
|
|
db, svc := setupWidgetServiceTest(t)
|
|
ctx := context.Background()
|
|
|
|
_, inbox := seedWidgetInbox(t, db)
|
|
|
|
// Create config first
|
|
config := &model.WidgetThemeConfig{
|
|
InboxID: inbox.ID,
|
|
PrimaryColor: "#445566",
|
|
}
|
|
_, err := svc.UpdateThemeConfig(ctx, inbox.ID, config)
|
|
require.NoError(t, err)
|
|
|
|
// Delete it
|
|
err = svc.DeleteThemeConfig(ctx, inbox.ID)
|
|
require.NoError(t, err)
|
|
|
|
// Verify it's gone
|
|
theme, err := svc.GetThemeConfigByInboxID(ctx, inbox.ID)
|
|
require.NoError(t, err)
|
|
assert.Nil(t, theme)
|
|
}
|
|
|
|
func TestWidgetService_DeleteThemeConfig_InvalidInboxID(t *testing.T) {
|
|
_, svc := setupWidgetServiceTest(t)
|
|
ctx := context.Background()
|
|
|
|
err := svc.DeleteThemeConfig(ctx, 0)
|
|
assert.Error(t, err)
|
|
assert.Contains(t, err.Error(), "inbox_id is required")
|
|
}
|
|
|
|
// ========== Pre-Chat Form Tests ==========
|
|
|
|
func TestWidgetService_GetPreChatForm(t *testing.T) {
|
|
db, svc := setupWidgetServiceTest(t)
|
|
ctx := context.Background()
|
|
|
|
_, inbox := seedWidgetInbox(t, db)
|
|
|
|
// No pre-chat form exists → returns a default based on WebWidgetConfig (PreChatFieldsEnabled=true)
|
|
form, err := svc.GetPreChatForm(ctx, "test_ws_token_123")
|
|
require.NoError(t, err)
|
|
// Since PreChatFieldsEnabled=true in our seed config, a default form should be returned
|
|
if form != nil {
|
|
assert.Equal(t, inbox.ID, form.InboxID)
|
|
assert.True(t, form.Enabled)
|
|
}
|
|
}
|
|
|
|
func TestWidgetService_GetPreChatFormByInboxID(t *testing.T) {
|
|
db, svc := setupWidgetServiceTest(t)
|
|
ctx := context.Background()
|
|
|
|
_, inbox := seedWidgetInbox(t, db)
|
|
|
|
// No form → nil
|
|
form, err := svc.GetPreChatFormByInboxID(ctx, inbox.ID)
|
|
require.NoError(t, err)
|
|
assert.Nil(t, form)
|
|
|
|
// Create form
|
|
newForm := &model.PreChatForm{
|
|
InboxID: inbox.ID,
|
|
Enabled: true,
|
|
Message: "Please tell us about yourself",
|
|
RequireName: true,
|
|
RequireEmail: true,
|
|
RequirePhone: false,
|
|
}
|
|
_, err = svc.UpdatePreChatForm(ctx, inbox.ID, newForm)
|
|
require.NoError(t, err)
|
|
|
|
// Now should find it
|
|
form, err = svc.GetPreChatFormByInboxID(ctx, inbox.ID)
|
|
require.NoError(t, err)
|
|
assert.NotNil(t, form)
|
|
assert.Equal(t, "Please tell us about yourself", form.Message)
|
|
}
|
|
|
|
func TestWidgetService_UpdatePreChatForm_Create(t *testing.T) {
|
|
db, svc := setupWidgetServiceTest(t)
|
|
ctx := context.Background()
|
|
|
|
_, inbox := seedWidgetInbox(t, db)
|
|
|
|
form := &model.PreChatForm{
|
|
InboxID: inbox.ID,
|
|
Enabled: true,
|
|
Message: "Share your details",
|
|
RequireName: true,
|
|
RequireEmail: true,
|
|
RequirePhone: true,
|
|
ShowPhoneNumber: true,
|
|
ShowCompany: true,
|
|
ShowCity: true,
|
|
ShowCountry: true,
|
|
ConsentEnabled: true,
|
|
ConsentMessage: "I agree to the privacy policy",
|
|
ConsentLink: "https://example.com/privacy",
|
|
}
|
|
|
|
result, err := svc.UpdatePreChatForm(ctx, inbox.ID, form)
|
|
require.NoError(t, err)
|
|
assert.NotNil(t, result)
|
|
assert.Equal(t, inbox.ID, result.InboxID)
|
|
assert.True(t, result.Enabled)
|
|
assert.Equal(t, "Share your details", result.Message)
|
|
}
|
|
|
|
func TestWidgetService_UpdatePreChatForm_UpdateExisting(t *testing.T) {
|
|
db, svc := setupWidgetServiceTest(t)
|
|
ctx := context.Background()
|
|
|
|
_, inbox := seedWidgetInbox(t, db)
|
|
|
|
// Create initial form
|
|
form1 := &model.PreChatForm{
|
|
InboxID: inbox.ID,
|
|
Enabled: true,
|
|
Message: "Original message",
|
|
RequireName: true,
|
|
RequireEmail: false,
|
|
}
|
|
created1, err := svc.UpdatePreChatForm(ctx, inbox.ID, form1)
|
|
require.NoError(t, err)
|
|
|
|
// Update
|
|
form2 := &model.PreChatForm{
|
|
InboxID: inbox.ID,
|
|
Enabled: true,
|
|
Message: "Updated message",
|
|
RequireName: false,
|
|
RequireEmail: true,
|
|
}
|
|
updated, err := svc.UpdatePreChatForm(ctx, inbox.ID, form2)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, created1.ID, updated.ID)
|
|
assert.Equal(t, "Updated message", updated.Message)
|
|
}
|
|
|
|
func TestWidgetService_UpdatePreChatForm_InvalidInboxID(t *testing.T) {
|
|
_, svc := setupWidgetServiceTest(t)
|
|
ctx := context.Background()
|
|
|
|
result, err := svc.UpdatePreChatForm(ctx, 0, &model.PreChatForm{})
|
|
assert.Nil(t, result)
|
|
assert.Error(t, err)
|
|
assert.Contains(t, err.Error(), "inbox_id is required")
|
|
}
|
|
|
|
func TestWidgetService_DeletePreChatForm(t *testing.T) {
|
|
db, svc := setupWidgetServiceTest(t)
|
|
ctx := context.Background()
|
|
|
|
_, inbox := seedWidgetInbox(t, db)
|
|
|
|
// Create form first
|
|
form := &model.PreChatForm{
|
|
InboxID: inbox.ID,
|
|
Enabled: true,
|
|
Message: "To be deleted",
|
|
}
|
|
_, err := svc.UpdatePreChatForm(ctx, inbox.ID, form)
|
|
require.NoError(t, err)
|
|
|
|
// Delete it
|
|
err = svc.DeletePreChatForm(ctx, inbox.ID)
|
|
require.NoError(t, err)
|
|
|
|
// Verify it's gone
|
|
found, err := svc.GetPreChatFormByInboxID(ctx, inbox.ID)
|
|
require.NoError(t, err)
|
|
assert.Nil(t, found)
|
|
}
|
|
|
|
// ========== SubmitPreChatForm Tests ==========
|
|
|
|
func TestWidgetService_SubmitPreChatForm(t *testing.T) {
|
|
db, svc := setupWidgetServiceTest(t)
|
|
ctx := context.Background()
|
|
|
|
_, inbox := seedWidgetInbox(t, db)
|
|
|
|
// Enable pre-chat form
|
|
form := &model.PreChatForm{
|
|
InboxID: inbox.ID,
|
|
Enabled: true,
|
|
Message: "Fill this form",
|
|
RequireName: true,
|
|
RequireEmail: true,
|
|
}
|
|
_, err := svc.UpdatePreChatForm(ctx, inbox.ID, form)
|
|
require.NoError(t, err)
|
|
|
|
submission := model.PreChatFormSubmission{
|
|
Name: "Form Submitter",
|
|
Email: "submitter@example.com",
|
|
Phone: "+15550001111",
|
|
}
|
|
|
|
resp, err := svc.SubmitPreChatForm(ctx, "test_ws_token_123", submission)
|
|
require.NoError(t, err)
|
|
assert.NotNil(t, resp)
|
|
assert.NotEmpty(t, resp.WidgetToken)
|
|
assert.Equal(t, inbox.ID, resp.InboxID)
|
|
}
|
|
|
|
func TestWidgetService_SubmitPreChatForm_Disabled(t *testing.T) {
|
|
db, svc := setupWidgetServiceTest(t)
|
|
ctx := context.Background()
|
|
|
|
account := &model.Account{Name: "No PreChat Account", Locale: "en", Status: "active"}
|
|
require.NoError(t, db.Create(account).Error)
|
|
|
|
widgetConfig := WebWidgetConfig{
|
|
WebsiteToken: "no_prechat_token",
|
|
PreChatFieldsEnabled: false, // disabled
|
|
}
|
|
configJSON, _ := json.Marshal(widgetConfig)
|
|
|
|
inbox := &model.Inbox{
|
|
AccountID: account.ID,
|
|
Name: "No PreChat Inbox",
|
|
ChannelType: "web_widget",
|
|
ChannelID: 1,
|
|
Enabled: true,
|
|
ChannelConfig: string(configJSON),
|
|
}
|
|
require.NoError(t, db.Create(inbox).Error)
|
|
|
|
resp, err := svc.SubmitPreChatForm(ctx, "no_prechat_token", model.PreChatFormSubmission{
|
|
Name: "Visitor",
|
|
Email: "visitor@example.com",
|
|
})
|
|
assert.Nil(t, resp)
|
|
assert.Error(t, err)
|
|
assert.Contains(t, err.Error(), "not enabled")
|
|
}
|
|
|
|
// ========== File Upload Tests ==========
|
|
|
|
func TestWidgetService_CleanupExpiredUploads(t *testing.T) {
|
|
db, svc := setupWidgetServiceTest(t)
|
|
ctx := context.Background()
|
|
|
|
_, inbox := seedWidgetInbox(t, db)
|
|
|
|
// Create expired uploads directly in DB
|
|
pastTime := time.Now().Add(-48 * time.Hour) // 48 hours ago = expired
|
|
for i := 0; i < 3; i++ {
|
|
upload := &model.WidgetFileUpload{
|
|
UploadUUID: fmt.Sprintf("expired-uuid-%d", i),
|
|
InboxID: inbox.ID,
|
|
Status: model.WidgetFileUploadStatusPending,
|
|
OriginalName: fmt.Sprintf("expired_file_%d.txt", i),
|
|
FileType: "file",
|
|
MimeType: "text/plain",
|
|
FileSize: 100,
|
|
FileURL: fmt.Sprintf("/uploads/expired_%d.txt", i),
|
|
ExpiresAt: pastTime,
|
|
}
|
|
require.NoError(t, db.Create(upload).Error)
|
|
}
|
|
|
|
// Create a non-expired upload (should NOT be cleaned up)
|
|
futureTime := time.Now().Add(24 * time.Hour)
|
|
activeUpload := &model.WidgetFileUpload{
|
|
UploadUUID: "active-uuid-1",
|
|
InboxID: inbox.ID,
|
|
Status: model.WidgetFileUploadStatusPending,
|
|
OriginalName: "active_file.txt",
|
|
FileType: "file",
|
|
MimeType: "text/plain",
|
|
FileSize: 200,
|
|
FileURL: "/uploads/active.txt",
|
|
ExpiresAt: futureTime,
|
|
}
|
|
require.NoError(t, db.Create(activeUpload).Error)
|
|
|
|
// Run cleanup
|
|
affected, err := svc.CleanupExpiredUploads(ctx)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, int64(3), affected, "should delete exactly 3 expired uploads")
|
|
|
|
// Verify active upload still exists
|
|
var remaining int64
|
|
db.Model(&model.WidgetFileUpload{}).Where("upload_uuid = ?", "active-uuid-1").Count(&remaining)
|
|
assert.Equal(t, int64(1), remaining, "non-expired upload should remain")
|
|
}
|
|
|
|
func TestWidgetService_CleanupExpiredUploads_NoExpired(t *testing.T) {
|
|
db, svc := setupWidgetServiceTest(t)
|
|
ctx := context.Background()
|
|
|
|
_, inbox := seedWidgetInbox(t, db)
|
|
|
|
// Only non-expired uploads
|
|
futureTime := time.Now().Add(24 * time.Hour)
|
|
upload := &model.WidgetFileUpload{
|
|
UploadUUID: "fresh-uuid-1",
|
|
InboxID: inbox.ID,
|
|
Status: model.WidgetFileUploadStatusPending,
|
|
OriginalName: "fresh_file.txt",
|
|
FileType: "file",
|
|
MimeType: "text/plain",
|
|
FileSize: 50,
|
|
FileURL: "/uploads/fresh.txt",
|
|
ExpiresAt: futureTime,
|
|
}
|
|
require.NoError(t, db.Create(upload).Error)
|
|
|
|
affected, err := svc.CleanupExpiredUploads(ctx)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, int64(0), affected, "no expired uploads to clean")
|
|
}
|
|
|
|
func TestWidgetService_AttachUploadToMessage(t *testing.T) {
|
|
db, svc := setupWidgetServiceTest(t)
|
|
ctx := context.Background()
|
|
|
|
_, inbox := seedWidgetInbox(t, db)
|
|
|
|
// Create a pending upload
|
|
futureTime := time.Now().Add(24 * time.Hour)
|
|
upload := &model.WidgetFileUpload{
|
|
UploadUUID: "attach-uuid-1",
|
|
InboxID: inbox.ID,
|
|
Status: model.WidgetFileUploadStatusPending,
|
|
OriginalName: "photo.png",
|
|
FileType: "image",
|
|
MimeType: "image/png",
|
|
FileSize: 1024,
|
|
FileURL: "/uploads/photo.png",
|
|
ExpiresAt: futureTime,
|
|
}
|
|
require.NoError(t, db.Create(upload).Error)
|
|
|
|
// Create a message for attachment
|
|
initResp, err := svc.Init(ctx, WidgetInitRequest{
|
|
WebsiteToken: "test_ws_token_123",
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
sendResp, err := svc.SendMessage(ctx, WidgetSendMessageRequest{
|
|
WidgetToken: initResp.WidgetToken,
|
|
Content: "Check this photo",
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
// Attach upload to message
|
|
err = svc.AttachUploadToMessage(ctx, upload.ID, sendResp.Message.ID)
|
|
require.NoError(t, err)
|
|
|
|
// Verify upload status changed to "attached"
|
|
var updated model.WidgetFileUpload
|
|
require.NoError(t, db.First(&updated, upload.ID).Error)
|
|
assert.Equal(t, model.WidgetFileUploadStatusAttached, updated.Status)
|
|
assert.NotNil(t, updated.MessageID)
|
|
assert.Equal(t, sendResp.Message.ID, *updated.MessageID)
|
|
}
|
|
|
|
func TestWidgetService_AttachUploadToMessage_AlreadyAttached(t *testing.T) {
|
|
db, svc := setupWidgetServiceTest(t)
|
|
ctx := context.Background()
|
|
|
|
_, inbox := seedWidgetInbox(t, db)
|
|
|
|
msgID := uint(999)
|
|
upload := &model.WidgetFileUpload{
|
|
UploadUUID: "already-attached-uuid",
|
|
InboxID: inbox.ID,
|
|
Status: model.WidgetFileUploadStatusAttached,
|
|
OriginalName: "already.png",
|
|
FileType: "image",
|
|
MimeType: "image/png",
|
|
FileSize: 500,
|
|
FileURL: "/uploads/already.png",
|
|
MessageID: &msgID,
|
|
ExpiresAt: time.Now().Add(24 * time.Hour),
|
|
}
|
|
require.NoError(t, db.Create(upload).Error)
|
|
|
|
err := svc.AttachUploadToMessage(ctx, upload.ID, 1001)
|
|
assert.Error(t, err)
|
|
assert.Contains(t, err.Error(), "not in pending status")
|
|
}
|
|
|
|
func TestWidgetService_AttachUploadToMessage_NotFound(t *testing.T) {
|
|
_, svc := setupWidgetServiceTest(t)
|
|
ctx := context.Background()
|
|
|
|
err := svc.AttachUploadToMessage(ctx, 99999, 1)
|
|
assert.Error(t, err)
|
|
assert.Contains(t, err.Error(), "upload not found")
|
|
}
|
|
|
|
// ========== HMAC Verification Tests ==========
|
|
|
|
func TestVerifyHMAC_Valid(t *testing.T) {
|
|
hmacToken := "secret_key_123"
|
|
identifier := "user@example.com"
|
|
|
|
// Compute expected HMAC
|
|
mac := hmac.New(sha256.New, []byte(hmacToken))
|
|
mac.Write([]byte(identifier))
|
|
expectedSig := hex.EncodeToString(mac.Sum(nil))
|
|
|
|
result := VerifyHMAC(hmacToken, identifier, expectedSig)
|
|
assert.True(t, result, "valid HMAC should verify successfully")
|
|
}
|
|
|
|
func TestVerifyHMAC_InvalidSignature(t *testing.T) {
|
|
result := VerifyHMAC("secret_key_123", "user@example.com", "wrong_signature_hex")
|
|
assert.False(t, result, "invalid signature should fail verification")
|
|
}
|
|
|
|
func TestVerifyHMAC_EmptyToken(t *testing.T) {
|
|
result := VerifyHMAC("", "user@example.com", "some_sig")
|
|
assert.False(t, result, "empty hmac_token should fail")
|
|
}
|
|
|
|
func TestVerifyHMAC_EmptyIdentifier(t *testing.T) {
|
|
result := VerifyHMAC("secret", "", "some_sig")
|
|
assert.False(t, result, "empty identifier should fail")
|
|
}
|
|
|
|
func TestVerifyHMAC_EmptySignature(t *testing.T) {
|
|
result := VerifyHMAC("secret", "user@example.com", "")
|
|
assert.False(t, result, "empty signature should fail")
|
|
}
|
|
|
|
func TestVerifyHMAC_TamperedIdentifier(t *testing.T) {
|
|
hmacToken := "secret_key_123"
|
|
identifier := "user@example.com"
|
|
|
|
mac := hmac.New(sha256.New, []byte(hmacToken))
|
|
mac.Write([]byte(identifier))
|
|
validSig := hex.EncodeToString(mac.Sum(nil))
|
|
|
|
// Verify with different identifier but same signature → should fail
|
|
result := VerifyHMAC(hmacToken, "attacker@example.com", validSig)
|
|
assert.False(t, result, "tampered identifier should fail HMAC verification")
|
|
}
|
|
|
|
// ========== ParseWebWidgetConfig Tests ==========
|
|
|
|
func TestParseWebWidgetConfig_Valid(t *testing.T) {
|
|
configJSON := `{"website_token":"abc","hmac_token":"secret","widget_color":"#1f93ff","welcome_title":"Hi"}`
|
|
config, err := ParseWebWidgetConfig(configJSON)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "abc", config.WebsiteToken)
|
|
assert.Equal(t, "secret", config.HMACToken)
|
|
assert.Equal(t, "#1f93ff", config.WidgetColor)
|
|
}
|
|
|
|
func TestParseWebWidgetConfig_Empty(t *testing.T) {
|
|
config, err := ParseWebWidgetConfig("")
|
|
assert.Nil(t, config)
|
|
assert.Error(t, err)
|
|
}
|
|
|
|
func TestParseWebWidgetConfig_InvalidJSON(t *testing.T) {
|
|
config, err := ParseWebWidgetConfig("{not json}")
|
|
assert.Nil(t, config)
|
|
assert.Error(t, err)
|
|
}
|
|
|
|
// ========== File Helper Tests ==========
|
|
|
|
func TestDetectMIMEFromFilename(t *testing.T) {
|
|
assert.Equal(t, "image/png", detectMIMEFromFilename("photo.png"))
|
|
assert.Equal(t, "image/jpeg", detectMIMEFromFilename("pic.jpg"))
|
|
assert.Equal(t, "image/jpeg", detectMIMEFromFilename("pic.jpeg"))
|
|
assert.Equal(t, "image/gif", detectMIMEFromFilename("anim.gif"))
|
|
assert.Equal(t, "image/webp", detectMIMEFromFilename("img.webp"))
|
|
assert.Equal(t, "image/svg+xml", detectMIMEFromFilename("icon.svg"))
|
|
assert.Equal(t, "audio/mpeg", detectMIMEFromFilename("song.mp3"))
|
|
assert.Equal(t, "video/mp4", detectMIMEFromFilename("clip.mp4"))
|
|
assert.Equal(t, "application/pdf", detectMIMEFromFilename("doc.pdf"))
|
|
assert.Equal(t, "text/plain", detectMIMEFromFilename("readme.txt"))
|
|
assert.Equal(t, "text/csv", detectMIMEFromFilename("data.csv"))
|
|
assert.Equal(t, "", detectMIMEFromFilename("unknown.xyz"))
|
|
}
|
|
|
|
func TestCategorizeMIME(t *testing.T) {
|
|
assert.Equal(t, "image", categorizeMIME("image/png"))
|
|
assert.Equal(t, "image", categorizeMIME("image/jpeg"))
|
|
assert.Equal(t, "audio", categorizeMIME("audio/mpeg"))
|
|
assert.Equal(t, "audio", categorizeMIME("audio/ogg"))
|
|
assert.Equal(t, "video", categorizeMIME("video/mp4"))
|
|
assert.Equal(t, "file", categorizeMIME("application/pdf"))
|
|
assert.Equal(t, "file", categorizeMIME("text/plain"))
|
|
assert.Equal(t, "file", categorizeMIME("text/csv"))
|
|
assert.Equal(t, "file", categorizeMIME("application/vnd.ms-excel"))
|
|
assert.Equal(t, "", categorizeMIME("application/unknown"))
|
|
}
|
|
|
|
// ========== InboxByWebsiteToken Tests ==========
|
|
|
|
func TestWidgetService_GetInboxByWebsiteToken(t *testing.T) {
|
|
db, svc := setupWidgetServiceTest(t)
|
|
ctx := context.Background()
|
|
|
|
_, inbox := seedWidgetInbox(t, db)
|
|
|
|
found, err := svc.GetInboxByWebsiteToken(ctx, "test_ws_token_123")
|
|
require.NoError(t, err)
|
|
assert.Equal(t, inbox.ID, found.ID)
|
|
assert.Equal(t, "web_widget", found.ChannelType)
|
|
}
|
|
|
|
func TestWidgetService_GetInboxByWebsiteToken_NotFound(t *testing.T) {
|
|
_, svc := setupWidgetServiceTest(t)
|
|
ctx := context.Background()
|
|
|
|
found, err := svc.GetInboxByWebsiteToken(ctx, "nonexistent_token")
|
|
assert.Nil(t, found)
|
|
assert.Error(t, err)
|
|
}
|
|
|
|
// ========== ConvertOfflineMessageToConversation Tests ==========
|
|
|
|
func TestWidgetService_ConvertOfflineMessageToConversation_Success(t *testing.T) {
|
|
db, svc := setupWidgetServiceTest(t)
|
|
ctx := context.Background()
|
|
|
|
_, inbox := seedWidgetInbox(t, db)
|
|
|
|
// Submit an offline message first
|
|
offlineMsg, err := svc.SubmitOfflineMessage(ctx, inbox.ID, inbox.AccountID, &model.WidgetOfflineMessageSubmission{
|
|
Name: "Bob Visitor",
|
|
Email: "bob@example.com",
|
|
Message: "I need help with my order",
|
|
}, "", "")
|
|
require.NoError(t, err)
|
|
assert.Equal(t, model.OfflineStatusPending, offlineMsg.Status)
|
|
|
|
// Convert it to a conversation
|
|
conversation, message, err := svc.ConvertOfflineMessageToConversation(ctx, offlineMsg.ID)
|
|
require.NoError(t, err)
|
|
|
|
assert.NotZero(t, conversation.ID)
|
|
assert.Equal(t, inbox.AccountID, conversation.AccountID)
|
|
assert.Equal(t, inbox.ID, conversation.InboxID)
|
|
assert.Equal(t, string(model.ConversationStatusOpen), conversation.Status)
|
|
assert.Equal(t, string(model.InboxChannelTypeWebWidget), conversation.ChannelType)
|
|
|
|
assert.NotZero(t, message.ID)
|
|
assert.Equal(t, conversation.ID, message.ConversationID)
|
|
assert.Equal(t, offlineMsg.Content, message.Content)
|
|
assert.Equal(t, "text", message.ContentType)
|
|
assert.Equal(t, string(model.MessageTypeIncoming), message.MessageType)
|
|
assert.Equal(t, "contact", message.SenderType)
|
|
|
|
// Verify the offline message is now marked as converted
|
|
updatedMsg, err := svc.offlineMessageRepo.FindByID(ctx, offlineMsg.ID)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, model.OfflineStatusConverted, updatedMsg.Status)
|
|
assert.NotNil(t, updatedMsg.ConversationID)
|
|
assert.Equal(t, conversation.ID, *updatedMsg.ConversationID)
|
|
}
|
|
|
|
func TestWidgetService_ConvertOfflineMessageToConversation_NotFound(t *testing.T) {
|
|
_, svc := setupWidgetServiceTest(t)
|
|
ctx := context.Background()
|
|
|
|
conversation, message, err := svc.ConvertOfflineMessageToConversation(ctx, 99999)
|
|
assert.Nil(t, conversation)
|
|
assert.Nil(t, message)
|
|
assert.Error(t, err)
|
|
assert.Contains(t, err.Error(), "offline message not found")
|
|
}
|
|
|
|
func TestWidgetService_ConvertOfflineMessageToConversation_AlreadyConverted(t *testing.T) {
|
|
db, svc := setupWidgetServiceTest(t)
|
|
ctx := context.Background()
|
|
|
|
_, inbox := seedWidgetInbox(t, db)
|
|
|
|
// Submit an offline message
|
|
offlineMsg, err := svc.SubmitOfflineMessage(ctx, inbox.ID, inbox.AccountID, &model.WidgetOfflineMessageSubmission{
|
|
Name: "Carol Visitor",
|
|
Email: "carol@example.com",
|
|
Message: "Question about shipping",
|
|
}, "", "")
|
|
require.NoError(t, err)
|
|
|
|
// Convert it once
|
|
conversation, _, err := svc.ConvertOfflineMessageToConversation(ctx, offlineMsg.ID)
|
|
require.NoError(t, err)
|
|
require.NotZero(t, conversation.ID)
|
|
|
|
// Try to convert it again — should fail (status is "converted")
|
|
conversation2, message2, err := svc.ConvertOfflineMessageToConversation(ctx, offlineMsg.ID)
|
|
assert.Nil(t, conversation2)
|
|
assert.Nil(t, message2)
|
|
assert.Error(t, err)
|
|
assert.Contains(t, err.Error(), "not pending")
|
|
}
|
|
|
|
func TestWidgetService_ConvertOfflineMessageToConversation_DismissedMessage(t *testing.T) {
|
|
db, svc := setupWidgetServiceTest(t)
|
|
ctx := context.Background()
|
|
|
|
_, inbox := seedWidgetInbox(t, db)
|
|
|
|
// Submit an offline message
|
|
offlineMsg, err := svc.SubmitOfflineMessage(ctx, inbox.ID, inbox.AccountID, &model.WidgetOfflineMessageSubmission{
|
|
Name: "Dismissed Visitor",
|
|
Message: "This was dismissed",
|
|
}, "", "")
|
|
require.NoError(t, err)
|
|
|
|
// Dismiss it first
|
|
err = svc.DismissOfflineMessage(ctx, offlineMsg.ID)
|
|
require.NoError(t, err)
|
|
|
|
// Try to convert — should fail (status is "dismissed")
|
|
conversation, message, err := svc.ConvertOfflineMessageToConversation(ctx, offlineMsg.ID)
|
|
assert.Nil(t, conversation)
|
|
assert.Nil(t, message)
|
|
assert.Error(t, err)
|
|
assert.Contains(t, err.Error(), "not pending")
|
|
}
|