package services import ( "agent-desk/internal/models" "agent-desk/internal/pkg/dto/request" "agent-desk/internal/pkg/dto/response" "agent-desk/internal/pkg/enums" "agent-desk/internal/repositories" "encoding/json" "fmt" "sync" "testing" "time" ) func calendarForTest(zone string, periods ...request.SupportCalendarWeeklyPeriod) *response.SupportCalendarResponse { return &response.SupportCalendarResponse{SupportCalendarRequest: request.SupportCalendarRequest{TeamID: 1, Mode: "recurring", Timezone: zone, Weekly: periods, Exceptions: []request.SupportCalendarException{}}, EffectiveTimezone: zone} } func weeklyForTest(day int, start, end string) request.SupportCalendarWeeklyPeriod { return request.SupportCalendarWeeklyPeriod{Weekday: day, SupportCalendarPeriod: request.SupportCalendarPeriod{Start: start, End: end}} } func instantForTest(s string) time.Time { t, err := time.Parse(time.RFC3339, s) if err != nil { panic(err) } return t } func TestSupportCalendarSplitOvernightExceptionAndNextOpening(t *testing.T) { c := calendarForTest("Europe/Berlin", weeklyForTest(1, "09:00", "12:00"), weeklyForTest(1, "13:00", "17:00"), weeklyForTest(5, "22:00", "06:00")) tests := []struct { at string open bool next string }{ {"2026-10-12T08:00:00Z", true, ""}, {"2026-10-12T10:30:00Z", false, "2026-10-12T11:00:00Z"}, {"2026-10-16T21:00:00Z", true, ""}, {"2026-10-17T03:59:59Z", true, ""}, {"2026-10-17T04:00:00Z", false, "2026-10-19T07:00:00Z"}, } for _, tt := range tests { got, err := evaluateRecurringSupportCalendar(c, instantForTest(tt.at)) if err != nil || got.Open != tt.open || supportTimeString(got.NextOpeningAt) != tt.next { t.Errorf("%s: %+v %v next %s", tt.at, got, err, supportTimeString(got.NextOpeningAt)) } } c.Exceptions = []request.SupportCalendarException{{Date: "2026-10-17", Closed: true}} got, err := evaluateRecurringSupportCalendar(c, instantForTest("2026-10-17T00:00:00+02:00")) if err != nil || got.Open { t.Fatalf("holiday must suppress overnight carry: %+v %v", got, err) } c.Exceptions = []request.SupportCalendarException{{Date: "2026-10-17", Periods: []request.SupportCalendarPeriod{{Start: "10:00", End: "11:00"}}}} got, err = evaluateRecurringSupportCalendar(c, instantForTest("2026-10-17T00:00:00+02:00")) if err != nil || supportTimeString(got.NextOpeningAt) != "2026-10-17T08:00:00Z" { t.Fatalf("date override: %+v %v", got, err) } } func TestSupportCalendarDSTGapAndFold(t *testing.T) { spring := calendarForTest("America/New_York", weeklyForTest(7, "02:30", "04:00")) got, err := evaluateRecurringSupportCalendar(spring, instantForTest("2026-03-08T06:59:00Z")) if err != nil || got.Open || supportTimeString(got.NextOpeningAt) != "2026-03-08T07:00:00Z" { t.Fatalf("gap boundary: %+v %v", got, err) } got, err = evaluateRecurringSupportCalendar(spring, instantForTest("2026-03-08T07:00:00Z")) if err != nil || !got.Open { t.Fatalf("gap opening: %+v %v", got, err) } fold := calendarForTest("America/New_York", weeklyForTest(7, "01:30", "01:45")) for _, at := range []string{"2026-11-01T05:30:00Z", "2026-11-01T06:15:00Z", "2026-11-01T06:44:59Z"} { got, err = evaluateRecurringSupportCalendar(fold, instantForTest(at)) if err != nil || !got.Open { t.Errorf("fold %s: %+v %v", at, got, err) } } got, err = evaluateRecurringSupportCalendar(fold, instantForTest("2026-11-01T06:45:00Z")) if err != nil || got.Open { t.Fatalf("fold end: %+v %v", got, err) } } func TestSupportCalendarValidationAndBoundedUnknownOpening(t *testing.T) { for _, req := range []request.SupportCalendarRequest{ {TeamID: 0, Mode: "recurring"}, {TeamID: 1, Mode: "recurring", Timezone: "Local"}, {TeamID: 1, Mode: "recurring", Timezone: "Not/AZone"}, {TeamID: 1, Mode: "recurring", Weekly: []request.SupportCalendarWeeklyPeriod{weeklyForTest(1, "09:00", "09:00")}}, {TeamID: 1, Mode: "recurring", Exceptions: []request.SupportCalendarException{{Date: "2026-02-30", Closed: true}}}, } { if validateSupportCalendar(req) == nil { t.Errorf("accepted invalid config: %+v", req) } } c := calendarForTest("UTC") got, err := evaluateRecurringSupportCalendar(c, time.Now()) if err != nil || got.Open || got.NextOpeningAt != nil { t.Fatalf("empty calendar invented opening: %+v %v", got, err) } } func TestSupportAvailabilityEffectiveAbsenceAndUnlimitedCapacity(t *testing.T) { start := instantForTest("2026-10-17T09:00:00Z") end := start.Add(48 * time.Hour) p := models.AgentProfile{Status: enums.StatusOk, ServiceStatus: enums.ServiceStatusVacation, AvailabilityReturnStatus: enums.ServiceStatusBusy, AbsenceStartAt: &start, AbsenceEndAt: &end, AutoAssignEnabled: true} for _, tt := range []struct { at time.Time want enums.ServiceStatus }{{start.Add(-time.Second), enums.ServiceStatusBusy}, {start, enums.ServiceStatusVacation}, {end.Add(-time.Second), enums.ServiceStatusVacation}, {end, enums.ServiceStatusBusy}} { if got := effectiveSupportStatus(p, tt.at); got != tt.want { t.Errorf("effective at %v: %v", tt.at, got) } } p.ServiceStatus = enums.ServiceStatusIdle p.MaxConcurrentCount = 0 if got := eligibilityReason(p, true, true, true, true, 999, true, start); got != "eligible" { t.Fatal(got) } p.MaxConcurrentCount = 1 if got := eligibilityReason(p, true, true, true, true, 1, true, start); got != "capacity" { t.Fatal(got) } p.MaxConcurrentCount = 0 p.AutoAssignEnabled = false if got := eligibilityReason(p, true, true, true, true, 0, true, start); got != "auto_assign_disabled" { t.Fatal(got) } if got := eligibilityReason(p, true, true, true, true, 0, false, start); got != "eligible" { t.Fatal(got) } p.ReceiveOfflineMessage = true if got := eligibilityReason(p, true, true, false, true, 0, false, start); got != "offline" { t.Fatal(got) } } func TestSupportAvailabilityAbsenceRequiresOffsetAndBounds(t *testing.T) { for _, req := range []request.SupportAvailabilityRequest{{ServiceStatus: 4}, {ServiceStatus: 4, AbsenceStartAt: "2026-10-16T10:00", AbsenceEndAt: "2026-10-17T10:00"}, {ServiceStatus: 4, AbsenceStartAt: "2026-10-17T10:00:00Z", AbsenceEndAt: "2026-10-16T10:00:00Z"}, {ServiceStatus: 99}} { if _, _, err := validateSupportAbsence(req); err == nil { t.Fatalf("accepted %+v", req) } } } func newAvailabilityFixture(t *testing.T) (*authSessionFixture, models.AgentProfile, models.AIAgent) { t.Helper() f := newWsAuthFixture(t, true) if err := f.db.AutoMigrate(&models.AgentPresenceLease{}, &models.SupportCalendar{}, &models.AgentTeam{}, &models.AgentTeamSchedule{}, &models.AgentProfile{}, &models.AIAgent{}, &models.Conversation{}, &models.ConversationAssignment{}, &models.ConversationEventLog{}); err != nil { t.Fatal(err) } team := models.AgentTeam{Name: "Support", Status: enums.StatusOk} if err := f.db.Create(&team).Error; err != nil { t.Fatal(err) } p := models.AgentProfile{UserID: f.user.ID, TeamID: team.ID, AgentCode: "test", DisplayName: "Test", Status: enums.StatusOk, AutoAssignEnabled: true, MaxConcurrentCount: 1} ai := models.AIAgent{Name: "Test", TeamIDs: fmt.Sprint(team.ID), Status: enums.StatusOk} weekly := make([]request.SupportCalendarWeeklyPeriod, 0, 7) for day := 1; day <= 7; day++ { weekly = append(weekly, weeklyForTest(day, "00:00", "24:00")) } raw, _ := json.Marshal(weekly) for _, item := range []any{&p, &ai, &models.SupportCalendar{TeamID: team.ID, Mode: "recurring", Timezone: "UTC", WeeklyJSON: string(raw), ExceptionsJSON: "[]"}, &models.AgentPresenceLease{ID: "one", UserID: f.user.ID, LoginSessionID: f.session.ID, ExpiresAt: f.now.Add(agentPresenceLeaseTTL)}} { if err := f.db.Create(item).Error; err != nil { t.Fatal(err) } } return f, p, ai } func TestSupportAvailabilityPresenceMultiTabAndRevocation(t *testing.T) { f, p, _ := newAvailabilityFixture(t) lease := models.AgentPresenceLease{ID: "second", UserID: p.UserID, LoginSessionID: f.session.ID, ExpiresAt: f.now.Add(agentPresenceLeaseTTL)} if err := f.db.Create(&lease).Error; err != nil { t.Fatal(err) } if err := repositories.SupportAvailabilityRepository.EndPresence(f.db, "one", f.now.Add(agentPresenceDisconnectGrace)); err != nil { t.Fatal(err) } users, err := SupportAvailabilityService.onlineUsers(f.db, []int64{p.UserID}, f.now.Add(20*time.Second)) if err != nil || !users[p.UserID] { t.Fatalf("closing one tab hid another: %v %v", users, err) } users, err = SupportAvailabilityService.onlineUsers(f.db, []int64{p.UserID}, f.now.Add(agentPresenceLeaseTTL)) if err != nil || users[p.UserID] { t.Fatalf("crashed sessions outlived TTL: %v %v", users, err) } if err := f.db.Model(&f.session).Update("revoked_at", f.now).Error; err != nil { t.Fatal(err) } users, err = SupportAvailabilityService.onlineUsers(f.db, []int64{p.UserID}, f.now) if err != nil || users[p.UserID] { t.Fatalf("revoked session still present: %v %v", users, err) } } func TestSupportAvailabilityPresencePermissionAndDisabledAccount(t *testing.T) { for _, column := range []string{"permission", "disabled", "deleted", "expired"} { t.Run(column, func(t *testing.T) { f, p, _ := newAvailabilityFixture(t) switch column { case "permission": f.db.Delete(&f.permission) case "disabled": f.db.Model(&f.user).Update("status", enums.StatusDisabled) case "deleted": f.db.Model(&f.user).Update("deleted_at", f.now) case "expired": f.db.Model(&f.session).Update("expired_at", f.now.Add(-time.Second)) } got, err := SupportAvailabilityService.Evaluate(f.db, p, true, f.now, 0) if err != nil || got.Eligible || got.Online { t.Fatalf("%s: %+v %v", column, got, err) } }) } } func TestSupportAvailabilityAbsenceDoesNotChangeExistingConversations(t *testing.T) { f, p, ai := newAvailabilityFixture(t) existing := models.Conversation{AIAgentID: ai.ID, CurrentAssigneeID: p.UserID, CurrentTeamID: p.TeamID, Status: enums.IMConversationStatusActive} if err := f.db.Create(&existing).Error; err != nil { t.Fatal(err) } req := request.SupportAvailabilityRequest{ServiceStatus: enums.ServiceStatusVacation, AbsenceStartAt: f.now.Add(-time.Minute).Format(time.RFC3339), AbsenceEndAt: f.now.Add(time.Hour).Format(time.RFC3339)} result, err := SupportAvailabilityService.Update(p.UserID, req, f.principal) if err != nil || !result.Online || result.Eligible || result.EffectiveServiceStatus != enums.ServiceStatusVacation { t.Fatalf("vacation: %+v %v", result, err) } var current models.Conversation if err := f.db.First(¤t, existing.ID).Error; err != nil { t.Fatal(err) } if current.Status != existing.Status || current.CurrentAssigneeID != existing.CurrentAssigneeID { t.Fatal("vacation changed existing assignment") } } func TestSupportAvailabilityLegacyCalendarIsNotReinterpreted(t *testing.T) { f, p, _ := newAvailabilityFixture(t) if err := f.db.Where("team_id = ?", p.TeamID).Delete(&models.SupportCalendar{}).Error; err != nil { t.Fatal(err) } start := f.now.Add(-time.Hour) end := f.now.Add(time.Hour) schedule := models.AgentTeamSchedule{TeamID: p.TeamID, StartAt: start, EndAt: end, Status: enums.StatusOk} if err := f.db.Create(&schedule).Error; err != nil { t.Fatal(err) } global := models.SupportCalendar{TeamID: 0, Timezone: "Pacific/Auckland", Mode: "recurring", WeeklyJSON: "[]", ExceptionsJSON: "[]"} if err := f.db.Create(&global).Error; err != nil { t.Fatal(err) } got, err := SupportCalendarService.Evaluate(f.db, p.TeamID, f.now) if err != nil || !got.Open || got.Mode != "legacy" || got.Timezone != time.Local.String() { t.Fatalf("legacy changed: %+v %v", got, err) } c := models.SupportCalendar{TeamID: p.TeamID, Mode: "recurring", Timezone: "", WeeklyJSON: "[]", ExceptionsJSON: "[]"} if err := f.db.Create(&c).Error; err != nil { t.Fatal(err) } got, err = SupportCalendarService.Evaluate(f.db, p.TeamID, f.now) if err != nil || got.Open || got.Timezone != "Pacific/Auckland" { t.Fatalf("explicit opt-in ignored: %+v %v", got, err) } } func TestSupportAvailabilityGuardPreventsCapacityAndStaleVacation(t *testing.T) { f, p, ai := newAvailabilityFixture(t) pending := []models.Conversation{{AIAgentID: ai.ID, Status: enums.IMConversationStatusPending}, {AIAgentID: ai.ID, Status: enums.IMConversationStatusPending}} if err := f.db.Create(&pending).Error; err != nil { t.Fatal(err) } sqlDB, _ := f.db.DB() sqlDB.SetMaxOpenConns(1) var wg sync.WaitGroup results := make(chan *models.Conversation, 2) errs := make(chan error, 2) for _, conversation := range pending { wg.Add(1) go func(id int64) { defer wg.Done() got, err := ConversationDispatchService.tryAssignConversation(id, p, "test") results <- got errs <- err }(conversation.ID) } wg.Wait() close(results) close(errs) count := 0 for got := range results { if got != nil { count++ } } for err := range errs { if err != nil { t.Fatal(err) } } if count != 1 { t.Fatalf("capacity=1 admitted %d", count) } // A candidate selected before the operator changes availability cannot commit. if err := f.db.Model(&models.Conversation{}).Where("current_assignee_id = ?", p.UserID).Update("status", enums.IMConversationStatusClosed).Error; err != nil { t.Fatal(err) } start, end := f.now.Add(-time.Hour), f.now.Add(time.Hour) if err := f.db.Model(&p).Updates(map[string]any{"service_status": enums.ServiceStatusVacation, "absence_start_at": start, "absence_end_at": end}).Error; err != nil { t.Fatal(err) } third := models.Conversation{AIAgentID: ai.ID, Status: enums.IMConversationStatusPending} f.db.Create(&third) got, err := ConversationDispatchService.tryAssignConversation(third.ID, p, "stale") if err != nil || got != nil { t.Fatalf("stale vacation assigned: %+v %v", got, err) } } func TestSupportAvailabilityPublicStatusIsChannelScopedAndMinimal(t *testing.T) { f, p, ai := newAvailabilityFixture(t) channel := &models.Channel{AIAgentID: ai.ID, Status: enums.StatusOk} got, err := SupportAvailabilityService.PublicStatus(channel) if err != nil || !got.Available || got.Reason != "available" { t.Fatalf("status %+v %v", got, err) } if err := f.db.Model(&p).Update("service_status", enums.ServiceStatusBusy).Error; err != nil { t.Fatal(err) } got, err = SupportAvailabilityService.PublicStatus(channel) if err != nil || got.Available || got.Reason != "temporarily_absent" { t.Fatalf("busy aggregate %+v %v", got, err) } bytes, _ := json.Marshal(got) var fields map[string]any json.Unmarshal(bytes, &fields) for key := range fields { if key != "available" && key != "reason" && key != "timezone" && key != "nextOpeningAt" { t.Fatalf("private status field %s", key) } } other := &models.Channel{AIAgentID: ai.ID + 999, Status: enums.StatusOk} got, err = SupportAvailabilityService.PublicStatus(other) if err != nil || got.Available { t.Fatalf("other channel leaked available operator: %+v %v", got, err) } } func TestSupportAvailabilityNoEnabledRoutedTeamIsNotOutsideHours(t *testing.T) { f, profile, ai := newAvailabilityFixture(t) if err := f.db.Model(&models.AgentTeam{}).Where("id = ?", profile.TeamID).Update("status", enums.StatusDisabled).Error; err != nil { t.Fatal(err) } got, err := SupportAvailabilityService.PublicStatus(&models.Channel{AIAgentID: ai.ID, Status: enums.StatusOk}) if err != nil || got.Reason != "temporarily_absent" || got.Available || got.NextOpeningAt != "" { t.Fatalf("disabled team: %+v %v", got, err) } if err := f.db.Model(&ai).Update("team_ids", "9999").Error; err != nil { t.Fatal(err) } got, err = SupportAvailabilityService.PublicStatus(&models.Channel{AIAgentID: ai.ID, Status: enums.StatusOk}) if err != nil || got.Reason != "temporarily_absent" || got.Available || got.NextOpeningAt != "" { t.Fatalf("missing team: %+v %v", got, err) } } func TestSupportAvailabilityWSLeaseDoesNotRenewAfterCloseOrFromVisitorSocket(t *testing.T) { f, profile, _ := newAvailabilityFixture(t) if err := f.db.Where("user_id = ?", profile.UserID).Delete(&models.AgentPresenceLease{}).Error; err != nil { t.Fatal(err) } ws := newWsService() session := &ClientSession{Principal: f.principal, Role: realtimeRoleNotification, PresenceLeaseID: "platform-connection"} ws.renewAgentPresence(session) var lease models.AgentPresenceLease if err := f.db.First(&lease, "id = ?", session.PresenceLeaseID).Error; err != nil { t.Fatal(err) } if lease.ExpiresAt.After(time.Now().Add(agentPresenceLeaseTTL)) || !lease.ExpiresAt.After(time.Now().Add(agentPresenceDisconnectGrace)) { t.Fatalf("unexpected lease expiry: %v", lease.ExpiresAt) } ws.endAgentPresence(session) session.PresenceRenewedAt.Store(0) ws.renewAgentPresence(session) if err := f.db.First(&lease, "id = ?", session.PresenceLeaseID).Error; err != nil { t.Fatal(err) } if lease.ExpiresAt.After(time.Now().Add(agentPresenceDisconnectGrace)) { t.Fatal("closed session was renewed") } visitor := &ClientSession{Principal: f.principal, Role: realtimeRoleUser, PresenceLeaseID: "visitor-connection"} ws.renewAgentPresence(visitor) var count int64 if err := f.db.Model(&models.AgentPresenceLease{}).Where("id = ?", visitor.PresenceLeaseID).Count(&count).Error; err != nil { t.Fatal(err) } if count != 0 { t.Fatal("visitor socket established platform presence") } }