Files
gochat/internal/service/applied_sla_service_test.go
T

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(&notifications).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(&notification).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)
}