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.
673 lines
29 KiB
Go
673 lines
29 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"
|
|
"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
|
|
}
|