package services import ( "context" "encoding/json" "errors" "fmt" "testing" "time" "agent-desk/internal/models" "agent-desk/internal/pkg/dto/request" "agent-desk/internal/pkg/enums" "agent-desk/internal/repositories" "github.com/mlogclub/simple/sqls" "gorm.io/gorm" ) func TestSupportReviewHalfHourDSTAndMissingWholePeriod(t *testing.T) { cases := []struct { name, start, end, at, next string open bool }{ {"half-hour gap", "02:10", "03:00", "2026-10-03T15:29:59Z", "2026-10-03T15:30:00Z", false}, {"gap opening", "02:10", "03:00", "2026-10-03T15:30:00Z", "", true}, {"whole period absent", "02:10", "02:20", "2026-10-03T15:30:00Z", "2026-10-10T15:10:00Z", false}, {"fold first occurrence", "01:45", "01:50", "2026-04-04T14:45:00Z", "", true}, {"fold second occurrence", "01:45", "01:50", "2026-04-04T15:19:59Z", "", true}, {"fold half-open end", "01:45", "01:50", "2026-04-04T15:20:00Z", "2026-04-11T15:15:00Z", false}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { calendar := calendarForTest("Australia/Lord_Howe", weeklyForTest(7, tc.start, tc.end)) got, err := evaluateRecurringSupportCalendar(calendar, instantForTest(tc.at)) if err != nil || got.Open != tc.open || supportTimeString(got.NextOpeningAt) != tc.next { t.Fatalf("got open=%v next=%s error=%v; want open=%v next=%s", got.Open, supportTimeString(got.NextOpeningAt), err, tc.open, tc.next) } }) } } func TestSupportReviewNextOpeningKeepsItsTimezone(t *testing.T) { f, p, ai := newAvailabilityFixture(t) secondTeam := models.AgentTeam{Name: "Later opening", Status: enums.StatusOk} if err := f.db.Create(&secondTeam).Error; err != nil { t.Fatal(err) } if err := f.db.Model(&ai).Update("team_ids", fmt.Sprintf("%d,%d", p.TeamID, secondTeam.ID)).Error; err != nil { t.Fatal(err) } for _, item := range []struct { teamID int64 zone string days int }{{p.TeamID, "Pacific/Auckland", 2}, {secondTeam.ID, "America/New_York", 5}} { loc, err := time.LoadLocation(item.zone) if err != nil { t.Fatal(err) } date := f.now.In(loc).AddDate(0, 0, item.days).Format(time.DateOnly) exceptions, err := json.Marshal([]request.SupportCalendarException{{Date: date, Periods: []request.SupportCalendarPeriod{{Start: "09:00", End: "10:00"}}}}) if err != nil { t.Fatal(err) } calendar := &models.SupportCalendar{TeamID: item.teamID, Mode: "recurring", Timezone: item.zone, WeeklyJSON: "[]", ExceptionsJSON: string(exceptions)} if err := repositories.SupportAvailabilityRepository.SaveCalendar(f.db, calendar); err != nil { t.Fatal(err) } } want, err := SupportCalendarService.Evaluate(f.db, p.TeamID, f.now) if err != nil { t.Fatal(err) } got, err := SupportAvailabilityService.PublicStatus(&models.Channel{AIAgentID: ai.ID, Status: enums.StatusOk}) if err != nil { t.Fatal(err) } if got.Available || got.Reason != "outside_hours" || got.NextOpeningAt != supportTimeString(want.NextOpeningAt) || got.Timezone != want.Timezone { t.Fatalf("next opening and timezone must belong to the same team: got %+v, want next=%s timezone=%s", got, supportTimeString(want.NextOpeningAt), want.Timezone) } } func TestSupportReviewFallbackGuardUsesTransactionConnection(t *testing.T) { f, p, ai := newAvailabilityFixture(t) if err := f.db.Model(&ai).Update("team_ids", "").Error; err != nil { t.Fatal(err) } conversation := models.Conversation{AIAgentID: ai.ID, Status: enums.IMConversationStatusPending} if err := f.db.Create(&conversation).Error; err != nil { t.Fatal(err) } sqlDB, err := f.db.DB() if err != nil { t.Fatal(err) } sqlDB.SetMaxOpenConns(1) ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() err = f.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { _, err := SupportAvailabilityService.GuardAssignment(tx, p.UserID, true, &conversation, f.now) return err }) if err != nil { t.Fatalf("empty AI team IDs must resolve without escaping the held transaction: %v", err) } } func TestSupportReviewDistinctSessionRevocationAndOwnerBinding(t *testing.T) { f, p, _ := newAvailabilityFixture(t) second := models.LoginSession{UserID: p.UserID, Token: "ak_test_only_second_session", ExpiredAt: f.now.Add(time.Hour)} if err := f.db.Create(&second).Error; err != nil { t.Fatal(err) } lease := models.AgentPresenceLease{ID: "separate-login", UserID: p.UserID, LoginSessionID: second.ID, ExpiresAt: f.now.Add(agentPresenceLeaseTTL)} if err := f.db.Create(&lease).Error; err != nil { t.Fatal(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("revoking one login must not hide another valid login: %v %v", users, err) } if err := f.db.Model(&second).Update("user_id", p.UserID+1000).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("lease must remain bound to login-session owner: %v %v", users, err) } } func TestSupportReviewGuardRejectsDisabledProfileDespiteLiveSession(t *testing.T) { f, p, ai := newAvailabilityFixture(t) if err := f.db.Model(&p).Update("status", enums.StatusDisabled).Error; err != nil { t.Fatal(err) } conversation := models.Conversation{AIAgentID: ai.ID, Status: enums.IMConversationStatusPending} if err := f.db.Create(&conversation).Error; err != nil { t.Fatal(err) } err := f.db.Transaction(func(tx *gorm.DB) error { _, err := SupportAvailabilityService.GuardAssignment(tx, p.UserID, false, &conversation, f.now) return err }) if !errors.Is(err, errSupportAssignmentIneligible) { t.Fatalf("live session must not bypass disabled profile: %v", err) } } func TestSupportReviewEffectiveStatusFilterAndPagination(t *testing.T) { f, p, _ := newAvailabilityFixture(t) before, now, after := f.now.Add(-time.Hour), f.now, f.now.Add(time.Hour) earlier, later := before.Add(-time.Hour), after.Add(time.Hour) cases := []struct { name string status, fallback enums.ServiceStatus start, end *time.Time want enums.ServiceStatus }{ {"ordinary busy ignores absence", enums.ServiceStatusBusy, enums.ServiceStatusIdle, &before, &after, enums.ServiceStatusBusy}, {"future vacation returns idle", enums.ServiceStatusVacation, enums.ServiceStatusIdle, &after, &later, enums.ServiceStatusIdle}, {"active vacation", enums.ServiceStatusVacation, enums.ServiceStatusBusy, &before, &after, enums.ServiceStatusVacation}, {"inclusive vacation start", enums.ServiceStatusVacation, enums.ServiceStatusIdle, &now, &after, enums.ServiceStatusVacation}, {"exclusive vacation end", enums.ServiceStatusVacation, enums.ServiceStatusBreak, &before, &now, enums.ServiceStatusBreak}, {"expired vacation returns idle", enums.ServiceStatusVacation, enums.ServiceStatusIdle, &earlier, &before, enums.ServiceStatusIdle}, {"missing bounds safe fallback", enums.ServiceStatusVacation, enums.ServiceStatusUnavailable, nil, nil, enums.ServiceStatusUnavailable}, {"invalid return status safe fallback", enums.ServiceStatusVacation, enums.ServiceStatusVacation, nil, nil, enums.ServiceStatusUnavailable}, } expected := map[enums.ServiceStatus][]int64{enums.ServiceStatusIdle: {p.ID}} for i, tc := range cases { profile := models.AgentProfile{UserID: p.UserID + int64(i) + 10, TeamID: p.TeamID, AgentCode: fmt.Sprintf("review-%d", i), DisplayName: tc.name, Status: enums.StatusOk, ServiceStatus: tc.status, AvailabilityReturnStatus: tc.fallback, AbsenceStartAt: tc.start, AbsenceEndAt: tc.end} if err := f.db.Create(&profile).Error; err != nil { t.Fatal(err) } if got := effectiveSupportStatus(profile, now); got != tc.want { t.Fatalf("%s: policy returned %v, want %v", tc.name, got, tc.want) } expected[tc.want] = append(expected[tc.want], profile.ID) } for _, status := range enums.ServiceStatusValues { wantIDs := expected[status] for page := 1; page <= len(wantIDs)+1; page++ { cnd := sqls.NewCnd().Asc("id").Page(page, 1) repositories.SupportAvailabilityRepository.EffectiveStatusCondition(cnd, status, now) got, paging := repositories.AgentProfileRepository.FindPageByCnd(f.db, cnd) if paging.Total != int64(len(wantIDs)) { t.Fatalf("status %d page %d: total %d, want %d", status, page, paging.Total, len(wantIDs)) } if page > len(wantIDs) { if len(got) != 0 { t.Fatalf("status %d: final page was not empty: %+v", status, got) } continue } if len(got) != 1 || got[0].ID != wantIDs[page-1] { t.Fatalf("status %d page %d: got %+v, want id=%d", status, page, got, wantIDs[page-1]) } } } for _, value := range []string{"99", "-1", "not-a-status"} { if err := AgentProfileService.FilterEffectiveServiceStatus(sqls.NewCnd(), value); err == nil { t.Fatalf("accepted invalid effective status %q", value) } } }