Files
gochat/internal/service/conversation_maintenance_worker_test.go
T

390 lines
18 KiB
Go

package service
import (
"context"
"fmt"
"strings"
"testing"
"time"
"github.com/gochat/gochat/internal/campaign"
"github.com/gochat/gochat/internal/model"
"github.com/gochat/gochat/internal/worker"
"gorm.io/gorm"
)
func TestConversationMaintenanceJobsTriggerScheduledItemsFanOut(t *testing.T) {
now := time.Date(2026, 6, 5, 19, 0, 0, 0, time.UTC)
db := setupServiceTestDB(t)
if err := db.AutoMigrate(&campaign.Campaign{}); err != nil {
t.Fatalf("migrate campaign: %v", err)
}
wp := worker.NewWorkerPoolWithOptions(db, worker.WithNow(func() time.Time { return now }))
registerConversationMaintenanceJobsWithNow(wp, db, func() time.Time { return now })
account := createTestAccount(t, db)
inbox := createTestInbox(t, db, account.ID, "sms")
contact := createTestContact(t, db, account.ID)
dueAt := now.Add(-time.Hour)
futureAt := now.Add(time.Hour)
dueCampaign := createTestOneoffCampaign(t, db, account.ID, inbox.ID, contact.ID, dueAt)
createTestOneoffCampaign(t, db, account.ID, inbox.ID, contact.ID, futureAt)
if _, err := EnqueueScheduledItemsTrigger(context.Background(), wp, now); err != nil {
t.Fatalf("enqueue scheduled trigger: %v", err)
}
processed, err := wp.ProcessOne(context.Background())
if err != nil || !processed {
t.Fatalf("process scheduled trigger: processed=%v err=%v", processed, err)
}
assertJobCount(t, db, TaskTypeCampaignTriggerOneoff, 1)
assertJobCount(t, db, TaskTypeConversationReopenSnoozed, 1)
assertJobCount(t, db, TaskTypeConversationResolutionScheduler, 1)
var campaignJob model.BackgroundJob
if err := db.Where("job_type = ?", TaskTypeCampaignTriggerOneoff).First(&campaignJob).Error; err != nil {
t.Fatalf("load campaign job: %v", err)
}
if want := fmt.Sprintf("campaign:trigger_oneoff:%d", dueCampaign.ID); campaignJob.IdempotencyKey != want {
t.Fatalf("expected due campaign idempotency key %q, got %q", want, campaignJob.IdempotencyKey)
}
var nextTrigger model.BackgroundJob
if err := db.Where("job_type = ? AND status = ?", TaskTypeScheduledTriggerItems, model.BackgroundJobStatusQueued).First(&nextTrigger).Error; err != nil {
t.Fatalf("load next trigger: %v", err)
}
if !nextTrigger.ScheduledAt.Equal(now.Add(scheduledItemsInterval)) {
t.Fatalf("expected next trigger at %s, got %s", now.Add(scheduledItemsInterval), nextTrigger.ScheduledAt)
}
}
func TestConversationMaintenanceJobsProcessCampaignSnoozeAndResolution(t *testing.T) {
now := time.Date(2026, 6, 5, 20, 0, 0, 0, time.UTC)
db := setupServiceTestDB(t)
if err := db.AutoMigrate(&campaign.Campaign{}); err != nil {
t.Fatalf("migrate campaign: %v", err)
}
wp := worker.NewWorkerPoolWithOptions(db, worker.WithNow(func() time.Time { return now }))
registerConversationMaintenanceJobsWithNow(wp, db, func() time.Time { return now })
account := createTestAccount(t, db)
account.AutoResolveDuration = 30
if err := db.Save(account).Error; err != nil {
t.Fatalf("save auto resolve account: %v", err)
}
inbox := createTestInbox(t, db, account.ID, "sms")
contact := createTestContact(t, db, account.ID)
dueCampaign := createTestOneoffCampaign(t, db, account.ID, inbox.ID, contact.ID, now.Add(-time.Hour))
dueSnooze := now.Add(-time.Minute).Unix()
snoozed := createTestConversation(t, db, account.ID, inbox.ID, contact.ID)
if err := db.Model(snoozed).Updates(map[string]any{"status": string(model.ConversationStatusSnoozed), "snoozed_until": dueSnooze}).Error; err != nil {
t.Fatalf("snooze conversation: %v", err)
}
oldActivity := now.Add(-45 * time.Minute).Unix()
oldOpen := createTestConversation(t, db, account.ID, inbox.ID, contact.ID)
if err := db.Model(oldOpen).Update("last_activity_at", oldActivity).Error; err != nil {
t.Fatalf("set old activity: %v", err)
}
recentActivity := now.Add(-5 * time.Minute).Unix()
recentOpen := createTestConversation(t, db, account.ID, inbox.ID, contact.ID)
if err := db.Model(recentOpen).Update("last_activity_at", recentActivity).Error; err != nil {
t.Fatalf("set recent activity: %v", err)
}
if _, err := wp.Enqueue(context.Background(), TaskTypeCampaignTriggerOneoff, campaignTriggerOneoffJob{CampaignID: dueCampaign.ID}, worker.WithQueue("low")); err != nil {
t.Fatalf("enqueue campaign: %v", err)
}
if _, err := wp.Enqueue(context.Background(), TaskTypeConversationReopenSnoozed, nil, worker.WithQueue("low")); err != nil {
t.Fatalf("enqueue reopen: %v", err)
}
if _, err := wp.Enqueue(context.Background(), TaskTypeConversationResolutionScheduler, nil, worker.WithQueue("scheduled_jobs")); err != nil {
t.Fatalf("enqueue scheduler: %v", err)
}
processRequiredJob(t, wp, "campaign")
processRequiredJob(t, wp, "reopen")
processRequiredJob(t, wp, "scheduler")
processRequiredJob(t, wp, "resolution")
var completed campaign.Campaign
if err := db.First(&completed, dueCampaign.ID).Error; err != nil {
t.Fatalf("load campaign: %v", err)
}
if completed.CampaignStatus != campaign.CampaignStatusCompleted {
t.Fatalf("expected completed campaign, got %s", completed.CampaignStatus)
}
var campaignMessages int64
if err := db.Model(&model.Message{}).Where("content = ?", dueCampaign.Message).Count(&campaignMessages).Error; err != nil {
t.Fatalf("count campaign messages: %v", err)
}
if campaignMessages != 1 {
t.Fatalf("expected one campaign message, got %d", campaignMessages)
}
if _, err := wp.Enqueue(context.Background(), TaskTypeCampaignTriggerOneoff, campaignTriggerOneoffJob{CampaignID: dueCampaign.ID}, worker.WithQueue("low")); err != nil {
t.Fatalf("enqueue duplicate campaign: %v", err)
}
processRequiredJob(t, wp, "duplicate campaign")
if err := db.Model(&model.Message{}).Where("content = ?", dueCampaign.Message).Count(&campaignMessages).Error; err != nil {
t.Fatalf("count duplicate campaign messages: %v", err)
}
if campaignMessages != 1 {
t.Fatalf("expected duplicate campaign job to be idempotent, got %d messages", campaignMessages)
}
var reopened model.Conversation
if err := db.First(&reopened, snoozed.ID).Error; err != nil {
t.Fatalf("load reopened conversation: %v", err)
}
if reopened.Status != string(model.ConversationStatusOpen) || reopened.SnoozedUntil != nil || reopened.ResumedAt == nil {
t.Fatalf("expected snoozed conversation reopened, got status=%s snoozed=%v resumed=%v", reopened.Status, reopened.SnoozedUntil, reopened.ResumedAt)
}
var resolved model.Conversation
if err := db.First(&resolved, oldOpen.ID).Error; err != nil {
t.Fatalf("load resolved conversation: %v", err)
}
if resolved.Status != string(model.ConversationStatusResolved) || resolved.ResolvedAt == nil {
t.Fatalf("expected old open conversation resolved, got status=%s resolved_at=%v", resolved.Status, resolved.ResolvedAt)
}
var recent model.Conversation
if err := db.First(&recent, recentOpen.ID).Error; err != nil {
t.Fatalf("load recent conversation: %v", err)
}
if recent.Status != string(model.ConversationStatusOpen) {
t.Fatalf("expected recent conversation to remain open, got %s", recent.Status)
}
}
func TestConversationMaintenanceJobsRetryMissingResolutionAccount(t *testing.T) {
now := time.Date(2026, 6, 5, 21, 0, 0, 0, time.UTC)
db := setupServiceTestDB(t)
wp := worker.NewWorkerPoolWithOptions(db, worker.WithNow(func() time.Time { return now }), worker.WithBackoff(func(attempt int) time.Duration { return time.Minute }))
registerConversationMaintenanceJobsWithNow(wp, db, func() time.Time { return now })
if _, err := wp.Enqueue(context.Background(), TaskTypeConversationResolutionForAccount, conversationResolutionJob{AccountID: 9999}, worker.WithQueue("low"), worker.WithMaxAttempts(3)); err != nil {
t.Fatalf("enqueue missing account: %v", err)
}
processed, err := wp.ProcessOne(context.Background())
if err == nil || !processed {
t.Fatalf("expected missing account to retry, processed=%v err=%v", processed, err)
}
var job model.BackgroundJob
if err := db.Where("job_type = ?", TaskTypeConversationResolutionForAccount).First(&job).Error; err != nil {
t.Fatalf("load resolution job: %v", err)
}
if job.Status != model.BackgroundJobStatusRetrying || job.LastError == "" {
t.Fatalf("expected retrying resolution job with error, got status=%s last_error=%q", job.Status, job.LastError)
}
}
func TestConversationMaintenanceJobsUpdateMessageStatus(t *testing.T) {
now := time.Date(2026, 6, 5, 22, 0, 0, 0, time.UTC)
db := setupServiceTestDB(t)
wp := worker.NewWorkerPoolWithOptions(db, worker.WithNow(func() time.Time { return now }))
registerConversationMaintenanceJobsWithNow(wp, db, func() time.Time { return now })
account := createTestAccount(t, db)
inbox := createTestInbox(t, db, account.ID, "web_widget")
contact := createTestContact(t, db, account.ID)
conversation := createTestConversation(t, db, account.ID, inbox.ID, contact.ID)
cutoff := now.Add(-time.Minute)
beforeSent := createConversationMaintenanceMessage(t, db, account.ID, inbox.ID, conversation.ID, "outgoing", string(model.MessageStatusSent), cutoff.Add(-time.Minute))
beforeDelivered := createConversationMaintenanceMessage(t, db, account.ID, inbox.ID, conversation.ID, "outgoing", string(model.MessageStatusDelivered), cutoff.Add(-30*time.Second))
incoming := createConversationMaintenanceMessage(t, db, account.ID, inbox.ID, conversation.ID, "incoming", string(model.MessageStatusSent), cutoff.Add(-time.Minute))
alreadyRead := createConversationMaintenanceMessage(t, db, account.ID, inbox.ID, conversation.ID, "outgoing", string(model.MessageStatusRead), cutoff.Add(-time.Minute))
afterCutoff := createConversationMaintenanceMessage(t, db, account.ID, inbox.ID, conversation.ID, "outgoing", string(model.MessageStatusSent), cutoff.Add(time.Minute))
if _, err := EnqueueConversationMessageStatusUpdate(context.Background(), wp, conversation.ID, cutoff, string(model.MessageStatusRead)); err != nil {
t.Fatalf("enqueue message status update: %v", err)
}
assertJobCount(t, db, TaskTypeConversationUpdateMessageStatus, 1)
var queued model.BackgroundJob
if err := db.Where("job_type = ?", TaskTypeConversationUpdateMessageStatus).First(&queued).Error; err != nil {
t.Fatalf("load queued message status job: %v", err)
}
if queued.Queue != "deferred" {
t.Fatalf("expected deferred queue, got %s", queued.Queue)
}
processRequiredJob(t, wp, "message status")
assertMessageStatus(t, db, beforeSent.ID, string(model.MessageStatusRead))
assertMessageStatus(t, db, beforeDelivered.ID, string(model.MessageStatusRead))
assertMessageStatus(t, db, incoming.ID, string(model.MessageStatusSent))
assertMessageStatus(t, db, alreadyRead.ID, string(model.MessageStatusRead))
assertMessageStatus(t, db, afterCutoff.ID, string(model.MessageStatusSent))
}
func TestConversationMaintenanceJobsIgnoreInvalidMessageStatus(t *testing.T) {
now := time.Date(2026, 6, 5, 22, 30, 0, 0, time.UTC)
db := setupServiceTestDB(t)
wp := worker.NewWorkerPoolWithOptions(db, worker.WithNow(func() time.Time { return now }))
registerConversationMaintenanceJobsWithNow(wp, db, func() time.Time { return now })
account := createTestAccount(t, db)
inbox := createTestInbox(t, db, account.ID, "web_widget")
contact := createTestContact(t, db, account.ID)
conversation := createTestConversation(t, db, account.ID, inbox.ID, contact.ID)
message := createConversationMaintenanceMessage(t, db, account.ID, inbox.ID, conversation.ID, "outgoing", string(model.MessageStatusSent), now.Add(-time.Minute))
if _, err := wp.Enqueue(context.Background(), TaskTypeConversationUpdateMessageStatus, conversationUpdateMessageStatusJob{ConversationID: conversation.ID, Timestamp: now.Unix(), Status: "failed"}, worker.WithQueue("deferred")); err != nil {
t.Fatalf("enqueue invalid status job: %v", err)
}
processRequiredJob(t, wp, "invalid message status")
assertMessageStatus(t, db, message.ID, string(model.MessageStatusSent))
}
func TestConversationMaintenanceJobsConversationBulkAction(t *testing.T) {
now := time.Date(2026, 6, 5, 23, 0, 0, 0, time.UTC)
db := setupServiceTestDB(t)
wp := worker.NewWorkerPoolWithOptions(db, worker.WithNow(func() time.Time { return now }))
registerConversationMaintenanceJobsWithNow(wp, db, func() time.Time { return now })
account := createTestAccount(t, db)
otherAccount := createTestAccount(t, db)
inbox := createTestInbox(t, db, account.ID, "web_widget")
contact := createTestContact(t, db, account.ID)
convA := createTestConversation(t, db, account.ID, inbox.ID, contact.ID)
convB := createTestConversation(t, db, account.ID, inbox.ID, contact.ID)
otherConv := createTestConversation(t, db, otherAccount.ID, inbox.ID, contact.ID)
displayA := uint(101)
displayB := uint(102)
sharedOtherDisplay := displayA
if err := db.Model(convA).Updates(map[string]any{"display_id": displayA, "labels": "vip,old"}).Error; err != nil {
t.Fatalf("set display A: %v", err)
}
if err := db.Model(convB).Updates(map[string]any{"display_id": displayB, "labels": "old"}).Error; err != nil {
t.Fatalf("set display B: %v", err)
}
if err := db.Model(otherConv).Updates(map[string]any{"display_id": sharedOtherDisplay, "labels": "other"}).Error; err != nil {
t.Fatalf("set display other: %v", err)
}
status := string(model.ConversationStatusSnoozed)
teamID := uint(77)
assigneeID := uint(88)
snoozedUntil := now.Add(time.Hour).Format(time.RFC3339)
_, err := EnqueueConversationBulkAction(context.Background(), wp, account.ID, 42, ConversationBulkActionParams{
Type: "Conversation",
IDs: []uint{displayA, displayB},
Fields: ConversationBulkActionFields{
Status: &status,
TeamID: &teamID,
AssigneeID: &assigneeID,
},
Labels: ConversationBulkActionLabels{Add: []string{"urgent", "vip"}, Remove: []string{"old"}},
SnoozedUntil: snoozedUntil,
})
if err != nil {
t.Fatalf("enqueue bulk action: %v", err)
}
assertJobCount(t, db, TaskTypeConversationBulkAction, 1)
var queued model.BackgroundJob
if err := db.Where("job_type = ?", TaskTypeConversationBulkAction).First(&queued).Error; err != nil {
t.Fatalf("load bulk action job: %v", err)
}
if queued.Queue != "medium" {
t.Fatalf("expected medium queue, got %s", queued.Queue)
}
processRequiredJob(t, wp, "conversation bulk action")
for _, id := range []uint{convA.ID, convB.ID} {
var conversation model.Conversation
if err := db.First(&conversation, id).Error; err != nil {
t.Fatalf("load conversation %d: %v", id, err)
}
if conversation.Status != status || conversation.TeamID == nil || *conversation.TeamID != teamID || conversation.AssigneeID == nil || *conversation.AssigneeID != assigneeID {
t.Fatalf("conversation %d not bulk updated: status=%s team=%v assignee=%v", id, conversation.Status, conversation.TeamID, conversation.AssigneeID)
}
if conversation.SnoozedUntil == nil || *conversation.SnoozedUntil != now.Add(time.Hour).Unix() {
t.Fatalf("conversation %d snoozed_until not updated: %v", id, conversation.SnoozedUntil)
}
if strings.Contains(conversation.Labels, "old") || !strings.Contains(conversation.Labels, "urgent") || !strings.Contains(conversation.Labels, "vip") {
t.Fatalf("conversation %d labels not merged, got %q", id, conversation.Labels)
}
}
var untouched model.Conversation
if err := db.First(&untouched, otherConv.ID).Error; err != nil {
t.Fatalf("load other conversation: %v", err)
}
if untouched.Labels != "other" || untouched.Status != string(model.ConversationStatusOpen) {
t.Fatalf("other account conversation should not change: status=%s labels=%s", untouched.Status, untouched.Labels)
}
}
func createTestOneoffCampaign(t *testing.T, db *gorm.DB, accountID, inboxID, contactID uint, scheduledAt time.Time) *campaign.Campaign {
t.Helper()
c := &campaign.Campaign{
AccountID: accountID,
InboxID: inboxID,
DisplayID: uint(time.Now().UnixNano()),
Title: fmt.Sprintf("Campaign %d", time.Now().UnixNano()),
Message: fmt.Sprintf("Campaign message %d", time.Now().UnixNano()),
CampaignStatus: campaign.CampaignStatusActive,
CampaignType: campaign.CampaignTypeOneOff,
Audience: fmt.Sprintf(`{"contact_ids":[%d]}`, contactID),
TriggerRules: `{}`,
TemplateParams: `{}`,
ScheduledAt: &scheduledAt,
Enabled: true,
}
if err := db.Create(c).Error; err != nil {
t.Fatalf("create campaign: %v", err)
}
return c
}
func createConversationMaintenanceMessage(t *testing.T, db *gorm.DB, accountID, inboxID, conversationID uint, messageType, status string, createdAt time.Time) *model.Message {
t.Helper()
message := &model.Message{
AccountID: accountID,
InboxID: inboxID,
ConversationID: conversationID,
Content: fmt.Sprintf("%s %d", messageType, time.Now().UnixNano()),
MessageType: messageType,
Status: status,
}
message.CreatedAt = createdAt
message.UpdatedAt = createdAt
if err := db.Create(message).Error; err != nil {
t.Fatalf("create message: %v", err)
}
return message
}
func assertMessageStatus(t *testing.T, db *gorm.DB, messageID uint, want string) {
t.Helper()
var message model.Message
if err := db.First(&message, messageID).Error; err != nil {
t.Fatalf("load message %d: %v", messageID, err)
}
if message.Status != want {
t.Fatalf("expected message %d status %s, got %s", messageID, want, message.Status)
}
}
func assertJobCount(t *testing.T, db *gorm.DB, jobType string, want int64) {
t.Helper()
var count int64
if err := db.Model(&model.BackgroundJob{}).Where("job_type = ? AND status = ?", jobType, model.BackgroundJobStatusQueued).Count(&count).Error; err != nil {
t.Fatalf("count jobs %s: %v", jobType, err)
}
if count != want {
t.Fatalf("expected %d queued %s jobs, got %d", want, jobType, count)
}
}
func processRequiredJob(t *testing.T, wp *worker.WorkerPool, name string) {
t.Helper()
processed, err := wp.ProcessOne(context.Background())
if err != nil || !processed {
t.Fatalf("process %s job: processed=%v err=%v", name, processed, err)
}
}