b128447a7e
- Updated titles and subtitles in channel_service.go for web channel and WeChat MP channel configurations. - Changed handoff messages in conversation_human_dispatch_service.go to English. - Modified notification messages in event handlers for ticket and conversation assignments to English. - Adjusted ticket creation messages in ticket_service.go to reflect English localization. - Updated default locale settings in i18n configuration files to English (en-US). - Changed HTML language attribute in layout.tsx to English. - Refactored widget locale detection logic in agent-desk-sdk.ts to prioritize English.
719 lines
23 KiB
Go
719 lines
23 KiB
Go
package services_test
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"agent-desk/internal/bootstrap"
|
|
"agent-desk/internal/events"
|
|
"agent-desk/internal/models"
|
|
"agent-desk/internal/pkg/config"
|
|
"agent-desk/internal/pkg/dto"
|
|
"agent-desk/internal/pkg/dto/request"
|
|
"agent-desk/internal/pkg/enums"
|
|
"agent-desk/internal/pkg/eventbus"
|
|
"agent-desk/internal/repositories"
|
|
"agent-desk/internal/services"
|
|
|
|
"github.com/mlogclub/simple/sqls"
|
|
)
|
|
|
|
func TestTicketLightweightStatuses(t *testing.T) {
|
|
if !enums.IsValidTicketStatus(string(enums.TicketStatusPending)) {
|
|
t.Fatalf("pending should be valid")
|
|
}
|
|
if !enums.IsValidTicketStatus(string(enums.TicketStatusInProgress)) {
|
|
t.Fatalf("in_progress should be valid")
|
|
}
|
|
if !enums.IsValidTicketStatus(string(enums.TicketStatusDone)) {
|
|
t.Fatalf("done should be valid")
|
|
}
|
|
for _, status := range []string{"new", "open", "pending_customer", "pending_internal", "resolved", "closed", "cancelled"} {
|
|
if enums.IsValidTicketStatus(status) {
|
|
t.Fatalf("legacy status %s should be invalid", status)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestTicketProgressModelExists(t *testing.T) {
|
|
item := models.TicketProgress{
|
|
TicketID: 12,
|
|
Content: "已电话联系客户确认问题仍存在",
|
|
AuthorID: 7,
|
|
}
|
|
if item.TicketID != 12 || item.AuthorID != 7 || item.Content == "" {
|
|
t.Fatalf("unexpected progress model: %+v", item)
|
|
}
|
|
}
|
|
|
|
func TestTicketServiceCreateTicketSetsPendingStatusAndTicketNo(t *testing.T) {
|
|
setupTicketTestDB(t)
|
|
operator := createTestOperator(t, "creator")
|
|
customerID := createTestCustomer(t, "create-customer")
|
|
tagID := createTestTag(t, "create-tag")
|
|
|
|
created, err := services.TicketService.CreateTicket(request.CreateTicketRequest{
|
|
Title: "create ticket",
|
|
Description: "create ticket description",
|
|
CustomerID: customerID,
|
|
TagIDs: []int64{tagID},
|
|
CurrentAssigneeID: operator.UserID,
|
|
}, operator)
|
|
if err != nil {
|
|
t.Fatalf("CreateTicket() error = %v", err)
|
|
}
|
|
if created.TicketNo == "" || !strings.HasPrefix(created.TicketNo, "TK") {
|
|
t.Fatalf("expected generated ticket number, got %q", created.TicketNo)
|
|
}
|
|
if created.Status != enums.TicketStatusPending {
|
|
t.Fatalf("expected pending status, got %s", created.Status)
|
|
}
|
|
if created.Source != enums.TicketSourceManual {
|
|
t.Fatalf("expected manual source, got %s", created.Source)
|
|
}
|
|
|
|
progresses := services.TicketProgressService.Find(sqls.NewCnd().Eq("ticket_id", created.ID))
|
|
if len(progresses) != 1 {
|
|
t.Fatalf("expected initial progress, got %d", len(progresses))
|
|
}
|
|
if progresses[0].Content != "Created ticket" || progresses[0].AuthorID != operator.UserID {
|
|
t.Fatalf("unexpected initial progress: %+v", progresses[0])
|
|
}
|
|
|
|
tags := services.TicketService.GetTags(created.ID)
|
|
if len(tags) != 1 || tags[0].ID != tagID {
|
|
t.Fatalf("expected ticket tag %d, got %+v", tagID, tags)
|
|
}
|
|
}
|
|
|
|
func TestTicketServiceCreateTicketPublishesTicketCreatedEvent(t *testing.T) {
|
|
setupTicketTestDB(t)
|
|
operator := createTestOperator(t, "event-creator")
|
|
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 TestTicketServiceLinkCustomerUpdatesTicketCustomerID(t *testing.T) {
|
|
setupTicketTestDB(t)
|
|
operator := createTestOperator(t, "link-ticket-customer")
|
|
customerID := createTestCustomer(t, "link-ticket-customer")
|
|
ticket, err := services.TicketService.CreateTicket(createTestTicketRequest("link-ticket-customer"), operator)
|
|
if err != nil {
|
|
t.Fatalf("CreateTicket() error = %v", err)
|
|
}
|
|
if ticket.CustomerID != 0 {
|
|
t.Fatalf("expected ticket without customer, got %d", ticket.CustomerID)
|
|
}
|
|
|
|
if err := services.TicketService.LinkCustomer(ticket.ID, customerID, operator); err != nil {
|
|
t.Fatalf("LinkCustomer() error = %v", err)
|
|
}
|
|
|
|
updated := services.TicketService.Get(ticket.ID)
|
|
if updated == nil {
|
|
t.Fatalf("expected ticket")
|
|
}
|
|
if updated.CustomerID != customerID {
|
|
t.Fatalf("expected customer id %d, got %d", customerID, updated.CustomerID)
|
|
}
|
|
}
|
|
|
|
func TestTicketServiceLinkCustomerRejectsMissingCustomer(t *testing.T) {
|
|
setupTicketTestDB(t)
|
|
operator := createTestOperator(t, "link-ticket-missing-customer")
|
|
ticket, err := services.TicketService.CreateTicket(createTestTicketRequest("link-ticket-missing-customer"), operator)
|
|
if err != nil {
|
|
t.Fatalf("CreateTicket() error = %v", err)
|
|
}
|
|
|
|
if err := services.TicketService.LinkCustomer(ticket.ID, 999999, operator); err == nil {
|
|
t.Fatalf("expected LinkCustomer() to reject missing customer")
|
|
}
|
|
}
|
|
|
|
func TestTicketServiceChangeStatusSetsHandledAt(t *testing.T) {
|
|
setupTicketTestDB(t)
|
|
operator := createTestOperator(t, "status-operator")
|
|
ticket, err := services.TicketService.CreateTicket(createTestTicketRequest("status-ticket"), operator)
|
|
if err != nil {
|
|
t.Fatalf("CreateTicket() error = %v", err)
|
|
}
|
|
|
|
if err := services.TicketService.ChangeStatus(request.ChangeTicketStatusRequest{
|
|
TicketID: ticket.ID,
|
|
Status: string(enums.TicketStatusInProgress),
|
|
}, operator); err != nil {
|
|
t.Fatalf("ChangeStatus() in_progress error = %v", err)
|
|
}
|
|
inProgress := services.TicketService.Get(ticket.ID)
|
|
if inProgress == nil {
|
|
t.Fatalf("expected ticket to exist")
|
|
}
|
|
if inProgress.Status != enums.TicketStatusInProgress {
|
|
t.Fatalf("expected in_progress status, got %s", inProgress.Status)
|
|
}
|
|
if inProgress.HandledAt != nil {
|
|
t.Fatalf("expected handled_at to remain nil before done")
|
|
}
|
|
|
|
if err := services.TicketService.ChangeStatus(request.ChangeTicketStatusRequest{
|
|
TicketID: ticket.ID,
|
|
Status: string(enums.TicketStatusDone),
|
|
}, operator); err != nil {
|
|
t.Fatalf("ChangeStatus() done error = %v", err)
|
|
}
|
|
done := services.TicketService.Get(ticket.ID)
|
|
if done == nil || done.HandledAt == nil {
|
|
t.Fatalf("expected handled_at to be set after done, got %+v", done)
|
|
}
|
|
|
|
if err := services.TicketService.ChangeStatus(request.ChangeTicketStatusRequest{
|
|
TicketID: ticket.ID,
|
|
Status: string(enums.TicketStatusPending),
|
|
}, operator); err != nil {
|
|
t.Fatalf("ChangeStatus() pending error = %v", err)
|
|
}
|
|
pending := services.TicketService.Get(ticket.ID)
|
|
if pending == nil || pending.HandledAt != nil {
|
|
t.Fatalf("expected handled_at to be cleared away from done, got %+v", pending)
|
|
}
|
|
}
|
|
|
|
func TestTicketServiceAddProgressStoresContentAndAuthor(t *testing.T) {
|
|
setupTicketTestDB(t)
|
|
operator := createTestOperator(t, "progress-operator")
|
|
ticket, err := services.TicketService.CreateTicket(createTestTicketRequest("progress-ticket"), operator)
|
|
if err != nil {
|
|
t.Fatalf("CreateTicket() error = %v", err)
|
|
}
|
|
|
|
progress, err := services.TicketService.AddProgress(request.CreateTicketProgressRequest{
|
|
TicketID: ticket.ID,
|
|
Content: "客户已确认问题复现路径",
|
|
}, operator)
|
|
if err != nil {
|
|
t.Fatalf("AddProgress() error = %v", err)
|
|
}
|
|
if progress.ID <= 0 {
|
|
t.Fatalf("expected progress id")
|
|
}
|
|
if progress.Content != "客户已确认问题复现路径" || progress.AuthorID != operator.UserID {
|
|
t.Fatalf("unexpected progress: %+v", progress)
|
|
}
|
|
}
|
|
|
|
func TestTicketServiceCreateTicketPreservesRichDescription(t *testing.T) {
|
|
setupTicketTestDB(t)
|
|
operator := createTestOperator(t, "rich-description-operator")
|
|
|
|
created, err := services.TicketService.CreateTicket(request.CreateTicketRequest{
|
|
Title: "rich description ticket",
|
|
Description: "<p>客户反馈<strong>无法登录</strong></p><ul><li>验证码错误</li></ul>",
|
|
}, operator)
|
|
if err != nil {
|
|
t.Fatalf("CreateTicket() error = %v", err)
|
|
}
|
|
if created.Description != "<p>客户反馈<strong>无法登录</strong></p><ul><li>验证码错误</li></ul>" {
|
|
t.Fatalf("expected rich description to be preserved, got %q", created.Description)
|
|
}
|
|
}
|
|
|
|
func TestTicketServiceAddProgressPreservesRichContent(t *testing.T) {
|
|
setupTicketTestDB(t)
|
|
operator := createTestOperator(t, "rich-progress-operator")
|
|
ticket, err := services.TicketService.CreateTicket(createTestTicketRequest("rich-progress-ticket"), operator)
|
|
if err != nil {
|
|
t.Fatalf("CreateTicket() error = %v", err)
|
|
}
|
|
|
|
progress, err := services.TicketService.AddProgress(request.CreateTicketProgressRequest{
|
|
TicketID: ticket.ID,
|
|
Content: "<p>已回访客户,结论:<strong>继续观察</strong></p>",
|
|
}, operator)
|
|
if err != nil {
|
|
t.Fatalf("AddProgress() error = %v", err)
|
|
}
|
|
if progress.Content != "<p>已回访客户,结论:<strong>继续观察</strong></p>" {
|
|
t.Fatalf("expected rich progress content to be preserved, got %q", progress.Content)
|
|
}
|
|
}
|
|
|
|
func TestTicketServiceAssignTicketRequiresTargetUser(t *testing.T) {
|
|
setupTicketTestDB(t)
|
|
operator := createTestOperator(t, "assign-operator")
|
|
ticket, err := services.TicketService.CreateTicket(createTestTicketRequest("assign-ticket"), operator)
|
|
if err != nil {
|
|
t.Fatalf("CreateTicket() error = %v", err)
|
|
}
|
|
|
|
err = services.TicketService.AssignTicket(request.AssignTicketRequest{
|
|
TicketID: ticket.ID,
|
|
ToUserID: 0,
|
|
Reason: "invalid assignment",
|
|
}, operator)
|
|
if err == nil {
|
|
t.Fatalf("expected AssignTicket() to reject empty target user")
|
|
}
|
|
}
|
|
|
|
func TestTicketServiceAssignTicketRejectsDisabledUser(t *testing.T) {
|
|
setupTicketTestDB(t)
|
|
operator := createTestOperator(t, "assign-disabled-operator")
|
|
disabledUserID := createTestUserWithStatus(t, "assign-disabled-user", enums.StatusDisabled)
|
|
ticket, err := services.TicketService.CreateTicket(createTestTicketRequest("assign-disabled-ticket"), operator)
|
|
if err != nil {
|
|
t.Fatalf("CreateTicket() error = %v", err)
|
|
}
|
|
|
|
err = services.TicketService.AssignTicket(request.AssignTicketRequest{
|
|
TicketID: ticket.ID,
|
|
ToUserID: disabledUserID,
|
|
Reason: "disabled assignment",
|
|
}, operator)
|
|
if err == nil {
|
|
t.Fatalf("expected AssignTicket() to reject disabled target user")
|
|
}
|
|
}
|
|
|
|
func TestTicketServiceAssignTicketCreatesProgressEntry(t *testing.T) {
|
|
setupTicketTestDB(t)
|
|
operator := createTestOperator(t, "assign-progress-operator")
|
|
firstAssignee := createTestOperator(t, "assign-progress-first")
|
|
nextAssignee := createTestOperator(t, "assign-progress-next")
|
|
ticket, err := services.TicketService.CreateTicket(request.CreateTicketRequest{
|
|
Title: "assign progress ticket",
|
|
Description: "assign progress description",
|
|
CurrentAssigneeID: firstAssignee.UserID,
|
|
}, operator)
|
|
if err != nil {
|
|
t.Fatalf("CreateTicket() error = %v", err)
|
|
}
|
|
|
|
if err := services.TicketService.AssignTicket(request.AssignTicketRequest{
|
|
TicketID: ticket.ID,
|
|
ToUserID: nextAssignee.UserID,
|
|
Reason: "需要二线继续跟进",
|
|
}, operator); err != nil {
|
|
t.Fatalf("AssignTicket() error = %v", err)
|
|
}
|
|
|
|
progresses := services.TicketProgressService.Find(sqls.NewCnd().Eq("ticket_id", ticket.ID).Asc("id"))
|
|
if len(progresses) != 2 {
|
|
t.Fatalf("expected create progress and assignment progress, got %d: %+v", len(progresses), progresses)
|
|
}
|
|
assignmentProgress := progresses[1]
|
|
if assignmentProgress.AuthorID != operator.UserID {
|
|
t.Fatalf("expected assignment progress author %d, got %d", operator.UserID, assignmentProgress.AuthorID)
|
|
}
|
|
if !strings.Contains(assignmentProgress.Content, "指派处理人") {
|
|
t.Fatalf("expected assignment progress content to mention assignment, got %q", assignmentProgress.Content)
|
|
}
|
|
if !strings.Contains(assignmentProgress.Content, firstAssignee.Username) || !strings.Contains(assignmentProgress.Content, nextAssignee.Username) {
|
|
t.Fatalf("expected assignment progress to include assignee names, got %q", assignmentProgress.Content)
|
|
}
|
|
if !strings.Contains(assignmentProgress.Content, "需要二线继续跟进") {
|
|
t.Fatalf("expected assignment reason in progress content, got %q", assignmentProgress.Content)
|
|
}
|
|
}
|
|
|
|
func TestTicketServiceCreateTicketRejectsMismatchedCustomerConversation(t *testing.T) {
|
|
setupTicketTestDB(t)
|
|
operator := createTestOperator(t, "mismatch-operator")
|
|
customerID := createTestCustomer(t, "mismatch-customer")
|
|
otherCustomerID := createTestCustomer(t, "mismatch-other-customer")
|
|
conversationID := createTestConversation(t, otherCustomerID, "mismatch-conversation")
|
|
|
|
_, err := services.TicketService.CreateTicket(request.CreateTicketRequest{
|
|
Title: "mismatch ticket",
|
|
Description: "mismatch ticket description",
|
|
CustomerID: customerID,
|
|
ConversationID: conversationID,
|
|
}, operator)
|
|
if err == nil {
|
|
t.Fatalf("expected CreateTicket() to reject mismatched customer and conversation")
|
|
}
|
|
}
|
|
|
|
func TestTicketServiceSummaryCountsStaleTickets(t *testing.T) {
|
|
setupTicketTestDB(t)
|
|
operator := createTestOperator(t, "summary-operator")
|
|
mine, err := services.TicketService.CreateTicket(request.CreateTicketRequest{
|
|
Title: "mine stale ticket",
|
|
Description: "mine stale description",
|
|
CurrentAssigneeID: operator.UserID,
|
|
}, operator)
|
|
if err != nil {
|
|
t.Fatalf("CreateTicket() mine error = %v", err)
|
|
}
|
|
if _, err := services.TicketService.CreateTicket(createTestTicketRequest("unassigned ticket"), operator); err != nil {
|
|
t.Fatalf("CreateTicket() unassigned error = %v", err)
|
|
}
|
|
staleUpdatedAt := time.Now().Add(-36 * time.Hour)
|
|
if err := repositories.TicketRepository.Updates(sqls.DB(), mine.ID, map[string]any{
|
|
"updated_at": staleUpdatedAt,
|
|
}); err != nil {
|
|
t.Fatalf("update stale ticket error = %v", err)
|
|
}
|
|
|
|
summary := services.TicketService.GetSummary(operator, 24)
|
|
if summary.All != 2 {
|
|
t.Fatalf("expected all count 2, got %d", summary.All)
|
|
}
|
|
if summary.Pending != 2 {
|
|
t.Fatalf("expected pending count 2, got %d", summary.Pending)
|
|
}
|
|
if summary.Mine != 1 {
|
|
t.Fatalf("expected mine count 1, got %d", summary.Mine)
|
|
}
|
|
if summary.Unassigned != 1 {
|
|
t.Fatalf("expected unassigned count 1, got %d", summary.Unassigned)
|
|
}
|
|
if summary.Stale != 1 {
|
|
t.Fatalf("expected stale count 1, got %d", summary.Stale)
|
|
}
|
|
|
|
summary48 := services.TicketService.GetSummary(operator, 48)
|
|
if summary48.Stale != 0 {
|
|
t.Fatalf("expected stale count 0 for 48 hour threshold, got %d", summary48.Stale)
|
|
}
|
|
summaryInvalid := services.TicketService.GetSummary(operator, 1<<30)
|
|
if summaryInvalid.Stale != 1 {
|
|
t.Fatalf("expected invalid stale threshold to use 24 hours, got %d", summaryInvalid.Stale)
|
|
}
|
|
}
|
|
|
|
func TestTicketServiceFindPageAggregateFiltersStaleTickets(t *testing.T) {
|
|
setupTicketTestDB(t)
|
|
operator := createTestOperator(t, "stale-list-operator")
|
|
staleOpen, err := services.TicketService.CreateTicket(createTestTicketRequest("stale open ticket"), operator)
|
|
if err != nil {
|
|
t.Fatalf("CreateTicket() stale open error = %v", err)
|
|
}
|
|
staleDone, err := services.TicketService.CreateTicket(createTestTicketRequest("stale done ticket"), operator)
|
|
if err != nil {
|
|
t.Fatalf("CreateTicket() stale done error = %v", err)
|
|
}
|
|
freshOpen, err := services.TicketService.CreateTicket(createTestTicketRequest("fresh open ticket"), operator)
|
|
if err != nil {
|
|
t.Fatalf("CreateTicket() fresh open error = %v", err)
|
|
}
|
|
|
|
if err := services.TicketService.ChangeStatus(request.ChangeTicketStatusRequest{
|
|
TicketID: staleDone.ID,
|
|
Status: string(enums.TicketStatusDone),
|
|
}, operator); err != nil {
|
|
t.Fatalf("ChangeStatus() stale done error = %v", err)
|
|
}
|
|
staleUpdatedAt := time.Now().Add(-48 * time.Hour)
|
|
for _, ticketID := range []int64{staleOpen.ID, staleDone.ID} {
|
|
if err := repositories.TicketRepository.Updates(sqls.DB(), ticketID, map[string]any{
|
|
"updated_at": staleUpdatedAt,
|
|
}); err != nil {
|
|
t.Fatalf("update stale ticket %d error = %v", ticketID, err)
|
|
}
|
|
}
|
|
|
|
aggregate, err := services.TicketService.FindPageAggregateByCnd(
|
|
services.TicketService.ApplyStaleFilter(sqls.NewCnd(), 24).Page(1, 10),
|
|
operator.UserID,
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("FindPageAggregateByCnd() error = %v", err)
|
|
}
|
|
if len(aggregate.List) != 1 {
|
|
t.Fatalf("expected 1 stale non-done ticket, got %d: %+v", len(aggregate.List), aggregate.List)
|
|
}
|
|
if aggregate.List[0].ID != staleOpen.ID {
|
|
t.Fatalf("expected stale open ticket %d, got %d", staleOpen.ID, aggregate.List[0].ID)
|
|
}
|
|
if aggregate.List[0].ID == freshOpen.ID || aggregate.List[0].ID == staleDone.ID {
|
|
t.Fatalf("stale list included fresh or done ticket: %+v", aggregate.List[0])
|
|
}
|
|
}
|
|
|
|
func TestTicketServiceFindPageAggregateEnrichesLookups(t *testing.T) {
|
|
setupTicketTestDB(t)
|
|
operator := createTestOperator(t, "aggregate-operator")
|
|
assignee := createTestOperator(t, "aggregate-assignee")
|
|
customerID := createTestCustomer(t, "aggregate-customer")
|
|
tagID := createTestTag(t, "aggregate-tag")
|
|
|
|
ticket, err := services.TicketService.CreateTicket(request.CreateTicketRequest{
|
|
Title: "aggregate ticket",
|
|
Description: "aggregate description",
|
|
CustomerID: customerID,
|
|
TagIDs: []int64{tagID},
|
|
CurrentAssigneeID: assignee.UserID,
|
|
}, operator)
|
|
if err != nil {
|
|
t.Fatalf("CreateTicket() 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 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[assignee.UserID] == nil {
|
|
t.Fatalf("expected assignee lookup to be populated")
|
|
}
|
|
}
|
|
|
|
func TestTicketServiceTicketNoNextConcurrent(t *testing.T) {
|
|
setupTicketTestDBWithMaxOpenConns(t, 8)
|
|
|
|
const count = 50
|
|
results := make(chan string, count)
|
|
errs := make(chan error, count)
|
|
var wg sync.WaitGroup
|
|
|
|
for range count {
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
ticketNo, err := services.TicketNoSequenceService.Next(time.Now())
|
|
if err != nil {
|
|
errs <- err
|
|
}
|
|
results <- ticketNo
|
|
}()
|
|
}
|
|
|
|
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 TestTicketServiceCreateTicketConcurrentAllocatesUniqueTicketNos(t *testing.T) {
|
|
setupTicketTestDBWithMaxOpenConns(t, 8)
|
|
operator := createTestOperator(t, "concurrent-create-operator")
|
|
|
|
const count = 50
|
|
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(index int) {
|
|
defer wg.Done()
|
|
ticket, err := services.TicketService.CreateTicket(request.CreateTicketRequest{
|
|
Title: fmt.Sprintf("concurrent ticket %d", index),
|
|
Description: fmt.Sprintf("concurrent ticket %d description", index),
|
|
}, operator)
|
|
if err != nil {
|
|
errs <- err
|
|
return
|
|
}
|
|
results <- ticket.TicketNo
|
|
}(i)
|
|
}
|
|
|
|
wg.Wait()
|
|
close(results)
|
|
close(errs)
|
|
|
|
for err := range errs {
|
|
if err != nil {
|
|
t.Fatalf("CreateTicket() concurrent error = %v", err)
|
|
}
|
|
}
|
|
|
|
seen := make(map[string]struct{}, count)
|
|
for ticketNo := range results {
|
|
if ticketNo == "" {
|
|
t.Fatalf("expected non-empty ticket number")
|
|
}
|
|
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 setupTicketTestDB(t *testing.T) {
|
|
setupTicketTestDBWithMaxOpenConns(t, 0)
|
|
}
|
|
|
|
func setupTicketTestDBWithMaxOpenConns(t *testing.T, maxOpenConns int) {
|
|
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: maxOpenConns,
|
|
})
|
|
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,
|
|
Description: title + " description",
|
|
}
|
|
}
|
|
|
|
func createTestOperator(t *testing.T, prefix string) *dto.AuthPrincipal {
|
|
t.Helper()
|
|
userID := createTestUser(t, prefix)
|
|
return &dto.AuthPrincipal{UserID: userID, Username: prefix}
|
|
}
|
|
|
|
func createTestUser(t *testing.T, prefix string) int64 {
|
|
return createTestUserWithStatus(t, prefix, enums.StatusOk)
|
|
}
|
|
|
|
func createTestUserWithStatus(t *testing.T, prefix string, status enums.Status) int64 {
|
|
t.Helper()
|
|
now := time.Now()
|
|
username := fmt.Sprintf("%s_%d", prefix, now.UnixNano())
|
|
user := &models.User{
|
|
Username: username,
|
|
Nickname: prefix,
|
|
Status: status,
|
|
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 createTestConversation(t *testing.T, customerID int64, prefix string) int64 {
|
|
t.Helper()
|
|
|
|
now := time.Now()
|
|
item := &models.Conversation{
|
|
CustomerID: customerID,
|
|
CustomerName: prefix,
|
|
Status: enums.IMConversationStatusActive,
|
|
ServiceMode: enums.IMConversationServiceModeAIOnly,
|
|
LastMessageAt: now,
|
|
LastActiveAt: now,
|
|
AuditFields: models.AuditFields{CreatedAt: now, UpdatedAt: now},
|
|
}
|
|
if err := repositories.ConversationRepository.Create(sqls.DB(), item); err != nil {
|
|
t.Fatalf("create conversation error = %v", err)
|
|
}
|
|
return item.ID
|
|
}
|
|
|
|
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
|
|
}
|