package services import ( "strings" "testing" "agent-desk/internal/models" "agent-desk/internal/pkg/config" "agent-desk/internal/pkg/enums" "agent-desk/internal/pkg/openidentity" "github.com/glebarez/sqlite" "github.com/mlogclub/simple/sqls" "gorm.io/gorm" "gorm.io/gorm/schema" ) // The whole point of CreateNew is that it does not behave like Create: it must // start a second thread instead of handing back the unfinished one. func TestCreateNewDoesNotResumeUnfinishedConversation(t *testing.T) { svc, external, aiAgentID := setupCustomerConversationTest(t, 3) first, err := svc.CreateNew(external, 1, aiAgentID, "Проблема с оплатой") if err != nil { t.Fatalf("CreateNew() error = %v", err) } second, err := svc.CreateNew(external, 1, aiAgentID, "Не приходит письмо") if err != nil { t.Fatalf("second CreateNew() error = %v", err) } if first.ID == second.ID { t.Fatalf("CreateNew reused conversation %d instead of starting a new one", first.ID) } if first.Subject != "Проблема с оплатой" || second.Subject != "Не приходит письмо" { t.Fatalf("subjects = %q / %q", first.Subject, second.Subject) } // The resume path must still rejoin the newest one, so a page reload lands // the visitor back in their live thread. resumed, err := svc.Create(external, 1, aiAgentID) if err != nil { t.Fatalf("Create() error = %v", err) } if resumed.ID != second.ID { t.Fatalf("Create() resumed conversation %d, want the newest %d", resumed.ID, second.ID) } } func TestCreateNewStoresANormalizedSubject(t *testing.T) { svc, external, aiAgentID := setupCustomerConversationTest(t, 3) item, err := svc.CreateNew(external, 1, aiAgentID, " Проблема с\nоплатой ") if err != nil { t.Fatalf("CreateNew() error = %v", err) } if item.Subject != "Проблема с оплатой" { t.Fatalf("Subject = %q, want collapsed single spaces", item.Subject) } } // An empty subject stays empty rather than becoming a blank-looking row, so the // frontend can substitute its own fallback text. func TestCreateNewKeepsAnEmptySubjectEmpty(t *testing.T) { svc, external, aiAgentID := setupCustomerConversationTest(t, 3) item, err := svc.CreateNew(external, 1, aiAgentID, " \n\t ") if err != nil { t.Fatalf("CreateNew() error = %v", err) } if item.Subject != "" { t.Fatalf("Subject = %q, want empty", item.Subject) } } func TestCreateNewEnforcesTheOpenConversationCap(t *testing.T) { svc, external, aiAgentID := setupCustomerConversationTest(t, 2) for i := 0; i < 2; i++ { if _, err := svc.CreateNew(external, 1, aiAgentID, ""); err != nil { t.Fatalf("CreateNew() #%d error = %v", i+1, err) } } _, err := svc.CreateNew(external, 1, aiAgentID, "") if err == nil { t.Fatal("expected the third open conversation to be refused") } if !strings.Contains(err.Error(), "2") { t.Fatalf("error %q should state the cap", err) } // Closing one frees a slot: the cap protects the queue, it is not a lifetime // limit on how many requests a visitor may ever file. if err := svc.CloseCustomerConversation(firstCustomerConversationID(t, svc, external), external); err != nil { t.Fatalf("CloseCustomerConversation() error = %v", err) } if _, err := svc.CreateNew(external, 1, aiAgentID, ""); err != nil { t.Fatalf("CreateNew() after closing one error = %v", err) } } func TestCreateNewHonoursADisabledCap(t *testing.T) { svc, external, aiAgentID := setupCustomerConversationTest(t, -1) for i := 0; i < 6; i++ { if _, err := svc.CreateNew(external, 1, aiAgentID, ""); err != nil { t.Fatalf("CreateNew() #%d error = %v with the cap disabled", i+1, err) } } } // A second visitor must never see the first visitor's requests. func TestListCustomerConversationsIsScopedToTheVisitor(t *testing.T) { svc, alice, aiAgentID := setupCustomerConversationTest(t, 5) bob := openidentity.ExternalUser{ ExternalSource: enums.ExternalSourceUser, ExternalID: "u_bob", ExternalName: "Bob", } if _, err := svc.CreateNew(alice, 1, aiAgentID, "Alice request"); err != nil { t.Fatalf("CreateNew(alice) error = %v", err) } if _, err := svc.CreateNew(alice, 1, aiAgentID, "Alice second"); err != nil { t.Fatalf("CreateNew(alice) error = %v", err) } bobItem, err := svc.CreateNew(bob, 1, aiAgentID, "Bob request") if err != nil { t.Fatalf("CreateNew(bob) error = %v", err) } aliceList, paging, err := svc.ListCustomerConversations(alice, sqls.NewCnd().Page(1, 10)) if err != nil { t.Fatalf("ListCustomerConversations(alice) error = %v", err) } if len(aliceList) != 2 { t.Fatalf("alice sees %d conversations, want 2", len(aliceList)) } if paging == nil || paging.Total != 2 { t.Fatalf("unexpected paging for alice: %+v", paging) } bobList, _, err := svc.ListCustomerConversations(bob, sqls.NewCnd().Page(1, 10)) if err != nil { t.Fatalf("ListCustomerConversations(bob) error = %v", err) } if len(bobList) != 1 || bobList[0].ID != bobItem.ID { t.Fatalf("bob sees %+v, want only his own", bobList) } } // A visitor who has never talked to us has no identity row. That is an empty // list, not an error and not somebody else's data. func TestListCustomerConversationsIsEmptyForAnUnknownVisitor(t *testing.T) { svc, _, _ := setupCustomerConversationTest(t, 3) unknown := openidentity.ExternalUser{ ExternalSource: enums.ExternalSourceGuest, ExternalID: "never_seen", } list, paging, err := svc.ListCustomerConversations(unknown, sqls.NewCnd().Page(1, 10)) if err != nil { t.Fatalf("ListCustomerConversations() error = %v", err) } if list == nil { t.Fatal("list must be an empty slice, not nil, so the client gets [] instead of null") } if len(list) != 0 { t.Fatalf("list = %+v, want empty", list) } if paging == nil { t.Fatal("paging must be present so the client can render pagination") } } // Newest activity first: the request the visitor is looking at should be on top. func TestListCustomerConversationsOrdersByRecentActivity(t *testing.T) { svc, external, aiAgentID := setupCustomerConversationTest(t, 5) older, err := svc.CreateNew(external, 1, aiAgentID, "older") if err != nil { t.Fatalf("CreateNew() error = %v", err) } newer, err := svc.CreateNew(external, 1, aiAgentID, "newer") if err != nil { t.Fatalf("CreateNew() error = %v", err) } if err := sqls.DB().Model(&models.Conversation{}).Where("id = ?", older.ID). Update("last_active_at", older.LastActiveAt.Add(-24*60*60*1e9)).Error; err != nil { t.Fatalf("backdate conversation error = %v", err) } list, _, err := svc.ListCustomerConversations(external, sqls.NewCnd().Page(1, 10)) if err != nil { t.Fatalf("ListCustomerConversations() error = %v", err) } if len(list) != 2 { t.Fatalf("list length = %d, want 2", len(list)) } if list[0].ID != newer.ID { t.Fatalf("first row = %d, want the most recently active %d", list[0].ID, newer.ID) } } func TestListCustomerConversationsRespectsPagination(t *testing.T) { svc, external, aiAgentID := setupCustomerConversationTest(t, 5) for i := 0; i < 3; i++ { if _, err := svc.CreateNew(external, 1, aiAgentID, ""); err != nil { t.Fatalf("CreateNew() #%d error = %v", i+1, err) } } firstPage, paging, err := svc.ListCustomerConversations(external, sqls.NewCnd().Page(1, 2)) if err != nil { t.Fatalf("ListCustomerConversations() error = %v", err) } if len(firstPage) != 2 { t.Fatalf("first page length = %d, want 2", len(firstPage)) } if paging.Total != 3 { t.Fatalf("paging.Total = %d, want 3", paging.Total) } secondPage, _, err := svc.ListCustomerConversations(external, sqls.NewCnd().Page(2, 2)) if err != nil { t.Fatalf("ListCustomerConversations() page 2 error = %v", err) } if len(secondPage) != 1 { t.Fatalf("second page length = %d, want 1", len(secondPage)) } for _, item := range firstPage { for _, other := range secondPage { if item.ID == other.ID { t.Fatalf("conversation %d appeared on both pages", item.ID) } } } } // The cap must not block the resume path: reloading a page with three open // conversations has to rejoin one, not fail. func TestCreateStillResumesWhenTheCapIsReached(t *testing.T) { svc, external, aiAgentID := setupCustomerConversationTest(t, 1) only, err := svc.CreateNew(external, 1, aiAgentID, "only") if err != nil { t.Fatalf("CreateNew() error = %v", err) } resumed, err := svc.Create(external, 1, aiAgentID) if err != nil { t.Fatalf("Create() at the cap error = %v", err) } if resumed.ID != only.ID { t.Fatalf("Create() returned %d, want %d", resumed.ID, only.ID) } } func TestNormalizeConversationSubject(t *testing.T) { tests := []struct { name string subject string want string }{ {name: "empty", subject: "", want: ""}, {name: "whitespace only", subject: " \t\n ", want: ""}, {name: "inner runs collapse", subject: "a b\t\tc", want: "a b c"}, {name: "edges trimmed", subject: " hello ", want: "hello"}, {name: "newlines become spaces", subject: "line one\nline two", want: "line one line two"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if got := normalizeConversationSubject(tt.subject); got != tt.want { t.Fatalf("normalizeConversationSubject(%q) = %q, want %q", tt.subject, got, tt.want) } }) } } // Truncation must count runes, not bytes. A byte-wise cut can split a multi-byte // character and produce invalid UTF-8, and it would also cut far too early: // MySQL counts a VARCHAR(255) in characters, so 255 Cyrillic runes is 510 bytes // yet still fits the column. func TestNormalizeConversationSubjectTruncatesOnRuneBoundaries(t *testing.T) { long := strings.Repeat("я", conversationSubjectMaxLength+50) got := normalizeConversationSubject(long) if len([]rune(got)) != conversationSubjectMaxLength { t.Fatalf("rune length = %d, want %d", len([]rune(got)), conversationSubjectMaxLength) } if !strings.HasPrefix(long, got) { t.Fatal("truncation must keep a prefix of the input") } } func firstCustomerConversationID(t *testing.T, svc *conversationService, external openidentity.ExternalUser) int64 { t.Helper() list, _, err := svc.ListCustomerConversations(external, sqls.NewCnd().Page(1, 10)) if err != nil { t.Fatalf("ListCustomerConversations() error = %v", err) } if len(list) == 0 { t.Fatal("expected at least one conversation") } return list[0].ID } // setupCustomerConversationTest gives each test its own in-memory database and // restores the process-wide singletons it touches, so tests stay independent // even though config and the DB handle are globals. func setupCustomerConversationTest(t *testing.T, maxOpen int) (*conversationService, openidentity.ExternalUser, int64) { t.Helper() dbName := strings.NewReplacer("/", "_", " ", "_").Replace(t.Name()) db, err := gorm.Open(sqlite.Open("file:"+dbName+"?mode=memory&cache=shared"), &gorm.Config{ NamingStrategy: schema.NamingStrategy{ TablePrefix: "t_", SingularTable: true, }, }) if err != nil { t.Fatalf("open sqlite error = %v", err) } t.Cleanup(func() { if sqlDB, err := db.DB(); err == nil { _ = sqlDB.Close() } }) if err := db.AutoMigrate( &models.Customer{}, &models.CustomerIdentity{}, &models.Channel{}, &models.AIAgent{}, &models.Conversation{}, &models.ConversationAssignment{}, &models.ConversationParticipant{}, &models.ConversationEventLog{}, &models.ConversationReadState{}, &models.Message{}, ); err != nil { t.Fatalf("auto migrate error = %v", err) } sqls.SetDB(db) previousConfig := config.GetCurrent() config.SetCurrent(&config.Config{ Conversation: config.ConversationConfig{CustomerMaxOpen: maxOpen}, }) previousWs := WsService WsService = newWsService() t.Cleanup(func() { config.SetCurrent(previousConfig) WsService = previousWs }) // AI-first keeps the test off the human dispatch path, which needs teams, // schedules and agent profiles that are irrelevant here. aiAgent := models.AIAgent{ Name: "测试AI", ServiceMode: enums.IMConversationServiceModeAIFirst, Status: enums.StatusOk, } if err := db.Create(&aiAgent).Error; err != nil { t.Fatalf("create ai agent error = %v", err) } external := openidentity.ExternalUser{ ExternalSource: enums.ExternalSourceUser, ExternalID: "u_alice", ExternalName: "Alice", } return ConversationService, external, aiAgent.ID }