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.
209 lines
8.8 KiB
Go
209 lines
8.8 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|