Files
gochat/backend/internal/service/widget_service_test.go
T
rogee aeddedf2a3 Reorganize repo: backend/, deploy/, docs/ layout + AGENTS.md
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.
2026-07-07 14:44:12 +08:00

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")
}