feat(automation): record rule execution outcomes
This commit is contained in:
@@ -16,9 +16,9 @@ Build GoChat as a Go backend that can directly reuse the frontend from `referenc
|
||||
|
||||
## Current Baseline
|
||||
|
||||
- Latest implementation checkpoint: `feat(automation): align rule trigger coverage`.
|
||||
- Latest documentation checkpoint: this checkpoint, recorded with the B9.1b implementation.
|
||||
- Worktree status at this implementation checkpoint: B9.1b Chatwoot listener trigger coverage is implemented; next active slice is B9.1c execution logs/stop-on-match, followed by B9.1d webhook/email transcript retries and B9.2 macro execution parity.
|
||||
- Latest implementation checkpoint: `feat(automation): record rule execution outcomes`.
|
||||
- Latest documentation checkpoint: this checkpoint, recorded with the B9.1c implementation.
|
||||
- Worktree status at this implementation checkpoint: B9.1c execution outcome logging is implemented; next active slice is B9.1d retryable webhook/email transcript actions, followed by B9.2 macro execution parity.
|
||||
- `go test ./...` passes.
|
||||
- Route dump succeeds with `TOTAL: 830` after adding the Chatwoot-compatible applied-SLA index route.
|
||||
- Route parity artifacts now exist under `docs/parity/` and are generated by `cmd/route_parity`.
|
||||
@@ -44,12 +44,11 @@ Next ordered checkpoints:
|
||||
|
||||
| Order | Slice | Required outcome | Primary verification |
|
||||
| --- | --- | --- | --- |
|
||||
| 1 | B9.1c | Automation execution logs, failed-action records, and stop-on-match semantics are explicit and tested. | Rule service/action tests for matched, skipped, failed, and stop-on-match flows. |
|
||||
| 2 | B9.1d | Webhook and email transcript actions are timeout-bound, retryable, and observable through the worker/action boundary. | Fake HTTP/mailer tests and log/retry metadata assertions. |
|
||||
| 3 | B9.2 | Macro CRUD and macro execute side effects match Chatwoot frontend expectations. | Macro handler/service tests that reload conversations/messages after execution. |
|
||||
| 4 | B10 | Audit, CustomRole, and remaining InboxLimit surfaces enforce Chatwoot admin behavior. | Permission matrix tests, audit writer/list tests, inbox/account limit tests. |
|
||||
| 5 | B11 | Captain/Copilot enterprise screens have real persistence and safe LLM feature gates. | Captain/Copilot handler/service fixtures and disabled-state tests. |
|
||||
| 6 | B12 | Reused Chatwoot frontend smoke runs repeatably against GoChat. | Checked smoke command plus gap report under `docs/parity/`. |
|
||||
| 1 | B9.1d | Webhook and email transcript actions are timeout-bound, retryable, and observable through the worker/action boundary. | Fake HTTP/mailer tests and log/retry metadata assertions. |
|
||||
| 2 | B9.2 | Macro CRUD and macro execute side effects match Chatwoot frontend expectations. | Macro handler/service tests that reload conversations/messages after execution. |
|
||||
| 3 | B10 | Audit, CustomRole, and remaining InboxLimit surfaces enforce Chatwoot admin behavior. | Permission matrix tests, audit writer/list tests, inbox/account limit tests. |
|
||||
| 4 | B11 | Captain/Copilot enterprise screens have real persistence and safe LLM feature gates. | Captain/Copilot handler/service fixtures and disabled-state tests. |
|
||||
| 5 | B12 | Reused Chatwoot frontend smoke runs repeatably against GoChat. | Checked smoke command plus gap report under `docs/parity/`. |
|
||||
|
||||
## Execution Snapshot
|
||||
|
||||
@@ -139,6 +138,7 @@ This ledger records the committed parity checkpoints that future slices should b
|
||||
| `feat(automation): align automation rule payloads` | Completed B9.1a automation-rule CRUD contract parity for the reused Chatwoot frontend: list/show/update/clone now return `{ payload }`, create returns the raw rule object, delete returns empty `200 OK`, conditions accept `attribute_key` and Chatwoot operators such as `equal_to`, action params accept frontend arrays, responses serialize `attribute_key`, array `action_params`, Unix `created_on`, and update/delete/clone are account-scoped. | `go test ./internal/handler/api/v1 -run AutomationRule -count=1`; `go test ./internal/automation -run 'AutomationRule\|Condition\|Action' -count=1`; `go test ./internal/handler/api/v1 -count=1`; `go test ./internal/automation -count=1`; `go test ./...`; `git diff --check`. No route changes; route dump remains `TOTAL: 830`. | Continue B9.1b trigger coverage, B9.1c execution logs/stop-on-match, and B9.2 macro payload/execute parity. |
|
||||
| `docs: detail automation trigger plan` | Fully landed the next B9 tracking contract: current baseline now points at `3403770`, the control board is ordered from B9.1b through B12, B9 status is `Doing`, and B9.1b explicitly follows the current Chatwoot listener events while excluding non-reference `contact_updated` automation triggers. | Documentation-only checkpoint; `git diff --check` passed before commit. | Start B9.1b implementation from the recorded listener/event contract. |
|
||||
| `feat(automation): align rule trigger coverage` | Completed B9.1b listener/event coverage: automation rules now honor Chatwoot skip rules for automation-origin events, auto-reply conversations, activity messages, and auto-reply emails; listener extraction works from event conversation ID, `conversation_id`, conversation payloads, and message payloads; conversation update/status/priority paths dispatch `changed_attributes`; initial conversation messages and provider webhook messages carry the message/conversation data needed by automation. | `go test ./internal/automation -run 'AutomationRuleListener\|MatchAndExecute\|Condition' -count=1`; `go test ./internal/service -run 'Conversation.*Automation\|Message.*Automation\|ConversationService' -count=1`; `go test ./internal/handler/webhook -run 'Incoming\|Webhook' -count=1`; `go test ./internal/automation -count=1`; `go test ./internal/service -count=1`; `go test ./internal/handler/webhook -count=1`; `go test ./...`; `git diff --check`. No route changes; route dump remains `TOTAL: 830`. | Continue B9.1c execution logs and stop-on-match behavior, then B9.1d retryable webhook/email transcript actions and B9.2 macro parity. |
|
||||
| `feat(automation): record rule execution outcomes` | Completed B9.1c observability/parity guard: rule evaluations now record skipped condition outcomes, condition failures, event names, aggregate action counts, and per-action success/failure results. Matching rules continue executing in ID order without stop-on-match, matching the current Chatwoot listener loop, and failed actions do not prevent later actions from running. | `go test ./internal/automation -run 'AutomationRuleService_MatchAndExecute\|ExecutionLogService' -count=1`; `go test ./internal/automation -count=1`; `go test ./internal/handler/webhook -run 'Incoming\|Webhook' -count=1`; `go test ./internal/service -run 'Conversation.*Automation\|Message.*Automation\|ConversationService' -count=1`; `go test ./...`; `git diff --check`. No route changes; route dump remains `TOTAL: 830`. | Continue B9.1d retryable webhook/email transcript actions, then B9.2 macro payload/execute parity. |
|
||||
|
||||
## Next Slice Contract
|
||||
|
||||
@@ -410,7 +410,7 @@ B9 automation and macro execution breakdown:
|
||||
| --- | --- | --- | --- | --- |
|
||||
| B9.1a | Align automation-rule CRUD request/response envelopes, condition/action validation, and rule status toggles. | Chatwoot automation rules controller/model and dashboard automation client. | Handler tests for index/show/create/update/delete/toggle payloads and validation failures. | Done by `feat(automation): align automation rule payloads` |
|
||||
| B9.1b | Implement real rule trigger coverage for Chatwoot automation events: conversation created/updated/opened/resolved and message created. Provider webhook persistence must dispatch into those same conversation/message events. `contact_updated` is not a current Chatwoot automation trigger and stays out unless the local reference/frontend proves otherwise. | `reference/chatwoot/app/listeners/automation_rule_listener.rb`, `reference/chatwoot/app/listeners/base_listener.rb`, existing `channel.Dispatcher` events. | Listener tests prove matching rules execute once per event with account/inbox filters, changed attributes, provider-event reachability, and Chatwoot skip rules. | Done by `feat(automation): align rule trigger coverage` |
|
||||
| B9.1c | Persist execution logs and stop-on-match behavior, including failed action records. | Chatwoot automation execution/log models and services. | Service tests cover success, skipped, failed, retryable, and stop-on-match flows. | Todo |
|
||||
| B9.1c | Persist execution logs and stop-on-match behavior, including failed action records. The current Chatwoot listener runs every matching rule, so Go explicitly preserves no-stop-on-match behavior and records it in tests. | `reference/chatwoot/app/listeners/automation_rule_listener.rb`, `reference/chatwoot/app/services/automation_rules/action_service.rb`, Go execution log service. | Service tests cover success, skipped, failed/partial, per-action result records, and all-matching-rules execution. | Done by `feat(automation): record rule execution outcomes` |
|
||||
| B9.1d | Make webhook and email transcript actions timeout-bound, retryable, and observable. | Chatwoot action services/jobs and Phase 5 worker plan. | Tests use fake HTTP/mailer boundaries and assert retry/log metadata. | Todo |
|
||||
| B9.2a | Align macro CRUD payloads, visibility rules, and validation. | Chatwoot macros controller/model and dashboard macro client. | Handler tests for raw macro payloads, ownership, account scoping, and delete status. | Todo |
|
||||
| B9.2b | Execute macro actions against real conversations: labels, status, priority, assignee, team, custom attributes, private notes, and attachments where supported. | Chatwoot macro execution service and conversation/message controllers. | Macro execute tests reload conversation/message rows and assert Chatwoot-shaped response payloads. | Todo |
|
||||
@@ -511,6 +511,26 @@ env GOCACHE=/tmp/gochat-gocache GOMODCACHE=/tmp/gochat-gomodcache go test ./...
|
||||
git diff --check
|
||||
```
|
||||
|
||||
B9.1c current checkpoint:
|
||||
|
||||
- Automation execution logs now include `event_name`, aggregate action counts, and JSON `action_results` for each action in a matched rule.
|
||||
- Rules whose event matches but conditions do not match now write `skipped` execution records with `conditions_not_matched`, giving the B9 tracker explicit skipped-flow evidence.
|
||||
- Condition matcher errors are logged as failed rule evaluations instead of disappearing into process logs only.
|
||||
- Failed actions record per-action errors while later actions still run, matching Chatwoot `AutomationRules::ActionService#perform` rescue-and-continue behavior.
|
||||
- The current Chatwoot listener loops through every matching rule; Go preserves this no-stop-on-match behavior and has regression coverage proving two matching rules both execute.
|
||||
- B9 remains `Doing` because B9.1d retryable webhook/email transcript actions and B9.2 macro parity are still active.
|
||||
|
||||
B9.1c verification:
|
||||
|
||||
```bash
|
||||
env GOCACHE=/tmp/gochat-gocache GOMODCACHE=/tmp/gochat-gomodcache go test ./internal/automation -run 'AutomationRuleService_MatchAndExecute|ExecutionLogService' -count=1
|
||||
env GOCACHE=/tmp/gochat-gocache GOMODCACHE=/tmp/gochat-gomodcache go test ./internal/automation -count=1
|
||||
env GOCACHE=/tmp/gochat-gocache GOMODCACHE=/tmp/gochat-gomodcache go test ./internal/handler/webhook -run 'Incoming|Webhook' -count=1
|
||||
env GOCACHE=/tmp/gochat-gocache GOMODCACHE=/tmp/gochat-gomodcache go test ./internal/service -run 'Conversation.*Automation|Message.*Automation|ConversationService' -count=1
|
||||
env GOCACHE=/tmp/gochat-gocache GOMODCACHE=/tmp/gochat-gomodcache go test ./...
|
||||
git diff --check
|
||||
```
|
||||
|
||||
B10 admin and limits breakdown:
|
||||
|
||||
| Step | Implementation target | Reference source | Required tests | Status |
|
||||
@@ -1157,3 +1177,4 @@ Verification milestone gates:
|
||||
- 2026-06-05: B9.1a automation-rule CRUD checkpoint committed as `feat(automation): align automation rule payloads`; automation-rule list/show/update/clone now return Chatwoot `{ payload }`, create returns the raw rule object, delete returns empty `200 OK`, frontend `attribute_key`/`equal_to` conditions and array `action_params` are accepted, responses serialize Chatwoot field names and Unix `created_on`, and get/update/delete/clone are account-scoped. Focused automation handler/service tests, handler and automation package tests, full `go test ./...`, and `git diff --check` passed. Next slice is B9.1b trigger coverage and action side-effect parity.
|
||||
- 2026-06-05: B9 planning tracker was fully landed for the next handoff: current baseline points at `3403770 feat(automation): align automation rule payloads`, the ordered control board now starts at B9.1b and runs through B12, B9 is marked `Doing`, and B9.1b records the exact Chatwoot listener contract. The current reference listener only subscribes to conversation created/updated/opened/resolved and message created, so `contact_updated` is explicitly not an automation trigger unless a later local reference/frontend audit proves otherwise.
|
||||
- 2026-06-05: B9.1b automation trigger checkpoint committed as `feat(automation): align rule trigger coverage`; automation listener now covers all current Chatwoot reference events, skips automation-origin/auto-reply/activity auto-reply-email cases, extracts account/conversation context from message and conversation event payloads, conversation update/status/priority paths dispatch `changed_attributes`, initial conversation messages dispatch full message-created context, and provider webhook persistence has a regression proving message-created automation reachability. Focused automation/service/webhook tests, package tests, full `go test ./...`, and `git diff --check` passed. Next slice is B9.1c execution log/stop-on-match parity.
|
||||
- 2026-06-05: B9.1c execution outcome checkpoint committed as `feat(automation): record rule execution outcomes`; automation evaluations now persist event names, skipped condition outcomes, condition errors, per-action success/failure JSON, and aggregate action counts. Failed actions continue to later actions, and every matching rule still executes in ID order to match the current Chatwoot listener's no-stop-on-match loop. Focused automation tests, automation package tests, relevant service/webhook regressions, full `go test ./...`, and `git diff --check` passed. Next slice is B9.1d retryable webhook/email transcript action delivery.
|
||||
|
||||
@@ -2,6 +2,9 @@ package automation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
|
||||
"gorm.io/datatypes"
|
||||
)
|
||||
|
||||
// ===========================
|
||||
@@ -15,6 +18,13 @@ type ExecutionLogService struct {
|
||||
db DBProvider
|
||||
}
|
||||
|
||||
// ActionExecutionResult records the result of one action within a rule execution.
|
||||
type ActionExecutionResult struct {
|
||||
ActionName string `json:"action_name"`
|
||||
Status string `json:"status"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// NewExecutionLogService creates a new ExecutionLogService.
|
||||
func NewExecutionLogService(db DBProvider) *ExecutionLogService {
|
||||
return &ExecutionLogService{db: db}
|
||||
@@ -22,18 +32,33 @@ func NewExecutionLogService(db DBProvider) *ExecutionLogService {
|
||||
|
||||
// LogRuleExecution records an automation rule execution event.
|
||||
func (s *ExecutionLogService) LogRuleExecution(ctx context.Context, accountID, ruleID, conversationID uint, status string, actionsExecuted, actionsFailed int, errorMsg string) error {
|
||||
return s.LogRuleExecutionWithResults(ctx, accountID, ruleID, conversationID, "", status, actionsExecuted, actionsFailed, errorMsg, nil)
|
||||
}
|
||||
|
||||
// LogRuleExecutionWithResults records a rule evaluation with optional event name and per-action results.
|
||||
func (s *ExecutionLogService) LogRuleExecutionWithResults(ctx context.Context, accountID, ruleID, conversationID uint, eventName, status string, actionsExecuted, actionsFailed int, errorMsg string, actionResults []ActionExecutionResult) error {
|
||||
resultsJSON := datatypes.JSON([]byte("[]"))
|
||||
if actionResults != nil {
|
||||
payload, err := json.Marshal(actionResults)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resultsJSON = datatypes.JSON(payload)
|
||||
}
|
||||
record := &AutomationExecution{
|
||||
AccountID: accountID,
|
||||
RuleID: ruleID,
|
||||
ConversationID: conversationID,
|
||||
EventName: eventName,
|
||||
Status: status,
|
||||
ActionsExecuted: actionsExecuted,
|
||||
ActionsFailed: actionsFailed,
|
||||
ActionResults: resultsJSON,
|
||||
ErrorMessage: errorMsg,
|
||||
}
|
||||
return s.db.DB().WithContext(ctx).Select(
|
||||
"AccountID", "RuleID", "ConversationID",
|
||||
"Status", "ActionsExecuted", "ActionsFailed", "ErrorMessage",
|
||||
"EventName", "Status", "ActionsExecuted", "ActionsFailed", "ActionResults", "ErrorMessage",
|
||||
).Create(record).Error
|
||||
}
|
||||
|
||||
@@ -103,4 +128,5 @@ const (
|
||||
ExecutionStatusSuccess = "success"
|
||||
ExecutionStatusPartial = "partial" // some actions succeeded, some failed
|
||||
ExecutionStatusFailed = "failed"
|
||||
)
|
||||
ExecutionStatusSkipped = "skipped"
|
||||
)
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/gochat/gochat/internal/model"
|
||||
"gorm.io/datatypes"
|
||||
)
|
||||
|
||||
// ===========================
|
||||
@@ -155,13 +156,15 @@ func (CsatSurveyResponse) TableName() string { return "csat_survey_responses" }
|
||||
// gochat adds this for debugging, monitoring, and compliance.
|
||||
type AutomationExecution struct {
|
||||
model.Base
|
||||
AccountID uint `gorm:"index;not null" json:"account_id"`
|
||||
RuleID uint `gorm:"index;not null" json:"rule_id"`
|
||||
ConversationID uint `gorm:"index;not null" json:"conversation_id"`
|
||||
Status string `gorm:"size:50;not null" json:"status"` // success, partial, failed
|
||||
ActionsExecuted int `gorm:"default:0" json:"actions_executed"`
|
||||
ActionsFailed int `gorm:"default:0" json:"actions_failed"`
|
||||
ErrorMessage string `gorm:"type:text" json:"error_message,omitempty"`
|
||||
AccountID uint `gorm:"index;not null" json:"account_id"`
|
||||
RuleID uint `gorm:"index;not null" json:"rule_id"`
|
||||
ConversationID uint `gorm:"index;not null" json:"conversation_id"`
|
||||
EventName string `gorm:"size:100;index" json:"event_name,omitempty"`
|
||||
Status string `gorm:"size:50;not null" json:"status"` // success, partial, failed, skipped
|
||||
ActionsExecuted int `gorm:"default:0" json:"actions_executed"`
|
||||
ActionsFailed int `gorm:"default:0" json:"actions_failed"`
|
||||
ActionResults datatypes.JSON `gorm:"type:jsonb" json:"action_results,omitempty"`
|
||||
ErrorMessage string `gorm:"type:text" json:"error_message,omitempty"`
|
||||
}
|
||||
|
||||
func (AutomationExecution) TableName() string { return "automation_executions" }
|
||||
|
||||
@@ -234,15 +234,22 @@ func (s *AutomationRuleService) MatchAndExecute(ctx context.Context, accountID u
|
||||
}
|
||||
|
||||
actionSvc := NewActionService(s.db)
|
||||
logSvc := NewExecutionLogService(s.db)
|
||||
|
||||
for _, rule := range rules {
|
||||
matched, err := MatchConditions(ctx, s.db.DB(), rule.Conditions, conversation, eventData)
|
||||
if err != nil {
|
||||
applogger.L().Errorf("condition matching failed for rule %d: %v", rule.ID, err)
|
||||
if logErr := logSvc.LogRuleExecutionWithResults(ctx, accountID, rule.ID, conversationID, eventName, ExecutionStatusFailed, 0, 0, fmt.Sprintf("condition matching failed: %v", err), nil); logErr != nil {
|
||||
applogger.L().Warnf("failed to log automation condition error for rule %d: %v", rule.ID, logErr)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if !matched {
|
||||
applogger.L().Debugf("rule %d did not match conditions for conversation %d", rule.ID, conversationID)
|
||||
if logErr := logSvc.LogRuleExecutionWithResults(ctx, accountID, rule.ID, conversationID, eventName, ExecutionStatusSkipped, 0, 0, "conditions_not_matched", nil); logErr != nil {
|
||||
applogger.L().Warnf("failed to log skipped automation rule %d: %v", rule.ID, logErr)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -253,6 +260,7 @@ func (s *AutomationRuleService) MatchAndExecute(ctx context.Context, accountID u
|
||||
actionsExecuted := 0
|
||||
actionsFailed := 0
|
||||
var firstErrorMsg string
|
||||
actionResults := make([]ActionExecutionResult, 0, len(rule.Actions))
|
||||
for _, action := range rule.Actions {
|
||||
if err := actionSvc.Execute(ctx, accountID, conversationID, action, ActionSourceAutomation, rule.ID); err != nil {
|
||||
applogger.L().Errorf("action %s failed for rule %d on conversation %d: %v", action.ActionName, rule.ID, conversationID, err)
|
||||
@@ -260,21 +268,22 @@ func (s *AutomationRuleService) MatchAndExecute(ctx context.Context, accountID u
|
||||
if firstErrorMsg == "" {
|
||||
firstErrorMsg = err.Error()
|
||||
}
|
||||
actionResults = append(actionResults, ActionExecutionResult{ActionName: action.ActionName, Status: ExecutionStatusFailed, Error: err.Error()})
|
||||
// Continue executing remaining actions (Chatwoot pattern)
|
||||
} else {
|
||||
actionsExecuted++
|
||||
actionResults = append(actionResults, ActionExecutionResult{ActionName: action.ActionName, Status: ExecutionStatusSuccess})
|
||||
}
|
||||
}
|
||||
|
||||
// Log execution for audit trail
|
||||
logSvc := NewExecutionLogService(s.db)
|
||||
status := ExecutionStatusSuccess
|
||||
if actionsFailed > 0 && actionsExecuted > 0 {
|
||||
status = ExecutionStatusPartial
|
||||
} else if actionsFailed > 0 && actionsExecuted == 0 {
|
||||
status = ExecutionStatusFailed
|
||||
}
|
||||
if logErr := logSvc.LogRuleExecution(ctx, accountID, rule.ID, conversationID, status, actionsExecuted, actionsFailed, firstErrorMsg); logErr != nil {
|
||||
if logErr := logSvc.LogRuleExecutionWithResults(ctx, accountID, rule.ID, conversationID, eventName, status, actionsExecuted, actionsFailed, firstErrorMsg, actionResults); logErr != nil {
|
||||
applogger.L().Warnf("failed to log automation execution for rule %d: %v", rule.ID, logErr)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package automation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -82,12 +83,12 @@ func TestAutomationRuleService_Update(t *testing.T) {
|
||||
svc := NewAutomationRuleService(dbProvider)
|
||||
|
||||
rule := &AutomationRule{
|
||||
AccountID: accountID,
|
||||
EventName: "conversation_created",
|
||||
Name: "Original name",
|
||||
Conditions: Conditions{},
|
||||
Actions: Actions{},
|
||||
Active: true,
|
||||
AccountID: accountID,
|
||||
EventName: "conversation_created",
|
||||
Name: "Original name",
|
||||
Conditions: Conditions{},
|
||||
Actions: Actions{},
|
||||
Active: true,
|
||||
}
|
||||
if err := svc.Create(context.Background(), rule); err != nil {
|
||||
t.Fatalf("setup: failed to create rule: %v", err)
|
||||
@@ -210,6 +211,157 @@ func TestAutomationRuleService_ListActiveByAccountAndEvent(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutomationRuleService_MatchAndExecute_LogsSkippedRules(t *testing.T) {
|
||||
dbProvider := setupAutomationTestDBProvider(t)
|
||||
db := dbProvider.DB()
|
||||
accountID, _ := seedTestAccount(db, t)
|
||||
inboxID := seedTestInbox(db, t, accountID)
|
||||
contactID := seedTestContact(db, t, accountID)
|
||||
conversationID := seedTestConversationWithDetails(db, t, accountID, inboxID, contactID, "open", "low", "web", 0)
|
||||
svc := NewAutomationRuleService(dbProvider)
|
||||
|
||||
rule := &AutomationRule{
|
||||
AccountID: accountID,
|
||||
EventName: "conversation_updated",
|
||||
Name: "skip when status mismatch",
|
||||
Conditions: Conditions{{
|
||||
Attribute: "status",
|
||||
FilterOperator: "equal",
|
||||
Values: []string{"resolved"},
|
||||
}},
|
||||
Actions: Actions{{ActionName: "add_label", ActionParams: map[string]interface{}{"labels": []string{"should-not-run"}}}},
|
||||
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_updated", 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 {
|
||||
t.Fatalf("expected 1 skipped log, got %d", len(logs))
|
||||
}
|
||||
if logs[0].Status != ExecutionStatusSkipped {
|
||||
t.Fatalf("expected skipped status, got %s", logs[0].Status)
|
||||
}
|
||||
if logs[0].EventName != "conversation_updated" {
|
||||
t.Fatalf("expected event name conversation_updated, got %s", logs[0].EventName)
|
||||
}
|
||||
if logs[0].ActionsExecuted != 0 || logs[0].ActionsFailed != 0 {
|
||||
t.Fatalf("expected zero action counts, got executed=%d failed=%d", logs[0].ActionsExecuted, logs[0].ActionsFailed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutomationRuleService_MatchAndExecute_RecordsPerActionFailuresAndContinues(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)
|
||||
svc := NewAutomationRuleService(dbProvider)
|
||||
|
||||
rule := &AutomationRule{
|
||||
AccountID: accountID,
|
||||
EventName: "conversation_created",
|
||||
Name: "partial action log",
|
||||
Conditions: Conditions{},
|
||||
Actions: Actions{
|
||||
{ActionName: "send_message", ActionParams: map[string]interface{}{}},
|
||||
{ActionName: "add_label", ActionParams: map[string]interface{}{"labels": []string{"after-failure"}}},
|
||||
},
|
||||
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 {
|
||||
t.Fatalf("expected 1 execution log, got %d", len(logs))
|
||||
}
|
||||
log := logs[0]
|
||||
if log.Status != ExecutionStatusPartial {
|
||||
t.Fatalf("expected partial status, got %s", log.Status)
|
||||
}
|
||||
if log.ActionsExecuted != 1 || log.ActionsFailed != 1 {
|
||||
t.Fatalf("expected executed=1 failed=1, got executed=%d failed=%d", log.ActionsExecuted, log.ActionsFailed)
|
||||
}
|
||||
if log.ErrorMessage == "" {
|
||||
t.Fatalf("expected first action error message")
|
||||
}
|
||||
var results []ActionExecutionResult
|
||||
if err := json.Unmarshal(log.ActionResults, &results); err != nil {
|
||||
t.Fatalf("unmarshal action results: %v", err)
|
||||
}
|
||||
if len(results) != 2 {
|
||||
t.Fatalf("expected 2 action result records, got %d", len(results))
|
||||
}
|
||||
if results[0].ActionName != "send_message" || results[0].Status != ExecutionStatusFailed || results[0].Error == "" {
|
||||
t.Fatalf("unexpected failed action result: %#v", results[0])
|
||||
}
|
||||
if results[1].ActionName != "add_label" || results[1].Status != ExecutionStatusSuccess {
|
||||
t.Fatalf("unexpected success action result: %#v", results[1])
|
||||
}
|
||||
|
||||
var label ConversationLabel
|
||||
if err := db.Where("conversation_id = ? AND label = ?", conversationID, "after-failure").First(&label).Error; err != nil {
|
||||
t.Fatalf("expected second action to continue and add label: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutomationRuleService_MatchAndExecute_RunsAllMatchingRulesWithoutStopOnMatch(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)
|
||||
svc := NewAutomationRuleService(dbProvider)
|
||||
|
||||
rules := []*AutomationRule{
|
||||
{AccountID: accountID, EventName: "conversation_created", Name: "first match", Conditions: Conditions{}, Actions: Actions{{ActionName: "add_label", ActionParams: map[string]interface{}{"labels": []string{"first-match"}}}}, Active: true},
|
||||
{AccountID: accountID, EventName: "conversation_created", Name: "second match", Conditions: Conditions{}, Actions: Actions{{ActionName: "add_label", ActionParams: map[string]interface{}{"labels": []string{"second-match"}}}}, Active: true},
|
||||
}
|
||||
for _, rule := range rules {
|
||||
if err := svc.Create(context.Background(), rule); err != nil {
|
||||
t.Fatalf("create rule %s: %v", rule.Name, err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := svc.MatchAndExecute(context.Background(), accountID, "conversation_created", conversationID, map[string]interface{}{}); err != nil {
|
||||
t.Fatalf("match and execute: %v", err)
|
||||
}
|
||||
|
||||
var logCount int64
|
||||
if err := db.Model(&AutomationExecution{}).Where("conversation_id = ? AND status = ?", conversationID, ExecutionStatusSuccess).Count(&logCount).Error; err != nil {
|
||||
t.Fatalf("count logs: %v", err)
|
||||
}
|
||||
if logCount != 2 {
|
||||
t.Fatalf("expected both matching rules to execute, got %d logs", logCount)
|
||||
}
|
||||
|
||||
for _, labelName := range []string{"first-match", "second-match"} {
|
||||
var label ConversationLabel
|
||||
if err := db.Where("conversation_id = ? AND label = ?", conversationID, labelName).First(&label).Error; err != nil {
|
||||
t.Fatalf("expected label %s from matching rule: %v", labelName, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutomationRuleService_ToggleActive(t *testing.T) {
|
||||
dbProvider := setupAutomationTestDBProvider(t)
|
||||
accountID, _ := seedTestAccount(dbProvider.DB(), t)
|
||||
|
||||
Reference in New Issue
Block a user