feat(sla): make sla breach events idempotent

This commit is contained in:
2026-06-05 07:26:12 +08:00
parent 85da0beb93
commit 213bf2b5f9
5 changed files with 305 additions and 61 deletions
+45 -45
View File
@@ -12,22 +12,22 @@ import (
// Fields: first_response_time_threshold (FRT), next_response_time_threshold (NRT),
// resolution_time_threshold (RT), only_during_business_hours
type SlaPolicy struct {
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
AccountID uint `gorm:"not null;index" json:"account_id"`
Name string `gorm:"size:255;not null" json:"name"`
Description string `gorm:"type:text" json:"description"`
FirstResponseTimeThreshold int `gorm:"default:0" json:"first_response_time_threshold"` // FRT in minutes
NextResponseTimeThreshold int `gorm:"default:0" json:"next_response_time_threshold"` // NRT in minutes
ResolutionTimeThreshold int `gorm:"default:0" json:"resolution_time_threshold"` // RT in minutes
OnlyDuringBusinessHours bool `gorm:"default:false" json:"only_during_business_hours"`
PausedAt *time.Time `gorm:"index" json:"paused_at,omitempty"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"deleted_at,omitempty"`
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
AccountID uint `gorm:"not null;index" json:"account_id"`
Name string `gorm:"size:255;not null" json:"name"`
Description string `gorm:"type:text" json:"description"`
FirstResponseTimeThreshold int `gorm:"default:0" json:"first_response_time_threshold"` // FRT in seconds
NextResponseTimeThreshold int `gorm:"default:0" json:"next_response_time_threshold"` // NRT in seconds
ResolutionTimeThreshold int `gorm:"default:0" json:"resolution_time_threshold"` // RT in seconds
OnlyDuringBusinessHours bool `gorm:"default:false" json:"only_during_business_hours"`
PausedAt *time.Time `gorm:"index" json:"paused_at,omitempty"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"deleted_at,omitempty"`
Account Account `gorm:"foreignKey:AccountID" json:"account,omitempty"`
AppliedSLAs []AppliedSLA `gorm:"foreignKey:SlaPolicyID" json:"applied_slas,omitempty"`
SlaPolicyInboxes []SlaPolicyInbox `gorm:"foreignKey:SlaPolicyID" json:"inboxes,omitempty"`
Account Account `gorm:"foreignKey:AccountID" json:"account,omitempty"`
AppliedSLAs []AppliedSLA `gorm:"foreignKey:SlaPolicyID" json:"applied_slas,omitempty"`
SlaPolicyInboxes []SlaPolicyInbox `gorm:"foreignKey:SlaPolicyID" json:"inboxes,omitempty"`
}
func (SlaPolicy) TableName() string { return "sla_policies" }
@@ -54,26 +54,26 @@ func (SlaPolicyInbox) TableName() string { return "sla_policy_inboxes" }
// Reference: Chatwoot app/models/applied_sla.rb
// Tracks SLA status, target timestamps (FRT/NRT/RT), and actual completion times.
type AppliedSLA struct {
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
SlaPolicyID uint `gorm:"not null;index" json:"sla_policy_id"`
ConversationID uint `gorm:"not null;index" json:"conversation_id"`
AccountID uint `gorm:"not null;index" json:"account_id"`
SLAStatus SLAStatus `gorm:"size:50;not null;default:active" json:"sla_status"`
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
SlaPolicyID uint `gorm:"not null;index" json:"sla_policy_id"`
ConversationID uint `gorm:"not null;index" json:"conversation_id"`
AccountID uint `gorm:"not null;index" json:"account_id"`
SLAStatus SLAStatus `gorm:"size:50;not null;default:active" json:"sla_status"`
// Target timestamps for FRT/NRT/RT (computed from policy + created_at)
FRTTargetAt *time.Time `gorm:"index" json:"frt_target_at,omitempty"`
NRTTargetAt *time.Time `gorm:"index" json:"nrt_target_at,omitempty"`
RTTargetAt *time.Time `gorm:"index" json:"rt_target_at,omitempty"`
FRTTargetAt *time.Time `gorm:"index" json:"frt_target_at,omitempty"`
NRTTargetAt *time.Time `gorm:"index" json:"nrt_target_at,omitempty"`
RTTargetAt *time.Time `gorm:"index" json:"rt_target_at,omitempty"`
// Actual timestamps when targets were hit or violated
FRTActualAt *time.Time `json:"frt_actual_at,omitempty"`
NRTActualAt *time.Time `json:"nrt_actual_at,omitempty"`
RTActualAt *time.Time `json:"rt_actual_at,omitempty"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"deleted_at,omitempty"`
FRTActualAt *time.Time `json:"frt_actual_at,omitempty"`
NRTActualAt *time.Time `json:"nrt_actual_at,omitempty"`
RTActualAt *time.Time `json:"rt_actual_at,omitempty"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"deleted_at,omitempty"`
SlaPolicy SlaPolicy `gorm:"foreignKey:SlaPolicyID" json:"sla_policy,omitempty"`
Conversation Conversation `gorm:"foreignKey:ConversationID" json:"conversation,omitempty"`
SlaEvents []SlaEvent `gorm:"foreignKey:AppliedSlaID" json:"sla_events,omitempty"`
SlaPolicy SlaPolicy `gorm:"foreignKey:SlaPolicyID" json:"sla_policy,omitempty"`
Conversation Conversation `gorm:"foreignKey:ConversationID" json:"conversation,omitempty"`
SlaEvents []SlaEvent `gorm:"foreignKey:AppliedSlaID" json:"sla_events,omitempty"`
}
func (AppliedSLA) TableName() string { return "applied_slas" }
@@ -82,20 +82,20 @@ func (AppliedSLA) TableName() string { return "applied_slas" }
// Reference: Chatwoot app/models/sla_event.rb
// Fields: event_type (frt/nrt/rt), meta (jsonb), account_id, conversation_id, inbox_id, sla_policy_id
type SlaEvent struct {
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
AppliedSlaID uint `gorm:"not null;index" json:"applied_sla_id"`
AccountID uint `gorm:"not null;index" json:"account_id"`
ConversationID uint `gorm:"not null;index" json:"conversation_id"`
InboxID uint `gorm:"not null;index" json:"inbox_id"`
SlaPolicyID uint `gorm:"not null;index" json:"sla_policy_id"`
EventType SLAEventType `gorm:"size:50;not null" json:"event_type"`
Meta datatypes.JSON `gorm:"type:jsonb" json:"meta,omitempty"` // e.g. {"message_id": 123} for NRT events
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"deleted_at,omitempty"`
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
AppliedSlaID uint `gorm:"not null;index" json:"applied_sla_id"`
AccountID uint `gorm:"not null;index" json:"account_id"`
ConversationID uint `gorm:"not null;index" json:"conversation_id"`
InboxID uint `gorm:"not null;index" json:"inbox_id"`
SlaPolicyID uint `gorm:"not null;index" json:"sla_policy_id"`
EventType SLAEventType `gorm:"size:50;not null" json:"event_type"`
Meta datatypes.JSON `gorm:"type:jsonb" json:"meta,omitempty"` // e.g. {"message_id": 123} for NRT events
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"deleted_at,omitempty"`
AppliedSLA AppliedSLA `gorm:"foreignKey:AppliedSlaID" json:"applied_sla,omitempty"`
Conversation Conversation `gorm:"foreignKey:ConversationID" json:"conversation,omitempty"`
AppliedSLA AppliedSLA `gorm:"foreignKey:AppliedSlaID" json:"applied_sla,omitempty"`
Conversation Conversation `gorm:"foreignKey:ConversationID" json:"conversation,omitempty"`
}
func (SlaEvent) TableName() string { return "sla_events" }
+15
View File
@@ -3,6 +3,7 @@ package repository
import (
"context"
"gorm.io/datatypes"
"gorm.io/gorm"
"github.com/gochat/gochat/internal/model"
@@ -128,6 +129,20 @@ func (r *SlaEventRepo) FindByAppliedSla(ctx context.Context, appliedSlaID uint)
return events, nil
}
// ExistsByAppliedSlaEventAndMeta checks whether a missed SLA threshold was already recorded.
func (r *SlaEventRepo) ExistsByAppliedSlaEventAndMeta(ctx context.Context, appliedSlaID uint, eventType model.SLAEventType, meta datatypes.JSON) (bool, error) {
var count int64
query := r.db.WithContext(ctx).Model(&model.SlaEvent{}).
Where("applied_sla_id = ? AND event_type = ?", appliedSlaID, eventType)
if len(meta) > 0 {
query = query.Where("meta = ?", meta)
}
if err := query.Count(&count).Error; err != nil {
return false, err
}
return count > 0, nil
}
// Create creates a new SLA event.
func (r *SlaEventRepo) Create(ctx context.Context, event *model.SlaEvent) error {
return r.db.WithContext(ctx).Create(event).Error
+63 -15
View File
@@ -2,12 +2,14 @@ package service
import (
"context"
"encoding/json"
"fmt"
"time"
"github.com/gochat/gochat/internal/model"
"github.com/gochat/gochat/internal/repository"
applogger "github.com/gochat/gochat/pkg/logger"
"gorm.io/datatypes"
)
// AppliedSlaService implements business logic for Applied SLA evaluation and lifecycle.
@@ -91,8 +93,8 @@ func (s *AppliedSlaService) CreateFromConversation(ctx context.Context, accountI
}
}
// Compute target timestamps from policy thresholds + conversation created_at
// Chatwoot: threshold is in minutes
// Compute target timestamps from policy thresholds + conversation created_at.
// Chatwoot stores SLA thresholds in seconds.
applied := &model.AppliedSLA{
SlaPolicyID: slaPolicyID,
ConversationID: conversationID,
@@ -100,9 +102,9 @@ func (s *AppliedSlaService) CreateFromConversation(ctx context.Context, accountI
SLAStatus: model.SLAStatusActive,
}
// FRT target: conversation.created_at + first_response_time_threshold (minutes)
// FRT target: conversation.created_at + first_response_time_threshold seconds.
if policy.FirstResponseTimeThreshold > 0 {
frtTarget := conversation.CreatedAt.Add(time.Duration(policy.FirstResponseTimeThreshold) * time.Minute)
frtTarget := conversation.CreatedAt.Add(time.Duration(policy.FirstResponseTimeThreshold) * time.Second)
applied.FRTTargetAt = &frtTarget
}
@@ -112,17 +114,17 @@ func (s *AppliedSlaService) CreateFromConversation(ctx context.Context, accountI
if policy.NextResponseTimeThreshold > 0 {
// Initial NRT target = FRT target (or created_at + NRT as fallback)
if applied.FRTTargetAt != nil {
nrtTarget := applied.FRTTargetAt.Add(time.Duration(policy.NextResponseTimeThreshold) * time.Minute)
nrtTarget := applied.FRTTargetAt.Add(time.Duration(policy.NextResponseTimeThreshold) * time.Second)
applied.NRTTargetAt = &nrtTarget
} else {
nrtTarget := conversation.CreatedAt.Add(time.Duration(policy.NextResponseTimeThreshold) * time.Minute)
nrtTarget := conversation.CreatedAt.Add(time.Duration(policy.NextResponseTimeThreshold) * time.Second)
applied.NRTTargetAt = &nrtTarget
}
}
// RT target: conversation.created_at + resolution_time_threshold (minutes)
// RT target: conversation.created_at + resolution_time_threshold seconds.
if policy.ResolutionTimeThreshold > 0 {
rtTarget := conversation.CreatedAt.Add(time.Duration(policy.ResolutionTimeThreshold) * time.Minute)
rtTarget := conversation.CreatedAt.Add(time.Duration(policy.ResolutionTimeThreshold) * time.Second)
applied.RTTargetAt = &rtTarget
}
@@ -157,6 +159,9 @@ func (s *AppliedSlaService) Evaluate(ctx context.Context, appliedSlaID uint) (*m
if err != nil {
return nil, fmt.Errorf("applied sla not found: %w", err)
}
if applied.SLAStatus == model.SLAStatusHit || applied.SLAStatus == model.SLAStatusMissed {
return applied, nil
}
policy, err := s.slaPolicyRepo.FindByID(ctx, applied.SlaPolicyID)
if err != nil {
@@ -173,8 +178,8 @@ func (s *AppliedSlaService) Evaluate(ctx context.Context, appliedSlaID uint) (*m
// threshold = conversation.created_at + policy.frt
// Missed if: first_reply_created_at is nil AND now > threshold
// Or: first_reply_created_at > threshold
now := time.Now()
if policy.FirstResponseTimeThreshold > 0 && applied.FRTTargetAt != nil {
now := time.Now()
if s.checkFRTMissed(conversation, applied, now) {
s.handleMissedSla(ctx, applied, model.SLAEventFRT, conversation)
} else if conversation.FirstReplyCreatedAt != nil {
@@ -192,7 +197,6 @@ func (s *AppliedSlaService) Evaluate(ctx context.Context, appliedSlaID uint) (*m
// Skip if: waiting_since is nil (customer not waiting)
// threshold = waiting_since + policy.nrt
if policy.NextResponseTimeThreshold > 0 && applied.NRTTargetAt != nil {
now := time.Now()
if s.checkNRTMissed(conversation, applied, now) {
s.handleMissedSla(ctx, applied, model.SLAEventNRT, conversation)
}
@@ -203,7 +207,6 @@ func (s *AppliedSlaService) Evaluate(ctx context.Context, appliedSlaID uint) (*m
// threshold = conversation.created_at + policy.rt
// Missed if: now > threshold AND conversation not yet resolved
if policy.ResolutionTimeThreshold > 0 && applied.RTTargetAt != nil {
now := time.Now()
if s.checkRTMissed(conversation, applied, now) {
s.handleMissedSla(ctx, applied, model.SLAEventRT, conversation)
}
@@ -232,7 +235,7 @@ func (s *AppliedSlaService) checkFRTMissed(conversation *model.Conversation, app
// No first reply yet — check if we're past the deadline
if conversation.FirstReplyCreatedAt == nil {
return now.After(*target)
return !now.Before(*target)
}
// First reply happened — was it within the threshold?
@@ -264,12 +267,12 @@ func (s *AppliedSlaService) checkNRTMissed(conversation *model.Conversation, app
if err != nil {
return false
}
nrtTarget := waitingSince.Add(time.Duration(policy.NextResponseTimeThreshold) * time.Minute)
nrtTarget := waitingSince.Add(time.Duration(policy.NextResponseTimeThreshold) * time.Second)
// Update the NRT target timestamp
applied.NRTTargetAt = &nrtTarget
return now.After(nrtTarget)
return !now.Before(nrtTarget)
}
// checkRTMissed checks if the resolution time threshold was missed.
@@ -279,12 +282,25 @@ func (s *AppliedSlaService) checkRTMissed(conversation *model.Conversation, appl
if conversation.Status == string(model.ConversationStatusResolved) {
return false // Already resolved — will be handled by handleHitSla
}
return now.After(*applied.RTTargetAt)
return !now.Before(*applied.RTTargetAt)
}
// handleMissedSla creates a SlaEvent for a missed threshold and updates status.
// Chatwoot: handle_missed_sla → creates SlaEvent with event_type, updates sla_status
func (s *AppliedSlaService) handleMissedSla(ctx context.Context, applied *model.AppliedSLA, eventType model.SLAEventType, conversation *model.Conversation) {
meta := s.slaEventMeta(ctx, eventType, conversation)
alreadyMissed, err := s.slaEventRepo.ExistsByAppliedSlaEventAndMeta(ctx, applied.ID, eventType, meta)
if err != nil {
applogger.L().Errorf("Failed to check existing SLA event for applied_sla %d: %v", applied.ID, err)
return
}
if alreadyMissed {
if applied.SLAStatus == model.SLAStatusActive {
applied.SLAStatus = model.SLAStatusActiveWithMisses
}
return
}
// Create SlaEvent record
event := &model.SlaEvent{
AppliedSlaID: applied.ID,
@@ -293,6 +309,7 @@ func (s *AppliedSlaService) handleMissedSla(ctx context.Context, applied *model.
InboxID: conversation.InboxID,
SlaPolicyID: applied.SlaPolicyID,
EventType: eventType,
Meta: meta,
}
if err := s.slaEventRepo.Create(ctx, event); err != nil {
@@ -309,6 +326,37 @@ func (s *AppliedSlaService) handleMissedSla(ctx context.Context, applied *model.
applogger.L().Infof("SLA %s missed for applied_sla %d on conversation %d", eventType, applied.ID, applied.ConversationID)
}
func (s *AppliedSlaService) slaEventMeta(ctx context.Context, eventType model.SLAEventType, conversation *model.Conversation) datatypes.JSON {
if eventType != model.SLAEventNRT {
return datatypes.JSON([]byte("{}"))
}
meta := map[string]any{"message_id": nil}
if messageID := s.lastIncomingMessageID(ctx, conversation); messageID != nil {
meta["message_id"] = *messageID
}
encoded, err := json.Marshal(meta)
if err != nil {
return datatypes.JSON([]byte(`{"message_id":null}`))
}
return datatypes.JSON(encoded)
}
func (s *AppliedSlaService) lastIncomingMessageID(ctx context.Context, conversation *model.Conversation) *uint {
if s.conversationRepo == nil || conversation == nil {
return nil
}
var message model.Message
err := s.conversationRepo.DB().WithContext(ctx).
Where("account_id = ? AND conversation_id = ? AND message_type = ?", conversation.AccountID, conversation.ID, string(model.MessageTypeIncoming)).
Order("id DESC").
First(&message).Error
if err != nil {
return nil
}
return &message.ID
}
// handleHitSla marks the SLA as hit when the conversation is resolved.
// Chatwoot: handle_hit_sla → if resolved, check if any thresholds were missed
// - If active_with_misses → missed (not hit)
@@ -0,0 +1,180 @@
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.Inbox{},
&model.Contact{},
&model.Conversation{},
&model.Message{},
&model.SlaPolicy{},
&model.AppliedSLA{},
&model.SlaEvent{},
))
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_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)
}
@@ -3,6 +3,7 @@ package service
import (
"context"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -142,7 +143,7 @@ func TestConversationService_Create_AppliesSlaPolicy(t *testing.T) {
require.NotNil(t, applied.FRTTargetAt)
require.NotNil(t, applied.NRTTargetAt)
require.NotNil(t, applied.RTTargetAt)
assert.Equal(t, conversation.CreatedAt.Add(10*60*1e9).Unix(), applied.FRTTargetAt.Unix())
assert.Equal(t, conversation.CreatedAt.Add(10*time.Second).Unix(), applied.FRTTargetAt.Unix())
}
func TestConversationService_Update_AppliesSlaPolicyOnce(t *testing.T) {