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