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{}, )) 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) }