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