515 lines
18 KiB
Go
515 lines
18 KiB
Go
package automation
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"testing"
|
|
|
|
"github.com/gochat/gochat/internal/model"
|
|
)
|
|
|
|
func TestAutomationRuleService_Create(t *testing.T) {
|
|
dbProvider := setupAutomationTestDBProvider(t)
|
|
accountID, _ := seedTestAccount(dbProvider.DB(), t)
|
|
svc := NewAutomationRuleService(dbProvider)
|
|
|
|
rule := &AutomationRule{
|
|
AccountID: accountID,
|
|
EventName: "conversation_created",
|
|
Name: "Auto-assign on create",
|
|
Description: "Assigns new conversations to team",
|
|
Conditions: Conditions{
|
|
{Attribute: "status", FilterOperator: "equal", Values: []string{"open"}, QueryOperator: "and"},
|
|
},
|
|
Actions: Actions{
|
|
{ActionName: "assign_team", ActionParams: map[string]interface{}{"team_id": "1"}},
|
|
},
|
|
Active: true,
|
|
}
|
|
|
|
err := svc.Create(context.Background(), rule)
|
|
if err != nil {
|
|
t.Fatalf("expected no error creating rule, got: %v", err)
|
|
}
|
|
if rule.ID == 0 {
|
|
t.Fatal("expected rule ID to be set after creation")
|
|
}
|
|
}
|
|
|
|
func TestAutomationRuleService_CreateNormalizesAttachmentActionsAndHydratesFiles(t *testing.T) {
|
|
dbProvider := setupAutomationTestDBProvider(t)
|
|
db := dbProvider.DB()
|
|
accountID, _ := seedTestAccount(db, t)
|
|
upload := &model.DirectUpload{UploadUUID: "automation-service-upload", AccountID: accountID, Status: model.DirectUploadStatusPending, Source: model.DirectUploadSourceAccount, OriginalName: "rule.pdf", FileType: "file", MimeType: "application/pdf", FileSize: 789, FileURL: "/uploads/account/1/rule.pdf"}
|
|
if err := db.Create(upload).Error; err != nil {
|
|
t.Fatalf("create upload: %v", err)
|
|
}
|
|
svc := NewAutomationRuleService(dbProvider)
|
|
|
|
rule := &AutomationRule{
|
|
AccountID: accountID,
|
|
EventName: "conversation_created",
|
|
Name: "Send attachment",
|
|
Conditions: Conditions{},
|
|
Actions: Actions{{ActionName: "send_attachment", ActionParams: map[string]interface{}{"blob_id": "automation-service-upload", "attachment_url": "stale"}}},
|
|
Active: true,
|
|
}
|
|
if err := svc.Create(context.Background(), rule); err != nil {
|
|
t.Fatalf("create rule: %v", err)
|
|
}
|
|
if got := macroBlobIDAsUint(rule.Actions[0].ActionParams["blob_id"]); got != upload.ID {
|
|
t.Fatalf("expected blob id %d, got %d", upload.ID, got)
|
|
}
|
|
if _, ok := rule.Actions[0].ActionParams["attachment_url"]; ok {
|
|
t.Fatal("expected attachment_url to be removed")
|
|
}
|
|
if len(rule.Files) != 1 || rule.Files[0].BlobID != upload.ID || rule.Files[0].AutomationRuleID != rule.ID {
|
|
t.Fatalf("unexpected files payload: %#v", rule.Files)
|
|
}
|
|
}
|
|
|
|
func TestAutomationRuleService_MatchAndExecuteCreatesAttachmentMessages(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)
|
|
upload := &model.DirectUpload{UploadUUID: "automation-exec-upload", AccountID: accountID, Status: model.DirectUploadStatusPending, Source: model.DirectUploadSourceAccount, OriginalName: "automation.pdf", FileType: "file", MimeType: "application/pdf", FileSize: 456, FileURL: "/uploads/account/1/automation.pdf"}
|
|
if err := db.Create(upload).Error; err != nil {
|
|
t.Fatalf("create upload: %v", err)
|
|
}
|
|
svc := NewAutomationRuleService(dbProvider)
|
|
rule := &AutomationRule{AccountID: accountID, EventName: "conversation_created", Name: "file rule", Conditions: Conditions{}, Actions: Actions{{ActionName: "send_attachment", ActionParams: map[string]interface{}{"blob_id": "automation-exec-upload"}}}, 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("execute automation: %v", err)
|
|
}
|
|
|
|
var message model.Message
|
|
if err := db.Where("conversation_id = ? AND content_type = ?", conversationID, "file").First(&message).Error; err != nil {
|
|
t.Fatalf("expected file message: %v", err)
|
|
}
|
|
var attachment model.Attachment
|
|
if err := db.Where("message_id = ?", message.ID).First(&attachment).Error; err != nil {
|
|
t.Fatalf("expected attachment: %v", err)
|
|
}
|
|
if attachment.FileURL != upload.FileURL || attachment.FileName != upload.OriginalName {
|
|
t.Fatalf("unexpected attachment: %#v", attachment)
|
|
}
|
|
}
|
|
|
|
func TestAutomationRuleService_GetByID(t *testing.T) {
|
|
dbProvider := setupAutomationTestDBProvider(t)
|
|
accountID, _ := seedTestAccount(dbProvider.DB(), t)
|
|
svc := NewAutomationRuleService(dbProvider)
|
|
|
|
rule := &AutomationRule{
|
|
AccountID: accountID,
|
|
EventName: "message_created",
|
|
Name: "Auto-label spam",
|
|
Conditions: Conditions{
|
|
{Attribute: "content", FilterOperator: "contains", Values: []string{"spam"}, QueryOperator: "and"},
|
|
},
|
|
Actions: Actions{
|
|
{ActionName: "add_label", ActionParams: map[string]interface{}{"labels": []string{"spam"}}},
|
|
},
|
|
Active: true,
|
|
}
|
|
if err := svc.Create(context.Background(), rule); err != nil {
|
|
t.Fatalf("setup: failed to create rule: %v", err)
|
|
}
|
|
|
|
found, err := svc.GetByID(context.Background(), rule.ID)
|
|
if err != nil {
|
|
t.Fatalf("expected no error getting rule, got: %v", err)
|
|
}
|
|
if found.ID != rule.ID {
|
|
t.Fatalf("expected rule ID %d, got %d", rule.ID, found.ID)
|
|
}
|
|
if found.Name != "Auto-label spam" {
|
|
t.Fatalf("expected name 'Auto-label spam', got %q", found.Name)
|
|
}
|
|
}
|
|
|
|
func TestAutomationRuleService_GetByID_NotFound(t *testing.T) {
|
|
dbProvider := setupAutomationTestDBProvider(t)
|
|
svc := NewAutomationRuleService(dbProvider)
|
|
|
|
_, err := svc.GetByID(context.Background(), 9999)
|
|
if err == nil {
|
|
t.Fatal("expected error for non-existent rule, got nil")
|
|
}
|
|
}
|
|
|
|
func TestAutomationRuleService_Update(t *testing.T) {
|
|
dbProvider := setupAutomationTestDBProvider(t)
|
|
accountID, _ := seedTestAccount(dbProvider.DB(), t)
|
|
svc := NewAutomationRuleService(dbProvider)
|
|
|
|
rule := &AutomationRule{
|
|
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)
|
|
}
|
|
|
|
rule.Name = "Updated name"
|
|
rule.Active = false
|
|
err := svc.Update(context.Background(), rule)
|
|
if err != nil {
|
|
t.Fatalf("expected no error updating rule, got: %v", err)
|
|
}
|
|
|
|
found, _ := svc.GetByID(context.Background(), rule.ID)
|
|
if found.Name != "Updated name" {
|
|
t.Fatalf("expected name 'Updated name', got %q", found.Name)
|
|
}
|
|
if found.Active != false {
|
|
t.Fatal("expected active to be false after update")
|
|
}
|
|
}
|
|
|
|
func TestAutomationRuleService_Delete(t *testing.T) {
|
|
dbProvider := setupAutomationTestDBProvider(t)
|
|
accountID, _ := seedTestAccount(dbProvider.DB(), t)
|
|
svc := NewAutomationRuleService(dbProvider)
|
|
|
|
rule := &AutomationRule{
|
|
AccountID: accountID,
|
|
EventName: "conversation_created",
|
|
Name: "Delete me",
|
|
Conditions: Conditions{},
|
|
Actions: Actions{},
|
|
Active: true,
|
|
}
|
|
if err := svc.Create(context.Background(), rule); err != nil {
|
|
t.Fatalf("setup: failed to create rule: %v", err)
|
|
}
|
|
|
|
err := svc.Delete(context.Background(), rule.ID)
|
|
if err != nil {
|
|
t.Fatalf("expected no error deleting rule, got: %v", err)
|
|
}
|
|
|
|
_, err = svc.GetByID(context.Background(), rule.ID)
|
|
if err == nil {
|
|
t.Fatal("expected error getting deleted rule, got nil")
|
|
}
|
|
}
|
|
|
|
func TestAutomationRuleService_ListByAccount(t *testing.T) {
|
|
dbProvider := setupAutomationTestDBProvider(t)
|
|
accountID, _ := seedTestAccount(dbProvider.DB(), t)
|
|
svc := NewAutomationRuleService(dbProvider)
|
|
|
|
// Create 3 rules
|
|
for i := 0; i < 3; i++ {
|
|
rule := &AutomationRule{
|
|
AccountID: accountID,
|
|
EventName: "conversation_created",
|
|
Name: "Rule " + string(rune('A'+i)),
|
|
Conditions: Conditions{},
|
|
Actions: Actions{},
|
|
Active: true,
|
|
}
|
|
if err := svc.Create(context.Background(), rule); err != nil {
|
|
t.Fatalf("setup: failed to create rule %d: %v", i, err)
|
|
}
|
|
}
|
|
|
|
rules, err := svc.ListByAccount(context.Background(), accountID)
|
|
if err != nil {
|
|
t.Fatalf("expected no error listing rules, got: %v", err)
|
|
}
|
|
if len(rules) != 3 {
|
|
t.Fatalf("expected 3 rules, got %d", len(rules))
|
|
}
|
|
}
|
|
|
|
func TestAutomationRuleService_ListActiveByAccountAndEvent(t *testing.T) {
|
|
dbProvider := setupAutomationTestDBProvider(t)
|
|
accountID, _ := seedTestAccount(dbProvider.DB(), t)
|
|
svc := NewAutomationRuleService(dbProvider)
|
|
|
|
// Create active rule for conversation_created
|
|
rule1 := &AutomationRule{
|
|
AccountID: accountID, EventName: "conversation_created", Name: "Active rule",
|
|
Conditions: Conditions{}, Actions: Actions{}, Active: true,
|
|
}
|
|
if err := svc.Create(context.Background(), rule1); err != nil {
|
|
t.Fatalf("setup: %v", err)
|
|
}
|
|
|
|
// Create inactive rule for conversation_created
|
|
rule2 := &AutomationRule{
|
|
AccountID: accountID, EventName: "conversation_created", Name: "Inactive rule",
|
|
Conditions: Conditions{}, Actions: Actions{}, Active: false,
|
|
}
|
|
if err := svc.Create(context.Background(), rule2); err != nil {
|
|
t.Fatalf("setup: %v", err)
|
|
}
|
|
|
|
// Create active rule for different event
|
|
rule3 := &AutomationRule{
|
|
AccountID: accountID, EventName: "message_created", Name: "Different event",
|
|
Conditions: Conditions{}, Actions: Actions{}, Active: true,
|
|
}
|
|
if err := svc.Create(context.Background(), rule3); err != nil {
|
|
t.Fatalf("setup: %v", err)
|
|
}
|
|
|
|
rules, err := svc.ListActiveByAccountAndEvent(context.Background(), accountID, "conversation_created")
|
|
if err != nil {
|
|
t.Fatalf("expected no error, got: %v", err)
|
|
}
|
|
if len(rules) != 1 {
|
|
t.Fatalf("expected 1 active rule for conversation_created, got %d", len(rules))
|
|
}
|
|
if rules[0].Name != "Active rule" {
|
|
t.Fatalf("expected 'Active rule', got %q", rules[0].Name)
|
|
}
|
|
}
|
|
|
|
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)
|
|
svc := NewAutomationRuleService(dbProvider)
|
|
|
|
rule := &AutomationRule{
|
|
AccountID: accountID, EventName: "conversation_created", Name: "Toggle test",
|
|
Conditions: Conditions{}, Actions: Actions{}, Active: true,
|
|
}
|
|
if err := svc.Create(context.Background(), rule); err != nil {
|
|
t.Fatalf("setup: %v", err)
|
|
}
|
|
|
|
// Toggle to inactive
|
|
err := svc.ToggleActive(context.Background(), rule.ID, false)
|
|
if err != nil {
|
|
t.Fatalf("expected no error toggling active, got: %v", err)
|
|
}
|
|
found, _ := svc.GetByID(context.Background(), rule.ID)
|
|
if found.Active {
|
|
t.Fatal("expected active=false after toggle")
|
|
}
|
|
|
|
// Toggle back to active
|
|
err = svc.ToggleActive(context.Background(), rule.ID, true)
|
|
if err != nil {
|
|
t.Fatalf("expected no error toggling active back, got: %v", err)
|
|
}
|
|
found, _ = svc.GetByID(context.Background(), rule.ID)
|
|
if !found.Active {
|
|
t.Fatal("expected active=true after toggle back")
|
|
}
|
|
}
|
|
|
|
func TestAutomationRuleService_Clone(t *testing.T) {
|
|
dbProvider := setupAutomationTestDBProvider(t)
|
|
accountID, _ := seedTestAccount(dbProvider.DB(), t)
|
|
svc := NewAutomationRuleService(dbProvider)
|
|
|
|
rule := &AutomationRule{
|
|
AccountID: accountID,
|
|
EventName: "conversation_created",
|
|
Name: "Source rule",
|
|
Conditions: Conditions{
|
|
{Attribute: "status", FilterOperator: "equal", Values: []string{"open"}, QueryOperator: "and"},
|
|
},
|
|
Actions: Actions{
|
|
{ActionName: "assign_team", ActionParams: map[string]interface{}{"team_id": "1"}},
|
|
},
|
|
Active: true,
|
|
}
|
|
if err := svc.Create(context.Background(), rule); err != nil {
|
|
t.Fatalf("setup: %v", err)
|
|
}
|
|
|
|
cloned, err := svc.Clone(context.Background(), rule.ID)
|
|
if err != nil {
|
|
t.Fatalf("expected no error cloning rule, got: %v", err)
|
|
}
|
|
if cloned.ID == rule.ID {
|
|
t.Fatal("expected cloned rule to have different ID")
|
|
}
|
|
if cloned.Name != "Source rule (copy)" {
|
|
t.Fatalf("expected cloned name 'Source rule (copy)', got %q", cloned.Name)
|
|
}
|
|
if len(cloned.Conditions) != 1 {
|
|
t.Fatalf("expected 1 cloned condition, got %d", len(cloned.Conditions))
|
|
}
|
|
if len(cloned.Actions) != 1 {
|
|
t.Fatalf("expected 1 cloned action, got %d", len(cloned.Actions))
|
|
}
|
|
}
|
|
|
|
func TestAutomationRuleService_Clone_NotFound(t *testing.T) {
|
|
dbProvider := setupAutomationTestDBProvider(t)
|
|
svc := NewAutomationRuleService(dbProvider)
|
|
|
|
_, err := svc.Clone(context.Background(), 9999)
|
|
if err == nil {
|
|
t.Fatal("expected error cloning non-existent rule, got nil")
|
|
}
|
|
}
|