package automation import ( "context" "encoding/json" "errors" "fmt" "io" "net/http" "strings" "testing" "time" "github.com/gochat/gochat/internal/model" "github.com/gochat/gochat/internal/worker" "gorm.io/datatypes" ) type roundTripFunc func(*http.Request) (*http.Response, error) func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { return f(req) } func TestHTTPAutomationWebhookDeliverer_RetriesRetryableResponses(t *testing.T) { attempts := 0 client := &http.Client{ Timeout: time.Second, Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { attempts++ if req.Header.Get("X-Webhook-Event") != "automation_event.conversation_created" { t.Fatalf("unexpected webhook event header: %s", req.Header.Get("X-Webhook-Event")) } body, _ := io.ReadAll(req.Body) if !strings.Contains(string(body), "conversation_id") { t.Fatalf("expected webhook body to include conversation_id, got %s", string(body)) } if attempts < 3 { return &http.Response{StatusCode: http.StatusInternalServerError, Body: io.NopCloser(strings.NewReader("retry me")), Header: make(http.Header)}, nil } return &http.Response{StatusCode: http.StatusNoContent, Body: io.NopCloser(strings.NewReader("")), Header: make(http.Header)}, nil }), } deliverer := NewHTTPAutomationWebhookDeliverer(client, 3, 0) result, err := deliverer.DeliverWebhook(context.Background(), AutomationWebhookRequest{ EventName: "conversation_created", URL: "https://example.test/webhook", Payload: map[string]interface{}{"conversation_id": float64(42)}, }) if err != nil { t.Fatalf("expected retry-to-success webhook delivery, got: %v", err) } if attempts != 3 || result.Attempts != 3 || result.ResponseCode != http.StatusNoContent { t.Fatalf("unexpected retry result: attempts=%d result=%#v", attempts, result) } } func TestAutomationRuleService_MatchAndExecute_RecordsWebhookDeliveryMetadata(t *testing.T) { dbProvider := setupAutomationTestDBProvider(t) db := dbProvider.DB() accountID, _ := seedTestAccount(db, t) inboxID := seedTestInbox(db, t, accountID) contactID := seedTestContact(db, t, accountID) conversationID := seedTestConversation(db, t, accountID, inboxID, contactID) restore := setAutomationActionDeliverersForTest(&recordingWebhookDeliverer{ result: ActionDeliveryResult{DeliveryType: "webhook", Target: "https://hooks.example/automation", Attempts: 2, ResponseCode: http.StatusOK}, }, &recordingTranscriptDeliverer{}) defer restore() if err := db.Create(&model.Message{ConversationID: conversationID, AccountID: accountID, InboxID: inboxID, Content: "hello", ContentType: "text", MessageType: "incoming"}).Error; err != nil { t.Fatalf("seed message: %v", err) } svc := NewAutomationRuleService(dbProvider) rule := &AutomationRule{ AccountID: accountID, EventName: "conversation_created", Name: "webhook delivery", Conditions: Conditions{}, Actions: Actions{{ActionName: "send_webhook_event", ActionParams: map[string]interface{}{ "url": "https://hooks.example/automation", }}}, Active: true, } if err := svc.Create(context.Background(), rule); err != nil { t.Fatalf("create rule: %v", err) } if err := svc.MatchAndExecute(context.Background(), accountID, "conversation_created", conversationID, map[string]interface{}{}); err != nil { t.Fatalf("match and execute: %v", err) } logs, err := NewExecutionLogService(dbProvider).ListRuleExecutions(context.Background(), accountID, rule.ID, 10) if err != nil { t.Fatalf("list executions: %v", err) } if len(logs) != 1 || logs[0].Status != ExecutionStatusSuccess { t.Fatalf("expected one successful execution log, got %#v", logs) } var results []ActionExecutionResult if err := json.Unmarshal(logs[0].ActionResults, &results); err != nil { t.Fatalf("unmarshal action results: %v", err) } if len(results) != 1 { t.Fatalf("expected one action result, got %d", len(results)) } result := results[0] if result.DeliveryType != "webhook" || result.Target != "https://hooks.example/automation" || result.Attempts != 2 || result.ResponseCode != http.StatusOK { t.Fatalf("unexpected webhook action metadata: %#v", result) } } func TestActionService_SendEmailTranscript_DeliversSplitRecipients(t *testing.T) { dbProvider := setupAutomationTestDBProvider(t) db := dbProvider.DB() accountID, _ := seedTestAccount(db, t) inboxID := seedTestInbox(db, t, accountID) contactID := seedTestContact(db, t, accountID) conversationID := seedTestConversation(db, t, accountID, inboxID, contactID) transcript := &recordingTranscriptDeliverer{result: ActionDeliveryResult{DeliveryType: "email_transcript", Attempts: 1}} restore := setAutomationActionDeliverersForTest(&recordingWebhookDeliverer{}, transcript) defer restore() if err := db.Create(&model.Message{ConversationID: conversationID, AccountID: accountID, InboxID: inboxID, Content: "transcript body", ContentType: "text", MessageType: "incoming"}).Error; err != nil { t.Fatalf("seed message: %v", err) } _ = db.Create(&model.Message{ConversationID: conversationID, AccountID: accountID, InboxID: inboxID, Content: "private body", ContentType: "text", MessageType: "incoming", Private: true}).Error _ = db.Create(&model.Message{ConversationID: conversationID, AccountID: accountID, InboxID: inboxID, Content: "activity body", ContentType: "text", MessageType: "activity"}).Error _ = db.Create(&model.Message{ConversationID: conversationID, AccountID: accountID, InboxID: inboxID, Content: "template body", ContentType: "text", MessageType: "template"}).Error result, err := NewActionService(dbProvider).ExecuteWithResult(context.Background(), accountID, conversationID, Action{ ActionName: "send_email_transcript", ActionParams: map[string]interface{}{"email": "first@example.com, second@example.com"}, }, ActionSourceAutomation, 99) if err != nil { t.Fatalf("expected transcript action success, got: %v", err) } if len(transcript.requests) != 2 { t.Fatalf("expected two transcript deliveries, got %d", len(transcript.requests)) } if transcript.requests[0].Recipient != "first@example.com" || transcript.requests[1].Recipient != "second@example.com" { t.Fatalf("unexpected transcript recipients: %#v", transcript.requests) } if !strings.Contains(transcript.requests[0].Subject, "Conversation Transcript") || !strings.Contains(transcript.requests[0].Body, "transcript body") { t.Fatalf("expected transcript subject/body to be populated: %#v", transcript.requests[0]) } if strings.Contains(transcript.requests[0].Body, "private body") || strings.Contains(transcript.requests[0].Body, "activity body") || strings.Contains(transcript.requests[0].Body, "template body") { t.Fatalf("transcript body should include only public incoming/outgoing chat messages: %q", transcript.requests[0].Body) } var account model.Account if err := db.First(&account, accountID).Error; err != nil || account.EmailsSentToday(time.Now()) != 2 { t.Fatalf("expected two transcript sends to increment account counter, account=%#v err=%v", account, err) } if result.DeliveryType != "email_transcript" || result.Target != "first@example.com,second@example.com" || result.Attempts != 2 { t.Fatalf("unexpected transcript action result metadata: %#v", result) } } func TestActionService_SendEmailTranscript_DisabledNoops(t *testing.T) { dbProvider := setupAutomationTestDBProvider(t) db := dbProvider.DB() accountID, _ := seedTestAccount(db, t) if err := db.Model(&model.Account{}).Where("id = ?", accountID).Update("limits", datatypes.JSON(`{"email_transcript_enabled":false}`)).Error; err != nil { t.Fatalf("disable transcripts: %v", err) } inboxID := seedTestInbox(db, t, accountID) contactID := seedTestContact(db, t, accountID) conversationID := seedTestConversation(db, t, accountID, inboxID, contactID) transcript := &recordingTranscriptDeliverer{result: ActionDeliveryResult{DeliveryType: "email_transcript", Attempts: 1}} restore := setAutomationActionDeliverersForTest(&recordingWebhookDeliverer{}, transcript) defer restore() result, err := NewActionService(dbProvider).ExecuteWithResult(context.Background(), accountID, conversationID, Action{ ActionName: "send_email_transcript", ActionParams: map[string]interface{}{"email": "first@example.com"}, }, ActionSourceAutomation, 99) if err != nil { t.Fatalf("disabled transcript action should no-op: %v", err) } if len(transcript.requests) != 0 || result.ResponseBody != "email_transcript_disabled" { t.Fatalf("expected disabled transcript no-op, result=%#v requests=%#v", result, transcript.requests) } } func TestActionService_SendEmailTranscript_StopsAtRateLimit(t *testing.T) { dbProvider := setupAutomationTestDBProvider(t) db := dbProvider.DB() accountID, _ := seedTestAccount(db, t) if err := db.Model(&model.Account{}).Where("id = ?", accountID).Updates(map[string]any{"limits": datatypes.JSON(`{"emails":1}`)}).Error; err != nil { t.Fatalf("set email limit: %v", err) } inboxID := seedTestInbox(db, t, accountID) contactID := seedTestContact(db, t, accountID) conversationID := seedTestConversation(db, t, accountID, inboxID, contactID) transcript := &recordingTranscriptDeliverer{result: ActionDeliveryResult{DeliveryType: "email_transcript", Attempts: 1}} restore := setAutomationActionDeliverersForTest(&recordingWebhookDeliverer{}, transcript) defer restore() _, err := NewActionService(dbProvider).ExecuteWithResult(context.Background(), accountID, conversationID, Action{ ActionName: "send_email_transcript", ActionParams: map[string]interface{}{"email": "first@example.com,second@example.com"}, }, ActionSourceAutomation, 99) if err != nil { t.Fatalf("rate-limited transcript action should stop without error: %v", err) } if len(transcript.requests) != 1 || transcript.requests[0].Recipient != "first@example.com" { t.Fatalf("expected only first recipient before rate limit, got %#v", transcript.requests) } } func TestActionService_SendWebhookEvent_QueuesDurableDelivery(t *testing.T) { dbProvider := setupAutomationTestDBProvider(t) db := dbProvider.DB() accountID, _ := seedTestAccount(db, t) inboxID := seedTestInbox(db, t, accountID) contactID := seedTestContact(db, t, accountID) conversationID := seedTestConversation(db, t, accountID, inboxID, contactID) webhook := &recordingWebhookDeliverer{result: ActionDeliveryResult{DeliveryType: "webhook", Attempts: 1, ResponseCode: http.StatusOK}} restore := setAutomationActionDeliverersForTest(webhook, &recordingTranscriptDeliverer{}) defer restore() if err := db.Create(&model.Message{ConversationID: conversationID, AccountID: accountID, InboxID: inboxID, Content: "queued webhook", ContentType: "text", MessageType: "incoming"}).Error; err != nil { t.Fatalf("seed message: %v", err) } wp := worker.NewWorkerPoolWithOptions(db, worker.WithNow(func() time.Time { return time.Date(2026, 6, 5, 12, 30, 0, 0, time.UTC) })) result, err := NewActionServiceWithWorker(dbProvider, wp).ExecuteWithResult(context.Background(), accountID, conversationID, Action{ ActionName: "send_webhook_event", ActionParams: map[string]interface{}{"url": "https://hooks.example/queued", "_event_name": "conversation_created"}, }, ActionSourceAutomation, 99) if err != nil { t.Fatalf("queue webhook action: %v", err) } if len(webhook.requests) != 0 { t.Fatalf("webhook should not deliver synchronously, got %d requests", len(webhook.requests)) } if !result.Queued || result.DeliveryType != "webhook" || result.Target != "https://hooks.example/queued" { t.Fatalf("unexpected queued webhook result: %#v", result) } var count int64 if err := db.Model(&model.BackgroundJob{}).Where("job_type = ? AND status = ?", TaskTypeAutomationWebhookDelivery, model.BackgroundJobStatusQueued).Count(&count).Error; err != nil { t.Fatalf("count webhook jobs: %v", err) } if count != 1 { t.Fatalf("expected one queued webhook job, got %d", count) } processed, err := wp.ProcessOne(context.Background()) if err != nil || !processed { t.Fatalf("process webhook job: processed=%v err=%v", processed, err) } if len(webhook.requests) != 1 || webhook.requests[0].URL != "https://hooks.example/queued" || webhook.requests[0].EventName != "conversation_created" { t.Fatalf("unexpected durable webhook request: %#v", webhook.requests) } } func TestActionService_SendEmailTranscript_QueuesDurableDeliveries(t *testing.T) { dbProvider := setupAutomationTestDBProvider(t) db := dbProvider.DB() accountID, _ := seedTestAccount(db, t) inboxID := seedTestInbox(db, t, accountID) contactID := seedTestContact(db, t, accountID) conversationID := seedTestConversation(db, t, accountID, inboxID, contactID) transcript := &recordingTranscriptDeliverer{result: ActionDeliveryResult{DeliveryType: "email_transcript", Attempts: 1}} restore := setAutomationActionDeliverersForTest(&recordingWebhookDeliverer{}, transcript) defer restore() if err := db.Create(&model.Message{ConversationID: conversationID, AccountID: accountID, InboxID: inboxID, Content: "queued transcript", ContentType: "text", MessageType: "incoming"}).Error; err != nil { t.Fatalf("seed message: %v", err) } wp := worker.NewWorkerPoolWithOptions(db, worker.WithNow(func() time.Time { return time.Date(2026, 6, 5, 12, 45, 0, 0, time.UTC) })) result, err := NewActionServiceWithWorker(dbProvider, wp).ExecuteWithResult(context.Background(), accountID, conversationID, Action{ ActionName: "send_email_transcript", ActionParams: map[string]interface{}{"email": "first@example.com, second@example.com"}, }, ActionSourceAutomation, 99) if err != nil { t.Fatalf("queue transcript action: %v", err) } if len(transcript.requests) != 0 { t.Fatalf("transcript should not deliver synchronously, got %d requests", len(transcript.requests)) } if !result.Queued || result.Target != "first@example.com,second@example.com" { t.Fatalf("unexpected queued transcript result: %#v", result) } for i := 0; i < 2; i++ { processed, err := wp.ProcessOne(context.Background()) if err != nil || !processed { t.Fatalf("process transcript job %d: processed=%v err=%v", i, processed, err) } } if len(transcript.requests) != 2 { t.Fatalf("expected two durable transcript deliveries, got %d", len(transcript.requests)) } if transcript.requests[0].Recipient != "first@example.com" || transcript.requests[1].Recipient != "second@example.com" { t.Fatalf("unexpected durable transcript recipients: %#v", transcript.requests) } if !strings.Contains(transcript.requests[0].Body, "queued transcript") { t.Fatalf("expected durable transcript body to be rendered at job time: %#v", transcript.requests[0]) } } func TestActionService_SendEmailToTeam_QueuesDurableTeamNotifications(t *testing.T) { dbProvider := setupAutomationTestDBProvider(t) db := dbProvider.DB() accountID, _ := seedTestAccount(db, t) inboxID := seedTestInbox(db, t, accountID) contactID := seedTestContact(db, t, accountID) conversationID := seedTestConversation(db, t, accountID, inboxID, contactID) team := &model.Team{AccountID: accountID, Name: "Escalations"} if err := db.Create(team).Error; err != nil { t.Fatalf("seed team: %v", err) } user := &model.User{AccountID: accountID, Name: "Team Agent", Email: "team-agent@example.com"} if err := db.Create(user).Error; err != nil { t.Fatalf("seed user: %v", err) } if err := db.Create(&model.AccountUser{AccountID: accountID, UserID: user.ID, Role: "agent"}).Error; err != nil { t.Fatalf("seed account user: %v", err) } if err := db.Create(&model.TeamMember{TeamID: team.ID, UserID: user.ID}).Error; err != nil { t.Fatalf("seed team member: %v", err) } transcript := &recordingTranscriptDeliverer{result: ActionDeliveryResult{DeliveryType: "team_email", Attempts: 1}} restore := setAutomationActionDeliverersForTest(&recordingWebhookDeliverer{}, transcript) defer restore() wp := worker.NewWorkerPool(db) result, err := NewActionServiceWithWorker(dbProvider, wp).ExecuteWithResult(context.Background(), accountID, conversationID, Action{ ActionName: "send_email_to_team", ActionParams: map[string]interface{}{ "team_ids": []interface{}{float64(team.ID)}, "message": "Please check this conversation", }, }, ActionSourceAutomation, 99) if err != nil { t.Fatalf("queue team email action: %v", err) } if !result.Queued || result.DeliveryType != "team_email" || result.Target != fmt.Sprintf("%d", team.ID) { t.Fatalf("unexpected queued team email result: %#v", result) } if len(transcript.requests) != 0 { t.Fatalf("team email should not deliver synchronously, got %d", len(transcript.requests)) } processed, err := wp.ProcessOne(context.Background()) if err != nil || !processed { t.Fatalf("process team email job: processed=%v err=%v", processed, err) } if len(transcript.requests) != 1 || transcript.requests[0].Recipient != "team-agent@example.com" { t.Fatalf("unexpected durable team email requests: %#v", transcript.requests) } if !strings.Contains(transcript.requests[0].Body, "Please check this conversation") { t.Fatalf("expected team email message body, got %#v", transcript.requests[0]) } } func TestActionService_AddSla_AttachesPolicyAndAppliedSLA(t *testing.T) { dbProvider := setupAutomationTestDBProvider(t) db := dbProvider.DB() accountID, _ := seedTestAccount(db, t) inboxID := seedTestInbox(db, t, accountID) contactID := seedTestContact(db, t, accountID) conversationID := seedTestConversation(db, t, accountID, inboxID, contactID) policy := &model.SlaPolicy{AccountID: accountID, Name: "Gold", FirstResponseTimeThreshold: 60, ResolutionTimeThreshold: 3600} if err := db.Create(policy).Error; err != nil { t.Fatalf("seed sla policy: %v", err) } _, err := NewActionService(dbProvider).ExecuteWithResult(context.Background(), accountID, conversationID, Action{ ActionName: "add_sla", ActionParams: map[string]interface{}{"sla_policy_id": float64(policy.ID)}, }, ActionSourceAutomation, 99) if err != nil { t.Fatalf("add sla action: %v", err) } var conversation model.Conversation if err := db.First(&conversation, conversationID).Error; err != nil { t.Fatalf("reload conversation: %v", err) } if conversation.SlaPolicyID == nil || *conversation.SlaPolicyID != policy.ID { t.Fatalf("expected conversation sla_policy_id %d, got %#v", policy.ID, conversation.SlaPolicyID) } var applied model.AppliedSLA if err := db.Where("conversation_id = ?", conversationID).First(&applied).Error; err != nil { t.Fatalf("expected applied sla: %v", err) } if applied.SlaPolicyID != policy.ID || applied.FRTTargetAt == nil || applied.RTTargetAt == nil { t.Fatalf("unexpected applied sla: %#v", applied) } _, err = NewActionService(dbProvider).ExecuteWithResult(context.Background(), accountID, conversationID, Action{ ActionName: "add_sla", ActionParams: map[string]interface{}{"sla_policy_id": float64(policy.ID)}, }, ActionSourceAutomation, 99) if err != nil { t.Fatalf("repeat add sla action should be idempotent: %v", err) } var count int64 if err := db.Model(&model.AppliedSLA{}).Where("conversation_id = ?", conversationID).Count(&count).Error; err != nil { t.Fatalf("count applied slas: %v", err) } if count != 1 { t.Fatalf("expected one applied sla after repeat, got %d", count) } } func TestActionService_MuteConversation_BlocksContactAndResolvesConversation(t *testing.T) { dbProvider := setupAutomationTestDBProvider(t) db := dbProvider.DB() accountID, userID := seedTestAccount(db, t) inboxID := seedTestInbox(db, t, accountID) contactID := seedTestContact(db, t, accountID) conversationID := seedTestConversation(db, t, accountID, inboxID, contactID) _, err := NewActionService(dbProvider).ExecuteWithResult(context.Background(), accountID, conversationID, Action{ ActionName: "mute_conversation", }, ActionSourceAutomation, userID) if err != nil { t.Fatalf("mute_conversation failed: %v", err) } var conversation model.Conversation if err := db.First(&conversation, conversationID).Error; err != nil { t.Fatalf("failed to load conversation: %v", err) } if conversation.Status != string(model.ConversationStatusResolved) { t.Fatalf("expected conversation status resolved, got %s", conversation.Status) } if !conversation.Muted { t.Fatal("expected conversation muted flag to be true") } var contact model.Contact if err := db.First(&contact, contactID).Error; err != nil { t.Fatalf("failed to load contact: %v", err) } if !contact.Blocked { t.Fatal("expected contact blocked flag to be true") } var legacyMuteCount int64 if err := db.Model(&ConversationMute{}).Where("conversation_id = ?", conversationID).Count(&legacyMuteCount).Error; err != nil { t.Fatalf("failed to count legacy mute rows: %v", err) } if legacyMuteCount != 0 { t.Fatalf("expected no legacy conversation_mutes row, got %d", legacyMuteCount) } } func TestActionService_SearchIndexesMessageAndConversationActions(t *testing.T) { dbProvider := setupAutomationTestDBProvider(t) db := dbProvider.DB() accountID, userID := seedTestAccount(db, t) inboxID := seedTestInbox(db, t, accountID) contactID := seedTestContact(db, t, accountID) conversationID := seedTestConversation(db, t, accountID, inboxID, contactID) indexer := &recordingActionSearchIndexer{} svc := NewActionService(dbProvider) svc.SetSearchIndexer(indexer) _, err := svc.ExecuteWithResult(context.Background(), accountID, conversationID, Action{ ActionName: "send_message", ActionParams: map[string]interface{}{"content": "indexed automation reply"}, }, ActionSourceAutomation, userID) if err != nil { t.Fatalf("send message action: %v", err) } if len(indexer.messages) != 1 || indexer.messages[0].Content != "indexed automation reply" { t.Fatalf("expected created message to be indexed, got %#v", indexer.messages) } if len(indexer.conversations) != 1 || indexer.conversations[0].ID != conversationID { t.Fatalf("expected parent conversation to be indexed, got %#v", indexer.conversations) } } func TestActionService_SearchIndexesConversationAndContactMutations(t *testing.T) { dbProvider := setupAutomationTestDBProvider(t) db := dbProvider.DB() accountID, userID := seedTestAccount(db, t) inboxID := seedTestInbox(db, t, accountID) contactID := seedTestContact(db, t, accountID) conversationID := seedTestConversation(db, t, accountID, inboxID, contactID) indexer := &recordingActionSearchIndexer{} svc := NewActionService(dbProvider) svc.SetSearchIndexer(indexer) _, err := svc.ExecuteWithResult(context.Background(), accountID, conversationID, Action{ ActionName: "mute_conversation", }, ActionSourceAutomation, userID) if err != nil { t.Fatalf("mute conversation action: %v", err) } if len(indexer.conversations) != 1 || indexer.conversations[0].Status != string(model.ConversationStatusResolved) || !indexer.conversations[0].Muted { t.Fatalf("expected resolved muted conversation to be indexed, got %#v", indexer.conversations) } if len(indexer.contacts) != 1 || indexer.contacts[0].ID != contactID || !indexer.contacts[0].Blocked { t.Fatalf("expected blocked contact to be indexed, got %#v", indexer.contacts) } } func TestAutomationRuleService_MatchAndExecute_PropagatesSearchIndexer(t *testing.T) { dbProvider := setupAutomationTestDBProvider(t) db := dbProvider.DB() accountID, _ := seedTestAccount(db, t) inboxID := seedTestInbox(db, t, accountID) contactID := seedTestContact(db, t, accountID) conversationID := seedTestConversation(db, t, accountID, inboxID, contactID) indexer := &recordingActionSearchIndexer{} svc := NewAutomationRuleService(dbProvider) svc.SetSearchIndexer(indexer) rule := &AutomationRule{ AccountID: accountID, EventName: "conversation_created", Name: "status index", Conditions: Conditions{}, Actions: Actions{{ActionName: "change_status", ActionParams: map[string]interface{}{"status": "resolved"}}}, Active: true, } if err := svc.Create(context.Background(), rule); err != nil { t.Fatalf("create automation rule: %v", err) } if err := svc.MatchAndExecute(context.Background(), accountID, "conversation_created", conversationID, map[string]interface{}{}); err != nil { t.Fatalf("match and execute: %v", err) } if len(indexer.conversations) != 1 || indexer.conversations[0].Status != string(model.ConversationStatusResolved) { t.Fatalf("expected automation rule action to index updated conversation, got %#v", indexer.conversations) } } func TestMacroService_WorkerPropagatesSearchIndexer(t *testing.T) { dbProvider := setupAutomationTestDBProvider(t) db := dbProvider.DB() accountID, userID := seedTestAccount(db, t) inboxID := seedTestInbox(db, t, accountID) contactID := seedTestContact(db, t, accountID) conversationID := seedTestConversation(db, t, accountID, inboxID, contactID) displayID := uint(42) if err := db.Model(&model.Conversation{}).Where("id = ?", conversationID).Update("display_id", displayID).Error; err != nil { t.Fatalf("set display id: %v", err) } macro := &Macro{AccountID: accountID, Name: "reply", CreatedByID: userID, UpdatedByID: userID, Actions: Actions{{ActionName: "send_message", ActionParams: map[string]interface{}{"content": "macro indexed"}}}} if err := NewMacroService(dbProvider).Create(context.Background(), macro); err != nil { t.Fatalf("create macro: %v", err) } wp := worker.NewWorkerPool(db) indexer := &recordingActionSearchIndexer{} svc := NewMacroServiceWithWorker(dbProvider, wp) svc.SetSearchIndexer(indexer) if err := svc.ExecuteForDisplayIDs(context.Background(), accountID, macro.ID, []uint{displayID}, userID); err != nil { t.Fatalf("enqueue macro: %v", err) } processed, err := wp.ProcessOne(context.Background()) if err != nil || !processed { t.Fatalf("process macro job: processed=%v err=%v", processed, err) } if len(indexer.messages) != 1 || indexer.messages[0].Content != "macro indexed" { t.Fatalf("expected macro-created message to be indexed, got %#v", indexer.messages) } if len(indexer.conversations) != 1 || indexer.conversations[0].ID != conversationID { t.Fatalf("expected macro conversation to be indexed, got %#v", indexer.conversations) } } func TestAutomationRuleService_MatchAndExecute_RecordsEmailTranscriptFailureMetadata(t *testing.T) { dbProvider := setupAutomationTestDBProvider(t) db := dbProvider.DB() accountID, _ := seedTestAccount(db, t) inboxID := seedTestInbox(db, t, accountID) contactID := seedTestContact(db, t, accountID) conversationID := seedTestConversation(db, t, accountID, inboxID, contactID) restore := setAutomationActionDeliverersForTest(&recordingWebhookDeliverer{}, &recordingTranscriptDeliverer{ result: ActionDeliveryResult{DeliveryType: "email_transcript", Target: "agent@example.com", Attempts: 3, Retryable: true}, err: errors.New("smtp failed after retries"), }) defer restore() svc := NewAutomationRuleService(dbProvider) rule := &AutomationRule{ AccountID: accountID, EventName: "conversation_resolved", Name: "transcript delivery", Conditions: Conditions{}, Actions: Actions{{ActionName: "send_email_transcript", ActionParams: map[string]interface{}{"email": "agent@example.com"}}}, Active: true, } if err := svc.Create(context.Background(), rule); err != nil { t.Fatalf("create rule: %v", err) } if err := svc.MatchAndExecute(context.Background(), accountID, "conversation_resolved", conversationID, map[string]interface{}{}); err != nil { t.Fatalf("match and execute: %v", err) } logs, err := NewExecutionLogService(dbProvider).ListRuleExecutions(context.Background(), accountID, rule.ID, 10) if err != nil { t.Fatalf("list executions: %v", err) } if len(logs) != 1 || logs[0].Status != ExecutionStatusFailed || logs[0].ActionsFailed != 1 { t.Fatalf("expected failed transcript execution log, got %#v", logs) } var results []ActionExecutionResult if err := json.Unmarshal(logs[0].ActionResults, &results); err != nil { t.Fatalf("unmarshal action results: %v", err) } if len(results) != 1 || results[0].Attempts != 3 || !results[0].Retryable || results[0].Error == "" { t.Fatalf("unexpected failed transcript metadata: %#v", results) } } type recordingWebhookDeliverer struct { requests []AutomationWebhookRequest result ActionDeliveryResult err error } func (d *recordingWebhookDeliverer) DeliverWebhook(ctx context.Context, req AutomationWebhookRequest) (ActionDeliveryResult, error) { d.requests = append(d.requests, req) result := d.result if result.DeliveryType == "" { result.DeliveryType = "webhook" } if result.Target == "" { result.Target = req.URL } return result, d.err } type recordingTranscriptDeliverer struct { requests []AutomationTranscriptRequest result ActionDeliveryResult err error } func (d *recordingTranscriptDeliverer) DeliverTranscript(ctx context.Context, req AutomationTranscriptRequest) (ActionDeliveryResult, error) { d.requests = append(d.requests, req) result := d.result if result.DeliveryType == "" { result.DeliveryType = "email_transcript" } if result.Target == "" { result.Target = req.Recipient } return result, d.err } type recordingActionSearchIndexer struct { conversations []model.Conversation messages []model.Message contacts []model.Contact } func (r *recordingActionSearchIndexer) IndexConversation(ctx context.Context, conversation *model.Conversation) error { r.conversations = append(r.conversations, *conversation) return nil } func (r *recordingActionSearchIndexer) IndexMessage(ctx context.Context, message *model.Message) error { r.messages = append(r.messages, *message) return nil } func (r *recordingActionSearchIndexer) IndexContact(ctx context.Context, contact *model.Contact) error { r.contacts = append(r.contacts, *contact) return nil }