Files
ai-agent/internal/services/ticket_service_test.go
T

932 lines
29 KiB
Go

package services_test
import (
"context"
"fmt"
"path/filepath"
"strings"
"sync"
"testing"
"time"
"cs-agent/internal/bootstrap"
"cs-agent/internal/builders"
"cs-agent/internal/events"
"cs-agent/internal/models"
"cs-agent/internal/pkg/config"
"cs-agent/internal/pkg/dto"
"cs-agent/internal/pkg/dto/request"
"cs-agent/internal/pkg/enums"
"cs-agent/internal/pkg/eventbus"
"cs-agent/internal/repositories"
"cs-agent/internal/services"
"github.com/mlogclub/simple/sqls"
)
func TestCreateTicketSetsTicketNoAndDeadlines(t *testing.T) {
setupTicketTestDB(t)
operator := &dto.AuthPrincipal{UserID: 1, Username: "admin"}
first, err := services.TicketService.CreateTicket(createTestTicketRequest("ticket-1"), operator)
if err != nil {
t.Fatalf("CreateTicket() first error = %v", err)
}
second, err := services.TicketService.CreateTicket(createTestTicketRequest("ticket-2"), operator)
if err != nil {
t.Fatalf("CreateTicket() second error = %v", err)
}
if first.TicketNo == "" || second.TicketNo == "" {
t.Fatalf("expected ticket numbers to be generated, got %q and %q", first.TicketNo, second.TicketNo)
}
if first.TicketNo == second.TicketNo {
t.Fatalf("expected distinct ticket numbers, got %q", first.TicketNo)
}
if !strings.HasPrefix(first.TicketNo, "TK") {
t.Fatalf("expected ticket number prefix TK, got %q", first.TicketNo)
}
detail := services.TicketService.Get(first.ID)
if detail == nil {
t.Fatalf("expected ticket to exist")
}
if detail.NextReplyDeadlineAt == nil {
t.Fatalf("expected next reply deadline to be populated")
}
if detail.ResolveDeadlineAt == nil {
t.Fatalf("expected resolve deadline to be populated")
}
slaList := services.TicketSLARecordService.Find(sqls.NewCnd().Eq("ticket_id", first.ID))
if len(slaList) != 2 {
t.Fatalf("expected 2 SLA records, got %d", len(slaList))
}
}
func TestCreateTicketPublishesTicketCreatedEvent(t *testing.T) {
setupTicketTestDB(t)
operator := &dto.AuthPrincipal{UserID: 1, Username: "admin"}
eventsCh := make(chan events.TicketCreatedEvent, 1)
_, unsubscribe := eventbus.Subscribe(func(ctx context.Context, event events.TicketCreatedEvent) error {
eventsCh <- event
return nil
})
defer unsubscribe()
created, err := services.TicketService.CreateTicket(createTestTicketRequest("event-ticket"), operator)
if err != nil {
t.Fatalf("CreateTicket() error = %v", err)
}
select {
case event := <-eventsCh:
if event.TicketID != created.ID {
t.Fatalf("expected ticket id %d, got %d", created.ID, event.TicketID)
}
if event.OperatorID != operator.UserID {
t.Fatalf("expected operator id %d, got %d", operator.UserID, event.OperatorID)
}
case <-time.After(time.Second):
t.Fatalf("expected ticket created event")
}
}
func TestAddInternalNoteAllowsMentionSameUserAcrossTickets(t *testing.T) {
setupTicketTestDB(t)
operator := &dto.AuthPrincipal{UserID: 1, Username: "admin"}
mentionedUserID := createTestUser(t, "mentioned")
first, err := services.TicketService.CreateTicket(createTestTicketRequest("note-ticket-1"), operator)
if err != nil {
t.Fatalf("CreateTicket() first error = %v", err)
}
second, err := services.TicketService.CreateTicket(createTestTicketRequest("note-ticket-2"), operator)
if err != nil {
t.Fatalf("CreateTicket() second error = %v", err)
}
payload := fmt.Sprintf(`{"mentionUserIds":[%d]}`, mentionedUserID)
if _, err := services.TicketService.AddInternalNote(requestInternalNote(first.ID, payload), operator); err != nil {
t.Fatalf("AddInternalNote() first error = %v", err)
}
if _, err := services.TicketService.AddInternalNote(requestInternalNote(second.ID, payload), operator); err != nil {
t.Fatalf("AddInternalNote() second error = %v", err)
}
mentions := services.TicketMentionService.Find(sqls.NewCnd().Eq("mentioned_user_id", mentionedUserID).Asc("id"))
if len(mentions) != 2 {
t.Fatalf("expected 2 mention records, got %d", len(mentions))
}
}
func TestBatchAssignTicketsPublishesTicketAssignedEvents(t *testing.T) {
setupTicketTestDB(t)
operator := &dto.AuthPrincipal{UserID: 1, Username: "admin"}
teamID, assigneeID := createTestAgentProfile(t, "batch-event-assignee")
first, err := services.TicketService.CreateTicket(createTestTicketRequest("batch-event-1"), operator)
if err != nil {
t.Fatalf("CreateTicket() first error = %v", err)
}
second, err := services.TicketService.CreateTicket(createTestTicketRequest("batch-event-2"), operator)
if err != nil {
t.Fatalf("CreateTicket() second error = %v", err)
}
eventsCh := make(chan events.TicketAssignedEvent, 2)
_, unsubscribe := eventbus.Subscribe(func(ctx context.Context, event events.TicketAssignedEvent) error {
eventsCh <- event
return nil
})
defer unsubscribe()
if err := services.TicketService.BatchAssignTickets(request.BatchAssignTicketRequest{
TicketIDs: []int64{first.ID, second.ID},
ToUserID: assigneeID,
ToTeamID: teamID,
Reason: "batch assign event",
}, operator); err != nil {
t.Fatalf("BatchAssignTickets() error = %v", err)
}
got := map[int64]events.TicketAssignedEvent{}
for len(got) < 2 {
select {
case event := <-eventsCh:
got[event.TicketID] = event
case <-time.After(time.Second):
t.Fatalf("expected 2 ticket assigned events, got %d", len(got))
}
}
for _, ticketID := range []int64{first.ID, second.ID} {
event, ok := got[ticketID]
if !ok {
t.Fatalf("missing event for ticket %d", ticketID)
}
if event.ToUserID != assigneeID {
t.Fatalf("expected assignee id %d, got %d", assigneeID, event.ToUserID)
}
}
}
func TestBatchChangeStatusRollsBackOnFailure(t *testing.T) {
setupTicketTestDB(t)
operator := &dto.AuthPrincipal{UserID: 1, Username: "admin"}
first, err := services.TicketService.CreateTicket(createTestTicketRequest("batch-ticket"), operator)
if err != nil {
t.Fatalf("CreateTicket() error = %v", err)
}
err = services.TicketService.BatchChangeStatus(request.BatchChangeTicketStatusRequest{
TicketIDs: []int64{first.ID, 999999},
Status: string(enums.TicketStatusOpen),
Reason: "batch open",
}, operator)
if err == nil {
t.Fatalf("expected batch change status to fail")
}
current := services.TicketService.Get(first.ID)
if current == nil {
t.Fatalf("expected ticket to exist")
}
if current.Status != enums.TicketStatusNew {
t.Fatalf("expected ticket status rollback to new, got %s", current.Status)
}
}
func TestAssignTicketPromotesNewTicketToOpenAndSetsTeamAssignee(t *testing.T) {
setupTicketTestDB(t)
operator := &dto.AuthPrincipal{UserID: 1, Username: "admin"}
ticket, err := services.TicketService.CreateTicket(createTestTicketRequest("assign-ticket"), operator)
if err != nil {
t.Fatalf("CreateTicket() error = %v", err)
}
teamID, assigneeID := createTestAgentProfile(t, "assignee")
if err := services.TicketService.AssignTicket(request.AssignTicketRequest{
TicketID: ticket.ID,
ToTeamID: teamID,
ToUserID: assigneeID,
Reason: "manual assign",
}, operator); err != nil {
t.Fatalf("AssignTicket() error = %v", err)
}
current := services.TicketService.Get(ticket.ID)
if current == nil {
t.Fatalf("expected ticket to exist")
}
if current.Status != enums.TicketStatusOpen {
t.Fatalf("expected assigned ticket status to be open, got %s", current.Status)
}
if current.CurrentTeamID != teamID {
t.Fatalf("expected current team id %d, got %d", teamID, current.CurrentTeamID)
}
if current.CurrentAssigneeID != assigneeID {
t.Fatalf("expected current assignee id %d, got %d", assigneeID, current.CurrentAssigneeID)
}
}
func TestFindPageAggregateByCndBuildsWatcherAndLookupMaps(t *testing.T) {
setupTicketTestDB(t)
operator := &dto.AuthPrincipal{UserID: 1, Username: "admin"}
teamID, assigneeID := createTestAgentProfile(t, "aggregate-agent")
customerID := createTestCustomer(t, "aggregate-customer")
tagID := createTestTag(t, "aggregate-tag")
ticket, err := services.TicketService.CreateTicket(request.CreateTicketRequest{
Title: "aggregate-ticket",
CustomerID: customerID,
TagIDs: []int64{tagID},
Priority: 3,
Severity: int(enums.TicketSeverityMajor),
CurrentTeamID: teamID,
CurrentAssigneeID: assigneeID,
}, operator)
if err != nil {
t.Fatalf("CreateTicket() error = %v", err)
}
if err := repositories.TicketWatcherRepository.Create(sqls.DB(), &models.TicketWatcher{
TicketID: ticket.ID,
UserID: operator.UserID,
CreatedAt: time.Now(),
}); err != nil {
t.Fatalf("create ticket watcher error = %v", err)
}
aggregate, err := services.TicketService.FindPageAggregateByCnd(
sqls.NewCnd().Eq("id", ticket.ID).Page(1, 10),
operator.UserID,
)
if err != nil {
t.Fatalf("FindPageAggregateByCnd() error = %v", err)
}
if len(aggregate.List) != 1 {
t.Fatalf("expected 1 ticket, got %d", len(aggregate.List))
}
if _, ok := aggregate.WatchedTicketIDs[ticket.ID]; !ok {
t.Fatalf("expected watched ticket id to be populated")
}
if len(aggregate.TagsByTicketID[ticket.ID]) != 1 || aggregate.TagsByTicketID[ticket.ID][0].ID != tagID {
t.Fatalf("expected tag lookup to be populated")
}
if aggregate.Customers[customerID] == nil {
t.Fatalf("expected customer lookup to be populated")
}
if aggregate.Users[assigneeID] == nil {
t.Fatalf("expected assignee lookup to be populated")
}
if aggregate.Teams[teamID] == nil {
t.Fatalf("expected team lookup to be populated")
}
if len(aggregate.SLAByTicketID[ticket.ID]) != 2 {
t.Fatalf("expected 2 sla records for ticket, got %d", len(aggregate.SLAByTicketID[ticket.ID]))
}
}
func TestWatchTicketAffectsSummaryAndListFilter(t *testing.T) {
setupTicketTestDB(t)
operator := &dto.AuthPrincipal{UserID: createTestUser(t, "watch-operator"), Username: "watch-operator"}
ticket, err := services.TicketService.CreateTicket(createTestTicketRequest("watch-ticket"), operator)
if err != nil {
t.Fatalf("CreateTicket() error = %v", err)
}
if err := services.TicketService.WatchTicket(ticket.ID, operator); err != nil {
t.Fatalf("WatchTicket() error = %v", err)
}
summary := services.TicketService.GetSummary(operator)
if summary.Watching != 1 {
t.Fatalf("expected watching summary to be 1, got %d", summary.Watching)
}
aggregate, err := services.TicketService.FindPageAggregateByCnd(
sqls.NewCnd().
Where("id IN (SELECT ticket_id FROM t_ticket_watcher WHERE user_id = ?)", operator.UserID).
Page(1, 10),
operator.UserID,
)
if err != nil {
t.Fatalf("FindPageAggregateByCnd() error = %v", err)
}
if len(aggregate.List) != 1 {
t.Fatalf("expected 1 watched ticket, got %d", len(aggregate.List))
}
if aggregate.List[0].ID != ticket.ID {
t.Fatalf("expected watched ticket id %d, got %d", ticket.ID, aggregate.List[0].ID)
}
if _, ok := aggregate.WatchedTicketIDs[ticket.ID]; !ok {
t.Fatalf("expected watched ticket id to be marked in aggregate")
}
}
func TestTicketNoServiceNextConcurrent(t *testing.T) {
setupTicketTestDB(t)
const count = 20
results := make(chan string, count)
errs := make(chan error, count)
var wg sync.WaitGroup
for i := 0; i < count; i++ {
wg.Add(1)
go func() {
defer wg.Done()
err := sqls.WithTransaction(func(ctx *sqls.TxContext) error {
ticketNo, err := services.TicketNoService.Next(ctx.Tx, time.Now())
if err != nil {
return err
}
results <- ticketNo
return nil
})
if err != nil {
errs <- err
}
}()
}
wg.Wait()
close(results)
close(errs)
for err := range errs {
if err != nil {
t.Fatalf("TicketNoService.Next() concurrent error = %v", err)
}
}
seen := make(map[string]struct{}, count)
for ticketNo := range results {
if _, ok := seen[ticketNo]; ok {
t.Fatalf("duplicate ticket number generated: %s", ticketNo)
}
seen[ticketNo] = struct{}{}
}
if len(seen) != count {
t.Fatalf("expected %d unique ticket numbers, got %d", count, len(seen))
}
}
func TestGetRiskPageAggregateReturnsAccurateHighRiskTickets(t *testing.T) {
setupTicketTestDB(t)
operator := &dto.AuthPrincipal{UserID: 1, Username: "admin"}
highRisk, err := services.TicketService.CreateTicket(createTestTicketRequest("high-risk-ticket"), operator)
if err != nil {
t.Fatalf("CreateTicket() highRisk error = %v", err)
}
safe, err := services.TicketService.CreateTicket(createTestTicketRequest("safe-ticket"), operator)
if err != nil {
t.Fatalf("CreateTicket() safe error = %v", err)
}
if err := services.TicketService.ChangeStatus(request.ChangeTicketStatusRequest{
TicketID: highRisk.ID,
Status: string(enums.TicketStatusOpen),
Reason: "pick up high risk",
}, operator); err != nil {
t.Fatalf("ChangeStatus() highRisk open error = %v", err)
}
if err := services.TicketService.ChangeStatus(request.ChangeTicketStatusRequest{
TicketID: safe.ID,
Status: string(enums.TicketStatusOpen),
Reason: "pick up safe",
}, operator); err != nil {
t.Fatalf("ChangeStatus() safe open error = %v", err)
}
now := time.Now()
if err := repositories.TicketRepository.Updates(sqls.DB(), highRisk.ID, map[string]any{
"resolve_deadline_at": now.Add(30 * time.Minute),
"updated_at": now,
}); err != nil {
t.Fatalf("update highRisk deadline error = %v", err)
}
if err := repositories.TicketRepository.Updates(sqls.DB(), safe.ID, map[string]any{
"resolve_deadline_at": now.Add(6 * time.Hour),
"updated_at": now,
}); err != nil {
t.Fatalf("update safe deadline error = %v", err)
}
aggregate, err := services.TicketService.GetRiskPageAggregate("high_risk", 0, 60, 1, 10, operator.UserID)
if err != nil {
t.Fatalf("GetRiskPageAggregate() error = %v", err)
}
if len(aggregate.List) != 1 {
t.Fatalf("expected 1 high risk ticket, got %d", len(aggregate.List))
}
if aggregate.List[0].ID != highRisk.ID {
t.Fatalf("expected high risk ticket id %d, got %d", highRisk.ID, aggregate.List[0].ID)
}
}
func TestBuildTicketDetailUsesAggregatedWatcherCollaboratorAndRelationLookups(t *testing.T) {
setupTicketTestDB(t)
operator := &dto.AuthPrincipal{UserID: 1, Username: "admin"}
teamID, assigneeID := createTestAgentProfile(t, "detail-agent")
parent, err := services.TicketService.CreateTicket(createTestTicketRequest("detail-parent"), operator)
if err != nil {
t.Fatalf("CreateTicket() parent error = %v", err)
}
child, err := services.TicketService.CreateTicket(request.CreateTicketRequest{
Title: "detail-child",
Priority: 1,
Severity: int(enums.TicketSeverityMinor),
CurrentTeamID: teamID,
CurrentAssigneeID: assigneeID,
}, operator)
if err != nil {
t.Fatalf("CreateTicket() child error = %v", err)
}
if err := repositories.TicketWatcherRepository.Create(sqls.DB(), &models.TicketWatcher{
TicketID: parent.ID,
UserID: assigneeID,
CreatedAt: time.Now(),
}); err != nil {
t.Fatalf("create watcher error = %v", err)
}
if err := repositories.TicketCollaboratorRepository.Create(sqls.DB(), &models.TicketCollaborator{
TicketID: parent.ID,
UserID: assigneeID,
CreatedAt: time.Now(),
}); err != nil {
t.Fatalf("create collaborator error = %v", err)
}
if err := repositories.TicketRelationRepository.Create(sqls.DB(), &models.TicketRelation{
TicketID: parent.ID,
RelatedTicketID: child.ID,
RelationType: enums.TicketRelationTypeChild,
CreatedAt: time.Now(),
}); err != nil {
t.Fatalf("create relation error = %v", err)
}
if err := repositories.TicketCommentRepository.Create(sqls.DB(), &models.TicketComment{
TicketID: parent.ID,
CommentType: enums.TicketCommentTypePublicReply,
AuthorType: enums.IMSenderTypeAgent,
AuthorID: assigneeID,
ContentType: "text",
Content: "reply",
CreatedAt: time.Now(),
}); err != nil {
t.Fatalf("create comment error = %v", err)
}
if err := repositories.TicketEventLogRepository.Create(sqls.DB(), &models.TicketEventLog{
TicketID: parent.ID,
EventType: enums.TicketEventTypeAssigned,
OperatorType: enums.IMSenderTypeAgent,
OperatorID: assigneeID,
Content: "assigned",
CreatedAt: time.Now(),
}); err != nil {
t.Fatalf("create event error = %v", err)
}
aggregate, err := services.TicketService.GetDetail(parent.ID)
if err != nil {
t.Fatalf("GetDetail() error = %v", err)
}
detail := builders.BuildTicketDetail(aggregate)
if detail == nil {
t.Fatalf("expected ticket detail to be built")
}
if len(detail.Watchers) != 1 || detail.Watchers[0].UserName == "" {
t.Fatalf("expected watcher user name to be populated")
}
if len(detail.Collaborators) != 1 || detail.Collaborators[0].UserName == "" || detail.Collaborators[0].TeamName == "" {
t.Fatalf("expected collaborator user and team names to be populated")
}
if len(detail.RelatedTickets) != 1 {
t.Fatalf("expected 1 related ticket, got %d", len(detail.RelatedTickets))
}
if detail.RelatedTickets[0].RelatedTicketNo == "" || detail.RelatedTickets[0].CurrentAssigneeName == "" || detail.RelatedTickets[0].CurrentTeamName == "" {
t.Fatalf("expected related ticket display fields to be populated")
}
if len(detail.Comments) != 1 || detail.Comments[0].AuthorName == "" {
t.Fatalf("expected comment author name to be populated")
}
if len(detail.Events) == 0 {
t.Fatalf("expected events to be populated")
}
hasNamedEvent := false
for i := range detail.Events {
if detail.Events[i].OperatorName != "" {
hasNamedEvent = true
break
}
}
if !hasNamedEvent {
t.Fatalf("expected at least one event operator name to be populated")
}
}
func TestCloseAndReopenTicketRefreshResolutionDeadline(t *testing.T) {
setupTicketTestDB(t)
operator := &dto.AuthPrincipal{UserID: 1, Username: "admin"}
ticket, err := services.TicketService.CreateTicket(createTestTicketRequest("deadline-ticket"), operator)
if err != nil {
t.Fatalf("CreateTicket() error = %v", err)
}
original := services.TicketService.Get(ticket.ID)
if original == nil || original.ResolveDeadlineAt == nil {
t.Fatalf("expected initial resolve deadline to exist")
}
if err := services.TicketService.ChangeStatus(request.ChangeTicketStatusRequest{
TicketID: ticket.ID,
Status: string(enums.TicketStatusOpen),
Reason: "pick up",
}, operator); err != nil {
t.Fatalf("ChangeStatus() open error = %v", err)
}
if err := services.TicketService.CloseTicket(request.CloseTicketRequest{
TicketID: ticket.ID,
CloseReason: "done",
}, operator); err != nil {
t.Fatalf("CloseTicket() error = %v", err)
}
closed := services.TicketService.Get(ticket.ID)
if closed == nil {
t.Fatalf("expected closed ticket to exist")
}
if closed.ResolveDeadlineAt != nil {
t.Fatalf("expected resolve deadline to be cleared after close")
}
if err := services.TicketService.ReopenTicket(request.ReopenTicketRequest{
TicketID: ticket.ID,
Reason: "need follow-up",
}, operator); err != nil {
t.Fatalf("ReopenTicket() error = %v", err)
}
reopened := services.TicketService.Get(ticket.ID)
if reopened == nil {
t.Fatalf("expected reopened ticket to exist")
}
if reopened.ResolveDeadlineAt == nil {
t.Fatalf("expected resolve deadline to be restored after reopen")
}
}
func TestChangeStatusPendingCustomerThenOpenRefreshesSLAFields(t *testing.T) {
setupTicketTestDB(t)
operator := &dto.AuthPrincipal{UserID: 1, Username: "admin"}
ticket, err := services.TicketService.CreateTicket(createTestTicketRequest("pending-ticket"), operator)
if err != nil {
t.Fatalf("CreateTicket() error = %v", err)
}
if err := services.TicketService.ChangeStatus(request.ChangeTicketStatusRequest{
TicketID: ticket.ID,
Status: string(enums.TicketStatusOpen),
Reason: "pick up",
}, operator); err != nil {
t.Fatalf("ChangeStatus() open error = %v", err)
}
beforePending := services.TicketService.Get(ticket.ID)
if beforePending == nil || beforePending.ResolveDeadlineAt == nil {
t.Fatalf("expected resolve deadline before pending")
}
if err := services.TicketService.ChangeStatus(request.ChangeTicketStatusRequest{
TicketID: ticket.ID,
Status: string(enums.TicketStatusPendingCustomer),
PendingReason: "waiting customer",
Reason: "pause for customer",
}, operator); err != nil {
t.Fatalf("ChangeStatus() pending error = %v", err)
}
pending := services.TicketService.Get(ticket.ID)
if pending == nil {
t.Fatalf("expected pending ticket to exist")
}
if pending.Status != enums.TicketStatusPendingCustomer {
t.Fatalf("expected pending status, got %s", pending.Status)
}
if pending.PendingReason != "waiting customer" {
t.Fatalf("expected pending reason to be persisted, got %q", pending.PendingReason)
}
if pending.ResolveDeadlineAt == nil {
t.Fatalf("expected resolve deadline to remain calculable while pending")
}
if err := services.TicketService.ChangeStatus(request.ChangeTicketStatusRequest{
TicketID: ticket.ID,
Status: string(enums.TicketStatusOpen),
Reason: "customer replied",
}, operator); err != nil {
t.Fatalf("ChangeStatus() reopen error = %v", err)
}
reopened := services.TicketService.Get(ticket.ID)
if reopened == nil {
t.Fatalf("expected reopened ticket to exist")
}
if reopened.Status != enums.TicketStatusOpen {
t.Fatalf("expected reopened status open, got %s", reopened.Status)
}
if reopened.PendingReason != "" {
t.Fatalf("expected pending reason to be cleared, got %q", reopened.PendingReason)
}
if reopened.ResolveDeadlineAt == nil {
t.Fatalf("expected resolve deadline after reopening")
}
}
func TestCloseTicketBlockedByOpenChild(t *testing.T) {
setupTicketTestDB(t)
operator := &dto.AuthPrincipal{UserID: 1, Username: "admin"}
parent, err := services.TicketService.CreateTicket(createTestTicketRequest("parent-ticket"), operator)
if err != nil {
t.Fatalf("CreateTicket() parent error = %v", err)
}
child, err := services.TicketService.CreateTicket(createTestTicketRequest("child-ticket"), operator)
if err != nil {
t.Fatalf("CreateTicket() child error = %v", err)
}
if err := repositories.TicketRelationRepository.Create(sqls.DB(), &models.TicketRelation{
TicketID: parent.ID,
RelatedTicketID: child.ID,
RelationType: enums.TicketRelationTypeChild,
CreatedAt: time.Now(),
}); err != nil {
t.Fatalf("create relation error = %v", err)
}
err = services.TicketService.CloseTicket(request.CloseTicketRequest{
TicketID: parent.ID,
CloseReason: "done",
}, operator)
if err == nil {
t.Fatalf("expected close ticket to be blocked by open child")
}
current := services.TicketService.Get(parent.ID)
if current == nil {
t.Fatalf("expected parent ticket to exist")
}
if current.Status != enums.TicketStatusNew {
t.Fatalf("expected parent ticket status to remain new, got %s", current.Status)
}
}
func TestTicketViewServiceSaveListAndDeleteOwnViews(t *testing.T) {
setupTicketTestDB(t)
operator := &dto.AuthPrincipal{UserID: createTestUser(t, "viewer"), Username: "viewer"}
created, err := services.TicketViewService.Save(request.SaveTicketViewRequest{
Name: "我的待处理",
Filters: map[string]any{
"quickView": "mine",
"statusFilter": "open",
},
}, operator)
if err != nil {
t.Fatalf("TicketViewService.Save() create error = %v", err)
}
if created.ID <= 0 {
t.Fatalf("expected created ticket view id")
}
updated, err := services.TicketViewService.Save(request.SaveTicketViewRequest{
ID: created.ID,
Name: "我的处理中",
Filters: map[string]any{
"quickView": "mine",
"statusFilter": "pending_internal",
},
}, operator)
if err != nil {
t.Fatalf("TicketViewService.Save() update error = %v", err)
}
if !strings.Contains(updated.FiltersJSON, "pending_internal") {
t.Fatalf("expected updated filters json, got %s", updated.FiltersJSON)
}
list := services.TicketViewService.ListByUser(operator.UserID)
if len(list) != 1 {
t.Fatalf("expected 1 ticket view, got %d", len(list))
}
if list[0].Name != "我的处理中" {
t.Fatalf("expected updated name, got %s", list[0].Name)
}
if err := services.TicketViewService.Delete(created.ID, operator); err != nil {
t.Fatalf("TicketViewService.Delete() error = %v", err)
}
if got := services.TicketViewService.ListByUser(operator.UserID); len(got) != 0 {
t.Fatalf("expected ticket views to be deleted, got %d", len(got))
}
}
func TestTicketViewServiceRejectsCrossUserUpdateAndDelete(t *testing.T) {
setupTicketTestDB(t)
owner := &dto.AuthPrincipal{UserID: createTestUser(t, "owner"), Username: "owner"}
other := &dto.AuthPrincipal{UserID: createTestUser(t, "other"), Username: "other"}
created, err := services.TicketViewService.Save(request.SaveTicketViewRequest{
Name: "owner-view",
Filters: map[string]any{
"quickView": "watching",
},
}, owner)
if err != nil {
t.Fatalf("TicketViewService.Save() create error = %v", err)
}
if _, err := services.TicketViewService.Save(request.SaveTicketViewRequest{
ID: created.ID,
Name: "hijack",
Filters: map[string]any{
"quickView": "all",
},
}, other); err == nil {
t.Fatalf("expected cross-user update to fail")
}
if err := services.TicketViewService.Delete(created.ID, other); err == nil {
t.Fatalf("expected cross-user delete to fail")
}
if got := services.TicketViewService.ListByUser(owner.UserID); len(got) != 1 {
t.Fatalf("expected owner view to remain, got %d", len(got))
}
}
func setupTicketTestDB(t *testing.T) {
t.Helper()
dbPath := filepath.Join(t.TempDir(), "ticket-test.db")
db, err := bootstrap.InitDB(config.DBConfig{
Type: "sqlite",
DSN: "file:" + dbPath + "?_busy_timeout=5000",
MaxIdleConns: 1,
MaxOpenConns: 1,
})
if err != nil {
t.Fatalf("InitDB() error = %v", err)
}
t.Cleanup(func() {
sqlDB, err := db.DB()
if err == nil {
_ = sqlDB.Close()
}
})
if err := bootstrap.InitMigrations(); err != nil {
t.Fatalf("InitMigrations() error = %v", err)
}
}
func createTestTicketRequest(title string) request.CreateTicketRequest {
return request.CreateTicketRequest{
Title: title,
Priority: 1,
Severity: int(enums.TicketSeverityMinor),
}
}
func requestInternalNote(ticketID int64, payload string) request.InternalNoteRequest {
return request.InternalNoteRequest{
TicketID: ticketID,
ContentType: "text",
Content: "note",
Payload: payload,
}
}
func createTestUser(t *testing.T, prefix string) int64 {
t.Helper()
return createTestUserWithID(t, 0, prefix)
}
func createTestUserWithID(t *testing.T, id int64, prefix string) int64 {
t.Helper()
now := time.Now()
username := fmt.Sprintf("%s_%d", prefix, now.UnixNano())
user := &models.User{
ID: id,
Username: username,
Nickname: prefix,
Status: enums.StatusOk,
AuditFields: models.AuditFields{
CreatedAt: now,
CreateUserID: 1,
CreateUserName: "admin",
UpdatedAt: now,
UpdateUserID: 1,
UpdateUserName: "admin",
},
}
if err := repositories.UserRepository.Create(sqls.DB(), user); err != nil {
t.Fatalf("create user error = %v", err)
}
return user.ID
}
func createTestAgentProfile(t *testing.T, prefix string) (int64, int64) {
t.Helper()
now := time.Now()
userID := createTestUser(t, prefix)
team := &models.AgentTeam{
Name: fmt.Sprintf("%s-team-%d", prefix, now.UnixNano()),
Status: enums.StatusOk,
Description: "test team",
AuditFields: models.AuditFields{
CreatedAt: now,
CreateUserID: 1,
CreateUserName: "admin",
UpdatedAt: now,
UpdateUserID: 1,
UpdateUserName: "admin",
},
}
if err := repositories.AgentTeamRepository.Create(sqls.DB(), team); err != nil {
t.Fatalf("create agent team error = %v", err)
}
profile := &models.AgentProfile{
UserID: userID,
TeamID: team.ID,
AgentCode: fmt.Sprintf("%s-code-%d", prefix, now.UnixNano()),
DisplayName: prefix,
ServiceStatus: enums.ServiceStatusIdle,
MaxConcurrentCount: 5,
AutoAssignEnabled: true,
Status: enums.StatusOk,
AuditFields: models.AuditFields{
CreatedAt: now,
CreateUserID: 1,
CreateUserName: "admin",
UpdatedAt: now,
UpdateUserID: 1,
UpdateUserName: "admin",
},
}
if err := repositories.AgentProfileRepository.Create(sqls.DB(), profile); err != nil {
t.Fatalf("create agent profile error = %v", err)
}
return team.ID, userID
}
func createTestCustomer(t *testing.T, prefix string) int64 {
t.Helper()
now := time.Now()
item := &models.Customer{
Name: fmt.Sprintf("%s-%d", prefix, now.UnixNano()),
Status: enums.StatusOk,
AuditFields: models.AuditFields{
CreatedAt: now,
CreateUserID: 1,
CreateUserName: "admin",
UpdatedAt: now,
UpdateUserID: 1,
UpdateUserName: "admin",
},
}
if err := repositories.CustomerRepository.Create(sqls.DB(), item); err != nil {
t.Fatalf("create customer error = %v", err)
}
return item.ID
}
func createTestTag(t *testing.T, prefix string) int64 {
t.Helper()
now := time.Now()
item := &models.Tag{
Name: fmt.Sprintf("%s-%d", prefix, now.UnixNano()),
Status: enums.StatusOk,
SortNo: 1,
AuditFields: models.AuditFields{
CreatedAt: now,
CreateUserID: 1,
CreateUserName: "admin",
UpdatedAt: now,
UpdateUserID: 1,
UpdateUserName: "admin",
},
}
if err := repositories.TagRepository.Create(sqls.DB(), item); err != nil {
t.Fatalf("create tag error = %v", err)
}
return item.ID
}