package rules import ( "context" "encoding/json" "strings" "testing" "github.com/ultisuite/ulti-backend/internal/mail/webhooks" ) func testMessage() *Message { return &Message{ ID: "msg-1", From: "Alice ", To: []string{"bob@example.com", "carol@example.com"}, Subject: "Invoice Q1", BodyText: "Please review the attached invoice.", HasAttachments: true, } } func TestMatchCondition_fieldsAndOperators(t *testing.T) { msg := testMessage() tests := []struct { name string cond Condition match bool }{ {"from contains", Condition{Field: "from", Operator: "contains", Value: "alice@"}, true}, {"from equals case insensitive", Condition{Field: "from", Operator: "equals", Value: "alice "}, true}, {"to contains", Condition{Field: "to", Operator: "contains", Value: "carol@"}, true}, {"subject starts_with", Condition{Field: "subject", Operator: "starts_with", Value: "invoice"}, true}, {"body ends_with", Condition{Field: "body", Operator: "ends_with", Value: "invoice."}, true}, {"has_attachment true", Condition{Field: "has_attachment", Operator: "equals", Value: "true"}, true}, {"has_attachment false", Condition{Field: "has_attachment", Operator: "equals", Value: "false"}, false}, {"not_contains", Condition{Field: "subject", Operator: "not_contains", Value: "spam"}, true}, {"unknown field", Condition{Field: "unknown", Operator: "contains", Value: "x"}, false}, {"unknown operator", Condition{Field: "subject", Operator: "matches", Value: "Invoice"}, false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if got := matchCondition(tt.cond, msg); got != tt.match { t.Fatalf("matchCondition() = %v, want %v", got, tt.match) } }) } } func TestMatchesAll(t *testing.T) { msg := testMessage() t.Run("all match", func(t *testing.T) { conditions := []Condition{ {Field: "from", Operator: "contains", Value: "alice"}, {Field: "subject", Operator: "contains", Value: "invoice"}, } if !matchesAll(conditions, msg) { t.Fatal("matchesAll() = false, want true") } }) t.Run("one fails", func(t *testing.T) { conditions := []Condition{ {Field: "from", Operator: "contains", Value: "alice"}, {Field: "subject", Operator: "equals", Value: "other"}, } if matchesAll(conditions, msg) { t.Fatal("matchesAll() = true, want false") } }) t.Run("empty conditions", func(t *testing.T) { if !matchesAll(nil, msg) { t.Fatal("matchesAll(nil) = false, want true") } }) } func TestExecuteAction_unknownType(t *testing.T) { e := &Engine{} err := e.executeAction(context.Background(), Action{Type: "forward", Value: "x@example.com"}, &Message{ID: "msg-1"}) if err == nil { t.Fatal("executeAction() error = nil, want unknown action type error") } if !strings.Contains(err.Error(), "unknown action type: forward") { t.Fatalf("executeAction() error = %v, want unknown action type", err) } } func TestActionResultJSON(t *testing.T) { results := []ActionResult{ {Type: "label", Value: "important", OK: true}, {Type: "webhook", Value: "tpl-1", OK: false, Error: "request failed: timeout"}, } data, err := json.Marshal(results) if err != nil { t.Fatalf("json.Marshal() error = %v", err) } var decoded []ActionResult if err := json.Unmarshal(data, &decoded); err != nil { t.Fatalf("json.Unmarshal() error = %v", err) } if len(decoded) != 2 { t.Fatalf("len(decoded) = %d, want 2", len(decoded)) } if decoded[0].Type != "label" || !decoded[0].OK || decoded[0].Error != "" { t.Fatalf("decoded[0] = %+v, want label ok=true empty error", decoded[0]) } if decoded[1].Type != "webhook" || decoded[1].OK || decoded[1].Error == "" { t.Fatalf("decoded[1] = %+v, want webhook ok=false with error", decoded[1]) } } func TestActionResultFrom(t *testing.T) { ok := actionResultFrom(Action{Type: "archive", Value: ""}, nil) if !ok.OK || ok.Error != "" { t.Fatalf("actionResultFrom(nil err) = %+v, want ok=true", ok) } fail := actionResultFrom(Action{Type: "webhook", Value: "tpl-1"}, context.DeadlineExceeded) if fail.OK || fail.Error == "" { t.Fatalf("actionResultFrom(err) = %+v, want ok=false with error", fail) } } func TestAggregateActionErrors(t *testing.T) { got := aggregateActionErrors([]ActionResult{ {Type: "label", OK: true}, {Type: "webhook", OK: false, Error: "timeout"}, {Type: "move", OK: false, Error: "folder missing"}, }) want := "webhook: timeout; move: folder missing" if got != want { t.Fatalf("aggregateActionErrors() = %q, want %q", got, want) } if aggregateActionErrors(nil) != "" { t.Fatal("aggregateActionErrors(nil) want empty string") } } func TestParseFromAddress(t *testing.T) { tests := []struct { from, wantName, wantEmail string }{ {"Alice ", "Alice", "alice@example.com"}, {`"Bob Smith" `, "Bob Smith", "bob@example.com"}, {"carol@example.com", "", "carol@example.com"}, {"", "", ""}, } for _, tt := range tests { name, email := parseFromAddress(tt.from) if name != tt.wantName || email != tt.wantEmail { t.Fatalf("parseFromAddress(%q) = (%q, %q), want (%q, %q)", tt.from, name, email, tt.wantName, tt.wantEmail) } } } func TestMessageToWebhookContext(t *testing.T) { msg := testMessage() ctx := messageToWebhookContext(msg) if ctx.SenderName != "Alice" || ctx.SenderEmail != "alice@example.com" { t.Fatalf("sender = (%q, %q), want (Alice, alice@example.com)", ctx.SenderName, ctx.SenderEmail) } if ctx.Subject != msg.Subject || ctx.BodyText != msg.BodyText { t.Fatal("subject/body not copied") } if ctx.Recipients != "bob@example.com, carol@example.com" { t.Fatalf("Recipients = %q", ctx.Recipients) } if !ctx.HasAttachment || ctx.MessageID != msg.ID { t.Fatalf("HasAttachment/MessageID = (%v, %q)", ctx.HasAttachment, ctx.MessageID) } } type mockWebhookExecutor struct { templateID string msgCtx *webhooks.MessageContext err error } func (m *mockWebhookExecutor) Execute(_ context.Context, templateID string, msgCtx *webhooks.MessageContext) error { m.templateID = templateID m.msgCtx = msgCtx return m.err } func TestExecuteAction_webhook(t *testing.T) { msg := testMessage() mock := &mockWebhookExecutor{} e := &Engine{webhookExec: mock} if err := e.executeAction(context.Background(), Action{Type: "webhook", Value: "tpl-abc"}, msg); err != nil { t.Fatalf("executeAction() error = %v", err) } if mock.templateID != "tpl-abc" { t.Fatalf("templateID = %q, want tpl-abc", mock.templateID) } if mock.msgCtx.MessageID != msg.ID { t.Fatalf("msgCtx.MessageID = %q, want %q", mock.msgCtx.MessageID, msg.ID) } e.webhookExec = nil err := e.executeAction(context.Background(), Action{Type: "webhook", Value: "tpl-abc"}, msg) if err == nil || !strings.Contains(err.Error(), "webhook executor not configured") { t.Fatalf("executeAction() without executor = %v, want not configured error", err) } }