package v1 import ( "context" "encoding/json" "fmt" "testing" "time" "github.com/gochat/gochat/internal/model" channelmodel "github.com/gochat/gochat/internal/model/channel" "github.com/stretchr/testify/require" "gorm.io/datatypes" "gorm.io/driver/sqlite" "gorm.io/gorm" "gorm.io/gorm/logger" ) type fakeContactPresence struct { statuses map[string]string } func (f fakeContactPresence) GetContactStatus(_ context.Context, contactID, accountID uint) (string, error) { status := f.statuses[fmt.Sprintf("%d:%d", contactID, accountID)] if status == "" { status = "offline" } return status, nil } func TestConversationCanReplyRespectsMessagingWindows(t *testing.T) { db := newConversationSerializerTestDB(t) ctx := context.Background() account := &model.Account{Name: "Reply Window Account", Active: true} require.NoError(t, db.Create(account).Error) contact := &model.Contact{AccountID: account.ID, Name: "Reply Window Contact"} require.NoError(t, db.Create(contact).Error) t.Run("api channel uses configured agent reply window", func(t *testing.T) { inbox := createSerializerInbox(t, db, account.ID, "Channel::Api", "API") require.NoError(t, db.Table("channel_api").Create(map[string]any{ "inbox_id": inbox.ID, "additional_attributes": datatypes.JSON([]byte(`{"agent_reply_time_window":"12"}`)), }).Error) conversation := createSerializerConversation(t, db, account.ID, inbox.ID, contact.ID) createSerializerMessage(t, db, account.ID, inbox.ID, conversation.ID, "incoming", time.Now().Add(-13*time.Hour)) require.False(t, conversationCanReply(ctx, db, conversation)) }) t.Run("facebook defaults to 24 hours", func(t *testing.T) { inbox := createSerializerInbox(t, db, account.ID, "Channel::FacebookPage", "Facebook") conversation := createSerializerConversation(t, db, account.ID, inbox.ID, contact.ID) createSerializerMessage(t, db, account.ID, inbox.ID, conversation.ID, "incoming", time.Now().Add(-25*time.Hour)) require.False(t, conversationCanReply(ctx, db, conversation)) }) t.Run("facebook human agent env extends to seven days", func(t *testing.T) { t.Setenv("ENABLE_MESSENGER_CHANNEL_HUMAN_AGENT", "true") inbox := createSerializerInbox(t, db, account.ID, "Channel::FacebookPage", "Facebook Human Agent") conversation := createSerializerConversation(t, db, account.ID, inbox.ID, contact.ID) createSerializerMessage(t, db, account.ID, inbox.ID, conversation.ID, "incoming", time.Now().Add(-6*24*time.Hour)) require.True(t, conversationCanReply(ctx, db, conversation)) }) t.Run("twilio sms has no reply window unless medium is whatsapp", func(t *testing.T) { smsInbox := createSerializerInbox(t, db, account.ID, "Channel::TwilioSms", "Twilio SMS") smsConversation := createSerializerConversation(t, db, account.ID, smsInbox.ID, contact.ID) createSerializerMessage(t, db, account.ID, smsInbox.ID, smsConversation.ID, "incoming", time.Now().Add(-10*24*time.Hour)) require.True(t, conversationCanReply(ctx, db, smsConversation)) whatsappInbox := createSerializerInbox(t, db, account.ID, "twilio_sms", "Twilio WhatsApp") whatsappInbox.ChannelConfig = `{"medium":"whatsapp"}` require.NoError(t, db.Save(whatsappInbox).Error) whatsappConversation := createSerializerConversation(t, db, account.ID, whatsappInbox.ID, contact.ID) createSerializerMessage(t, db, account.ID, whatsappInbox.ID, whatsappConversation.ID, "incoming", time.Now().Add(-25*time.Hour)) require.False(t, conversationCanReply(ctx, db, whatsappConversation)) }) } func TestSerializeMessageIncludesAgentBotSender(t *testing.T) { db := newConversationSerializerTestDB(t) ctx := context.Background() account := &model.Account{Name: "Agent Bot Sender Account", Active: true} require.NoError(t, db.Create(account).Error) contact := &model.Contact{AccountID: account.ID, Name: "Agent Bot Sender Contact"} require.NoError(t, db.Create(contact).Error) inbox := createSerializerInbox(t, db, account.ID, "web_widget", "Web Widget") conversation := createSerializerConversation(t, db, account.ID, inbox.ID, contact.ID) agentBot := &model.AgentBot{AccountID: &account.ID, Name: "Reply Bot", AvatarURL: "https://example.com/bot.png"} require.NoError(t, db.Create(agentBot).Error) message := &model.Message{ AccountID: account.ID, InboxID: inbox.ID, ConversationID: conversation.ID, MessageType: "outgoing", ContentType: "text", Content: "bot reply", SenderID: &agentBot.ID, SenderType: string(model.SenderTypeAgentBot), ContentAttributes: datatypes.JSON([]byte(`{}`)), } require.NoError(t, db.Create(message).Error) payload := serializeMessage(ctx, db, message, conversation) require.Equal(t, account.ID, payload.AccountID) sender := payload.Sender require.Equal(t, agentBot.ID, sender["id"]) require.Equal(t, "Reply Bot", sender["name"]) require.Equal(t, "agent_bot", sender["type"]) } func TestSerializeVoiceCallMessageIncludesCallPayload(t *testing.T) { db := newConversationSerializerTestDB(t) ctx := context.Background() account := &model.Account{Name: "Voice Call Account", Active: true} require.NoError(t, db.Create(account).Error) contact := &model.Contact{AccountID: account.ID, Name: "Call Contact", PhoneNumber: "+1555010001"} require.NoError(t, db.Create(contact).Error) inbox := createSerializerInbox(t, db, account.ID, "Channel::TwilioSms", "Voice Inbox") require.NoError(t, db.Create(&channelmodel.ChannelTwilioSMS{AccountID: account.ID, InboxID: inbox.ID, AccountSID: "AC123", PhoneNumber: "+1555010000"}).Error) agent := &model.User{AccountID: account.ID, Name: "Voice Agent", DisplayName: "Agent Voice", Email: "voice@example.com", Password: "pass"} require.NoError(t, db.Create(agent).Error) conversation := createSerializerConversation(t, db, account.ID, inbox.ID, contact.ID) startedAt := time.Date(2026, 6, 10, 12, 0, 0, 0, time.UTC) message := &model.Message{ AccountID: account.ID, InboxID: inbox.ID, ConversationID: conversation.ID, SenderID: &agent.ID, SenderType: "user", MessageType: "outgoing", ContentType: "voice_call", Content: "Twilio voice call", Status: "sent", } require.NoError(t, db.Create(message).Error) call := &model.Call{ AccountID: account.ID, InboxID: inbox.ID, ConversationID: conversation.ID, ContactID: contact.ID, MessageID: &message.ID, AcceptedByAgentID: &agent.ID, Provider: "twilio", Direction: "outgoing", ProviderCallID: "CA123", ConferenceSID: "CF123", StartedAt: &startedAt, CallerType: "User", CallerID: agent.ID, Status: "in_progress", Duration: 42, CallDirection: "outbound", RecordingURL: "https://recordings.example/call.mp3", AdditionalAttributes: json.RawMessage([]byte(`{"ended_at":1760000000,"transcript":"hello"}`)), } require.NoError(t, db.Create(call).Error) payload := serializeMessage(ctx, db, message, conversation) require.NotNil(t, payload.Call) require.Equal(t, call.ID, payload.Call["id"]) require.Equal(t, "CA123", payload.Call["provider_call_id"]) require.Equal(t, "twilio", payload.Call["provider"]) require.Equal(t, "outgoing", payload.Call["direction"]) require.Equal(t, "in-progress", payload.Call["status"]) require.Equal(t, 42, payload.Call["duration_seconds"]) require.Equal(t, "CF123", payload.Call["conference_sid"]) require.Equal(t, agent.ID, payload.Call["accepted_by_agent_id"]) require.Equal(t, "Agent Voice", payload.Call["accepted_by_agent_name"]) require.Equal(t, startedAt.Unix(), payload.Call["started_at"]) require.Equal(t, float64(1760000000), payload.Call["ended_at"]) require.Equal(t, "+1555010000", payload.Call["from_number"]) require.Equal(t, "+1555010001", payload.Call["to_number"]) require.Equal(t, "https://recordings.example/call.mp3", payload.Call["recording_url"]) require.Equal(t, "hello", payload.Call["transcript"]) } func TestSerializeConversationLastNonActivityMessageSkipsLatestActivity(t *testing.T) { db := newConversationSerializerTestDB(t) ctx := context.Background() account := &model.Account{Name: "Last Non Activity Account", Active: true} require.NoError(t, db.Create(account).Error) contact := &model.Contact{AccountID: account.ID, Name: "Last Non Activity Contact"} require.NoError(t, db.Create(contact).Error) inbox := createSerializerInbox(t, db, account.ID, "web_widget", "Web Widget") conversation := createSerializerConversation(t, db, account.ID, inbox.ID, contact.ID) olderMessage := &model.Message{AccountID: account.ID, InboxID: inbox.ID, ConversationID: conversation.ID, MessageType: "incoming", ContentType: "text", Content: "older reply"} olderMessage.CreatedAt = time.Now().Add(-3 * time.Hour) olderMessage.UpdatedAt = olderMessage.CreatedAt require.NoError(t, db.Create(olderMessage).Error) recentMessage := &model.Message{AccountID: account.ID, InboxID: inbox.ID, ConversationID: conversation.ID, MessageType: "outgoing", ContentType: "text", Content: "recent reply"} recentMessage.CreatedAt = time.Now().Add(-2 * time.Hour) recentMessage.UpdatedAt = recentMessage.CreatedAt require.NoError(t, db.Create(recentMessage).Error) activityMessage := &model.Message{AccountID: account.ID, InboxID: inbox.ID, ConversationID: conversation.ID, MessageType: "activity", ContentType: "text", Content: "activity event"} activityMessage.CreatedAt = time.Now().Add(-1 * time.Hour) activityMessage.UpdatedAt = activityMessage.CreatedAt require.NoError(t, db.Create(activityMessage).Error) payload := serializeConversation(ctx, db, conversation) require.Len(t, payload.Messages, 1) require.Equal(t, activityMessage.ID, payload.Messages[0].ID) require.NotNil(t, payload.LastNonActivityMessage) require.Equal(t, recentMessage.ID, payload.LastNonActivityMessage.ID) require.Equal(t, "recent reply", payload.LastNonActivityMessage.Content) } func TestSerializeAttachmentOmitAccountID(t *testing.T) { db := newConversationSerializerTestDB(t) ctx := context.Background() account := &model.Account{Name: "Attachment Account", Active: true} require.NoError(t, db.Create(account).Error) contact := &model.Contact{AccountID: account.ID, Name: "Contact"} require.NoError(t, db.Create(contact).Error) inbox := createSerializerInbox(t, db, account.ID, "web_widget", "Web Widget") conversation := createSerializerConversation(t, db, account.ID, inbox.ID, contact.ID) message := &model.Message{AccountID: account.ID, InboxID: inbox.ID, ConversationID: conversation.ID, MessageType: "incoming", Content: "hi"} require.NoError(t, db.Create(message).Error) attachment := &model.Attachment{AccountID: account.ID, MessageID: message.ID, FileType: "file", FileName: "report.pdf", FileURL: "https://files.example/report.pdf", FileSize: 512} require.NoError(t, db.Create(attachment).Error) payload := serializeAttachment(ctx, db, attachment) require.Equal(t, message.ID, payload["message_id"]) require.Equal(t, "file", payload["file_type"]) require.Equal(t, "https://files.example/report.pdf", payload["data_url"]) require.Equal(t, "pdf", payload["extension"]) require.NotContains(t, payload, "account_id") } func TestSerializeMessageAttachmentIncludesAccountID(t *testing.T) { db := newConversationSerializerTestDB(t) ctx := context.Background() account := &model.Account{Name: "Message Attachment Account", Active: true} require.NoError(t, db.Create(account).Error) contact := &model.Contact{AccountID: account.ID, Name: "Contact"} require.NoError(t, db.Create(contact).Error) inbox := createSerializerInbox(t, db, account.ID, "web_widget", "Web Widget") conversation := createSerializerConversation(t, db, account.ID, inbox.ID, contact.ID) message := &model.Message{AccountID: account.ID, InboxID: inbox.ID, ConversationID: conversation.ID, MessageType: "incoming", ContentType: "text", Content: "hi"} require.NoError(t, db.Create(message).Error) attachment := &model.Attachment{AccountID: account.ID, MessageID: message.ID, FileType: "image", FileName: "photo.png", FileURL: "https://files.example/photo.png", FileSize: 128, Width: 80, Height: 60} require.NoError(t, db.Create(attachment).Error) payload := serializeMessage(ctx, db, message, conversation) require.Len(t, payload.Attachments, 1) attachmentPayload, ok := payload.Attachments[0].(map[string]any) require.True(t, ok) require.Equal(t, account.ID, attachmentPayload["account_id"]) require.Equal(t, attachment.ID, attachmentPayload["id"]) require.Equal(t, message.ID, attachmentPayload["message_id"]) require.Equal(t, "image", attachmentPayload["file_type"]) require.Equal(t, "https://files.example/photo.png", attachmentPayload["data_url"]) require.Equal(t, "png", attachmentPayload["extension"]) require.NotContains(t, attachmentPayload, "created_at") require.NotContains(t, attachmentPayload, "sender") } func TestSerializeConversationUsesLatestMessageByCreatedAt(t *testing.T) { db := newConversationSerializerTestDB(t) ctx := context.Background() account := &model.Account{Name: "Latest Message Account", Active: true} require.NoError(t, db.Create(account).Error) contact := &model.Contact{AccountID: account.ID, Name: "Latest Message Contact"} require.NoError(t, db.Create(contact).Error) inbox := createSerializerInbox(t, db, account.ID, "web_widget", "Web Widget") conversation := createSerializerConversation(t, db, account.ID, inbox.ID, contact.ID) newerCreatedMessage := &model.Message{AccountID: account.ID, InboxID: inbox.ID, ConversationID: conversation.ID, MessageType: "incoming", ContentType: "text", Content: "newer by timestamp"} newerCreatedMessage.CreatedAt = time.Now().Add(-1 * time.Hour) newerCreatedMessage.UpdatedAt = newerCreatedMessage.CreatedAt require.NoError(t, db.Create(newerCreatedMessage).Error) olderCreatedMessage := &model.Message{AccountID: account.ID, InboxID: inbox.ID, ConversationID: conversation.ID, MessageType: "incoming", ContentType: "text", Content: "older by timestamp"} olderCreatedMessage.CreatedAt = time.Now().Add(-2 * time.Hour) olderCreatedMessage.UpdatedAt = olderCreatedMessage.CreatedAt require.NoError(t, db.Create(olderCreatedMessage).Error) require.Greater(t, olderCreatedMessage.ID, newerCreatedMessage.ID) payload := serializeConversation(ctx, db, conversation) require.Len(t, payload.Messages, 1) require.Equal(t, newerCreatedMessage.ID, payload.Messages[0].ID) require.Equal(t, "newer by timestamp", payload.Messages[0].Content) } func TestSerializeConversationMetaIncludesAgentBotAssignee(t *testing.T) { db := newConversationSerializerTestDB(t) ctx := context.Background() account := &model.Account{Name: "Bot Assignee Account", Active: true} require.NoError(t, db.Create(account).Error) contact := &model.Contact{AccountID: account.ID, Name: "Bot Assignee Contact"} require.NoError(t, db.Create(contact).Error) inbox := createSerializerInbox(t, db, account.ID, "web_widget", "Web Widget") agentBot := &model.AgentBot{AccountID: &account.ID, Name: "Routing Bot", Description: "Routes conversations", AvatarURL: "https://example.com/routing-bot.png", OutgoingURL: "https://example.com/webhook", BotType: "webhook"} require.NoError(t, db.Create(agentBot).Error) conversation := createSerializerConversation(t, db, account.ID, inbox.ID, contact.ID) conversation.AssigneeAgentBotID = &agentBot.ID require.NoError(t, db.Save(conversation).Error) payload := serializeConversation(ctx, db, conversation) require.Equal(t, "AgentBot", payload.Meta.AssigneeType) require.Equal(t, agentBot.ID, payload.Meta.Assignee["id"]) require.Equal(t, "Routing Bot", payload.Meta.Assignee["name"]) require.Equal(t, "Routes conversations", payload.Meta.Assignee["description"]) require.Equal(t, "https://example.com/routing-bot.png", payload.Meta.Assignee["thumbnail"]) require.Equal(t, "https://example.com/webhook", payload.Meta.Assignee["outgoing_url"]) require.Equal(t, "webhook", payload.Meta.Assignee["bot_type"]) } func TestSerializeConversationMetaIncludesAgentCustomFields(t *testing.T) { db := newConversationSerializerTestDB(t) ctx := context.Background() account := &model.Account{Name: "Agent Custom Fields Account", Active: true} require.NoError(t, db.Create(account).Error) contact := &model.Contact{AccountID: account.ID, Name: "Agent Custom Fields Contact"} require.NoError(t, db.Create(contact).Error) inbox := createSerializerInbox(t, db, account.ID, "web_widget", "Web Widget") customRoleID := uint(77) agent := &model.User{AccountID: account.ID, Name: "Custom Agent", Email: "custom-agent@example.com", Password: "pass", Role: "agent", CustomRoleID: &customRoleID, CustomAttributes: datatypes.JSON([]byte(`{"region":"emea"}`))} require.NoError(t, db.Create(agent).Error) conversation := createSerializerConversation(t, db, account.ID, inbox.ID, contact.ID) conversation.AssigneeID = &agent.ID require.NoError(t, db.Save(conversation).Error) payload := serializeConversation(ctx, db, conversation) require.Equal(t, "User", payload.Meta.AssigneeType) require.Equal(t, agent.ID, payload.Meta.Assignee["id"]) require.Equal(t, "Custom Agent", payload.Meta.Assignee["name"]) require.Equal(t, map[string]any{"region": "emea"}, payload.Meta.Assignee["custom_attributes"]) require.Equal(t, customRoleID, payload.Meta.Assignee["custom_role_id"]) } func TestSerializeConversationMetaTeamUsesChatwootFieldNames(t *testing.T) { db := newConversationSerializerTestDB(t) ctx := context.Background() account := &model.Account{Name: "Team Meta Account", Active: true} require.NoError(t, db.Create(account).Error) contact := &model.Contact{AccountID: account.ID, Name: "Team Meta Contact"} require.NoError(t, db.Create(contact).Error) inbox := createSerializerInbox(t, db, account.ID, "web_widget", "Web Widget") team := &model.Team{AccountID: account.ID, Name: "Tier 1", Description: "Support team", AllowAutoAssignment: true} require.NoError(t, db.Create(team).Error) conversation := createSerializerConversation(t, db, account.ID, inbox.ID, contact.ID) conversation.TeamID = &team.ID require.NoError(t, db.Save(conversation).Error) payload := serializeConversation(ctx, db, conversation) require.NotNil(t, payload.Meta.Team) require.Equal(t, team.ID, payload.Meta.Team["id"]) require.Equal(t, "Tier 1", payload.Meta.Team["name"]) require.Equal(t, true, payload.Meta.Team["allow_auto_assign"]) require.Equal(t, false, payload.Meta.Team["is_member"]) require.NotContains(t, payload.Meta.Team, "allow_auto_assignment") require.NotContains(t, payload.Meta.Team, "created_at") require.NotContains(t, payload.Meta.Team, "updated_at") } func TestSerializeConversationIncludesSlaEvents(t *testing.T) { db := newConversationSerializerTestDB(t) ctx := context.Background() account := &model.Account{Name: "SLA Event Account", Active: true} require.NoError(t, db.Create(account).Error) contact := &model.Contact{AccountID: account.ID, Name: "SLA Event Contact"} require.NoError(t, db.Create(contact).Error) inbox := createSerializerInbox(t, db, account.ID, "web_widget", "Web Widget") conversation := createSerializerConversation(t, db, account.ID, inbox.ID, contact.ID) policy := &model.SlaPolicy{AccountID: account.ID, Name: "Gold SLA", FirstResponseTimeThreshold: 10} require.NoError(t, db.Create(policy).Error) applied := &model.AppliedSLA{AccountID: account.ID, ConversationID: conversation.ID, SlaPolicyID: policy.ID, SLAStatus: model.SLAStatusActive} require.NoError(t, db.Create(applied).Error) eventCreatedAt := time.Now().Add(-30 * time.Minute).UTC().Truncate(time.Second) event := &model.SlaEvent{AppliedSlaID: applied.ID, AccountID: account.ID, ConversationID: conversation.ID, InboxID: inbox.ID, SlaPolicyID: policy.ID, EventType: model.SLAEventNRT, Meta: datatypes.JSON([]byte(`{"message_id":123}`))} event.CreatedAt = eventCreatedAt event.UpdatedAt = eventCreatedAt.Add(5 * time.Minute) require.NoError(t, db.Create(event).Error) payload := serializeConversation(ctx, db, conversation) require.NotNil(t, payload.AppliedSLA) require.Equal(t, policy.ID, payload.AppliedSLA["sla_id"]) require.Len(t, payload.SlaEvents, 1) require.Equal(t, event.ID, payload.SlaEvents[0]["id"]) require.Equal(t, "nrt", payload.SlaEvents[0]["event_type"]) require.Equal(t, map[string]any{"message_id": float64(123)}, payload.SlaEvents[0]["meta"]) require.Equal(t, event.CreatedAt.Unix(), payload.SlaEvents[0]["created_at"]) require.Equal(t, event.UpdatedAt.Unix(), payload.SlaEvents[0]["updated_at"]) } func TestSerializeContactUsesPresenceStatus(t *testing.T) { contact := &model.Contact{AccountID: 7, Name: "Online Contact"} contact.ID = 42 ctx := withContactPresence(context.Background(), fakeContactPresence{statuses: map[string]string{"42:7": "online"}}) payload := serializeContactWithContext(ctx, contact) require.Equal(t, "online", payload["availability_status"]) require.Equal(t, "contact", payload["type"]) require.Equal(t, "offline", serializeContact(contact)["availability_status"]) } func TestSerializeUserIncludesSenderType(t *testing.T) { user := &model.User{AccountID: 7, Name: "Support Agent"} user.ID = 43 payload := serializeUser(user, user.AccountID) require.Equal(t, "user", payload["type"]) } func TestSerializeCRMContactUsesPresenceStatus(t *testing.T) { contact := &model.Contact{AccountID: 9, Name: "CRM Online Contact"} contact.ID = 99 ctx := withContactPresence(context.Background(), fakeContactPresence{statuses: map[string]string{"99:9": "online"}}) payload := serializeCRMContact(ctx, nil, contact, false) require.Equal(t, "online", payload["availability_status"]) } func newConversationSerializerTestDB(t *testing.T) *gorm.DB { t.Helper() db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)}) require.NoError(t, err) require.NoError(t, db.AutoMigrate(&model.Account{}, &model.User{}, &model.Inbox{}, &model.Contact{}, &model.Conversation{}, &model.Message{}, &model.Attachment{}, &model.AgentBot{}, &model.Team{}, &model.SlaPolicy{}, &model.AppliedSLA{}, &model.SlaEvent{}, &model.Call{}, &channelmodel.ChannelTwilioSMS{}, &channelmodel.ChannelWhatsApp{})) require.NoError(t, db.Exec(`CREATE TABLE channel_api (id integer primary key autoincrement, inbox_id integer, additional_attributes json)`).Error) return db } func createSerializerInbox(t *testing.T, db *gorm.DB, accountID uint, channelType, name string) *model.Inbox { t.Helper() inbox := &model.Inbox{AccountID: accountID, Name: name, ChannelType: channelType, ChannelID: 1, Enabled: true} require.NoError(t, db.Create(inbox).Error) return inbox } func createSerializerConversation(t *testing.T, db *gorm.DB, accountID, inboxID, contactID uint) *model.Conversation { t.Helper() conversation := &model.Conversation{AccountID: accountID, InboxID: inboxID, ContactID: contactID, Status: "open"} require.NoError(t, db.Create(conversation).Error) return conversation } func createSerializerMessage(t *testing.T, db *gorm.DB, accountID, inboxID, conversationID uint, messageType string, createdAt time.Time) { t.Helper() message := &model.Message{AccountID: accountID, InboxID: inboxID, ConversationID: conversationID, MessageType: messageType, Content: "hello"} message.CreatedAt = createdAt message.UpdatedAt = createdAt require.NoError(t, db.Create(message).Error) }