ultisuite-backend/internal/mail/rules/engine_test.go
R3D347HR4Y cd0a80f5e8 huhu
2026-05-25 13:52:27 +02:00

219 lines
7.3 KiB
Go

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 <alice@example.com>",
To: []string{"bob@example.com", "carol@example.com"},
Subject: "Invoice Q1",
BodyText: "Please review the attached invoice.",
HasAttachments: true,
Labels: []string{"work", "finance"},
}
}
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 <alice@example.com>"}, 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: "unknown_op", Value: "Invoice"}, false},
{"regex match", Condition{Field: "subject", Operator: "regex", Value: `(?i)invoice`}, true},
{"regex no match", Condition{Field: "subject", Operator: "regex", Value: `^Spam`}, false},
{"not_regex", Condition{Field: "subject", Operator: "not_regex", Value: `^Spam`}, true},
{"label has", Condition{Field: "label", Operator: "has", Value: "work"}, true},
{"label not_has", Condition{Field: "label", Operator: "not_has", Value: "spam"}, true},
}
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: "unknown_action", 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: unknown_action") {
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@example.com>", "Alice", "alice@example.com"},
{`"Bob Smith" <bob@example.com>`, "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)
}
}