426 lines
18 KiB
Go
426 lines
18 KiB
Go
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
|
|
}
|