- Introduced a new sync pipeline for IMAP that integrates a rules engine and webhook execution. - Enhanced the `SyncWorker` to support attachment management and folder synchronization. - Added functionality to detect special folder types (Sent, Drafts, Trash, Archive, Spam) during sync. - Implemented a database schema for tracking rule executions and their outcomes. - Created unit tests for the new rules engine and webhook execution logic. - Updated migration scripts to accommodate new database structures for rule executions and folder states. - Enhanced error handling and logging throughout the sync process for better observability.
213 lines
6.8 KiB
Go
213 lines
6.8 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,
|
|
}
|
|
}
|
|
|
|
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: "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@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)
|
|
}
|
|
}
|