Source-only publication of reviewed commit 64e96a7f423673dc29ad56485be9fc79bce8af1c.
Passed in isolation: focused frontend/backend tests, race checks, TypeScript typecheck, lint, Go compilation, Flowgram build, and deterministic SDK build/tests.
Not accepted as a production release: Next.js export/native assembly remains blocked by environment memory limits; browser interaction and MySQL staging acceptance remain unverified. No production server, database/schema, runtime configuration, deployment, release tag, or workflow execution was performed.
Expected source tree: 0a4a7528e9.
385 lines
17 KiB
Go
385 lines
17 KiB
Go
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")
|
|
}
|
|
}
|