Files
gochat/internal/automation/new_features_test.go
T
2026-06-04 15:44:48 +08:00

577 lines
19 KiB
Go

package automation
import (
"context"
"testing"
"github.com/gochat/gochat/internal/model"
)
// ===========================
// Macro Clone tests
// ===========================
func TestMacroService_Clone(t *testing.T) {
dbProvider := setupAutomationTestDBProvider(t)
accountID, userID := seedTestAccount(dbProvider.DB(), t)
macroSvc := NewMacroService(dbProvider)
// Create original macro
original := &Macro{
AccountID: accountID,
Name: "Close Ticket",
Actions: Actions{
{ActionName: "change_status", ActionParams: map[string]interface{}{"status": "resolved"}},
{ActionName: "add_label", ActionParams: map[string]interface{}{"label": "closed"}},
},
Visibility: MacroVisibilityGlobal,
Active: true,
CreatedByID: userID,
UpdatedByID: userID,
}
if err := macroSvc.Create(context.Background(), original); err != nil {
t.Fatalf("failed to create original macro: %v", err)
}
// Clone the macro
cloned, err := macroSvc.Clone(context.Background(), original.ID)
if err != nil {
t.Fatalf("failed to clone macro: %v", err)
}
// Verify clone properties
if cloned.ID == original.ID {
t.Fatalf("cloned macro should have a different ID")
}
if cloned.Name != "Close Ticket (copy)" {
t.Fatalf("expected name 'Close Ticket (copy)', got '%s'", cloned.Name)
}
if cloned.AccountID != original.AccountID {
t.Fatalf("expected account_id %d, got %d", original.AccountID, cloned.AccountID)
}
if cloned.Visibility != original.Visibility {
t.Fatalf("expected visibility %d, got %d", original.Visibility, cloned.Visibility)
}
if len(cloned.Actions) != len(original.Actions) {
t.Fatalf("expected %d actions, got %d", len(original.Actions), len(cloned.Actions))
}
if cloned.Actions[0].ActionName != "change_status" {
t.Fatalf("expected first action 'change_status', got '%s'", cloned.Actions[0].ActionName)
}
}
func TestMacroService_Clone_NotFound(t *testing.T) {
dbProvider := setupAutomationTestDBProvider(t)
macroSvc := NewMacroService(dbProvider)
_, err := macroSvc.Clone(context.Background(), 9999)
if err == nil {
t.Fatalf("expected error for non-existent macro, got nil")
}
}
// ===========================
// Macro ToggleActive tests
// ===========================
func TestMacroService_ToggleActive_Deactivate(t *testing.T) {
dbProvider := setupAutomationTestDBProvider(t)
accountID, userID := seedTestAccount(dbProvider.DB(), t)
macroSvc := NewMacroService(dbProvider)
// Create active macro
macro := &Macro{
AccountID: accountID,
Name: "Active Macro",
Actions: Actions{{ActionName: "change_status", ActionParams: map[string]interface{}{"status": "resolved"}}},
Visibility: MacroVisibilityGlobal,
Active: true,
CreatedByID: userID,
UpdatedByID: userID,
}
if err := macroSvc.Create(context.Background(), macro); err != nil {
t.Fatalf("failed to create macro: %v", err)
}
// Verify it starts active
found, err := macroSvc.GetByID(context.Background(), macro.ID)
if err != nil {
t.Fatalf("failed to get macro: %v", err)
}
if !found.Active {
t.Fatalf("expected macro to be active initially")
}
// Toggle to inactive
if err := macroSvc.ToggleActive(context.Background(), macro.ID, false); err != nil {
t.Fatalf("failed to toggle macro inactive: %v", err)
}
// Verify it's now inactive
found, err = macroSvc.GetByID(context.Background(), macro.ID)
if err != nil {
t.Fatalf("failed to get macro: %v", err)
}
if found.Active {
t.Fatalf("expected macro to be inactive after toggle, got active")
}
}
func TestMacroService_ToggleActive_Reactivate(t *testing.T) {
dbProvider := setupAutomationTestDBProvider(t)
accountID, userID := seedTestAccount(dbProvider.DB(), t)
macroSvc := NewMacroService(dbProvider)
// Create inactive macro
macro := &Macro{
AccountID: accountID,
Name: "Inactive Macro",
Actions: Actions{{ActionName: "add_label", ActionParams: map[string]interface{}{"label": "test"}}},
Visibility: MacroVisibilityPersonal,
Active: false,
CreatedByID: userID,
UpdatedByID: userID,
}
// Use Select to explicitly set Active=false (avoid GORM default override)
if err := dbProvider.DB().Select(
"AccountID", "Name", "Actions", "Visibility", "Active", "CreatedByID", "UpdatedByID",
).Create(macro).Error; err != nil {
t.Fatalf("failed to create inactive macro: %v", err)
}
// Verify it starts inactive
found, err := macroSvc.GetByID(context.Background(), macro.ID)
if err != nil {
t.Fatalf("failed to get macro: %v", err)
}
if found.Active {
t.Fatalf("expected macro to be inactive initially")
}
// Toggle to active
if err := macroSvc.ToggleActive(context.Background(), macro.ID, true); err != nil {
t.Fatalf("failed to toggle macro active: %v", err)
}
// Verify it's now active
found, err = macroSvc.GetByID(context.Background(), macro.ID)
if err != nil {
t.Fatalf("failed to get macro: %v", err)
}
if !found.Active {
t.Fatalf("expected macro to be active after toggle, got inactive")
}
}
// ===========================
// ExecutionLogService tests
// ===========================
func TestExecutionLogService_LogRuleExecution(t *testing.T) {
dbProvider := setupAutomationTestDBProvider(t)
accountID, _ := seedTestAccount(dbProvider.DB(), t)
logSvc := NewExecutionLogService(dbProvider)
ctx := context.Background()
err := logSvc.LogRuleExecution(ctx, accountID, 1, 10, ExecutionStatusSuccess, 3, 0, "")
if err != nil {
t.Fatalf("failed to log rule execution: %v", err)
}
// Retrieve the log
logs, err := logSvc.ListRuleExecutions(ctx, accountID, 1, 10)
if err != nil {
t.Fatalf("failed to list rule executions: %v", err)
}
if len(logs) != 1 {
t.Fatalf("expected 1 log, got %d", len(logs))
}
if logs[0].Status != ExecutionStatusSuccess {
t.Fatalf("expected status '%s', got '%s'", ExecutionStatusSuccess, logs[0].Status)
}
if logs[0].ActionsExecuted != 3 {
t.Fatalf("expected 3 actions_executed, got %d", logs[0].ActionsExecuted)
}
if logs[0].ActionsFailed != 0 {
t.Fatalf("expected 0 actions_failed, got %d", logs[0].ActionsFailed)
}
}
func TestExecutionLogService_LogRuleExecution_Partial(t *testing.T) {
dbProvider := setupAutomationTestDBProvider(t)
accountID, _ := seedTestAccount(dbProvider.DB(), t)
logSvc := NewExecutionLogService(dbProvider)
ctx := context.Background()
err := logSvc.LogRuleExecution(ctx, accountID, 2, 20, ExecutionStatusPartial, 2, 1, "action add_label failed: label not found")
if err != nil {
t.Fatalf("failed to log partial execution: %v", err)
}
logs, err := logSvc.ListRuleExecutions(ctx, accountID, 2, 10)
if err != nil {
t.Fatalf("failed to list rule executions: %v", err)
}
if len(logs) != 1 {
t.Fatalf("expected 1 log, got %d", len(logs))
}
if logs[0].Status != ExecutionStatusPartial {
t.Fatalf("expected status '%s', got '%s'", ExecutionStatusPartial, logs[0].Status)
}
if logs[0].ActionsFailed != 1 {
t.Fatalf("expected 1 actions_failed, got %d", logs[0].ActionsFailed)
}
if logs[0].ErrorMessage == "" {
t.Fatalf("expected non-empty error message")
}
}
func TestExecutionLogService_LogRuleExecution_Failed(t *testing.T) {
dbProvider := setupAutomationTestDBProvider(t)
accountID, _ := seedTestAccount(dbProvider.DB(), t)
logSvc := NewExecutionLogService(dbProvider)
ctx := context.Background()
err := logSvc.LogRuleExecution(ctx, accountID, 3, 30, ExecutionStatusFailed, 0, 2, "all actions failed")
if err != nil {
t.Fatalf("failed to log failed execution: %v", err)
}
logs, err := logSvc.ListRuleExecutions(ctx, accountID, 3, 10)
if err != nil {
t.Fatalf("failed to list rule executions: %v", err)
}
if logs[0].Status != ExecutionStatusFailed {
t.Fatalf("expected status '%s', got '%s'", ExecutionStatusFailed, logs[0].Status)
}
}
func TestExecutionLogService_ListConversationExecutions(t *testing.T) {
dbProvider := setupAutomationTestDBProvider(t)
accountID, _ := seedTestAccount(dbProvider.DB(), t)
logSvc := NewExecutionLogService(dbProvider)
ctx := context.Background()
// Log multiple executions for the same conversation
logSvc.LogRuleExecution(ctx, accountID, 1, 100, ExecutionStatusSuccess, 3, 0, "")
logSvc.LogRuleExecution(ctx, accountID, 2, 100, ExecutionStatusPartial, 2, 1, "some failed")
logSvc.LogRuleExecution(ctx, accountID, 1, 200, ExecutionStatusSuccess, 1, 0, "")
// List executions for conversation 100
logs, err := logSvc.ListConversationExecutions(ctx, accountID, 100, 10)
if err != nil {
t.Fatalf("failed to list conversation executions: %v", err)
}
if len(logs) != 2 {
t.Fatalf("expected 2 logs for conversation 100, got %d", len(logs))
}
// List executions for conversation 200
logs, err = logSvc.ListConversationExecutions(ctx, accountID, 200, 10)
if err != nil {
t.Fatalf("failed to list conversation executions: %v", err)
}
if len(logs) != 1 {
t.Fatalf("expected 1 log for conversation 200, got %d", len(logs))
}
}
func TestExecutionLogService_LogMacroExecution(t *testing.T) {
dbProvider := setupAutomationTestDBProvider(t)
accountID, userID := seedTestAccount(dbProvider.DB(), t)
logSvc := NewExecutionLogService(dbProvider)
ctx := context.Background()
// Create a macro first so the FK constraint works
macro := &Macro{
AccountID: accountID,
Name: "Test Macro",
Actions: Actions{{ActionName: "change_status", ActionParams: map[string]interface{}{"status": "resolved"}}},
Visibility: MacroVisibilityGlobal,
Active: true,
CreatedByID: userID,
UpdatedByID: userID,
}
if err := dbProvider.DB().Select(
"AccountID", "Name", "Actions", "Visibility", "Active", "CreatedByID", "UpdatedByID",
).Create(macro).Error; err != nil {
t.Fatalf("failed to create macro: %v", err)
}
err := logSvc.LogMacroExecution(ctx, macro.ID, 42, userID)
if err != nil {
t.Fatalf("failed to log macro execution: %v", err)
}
logs, err := logSvc.ListMacroExecutions(ctx, macro.ID, 10)
if err != nil {
t.Fatalf("failed to list macro executions: %v", err)
}
if len(logs) != 1 {
t.Fatalf("expected 1 log, got %d", len(logs))
}
if logs[0].ExecutedByID != userID {
t.Fatalf("expected executed_by_id %d, got %d", userID, logs[0].ExecutedByID)
}
}
func TestExecutionLogService_MultipleRuleExecutions(t *testing.T) {
dbProvider := setupAutomationTestDBProvider(t)
accountID, _ := seedTestAccount(dbProvider.DB(), t)
logSvc := NewExecutionLogService(dbProvider)
ctx := context.Background()
// Log 5 executions for the same rule
for i := 0; i < 5; i++ {
err := logSvc.LogRuleExecution(ctx, accountID, 1, uint(i+1), ExecutionStatusSuccess, 2, 0, "")
if err != nil {
t.Fatalf("failed to log execution %d: %v", i, err)
}
}
// List with limit 3 — should return the 3 most recent
logs, err := logSvc.ListRuleExecutions(ctx, accountID, 1, 3)
if err != nil {
t.Fatalf("failed to list rule executions: %v", err)
}
if len(logs) != 3 {
t.Fatalf("expected 3 logs (limited), got %d", len(logs))
}
// List without limit — should return all 5
logs, err = logSvc.ListRuleExecutions(ctx, accountID, 1, 0)
if err != nil {
t.Fatalf("failed to list rule executions: %v", err)
}
if len(logs) != 5 {
t.Fatalf("expected 5 logs (no limit), got %d", len(logs))
}
}
// ===========================
// Macro Execution + Template Variable Integration tests
// ===========================
// TestMacroService_Execute_WithTemplateVars verifies that template variables
// in macro action params are resolved before execution. When a macro contains
// {{contact.name}} in a send_message action, the resulting message should
// contain the actual contact name, not the raw template placeholder.
func TestMacroService_Execute_WithTemplateVars(t *testing.T) {
dbProvider := setupAutomationTestDBProvider(t)
accountID, userID := seedTestAccount(dbProvider.DB(), t)
inboxID := seedTestInbox(dbProvider.DB(), t, accountID)
contactID := seedTestContact(dbProvider.DB(), t, accountID)
conversationID := seedTestConversation(dbProvider.DB(), t, accountID, inboxID, contactID)
macroSvc := NewMacroService(dbProvider)
ctx := context.Background()
// Create a macro with a send_message action containing {{contact.name}}
macro := &Macro{
AccountID: accountID,
Name: "Greet Contact",
Actions: Actions{
{
ActionName: "send_message",
ActionParams: map[string]interface{}{
"content": "Hello {{contact.name}}, your ticket is being processed.",
},
},
},
Visibility: MacroVisibilityGlobal,
Active: true,
CreatedByID: userID,
UpdatedByID: userID,
}
if err := macroSvc.Create(ctx, macro); err != nil {
t.Fatalf("failed to create macro: %v", err)
}
// Execute the macro on the conversation
if err := macroSvc.Execute(ctx, accountID, conversationID, macro.ID, userID); err != nil {
t.Fatalf("failed to execute macro: %v", err)
}
// Verify the message was created with resolved template variables
var messages []model.Message
if err := dbProvider.DB().WithContext(ctx).
Where("conversation_id = ? AND account_id = ? AND message_type = 'outgoing'", conversationID, accountID).
Find(&messages).Error; err != nil {
t.Fatalf("failed to query messages: %v", err)
}
if len(messages) != 1 {
t.Fatalf("expected 1 outgoing message, got %d", len(messages))
}
// The template variable {{contact.name}} should have been resolved to "Test Contact"
// (seedTestContact creates a contact with Name="Test Contact")
if messages[0].Content != "Hello Test Contact, your ticket is being processed." {
t.Fatalf("expected resolved content 'Hello Test Contact, your ticket is being processed.', got '%s'", messages[0].Content)
}
// Verify the sender is set as agent (macro execution uses agent sender type)
if messages[0].SenderType != "agent" {
t.Fatalf("expected sender_type 'agent', got '%s'", messages[0].SenderType)
}
}
// TestMacroService_Execute_SelfAssign verifies that macros with assign_agent
// action using "self" as the user_id parameter correctly assign the executing user.
// Reference: Chatwoot macros support "self" assignment — the agent running the macro
// becomes the assignee.
func TestMacroService_Execute_SelfAssign(t *testing.T) {
dbProvider := setupAutomationTestDBProvider(t)
accountID, userID := seedTestAccount(dbProvider.DB(), t)
inboxID := seedTestInbox(dbProvider.DB(), t, accountID)
contactID := seedTestContact(dbProvider.DB(), t, accountID)
conversationID := seedTestConversation(dbProvider.DB(), t, accountID, inboxID, contactID)
macroSvc := NewMacroService(dbProvider)
ctx := context.Background()
// Create a macro that assigns "self" (the executing user)
macro := &Macro{
AccountID: accountID,
Name: "Assign to Self",
Actions: Actions{
{
ActionName: "assign_agent",
ActionParams: map[string]interface{}{
"assignee_id": "self",
},
},
},
Visibility: MacroVisibilityGlobal,
Active: true,
CreatedByID: userID,
UpdatedByID: userID,
}
if err := macroSvc.Create(ctx, macro); err != nil {
t.Fatalf("failed to create macro: %v", err)
}
// Execute the macro
if err := macroSvc.Execute(ctx, accountID, conversationID, macro.ID, userID); err != nil {
t.Fatalf("failed to execute macro: %v", err)
}
// Verify the conversation was assigned to the executing user
var conv model.Conversation
if err := dbProvider.DB().WithContext(ctx).First(&conv, conversationID).Error; err != nil {
t.Fatalf("failed to query conversation: %v", err)
}
if *conv.AssigneeID != userID {
t.Fatalf("expected assignee_id %d (self), got %d", userID, *conv.AssigneeID)
}
}
// TestMacroService_Execute_MultipleActions verifies that a macro with multiple
// actions executes all of them in sequence.
func TestMacroService_Execute_MultipleActions(t *testing.T) {
dbProvider := setupAutomationTestDBProvider(t)
accountID, userID := seedTestAccount(dbProvider.DB(), t)
inboxID := seedTestInbox(dbProvider.DB(), t, accountID)
contactID := seedTestContact(dbProvider.DB(), t, accountID)
conversationID := seedTestConversationWithDetails(dbProvider.DB(), t, accountID, inboxID, contactID, "open", "", "web", 0)
macroSvc := NewMacroService(dbProvider)
ctx := context.Background()
// Create a macro with multiple actions
macro := &Macro{
AccountID: accountID,
Name: "Resolve and Label",
Actions: Actions{
{ActionName: "change_status", ActionParams: map[string]interface{}{"status": "resolved"}},
{ActionName: "add_label", ActionParams: map[string]interface{}{"label": "macro-resolved"}},
},
Visibility: MacroVisibilityGlobal,
Active: true,
CreatedByID: userID,
UpdatedByID: userID,
}
if err := macroSvc.Create(ctx, macro); err != nil {
t.Fatalf("failed to create macro: %v", err)
}
// Execute the macro
if err := macroSvc.Execute(ctx, accountID, conversationID, macro.ID, userID); err != nil {
t.Fatalf("failed to execute macro: %v", err)
}
// Verify the conversation status was changed
var conv model.Conversation
if err := dbProvider.DB().WithContext(ctx).First(&conv, conversationID).Error; err != nil {
t.Fatalf("failed to query conversation: %v", err)
}
if conv.Status != "resolved" {
t.Fatalf("expected status 'resolved', got '%s'", conv.Status)
}
// Verify the label was added
var labels []ConversationLabel
if err := dbProvider.DB().WithContext(ctx).
Where("conversation_id = ?", conversationID).
Find(&labels).Error; err != nil {
t.Fatalf("failed to query labels: %v", err)
}
found := false
for _, l := range labels {
if l.Label == "macro-resolved" {
found = true
break
}
}
if !found {
t.Fatalf("expected label 'macro-resolved' to be added, but it was not found among %d labels", len(labels))
}
}
// TestMacroService_Execute_RecordsExecution verifies that executing a macro
// creates a MacroExecution audit record.
func TestMacroService_Execute_RecordsExecution(t *testing.T) {
dbProvider := setupAutomationTestDBProvider(t)
accountID, userID := seedTestAccount(dbProvider.DB(), t)
inboxID := seedTestInbox(dbProvider.DB(), t, accountID)
contactID := seedTestContact(dbProvider.DB(), t, accountID)
conversationID := seedTestConversation(dbProvider.DB(), t, accountID, inboxID, contactID)
macroSvc := NewMacroService(dbProvider)
ctx := context.Background()
macro := &Macro{
AccountID: accountID,
Name: "Simple Macro",
Actions: Actions{
{ActionName: "change_status", ActionParams: map[string]interface{}{"status": "resolved"}},
},
Visibility: MacroVisibilityGlobal,
Active: true,
CreatedByID: userID,
UpdatedByID: userID,
}
if err := macroSvc.Create(ctx, macro); err != nil {
t.Fatalf("failed to create macro: %v", err)
}
if err := macroSvc.Execute(ctx, accountID, conversationID, macro.ID, userID); err != nil {
t.Fatalf("failed to execute macro: %v", err)
}
// Verify execution record was created
var executions []MacroExecution
if err := dbProvider.DB().WithContext(ctx).
Where("macro_id = ? AND conversation_id = ?", macro.ID, conversationID).
Find(&executions).Error; err != nil {
t.Fatalf("failed to query macro executions: %v", err)
}
if len(executions) != 1 {
t.Fatalf("expected 1 execution record, got %d", len(executions))
}
if executions[0].ExecutedByID != userID {
t.Fatalf("expected executed_by_id %d, got %d", userID, executions[0].ExecutedByID)
}
}