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.
770 lines
35 KiB
Go
770 lines
35 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/gochat/gochat/internal/campaign"
|
|
"github.com/gochat/gochat/internal/channel"
|
|
"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 campaignJob.Queue != "low" || campaignJob.Status != model.BackgroundJobStatusQueued || campaignJob.MaxAttempts != 3 {
|
|
t.Fatalf("expected queued low-priority campaign trigger job, got queue=%s status=%s max_attempts=%d", campaignJob.Queue, campaignJob.Status, campaignJob.MaxAttempts)
|
|
}
|
|
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 campaignPayload campaignTriggerOneoffJob
|
|
if err := json.Unmarshal(campaignJob.Payload, &campaignPayload); err != nil {
|
|
t.Fatalf("unmarshal campaign trigger payload: %v", err)
|
|
}
|
|
if campaignPayload.CampaignID != dueCampaign.ID {
|
|
t.Fatalf("expected campaign trigger payload campaign_id=%d, got %d", dueCampaign.ID, campaignPayload.CampaignID)
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
|
|
type conversationMaintenanceRecordingListener struct {
|
|
events []*channel.ChannelEvent
|
|
}
|
|
|
|
func (l *conversationMaintenanceRecordingListener) Name() string {
|
|
return "conversation_maintenance_recording_listener"
|
|
}
|
|
|
|
func (l *conversationMaintenanceRecordingListener) OnEvent(_ context.Context, event *channel.ChannelEvent) error {
|
|
l.events = append(l.events, event)
|
|
return nil
|
|
}
|
|
|
|
func (l *conversationMaintenanceRecordingListener) eventsByType(eventType channel.EventType) []*channel.ChannelEvent {
|
|
var events []*channel.ChannelEvent
|
|
for _, event := range l.events {
|
|
if event.Type == eventType {
|
|
events = append(events, event)
|
|
}
|
|
}
|
|
return events
|
|
}
|
|
|
|
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 completedCampaignJob model.BackgroundJob
|
|
if err := db.Where("job_type = ? AND json_extract(payload, '$.campaign_id') = ?", TaskTypeCampaignTriggerOneoff, dueCampaign.ID).First(&completedCampaignJob).Error; err != nil {
|
|
t.Fatalf("load completed campaign trigger job: %v", err)
|
|
}
|
|
if completedCampaignJob.Status != model.BackgroundJobStatusCompleted || completedCampaignJob.FinishedAt == nil || completedCampaignJob.LastError != "" {
|
|
t.Fatalf("expected completed campaign trigger job, got status=%s finished=%v last_error=%q", completedCampaignJob.Status, completedCampaignJob.FinishedAt, completedCampaignJob.LastError)
|
|
}
|
|
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 TestConversationMaintenanceJobsCampaignTriggerDispatchesEvents(t *testing.T) {
|
|
now := time.Date(2026, 6, 5, 20, 15, 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 }))
|
|
dispatcher := channel.NewDispatcher()
|
|
listener := &conversationMaintenanceRecordingListener{}
|
|
dispatcher.Register(listener)
|
|
registerConversationMaintenanceJobsWithNow(wp, db, func() time.Time { return now }, dispatcher)
|
|
|
|
account := createTestAccount(t, db)
|
|
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))
|
|
if _, err := wp.Enqueue(context.Background(), TaskTypeCampaignTriggerOneoff, campaignTriggerOneoffJob{CampaignID: dueCampaign.ID}, worker.WithQueue("low")); err != nil {
|
|
t.Fatalf("enqueue campaign: %v", err)
|
|
}
|
|
processRequiredJob(t, wp, "campaign realtime dispatch")
|
|
|
|
conversationEvents := listener.eventsByType(channel.EventConversationCreated)
|
|
if len(conversationEvents) != 1 {
|
|
t.Fatalf("expected one conversation.created event, got %#v", listener.events)
|
|
}
|
|
conversationEvent := conversationEvents[0]
|
|
if conversationEvent.AccountID != account.ID || conversationEvent.InboxID != inbox.ID || conversationEvent.ContactID != contact.ID || conversationEvent.Data["campaign_id"] != dueCampaign.ID {
|
|
t.Fatalf("unexpected conversation.created campaign event: %#v", conversationEvent)
|
|
}
|
|
if _, ok := conversationEvent.Data["conversation"].(*model.Conversation); !ok {
|
|
t.Fatalf("expected conversation payload, got %#v", conversationEvent.Data["conversation"])
|
|
}
|
|
|
|
messageEvents := listener.eventsByType(channel.EventMessageCreated)
|
|
if len(messageEvents) != 1 {
|
|
t.Fatalf("expected one message.created event, got %#v", listener.events)
|
|
}
|
|
messageEvent := messageEvents[0]
|
|
message, ok := messageEvent.Data["message"].(*model.Message)
|
|
if !ok || message.Content != dueCampaign.Message || message.MessageType != "outgoing" {
|
|
t.Fatalf("unexpected message.created payload: %#v", messageEvent.Data["message"])
|
|
}
|
|
if len(listener.eventsByType(channel.EventConversationOpened)) != 1 || len(listener.eventsByType(channel.EventMessageOutgoing)) != 1 {
|
|
t.Fatalf("expected opened and outgoing campaign events, got %#v", listener.events)
|
|
}
|
|
}
|
|
|
|
func TestConversationMaintenanceJobsStatusMaintenanceQueuesSearchIndex(t *testing.T) {
|
|
now := time.Date(2026, 6, 5, 20, 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 })
|
|
delegate := &recordingDurableSearchIndexer{}
|
|
searchIndexer := NewDurableSearchIndexer(db, wp, delegate)
|
|
RegisterConversationMaintenanceSearchIndexer(wp, db, searchIndexer)
|
|
|
|
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, "web_widget")
|
|
contact := createTestContact(t, db, account.ID)
|
|
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": now.Add(-time.Minute).Unix()}).Error; err != nil {
|
|
t.Fatalf("snooze conversation: %v", err)
|
|
}
|
|
oldOpen := createTestConversation(t, db, account.ID, inbox.ID, contact.ID)
|
|
if err := db.Model(oldOpen).Update("last_activity_at", now.Add(-45*time.Minute).Unix()).Error; err != nil {
|
|
t.Fatalf("set old activity: %v", err)
|
|
}
|
|
|
|
if _, err := wp.Enqueue(context.Background(), TaskTypeConversationReopenSnoozed, nil, worker.WithQueue("low")); err != nil {
|
|
t.Fatalf("enqueue reopen: %v", err)
|
|
}
|
|
processRequiredJob(t, wp, "reopen with index")
|
|
processRequiredJob(t, wp, "reopen search index")
|
|
assertIndexedConversationStatus(t, delegate, snoozed.ID, string(model.ConversationStatusOpen))
|
|
|
|
if _, err := wp.Enqueue(context.Background(), TaskTypeConversationResolutionForAccount, conversationResolutionJob{AccountID: account.ID}, worker.WithQueue("low")); err != nil {
|
|
t.Fatalf("enqueue resolution: %v", err)
|
|
}
|
|
processRequiredJob(t, wp, "resolution with index")
|
|
processRequiredJob(t, wp, "resolution search index")
|
|
assertIndexedConversationStatus(t, delegate, oldOpen.ID, string(model.ConversationStatusResolved))
|
|
}
|
|
|
|
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 TestConversationMaintenanceJobsUpdateMessageStatusQueuesSearchIndex(t *testing.T) {
|
|
now := time.Date(2026, 6, 5, 22, 15, 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 })
|
|
delegate := &recordingDurableSearchIndexer{}
|
|
searchIndexer := NewDurableSearchIndexer(db, wp, delegate)
|
|
RegisterConversationMaintenanceSearchIndexer(wp, db, searchIndexer)
|
|
|
|
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)
|
|
message := 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)
|
|
}
|
|
processRequiredJob(t, wp, "message status with index")
|
|
processRequiredJob(t, wp, "message search index")
|
|
processRequiredJob(t, wp, "conversation search index")
|
|
|
|
if len(delegate.indexedMessages) != 1 || delegate.indexedMessages[0].ID != message.ID || delegate.indexedMessages[0].Status != string(model.MessageStatusRead) {
|
|
t.Fatalf("expected indexed read message, got %#v", delegate.indexedMessages)
|
|
}
|
|
assertIndexedConversationStatus(t, delegate, conversation.ID, string(model.ConversationStatusOpen))
|
|
}
|
|
|
|
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 TestConversationMaintenanceJobsConversationBulkActionQueuesSearchIndex(t *testing.T) {
|
|
now := time.Date(2026, 6, 6, 0, 20, 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 })
|
|
delegate := &recordingDurableSearchIndexer{}
|
|
searchIndexer := NewDurableSearchIndexer(db, wp, delegate)
|
|
RegisterBulkActionSearchIndexer(wp, db, searchIndexer)
|
|
|
|
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)
|
|
displayID := uint(501)
|
|
if err := db.Model(conversation).Updates(map[string]any{"display_id": displayID, "labels": "old"}).Error; err != nil {
|
|
t.Fatalf("set display id: %v", err)
|
|
}
|
|
status := string(model.ConversationStatusResolved)
|
|
|
|
_, err := EnqueueConversationBulkAction(context.Background(), wp, account.ID, 42, ConversationBulkActionParams{
|
|
Type: "Conversation",
|
|
IDs: []uint{displayID},
|
|
Fields: ConversationBulkActionFields{Status: &status},
|
|
Labels: ConversationBulkActionLabels{Add: []string{"urgent"}, Remove: []string{"old"}},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("enqueue conversation bulk action: %v", err)
|
|
}
|
|
processRequiredJob(t, wp, "conversation bulk action")
|
|
processRequiredJob(t, wp, "conversation search index")
|
|
|
|
if len(delegate.indexedConversations) != 1 {
|
|
t.Fatalf("expected one indexed conversation, got %#v", delegate.indexedConversations)
|
|
}
|
|
indexed := delegate.indexedConversations[0]
|
|
if indexed.ID != conversation.ID || indexed.Status != status || indexed.Labels != "urgent" {
|
|
t.Fatalf("expected indexed updated conversation, got id=%d status=%s labels=%q", indexed.ID, indexed.Status, indexed.Labels)
|
|
}
|
|
if indexed.Contact == nil || indexed.Inbox == nil {
|
|
t.Fatalf("expected indexed conversation relations, got contact=%#v inbox=%#v", indexed.Contact, indexed.Inbox)
|
|
}
|
|
|
|
var bulkJob model.BackgroundJob
|
|
if err := db.Where("job_type = ?", TaskTypeConversationBulkAction).First(&bulkJob).Error; err != nil {
|
|
t.Fatalf("load completed conversation bulk action job: %v", err)
|
|
}
|
|
if bulkJob.Status != model.BackgroundJobStatusCompleted || bulkJob.FinishedAt == nil || bulkJob.LastError != "" {
|
|
t.Fatalf("expected completed conversation bulk action job, got status=%s finished=%v last_error=%q", bulkJob.Status, bulkJob.FinishedAt, bulkJob.LastError)
|
|
}
|
|
}
|
|
|
|
func TestConversationMaintenanceJobsContactBulkAction(t *testing.T) {
|
|
now := time.Date(2026, 6, 6, 0, 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)
|
|
contactA := createTestContact(t, db, account.ID)
|
|
contactB := createTestContact(t, db, account.ID)
|
|
otherContact := createTestContact(t, db, otherAccount.ID)
|
|
oldTag := model.Tag{AccountID: account.ID, Name: "old"}
|
|
if err := db.Create(&oldTag).Error; err != nil {
|
|
t.Fatalf("create old tag: %v", err)
|
|
}
|
|
if err := db.Create(&model.ContactLabel{AccountID: account.ID, ContactID: contactA.ID, TagID: oldTag.ID}).Error; err != nil {
|
|
t.Fatalf("label contact A: %v", err)
|
|
}
|
|
if err := db.Create(&model.ContactLabel{AccountID: account.ID, ContactID: contactB.ID, TagID: oldTag.ID}).Error; err != nil {
|
|
t.Fatalf("label contact B: %v", err)
|
|
}
|
|
|
|
_, err := EnqueueContactBulkAction(context.Background(), wp, account.ID, 42, ContactBulkActionParams{
|
|
Type: "Contact",
|
|
IDs: []uint{contactA.ID, contactB.ID, otherContact.ID},
|
|
Labels: ConversationBulkActionLabels{Add: []string{"vip", "vip"}},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("enqueue contact label add: %v", err)
|
|
}
|
|
assertJobCount(t, db, TaskTypeContactBulkAction, 1)
|
|
processRequiredJob(t, wp, "contact bulk add labels")
|
|
assertContactHasLabel(t, db, account.ID, contactA.ID, "vip", true)
|
|
assertContactHasLabel(t, db, account.ID, contactB.ID, "vip", true)
|
|
assertContactHasLabel(t, db, otherAccount.ID, otherContact.ID, "vip", false)
|
|
|
|
_, err = EnqueueContactBulkAction(context.Background(), wp, account.ID, 42, ContactBulkActionParams{
|
|
Type: "Contact",
|
|
IDs: []uint{contactA.ID, contactB.ID},
|
|
Labels: ConversationBulkActionLabels{Remove: []string{"old"}},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("enqueue contact label remove: %v", err)
|
|
}
|
|
processRequiredJob(t, wp, "contact bulk remove labels")
|
|
assertContactHasLabel(t, db, account.ID, contactA.ID, "old", false)
|
|
assertContactHasLabel(t, db, account.ID, contactB.ID, "old", false)
|
|
assertContactHasLabel(t, db, account.ID, contactA.ID, "vip", true)
|
|
|
|
_, err = EnqueueContactBulkAction(context.Background(), wp, account.ID, 42, ContactBulkActionParams{
|
|
Type: "Contact",
|
|
ActionName: "delete",
|
|
IDs: []uint{contactA.ID, otherContact.ID},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("enqueue contact delete: %v", err)
|
|
}
|
|
processRequiredJob(t, wp, "contact bulk delete")
|
|
|
|
var deleted model.Contact
|
|
if err := db.First(&deleted, contactA.ID).Error; err == nil {
|
|
t.Fatalf("expected account contact to be soft deleted")
|
|
}
|
|
var untouched model.Contact
|
|
if err := db.First(&untouched, otherContact.ID).Error; err != nil {
|
|
t.Fatalf("other account contact should remain visible: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestConversationMaintenanceJobsContactBulkActionQueuesSearchIndexAndCompletes(t *testing.T) {
|
|
now := time.Date(2026, 6, 6, 0, 40, 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 })
|
|
delegate := &recordingDurableSearchIndexer{}
|
|
searchIndexer := NewDurableSearchIndexer(db, wp, delegate)
|
|
RegisterContactBulkActionSearchIndexer(wp, db, searchIndexer)
|
|
|
|
account := createTestAccount(t, db)
|
|
contact := createTestContact(t, db, account.ID)
|
|
if _, err := EnqueueContactBulkAction(context.Background(), wp, account.ID, 42, ContactBulkActionParams{
|
|
Type: "Contact",
|
|
IDs: []uint{contact.ID},
|
|
Labels: ConversationBulkActionLabels{Add: []string{"vip"}},
|
|
}); err != nil {
|
|
t.Fatalf("enqueue contact bulk action: %v", err)
|
|
}
|
|
processRequiredJob(t, wp, "contact bulk add labels")
|
|
processRequiredJob(t, wp, "contact search index")
|
|
|
|
if len(delegate.indexedContacts) != 1 || delegate.indexedContacts[0] != contact.ID {
|
|
t.Fatalf("expected indexed contact %d, got %#v", contact.ID, delegate.indexedContacts)
|
|
}
|
|
assertContactHasLabel(t, db, account.ID, contact.ID, "vip", true)
|
|
|
|
var bulkJob model.BackgroundJob
|
|
if err := db.Where("job_type = ?", TaskTypeContactBulkAction).First(&bulkJob).Error; err != nil {
|
|
t.Fatalf("load completed contact bulk action job: %v", err)
|
|
}
|
|
if bulkJob.Status != model.BackgroundJobStatusCompleted || bulkJob.FinishedAt == nil || bulkJob.LastError != "" {
|
|
t.Fatalf("expected completed contact bulk action job, got status=%s finished=%v last_error=%q", bulkJob.Status, bulkJob.FinishedAt, bulkJob.LastError)
|
|
}
|
|
}
|
|
|
|
func TestConversationMaintenanceJobsContactBulkActionQueuesSearchIndex(t *testing.T) {
|
|
now := time.Date(2026, 6, 6, 0, 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 })
|
|
delegate := &recordingDurableSearchIndexer{}
|
|
searchIndexer := NewDurableSearchIndexer(db, wp, delegate)
|
|
RegisterContactBulkActionSearchIndexer(wp, db, searchIndexer)
|
|
|
|
account := createTestAccount(t, db)
|
|
contact := createTestContact(t, db, account.ID)
|
|
|
|
_, err := EnqueueContactBulkAction(context.Background(), wp, account.ID, 42, ContactBulkActionParams{
|
|
Type: "Contact",
|
|
IDs: []uint{contact.ID},
|
|
Labels: ConversationBulkActionLabels{Add: []string{"vip"}},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("enqueue contact bulk action: %v", err)
|
|
}
|
|
processRequiredJob(t, wp, "contact bulk label add")
|
|
processRequiredJob(t, wp, "contact search index")
|
|
|
|
if got := delegate.indexedContactLabels[contact.ID]; len(got) != 1 || got[0] != "vip" {
|
|
t.Fatalf("expected contact search reindex with vip label, got %#v", got)
|
|
}
|
|
|
|
_, err = EnqueueContactBulkAction(context.Background(), wp, account.ID, 42, ContactBulkActionParams{
|
|
Type: "Contact",
|
|
ActionName: "delete",
|
|
IDs: []uint{contact.ID},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("enqueue contact delete: %v", err)
|
|
}
|
|
processRequiredJob(t, wp, "contact bulk delete")
|
|
processRequiredJob(t, wp, "contact search delete")
|
|
|
|
if len(delegate.deletedContacts) != 1 || delegate.deletedContacts[0] != contact.ID {
|
|
t.Fatalf("expected contact search delete, got %#v", delegate.deletedContacts)
|
|
}
|
|
}
|
|
|
|
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 assertContactHasLabel(t *testing.T, db *gorm.DB, accountID, contactID uint, label string, want bool) {
|
|
t.Helper()
|
|
var count int64
|
|
if err := db.Table("contact_labels").
|
|
Joins("JOIN tags ON tags.id = contact_labels.tag_id").
|
|
Where("contact_labels.account_id = ? AND contact_labels.contact_id = ? AND tags.name = ?", accountID, contactID, label).
|
|
Count(&count).Error; err != nil {
|
|
t.Fatalf("count contact label %s: %v", label, err)
|
|
}
|
|
if got := count > 0; got != want {
|
|
t.Fatalf("contact %d label %q presence = %v, want %v", contactID, label, got, want)
|
|
}
|
|
}
|
|
|
|
func assertIndexedConversationStatus(t *testing.T, indexer *recordingDurableSearchIndexer, conversationID uint, status string) {
|
|
t.Helper()
|
|
for _, conversation := range indexer.indexedConversations {
|
|
if conversation.ID == conversationID && conversation.Status == status {
|
|
return
|
|
}
|
|
}
|
|
t.Fatalf("expected indexed conversation %d with status %s, got %#v", conversationID, status, indexer.indexedConversations)
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|