297 lines
12 KiB
Go
297 lines
12 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"gorm.io/driver/sqlite"
|
|
"gorm.io/gorm"
|
|
"gorm.io/gorm/logger"
|
|
|
|
"github.com/gochat/gochat/internal/model"
|
|
"github.com/gochat/gochat/internal/repository"
|
|
)
|
|
|
|
func setupAppliedSlaLifecycleTest(t *testing.T) (*AppliedSlaService, *gorm.DB) {
|
|
t.Helper()
|
|
db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
|
|
require.NoError(t, err)
|
|
require.NoError(t, db.AutoMigrate(
|
|
&model.Account{},
|
|
&model.User{},
|
|
&model.AccountUser{},
|
|
&model.Inbox{},
|
|
&model.Contact{},
|
|
&model.Conversation{},
|
|
&model.ConversationParticipant{},
|
|
&model.Message{},
|
|
&model.SlaPolicy{},
|
|
&model.AppliedSLA{},
|
|
&model.SlaEvent{},
|
|
&model.Notification{},
|
|
&model.BackgroundJob{},
|
|
))
|
|
t.Cleanup(func() {
|
|
sqlDB, _ := db.DB()
|
|
sqlDB.Close()
|
|
})
|
|
|
|
convRepo := repository.NewConversationRepo(db)
|
|
svc := NewAppliedSlaService(
|
|
repository.NewAppliedSlaRepo(db),
|
|
repository.NewSlaEventRepo(db),
|
|
repository.NewSlaPolicyRepo(db),
|
|
convRepo,
|
|
)
|
|
return svc, db
|
|
}
|
|
|
|
func seedAppliedSlaConversation(t *testing.T, db *gorm.DB, createdAt time.Time, policy model.SlaPolicy) (*model.Account, *model.Inbox, *model.Contact, *model.SlaPolicy, *model.Conversation) {
|
|
t.Helper()
|
|
account := &model.Account{Name: "SLA Lifecycle Org", Locale: "en", Active: true}
|
|
require.NoError(t, db.Create(account).Error)
|
|
inbox := &model.Inbox{AccountID: account.ID, Name: "SLA Inbox", ChannelType: "web_widget", ChannelID: 1, Enabled: true}
|
|
require.NoError(t, db.Create(inbox).Error)
|
|
contact := &model.Contact{AccountID: account.ID, Name: "SLA Contact"}
|
|
require.NoError(t, db.Create(contact).Error)
|
|
policy.AccountID = account.ID
|
|
if policy.Name == "" {
|
|
policy.Name = "SLA Policy"
|
|
}
|
|
require.NoError(t, db.Create(&policy).Error)
|
|
conversation := &model.Conversation{
|
|
Base: model.Base{
|
|
CreatedAt: createdAt,
|
|
UpdatedAt: createdAt,
|
|
},
|
|
AccountID: account.ID,
|
|
InboxID: inbox.ID,
|
|
ContactID: contact.ID,
|
|
Status: string(model.ConversationStatusOpen),
|
|
ChannelType: "web_widget",
|
|
Channel: "web_widget",
|
|
}
|
|
require.NoError(t, db.Create(conversation).Error)
|
|
return account, inbox, contact, &policy, conversation
|
|
}
|
|
|
|
func TestAppliedSlaService_Evaluate_FRTMissIsIdempotent(t *testing.T) {
|
|
svc, db := setupAppliedSlaLifecycleTest(t)
|
|
createdAt := time.Now().Add(-2 * time.Hour)
|
|
account, _, _, policy, conversation := seedAppliedSlaConversation(t, db, createdAt, model.SlaPolicy{
|
|
FirstResponseTimeThreshold: 60,
|
|
})
|
|
|
|
applied, err := svc.CreateFromConversation(context.Background(), account.ID, conversation.ID, policy.ID)
|
|
require.NoError(t, err)
|
|
|
|
applied, err = svc.Evaluate(context.Background(), applied.ID)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, model.SLAStatusActiveWithMisses, applied.SLAStatus)
|
|
|
|
_, err = svc.Evaluate(context.Background(), applied.ID)
|
|
require.NoError(t, err)
|
|
|
|
var events []model.SlaEvent
|
|
require.NoError(t, db.Where("applied_sla_id = ?", applied.ID).Find(&events).Error)
|
|
require.Len(t, events, 1)
|
|
assert.Equal(t, model.SLAEventFRT, events[0].EventType)
|
|
assert.JSONEq(t, `{}`, string(events[0].Meta))
|
|
}
|
|
|
|
func TestAppliedSlaService_Evaluate_CreatesSlaMissNotifications(t *testing.T) {
|
|
svc, db := setupAppliedSlaLifecycleTest(t)
|
|
createdAt := time.Now().Add(-2 * time.Hour)
|
|
account, _, _, policy, conversation := seedAppliedSlaConversation(t, db, createdAt, model.SlaPolicy{
|
|
FirstResponseTimeThreshold: 60,
|
|
})
|
|
|
|
participant := &model.User{AccountID: account.ID, Name: "Participant", Email: "participant@example.com", Role: "agent", Active: true}
|
|
admin := &model.User{AccountID: account.ID, Name: "Admin", Email: "admin@example.com", Role: "administrator", Active: true}
|
|
assignee := &model.User{AccountID: account.ID, Name: "Assignee", Email: "assignee@example.com", Role: "agent", Active: true}
|
|
removedParticipant := &model.User{AccountID: account.ID, Name: "Removed", Email: "removed@example.com", Role: "agent", Active: true}
|
|
require.NoError(t, db.Create(participant).Error)
|
|
require.NoError(t, db.Create(admin).Error)
|
|
require.NoError(t, db.Create(assignee).Error)
|
|
require.NoError(t, db.Create(removedParticipant).Error)
|
|
require.NoError(t, db.Create(&model.AccountUser{AccountID: account.ID, UserID: participant.ID, Role: "agent"}).Error)
|
|
require.NoError(t, db.Create(&model.AccountUser{AccountID: account.ID, UserID: admin.ID, Role: "administrator"}).Error)
|
|
require.NoError(t, db.Create(&model.AccountUser{AccountID: account.ID, UserID: assignee.ID, Role: "agent"}).Error)
|
|
require.NoError(t, db.Create(&model.ConversationParticipant{AccountID: account.ID, ConversationID: conversation.ID, UserID: participant.ID}).Error)
|
|
require.NoError(t, db.Create(&model.ConversationParticipant{AccountID: account.ID, ConversationID: conversation.ID, UserID: admin.ID}).Error)
|
|
require.NoError(t, db.Create(&model.ConversationParticipant{AccountID: account.ID, ConversationID: conversation.ID, UserID: removedParticipant.ID}).Error)
|
|
require.NoError(t, db.Model(conversation).Update("assignee_id", assignee.ID).Error)
|
|
conversation.AssigneeID = &assignee.ID
|
|
|
|
applied, err := svc.CreateFromConversation(context.Background(), account.ID, conversation.ID, policy.ID)
|
|
require.NoError(t, err)
|
|
|
|
_, err = svc.Evaluate(context.Background(), applied.ID)
|
|
require.NoError(t, err)
|
|
_, err = svc.Evaluate(context.Background(), applied.ID)
|
|
require.NoError(t, err)
|
|
|
|
var notifications []model.Notification
|
|
require.NoError(t, db.Order("user_id ASC").Find(¬ifications).Error)
|
|
require.Len(t, notifications, 3)
|
|
assert.Equal(t, []uint{participant.ID, admin.ID, assignee.ID}, []uint{notifications[0].UserID, notifications[1].UserID, notifications[2].UserID})
|
|
for _, notification := range notifications {
|
|
assert.Equal(t, "sla_missed_first_response", notification.NotificationType)
|
|
assert.Equal(t, "Conversation", notification.PrimaryActorType)
|
|
assert.Equal(t, conversation.ID, notification.PrimaryActorID)
|
|
assert.Equal(t, "SlaPolicy", notification.SecondaryActorType)
|
|
assert.Equal(t, policy.ID, notification.SecondaryActorID)
|
|
require.NotNil(t, notification.AccountID)
|
|
assert.Equal(t, account.ID, *notification.AccountID)
|
|
}
|
|
}
|
|
|
|
func TestAppliedSlaService_Evaluate_SlaNotificationTypes(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
name string
|
|
policy model.SlaPolicy
|
|
conversationPatch func(*testing.T, *gorm.DB, *model.Conversation)
|
|
expectedType string
|
|
}{
|
|
{
|
|
name: "next response",
|
|
policy: model.SlaPolicy{NextResponseTimeThreshold: 60},
|
|
conversationPatch: func(t *testing.T, db *gorm.DB, conversation *model.Conversation) {
|
|
firstReply := time.Now().Add(-90 * time.Minute).Unix()
|
|
waitingSince := time.Now().Add(-10 * time.Minute).Unix()
|
|
require.NoError(t, db.Model(conversation).Updates(map[string]any{"first_reply_created_at": firstReply, "waiting_since": waitingSince}).Error)
|
|
},
|
|
expectedType: "sla_missed_next_response",
|
|
},
|
|
{
|
|
name: "resolution",
|
|
policy: model.SlaPolicy{ResolutionTimeThreshold: 60},
|
|
conversationPatch: func(t *testing.T, db *gorm.DB, conversation *model.Conversation) {
|
|
},
|
|
expectedType: "sla_missed_resolution",
|
|
},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
svc, db := setupAppliedSlaLifecycleTest(t)
|
|
createdAt := time.Now().Add(-2 * time.Hour)
|
|
account, _, _, policy, conversation := seedAppliedSlaConversation(t, db, createdAt, tc.policy)
|
|
user := &model.User{AccountID: account.ID, Name: "Watcher", Email: tc.name + "@example.com", Role: "agent", Active: true}
|
|
require.NoError(t, db.Create(user).Error)
|
|
require.NoError(t, db.Create(&model.AccountUser{AccountID: account.ID, UserID: user.ID, Role: "agent"}).Error)
|
|
require.NoError(t, db.Create(&model.ConversationParticipant{AccountID: account.ID, ConversationID: conversation.ID, UserID: user.ID}).Error)
|
|
tc.conversationPatch(t, db, conversation)
|
|
|
|
applied, err := svc.CreateFromConversation(context.Background(), account.ID, conversation.ID, policy.ID)
|
|
require.NoError(t, err)
|
|
_, err = svc.Evaluate(context.Background(), applied.ID)
|
|
require.NoError(t, err)
|
|
|
|
var notification model.Notification
|
|
require.NoError(t, db.First(¬ification).Error)
|
|
assert.Equal(t, tc.expectedType, notification.NotificationType)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestAppliedSlaService_CreateFromConversation_OnlyDuringBusinessHoursMatchesReferenceTiming(t *testing.T) {
|
|
svc, db := setupAppliedSlaLifecycleTest(t)
|
|
createdAt := time.Date(2026, 6, 5, 20, 0, 0, 0, time.UTC)
|
|
account, _, _, policy, conversation := seedAppliedSlaConversation(t, db, createdAt, model.SlaPolicy{
|
|
FirstResponseTimeThreshold: 3600,
|
|
OnlyDuringBusinessHours: true,
|
|
})
|
|
|
|
applied, err := svc.CreateFromConversation(context.Background(), account.ID, conversation.ID, policy.ID)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, applied.FRTTargetAt)
|
|
|
|
// The current reference/chatwoot EvaluateAppliedSlaService stores the flag but
|
|
// calculates thresholds as created_at + threshold seconds.
|
|
assert.Equal(t, createdAt.Add(time.Hour), *applied.FRTTargetAt)
|
|
}
|
|
|
|
func TestAppliedSlaService_Evaluate_NRTMissStoresMessageMetaOnce(t *testing.T) {
|
|
svc, db := setupAppliedSlaLifecycleTest(t)
|
|
createdAt := time.Now().Add(-2 * time.Hour)
|
|
account, inbox, _, policy, conversation := seedAppliedSlaConversation(t, db, createdAt, model.SlaPolicy{
|
|
NextResponseTimeThreshold: 60,
|
|
})
|
|
firstReply := time.Now().Add(-90 * time.Minute).Unix()
|
|
waitingSince := time.Now().Add(-10 * time.Minute).Unix()
|
|
require.NoError(t, db.Model(conversation).Updates(map[string]any{
|
|
"first_reply_created_at": firstReply,
|
|
"waiting_since": waitingSince,
|
|
}).Error)
|
|
conversation.FirstReplyCreatedAt = &firstReply
|
|
conversation.WaitingSince = &waitingSince
|
|
|
|
message := &model.Message{
|
|
AccountID: account.ID,
|
|
InboxID: inbox.ID,
|
|
ConversationID: conversation.ID,
|
|
MessageType: string(model.MessageTypeIncoming),
|
|
Content: "customer waiting",
|
|
ContentType: "text",
|
|
}
|
|
require.NoError(t, db.Create(message).Error)
|
|
|
|
applied, err := svc.CreateFromConversation(context.Background(), account.ID, conversation.ID, policy.ID)
|
|
require.NoError(t, err)
|
|
_, err = svc.Evaluate(context.Background(), applied.ID)
|
|
require.NoError(t, err)
|
|
_, err = svc.Evaluate(context.Background(), applied.ID)
|
|
require.NoError(t, err)
|
|
|
|
var events []model.SlaEvent
|
|
require.NoError(t, db.Where("applied_sla_id = ?", applied.ID).Find(&events).Error)
|
|
require.Len(t, events, 1)
|
|
assert.Equal(t, model.SLAEventNRT, events[0].EventType)
|
|
|
|
var meta map[string]uint
|
|
require.NoError(t, json.Unmarshal(events[0].Meta, &meta))
|
|
assert.Equal(t, message.ID, meta["message_id"])
|
|
}
|
|
|
|
func TestAppliedSlaService_Evaluate_ResolvedConversationHitOrMiss(t *testing.T) {
|
|
svc, db := setupAppliedSlaLifecycleTest(t)
|
|
now := time.Now()
|
|
account, _, _, policy, conversation := seedAppliedSlaConversation(t, db, now.Add(-10*time.Minute), model.SlaPolicy{
|
|
FirstResponseTimeThreshold: 3600,
|
|
ResolutionTimeThreshold: 7200,
|
|
})
|
|
firstReply := now.Add(-9 * time.Minute).Unix()
|
|
resolvedAt := now
|
|
require.NoError(t, db.Model(conversation).Updates(map[string]any{
|
|
"first_reply_created_at": firstReply,
|
|
"status": string(model.ConversationStatusResolved),
|
|
"resolved_at": resolvedAt,
|
|
}).Error)
|
|
|
|
applied, err := svc.CreateFromConversation(context.Background(), account.ID, conversation.ID, policy.ID)
|
|
require.NoError(t, err)
|
|
applied, err = svc.Evaluate(context.Background(), applied.ID)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, model.SLAStatusHit, applied.SLAStatus)
|
|
|
|
account2, _, _, policy2, conversation2 := seedAppliedSlaConversation(t, db, now.Add(-2*time.Hour), model.SlaPolicy{
|
|
FirstResponseTimeThreshold: 60,
|
|
})
|
|
applied2, err := svc.CreateFromConversation(context.Background(), account2.ID, conversation2.ID, policy2.ID)
|
|
require.NoError(t, err)
|
|
applied2, err = svc.Evaluate(context.Background(), applied2.ID)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, model.SLAStatusActiveWithMisses, applied2.SLAStatus)
|
|
|
|
require.NoError(t, db.Model(conversation2).Updates(map[string]any{
|
|
"status": string(model.ConversationStatusResolved),
|
|
"resolved_at": now,
|
|
}).Error)
|
|
applied2, err = svc.Evaluate(context.Background(), applied2.ID)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, model.SLAStatusMissed, applied2.SLAStatus)
|
|
}
|