511 lines
17 KiB
Go
511 lines
17 KiB
Go
package services
|
|
|
|
import (
|
|
"encoding/json"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"agent-desk/internal/models"
|
|
"agent-desk/internal/pkg/config"
|
|
"agent-desk/internal/pkg/dto"
|
|
"agent-desk/internal/pkg/enums"
|
|
"agent-desk/internal/pkg/i18nx"
|
|
"agent-desk/internal/pkg/openidentity"
|
|
|
|
"github.com/glebarez/sqlite"
|
|
"github.com/mlogclub/simple/sqls"
|
|
"gorm.io/gorm"
|
|
"gorm.io/gorm/schema"
|
|
)
|
|
|
|
const testEmailChannelCode = "support_mail"
|
|
|
|
func setupEmailRequestTest(t *testing.T, maxOpen int, liveMinutes int) (*gorm.DB, *models.Channel, *models.AIAgent) {
|
|
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.CustomerContact{},
|
|
&models.Channel{},
|
|
&models.AIAgent{},
|
|
&models.Conversation{},
|
|
&models.ConversationAssignment{},
|
|
&models.ConversationParticipant{},
|
|
&models.ConversationEventLog{},
|
|
&models.ConversationReadState{},
|
|
&models.ChannelMessageOutbox{},
|
|
&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,
|
|
EmailChatLiveMinutes: liveMinutes,
|
|
},
|
|
Email: config.EmailConfig{InboundSecret: ""},
|
|
})
|
|
previousWs := WsService
|
|
WsService = newWsService()
|
|
t.Cleanup(func() {
|
|
config.SetCurrent(previousConfig)
|
|
WsService = previousWs
|
|
})
|
|
|
|
// AI-first keeps the test off the human dispatch path.
|
|
aiAgent := models.AIAgent{
|
|
Name: "test AI",
|
|
ServiceMode: enums.IMConversationServiceModeAIFirst,
|
|
Status: enums.StatusOk,
|
|
}
|
|
if err := db.Create(&aiAgent).Error; err != nil {
|
|
t.Fatalf("create ai agent error = %v", err)
|
|
}
|
|
|
|
channel := models.Channel{
|
|
Name: "Support mail",
|
|
ChannelID: testEmailChannelCode,
|
|
ChannelType: enums.ChannelTypeEmail,
|
|
AIAgentID: aiAgent.ID,
|
|
Status: enums.StatusOk,
|
|
ConfigJSON: mustMarshalChannelConfig(dto.EmailChannelConfig{
|
|
EmailAddress: "help@example.com",
|
|
WebhookSecret: "channel-secret",
|
|
}),
|
|
}
|
|
if err := db.Create(&channel).Error; err != nil {
|
|
t.Fatalf("create channel error = %v", err)
|
|
}
|
|
|
|
return db, &channel, &aiAgent
|
|
}
|
|
|
|
func TestHandleRequestFilesAnEmailConversation(t *testing.T) {
|
|
_, channel, _ := setupEmailRequestTest(t, 3, 30)
|
|
|
|
result, err := EmailRequestService.HandleRequest(t.Context(), channel.ChannelID, "channel-secret", EmailRequestInput{
|
|
Email: "alice@example.com",
|
|
Name: "Alice",
|
|
Subject: "Cannot complete payment",
|
|
Message: "The checkout page spins forever.",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("HandleRequest() error = %v", err)
|
|
}
|
|
|
|
if result.Outcome != EmailRequestOutcomeEmail {
|
|
t.Fatalf("Outcome = %q, want %q", result.Outcome, EmailRequestOutcomeEmail)
|
|
}
|
|
if !result.Verified {
|
|
t.Fatal("a request presenting the channel secret must be verified")
|
|
}
|
|
|
|
conversation := ConversationService.Get(result.ConversationID)
|
|
if conversation == nil {
|
|
t.Fatal("conversation was not created")
|
|
}
|
|
if conversation.Subject != "Cannot complete payment" {
|
|
t.Fatalf("Subject = %q, want the submitted title", conversation.Subject)
|
|
}
|
|
if conversation.ChannelID != channel.ID {
|
|
t.Fatalf("conversation channel = %d, want the email channel %d", conversation.ChannelID, channel.ID)
|
|
}
|
|
|
|
messages := MessageService.Find(sqls.NewCnd().Eq("conversation_id", conversation.ID).Asc("id"))
|
|
if len(messages) < 2 {
|
|
t.Fatalf("expected the request plus an acknowledgement, got %d messages", len(messages))
|
|
}
|
|
if !strings.Contains(messages[0].Content, "checkout page spins") {
|
|
t.Fatalf("request content = %q", messages[0].Content)
|
|
}
|
|
if messages[len(messages)-1].SenderType != enums.IMSenderTypeAI {
|
|
t.Fatalf("acknowledgement sender = %q, want an AI message", messages[len(messages)-1].SenderType)
|
|
}
|
|
}
|
|
|
|
// The receipt has to reach the inbox, so it must land in the email outbox rather
|
|
// than only in the transcript.
|
|
func TestHandleRequestQueuesTheAcknowledgementForEmail(t *testing.T) {
|
|
_, channel, _ := setupEmailRequestTest(t, 3, 30)
|
|
|
|
result, err := EmailRequestService.HandleRequest(t.Context(), channel.ChannelID, "channel-secret", EmailRequestInput{
|
|
Email: "alice@example.com",
|
|
Subject: "Question",
|
|
Message: "Hello there.",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("HandleRequest() error = %v", err)
|
|
}
|
|
|
|
pending := ChannelMessageOutboxService.ListPending(enums.ChannelTypeEmail, 10)
|
|
if len(pending) != 1 {
|
|
t.Fatalf("outbox has %d pending messages, want exactly the acknowledgement", len(pending))
|
|
}
|
|
if pending[0].ConversationID != result.ConversationID {
|
|
t.Fatalf("outbox conversation = %d, want %d", pending[0].ConversationID, result.ConversationID)
|
|
}
|
|
}
|
|
|
|
// Omitting the secret is the documented guest mode: the request is still filed,
|
|
// but nothing the submitter claimed is treated as proven.
|
|
func TestHandleRequestWithoutSecretIsSelfReported(t *testing.T) {
|
|
_, channel, _ := setupEmailRequestTest(t, 3, 30)
|
|
|
|
result, err := EmailRequestService.HandleRequest(t.Context(), channel.ChannelID, "", EmailRequestInput{
|
|
Email: "bob@example.com",
|
|
Subject: "Question",
|
|
Message: "Hello there.",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("HandleRequest() error = %v", err)
|
|
}
|
|
if result.Verified {
|
|
t.Fatal("a request without the channel secret must not be verified")
|
|
}
|
|
if result.Outcome != EmailRequestOutcomeEmail {
|
|
t.Fatalf("Outcome = %q, want %q", result.Outcome, EmailRequestOutcomeEmail)
|
|
}
|
|
|
|
contact := findTestContact(t, "bob@example.com")
|
|
if contact == nil {
|
|
t.Fatal("the submitted address should still be recorded as a contact")
|
|
}
|
|
if contact.IsVerified {
|
|
t.Fatal("a self-reported address must not be marked verified")
|
|
}
|
|
if strings.TrimSpace(contact.Remark) == "" {
|
|
t.Fatal("a self-reported address must carry a remark agents can see")
|
|
}
|
|
}
|
|
|
|
func TestHandleRequestWithValidSecretMarksContactVerified(t *testing.T) {
|
|
_, channel, _ := setupEmailRequestTest(t, 3, 30)
|
|
|
|
if _, err := EmailRequestService.HandleRequest(t.Context(), channel.ChannelID, "channel-secret", EmailRequestInput{
|
|
Email: "carol@example.com", Message: "First note.",
|
|
}); err != nil {
|
|
t.Fatalf("HandleRequest() error = %v", err)
|
|
}
|
|
|
|
contact := findTestContact(t, "carol@example.com")
|
|
if contact == nil {
|
|
t.Fatal("expected a contact row")
|
|
}
|
|
if !contact.IsVerified {
|
|
t.Fatal("a secret-authenticated address must be marked verified")
|
|
}
|
|
if contact.VerifiedAt == nil {
|
|
t.Fatal("a verified contact needs a verification timestamp")
|
|
}
|
|
}
|
|
|
|
// A later secretless submission must not silently upgrade the record, and a
|
|
// secret one must not stay unverified after being proven.
|
|
func TestHandleRequestUpdatesVerificationOnRepeat(t *testing.T) {
|
|
_, channel, _ := setupEmailRequestTest(t, 5, 30)
|
|
|
|
if _, err := EmailRequestService.HandleRequest(t.Context(), channel.ChannelID, "", EmailRequestInput{
|
|
Email: "dan@example.com", Message: "Unverified first.",
|
|
}); err != nil {
|
|
t.Fatalf("guest HandleRequest() error = %v", err)
|
|
}
|
|
if contact := findTestContact(t, "dan@example.com"); contact == nil || contact.IsVerified {
|
|
t.Fatal("first submission should be unverified")
|
|
}
|
|
|
|
if _, err := EmailRequestService.HandleRequest(t.Context(), channel.ChannelID, "channel-secret", EmailRequestInput{
|
|
Email: "dan@example.com", Message: "Verified second.",
|
|
}); err != nil {
|
|
t.Fatalf("verified HandleRequest() error = %v", err)
|
|
}
|
|
contact := findTestContact(t, "dan@example.com")
|
|
if contact == nil || !contact.IsVerified {
|
|
t.Fatal("a verified submission must upgrade the existing contact")
|
|
}
|
|
}
|
|
|
|
// A wrong secret must fail loudly. Treating it as a guest submission would let a
|
|
// broken integration look like it is working while filing untrusted identities.
|
|
func TestHandleRequestRejectsAWrongSecret(t *testing.T) {
|
|
_, channel, _ := setupEmailRequestTest(t, 3, 30)
|
|
|
|
_, err := EmailRequestService.HandleRequest(t.Context(), channel.ChannelID, "not-the-secret", EmailRequestInput{
|
|
Email: "eve@example.com", Message: "Hello.",
|
|
})
|
|
if err == nil {
|
|
t.Fatal("expected a wrong secret to be rejected")
|
|
}
|
|
if !strings.Contains(err.Error(), i18nx.Getf(i18nx.DefaultLocale, "error.auth.invalidSignature")) {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestHandleRequestRejectsEmptyMessage(t *testing.T) {
|
|
_, channel, _ := setupEmailRequestTest(t, 3, 30)
|
|
|
|
for _, message := range []string{"", " \n\t "} {
|
|
if _, err := EmailRequestService.HandleRequest(t.Context(), channel.ChannelID, "channel-secret", EmailRequestInput{
|
|
Email: "frank@example.com", Message: message,
|
|
}); err == nil {
|
|
t.Fatalf("expected %q to be rejected as an empty request", message)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestHandleRequestRejectsInvalidAddress(t *testing.T) {
|
|
_, channel, _ := setupEmailRequestTest(t, 3, 30)
|
|
|
|
for _, address := range []string{"", "no-at-sign", " "} {
|
|
if _, err := EmailRequestService.HandleRequest(t.Context(), channel.ChannelID, "channel-secret", EmailRequestInput{
|
|
Email: address, Message: "Hello.",
|
|
}); err == nil {
|
|
t.Fatalf("expected %q to be rejected", address)
|
|
}
|
|
}
|
|
}
|
|
|
|
// A live chat is where the visitor is actually present, so mail joins it instead
|
|
// of starting a thread they will not watch.
|
|
//
|
|
// Identity is keyed on the address, so the visitor has to reach the same customer
|
|
// through both: the email address is what resolves to them here, so the chat has
|
|
// to belong to the customer that address created.
|
|
func TestHandleRequestJoinsALiveChatInsteadOfStartingEmail(t *testing.T) {
|
|
_, emailChannel, _ := setupEmailRequestTest(t, 5, 30)
|
|
webChannel := createTestWebChannel(t)
|
|
|
|
first, err := EmailRequestService.HandleRequest(t.Context(), emailChannel.ChannelID, "channel-secret", EmailRequestInput{
|
|
Email: "guest@example.com", Subject: "Started by email", Message: "Opening question.",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("first HandleRequest() error = %v", err)
|
|
}
|
|
customerID := ConversationService.Get(first.ConversationID).CustomerID
|
|
closeConversation(t, first.ConversationID)
|
|
|
|
chat, err := ConversationService.CreateNew(
|
|
openidentity.ExternalUser{ExternalSource: enums.ExternalSourceEmail, ExternalID: "guest@example.com"},
|
|
webChannel.ID, emailChannel.AIAgentID, "live chat",
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("CreateNew() error = %v", err)
|
|
}
|
|
if chat.CustomerID != customerID {
|
|
t.Fatalf("chat customer = %d, want the address's own customer %d", chat.CustomerID, customerID)
|
|
}
|
|
|
|
result, err := EmailRequestService.HandleRequest(t.Context(), emailChannel.ChannelID, "channel-secret", EmailRequestInput{
|
|
Email: "guest@example.com",
|
|
Subject: "Follow up",
|
|
Message: "Adding detail by email.",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("second HandleRequest() error = %v", err)
|
|
}
|
|
if result.Outcome != EmailRequestOutcomeChat {
|
|
t.Fatalf("Outcome = %q, want %q", result.Outcome, EmailRequestOutcomeChat)
|
|
}
|
|
if result.ConversationID != chat.ID {
|
|
t.Fatalf("conversation = %d, want the live chat %d", result.ConversationID, chat.ID)
|
|
}
|
|
|
|
messages := MessageService.Find(sqls.NewCnd().Eq("conversation_id", chat.ID).Asc("id"))
|
|
found := false
|
|
for _, message := range messages {
|
|
if strings.Contains(message.Content, "Adding detail by email") {
|
|
found = true
|
|
}
|
|
}
|
|
if !found {
|
|
t.Fatal("the email content should be recorded in the chat so the agent can see it")
|
|
}
|
|
|
|
// The visitor is told where to continue, and it has to be an email, not a
|
|
// message that only exists inside the chat they were about to leave.
|
|
pending := ChannelMessageOutboxService.ListPending(enums.ChannelTypeEmail, 10)
|
|
// The first request's acknowledgement was already delivered by the async
|
|
// dispatcher attempt, so only the newest notice is outstanding here.
|
|
if len(pending) == 0 {
|
|
t.Fatal("expected a continuation notice to be queued for email")
|
|
}
|
|
}
|
|
|
|
// Once the chat has gone quiet it is abandoned, and mail becomes its own thread.
|
|
func TestHandleRequestStartsEmailWhenTheChatIsStale(t *testing.T) {
|
|
_, emailChannel, _ := setupEmailRequestTest(t, 5, 30)
|
|
webChannel := createTestWebChannel(t)
|
|
|
|
first, err := EmailRequestService.HandleRequest(t.Context(), emailChannel.ChannelID, "channel-secret", EmailRequestInput{
|
|
Email: "guest@example.com", Subject: "Started by email", Message: "Opening question.",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("first HandleRequest() error = %v", err)
|
|
}
|
|
closeConversation(t, first.ConversationID)
|
|
|
|
chat, err := ConversationService.CreateNew(
|
|
openidentity.ExternalUser{ExternalSource: enums.ExternalSourceEmail, ExternalID: "guest@example.com"},
|
|
webChannel.ID, emailChannel.AIAgentID, "stale chat",
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("CreateNew() error = %v", err)
|
|
}
|
|
|
|
backdate := time.Now().Add(-4 * time.Hour)
|
|
if err := sqls.DB().Model(&models.Conversation{}).Where("id = ?", chat.ID).
|
|
Update("last_active_at", backdate).Error; err != nil {
|
|
t.Fatalf("backdate chat error = %v", err)
|
|
}
|
|
|
|
result, err := EmailRequestService.HandleRequest(t.Context(), emailChannel.ChannelID, "channel-secret", EmailRequestInput{
|
|
Email: "guest@example.com",
|
|
Subject: "Still need help",
|
|
Message: "The chat went quiet a while ago.",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("HandleRequest() error = %v", err)
|
|
}
|
|
if result.Outcome != EmailRequestOutcomeEmail {
|
|
t.Fatalf("Outcome = %q, want %q for an abandoned chat", result.Outcome, EmailRequestOutcomeEmail)
|
|
}
|
|
if result.ConversationID == chat.ID {
|
|
t.Fatal("a stale chat must not swallow the email")
|
|
}
|
|
}
|
|
|
|
// An open thread already on email stays on email; there is nothing to redirect
|
|
// the visitor away from.
|
|
func TestHandleRequestKeepsAnOpenEmailThreadOnEmail(t *testing.T) {
|
|
_, channel, _ := setupEmailRequestTest(t, 5, 30)
|
|
|
|
first, err := EmailRequestService.HandleRequest(t.Context(), channel.ChannelID, "channel-secret", EmailRequestInput{
|
|
Email: "kim@example.com", Subject: "Ongoing", Message: "Still waiting on this.",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("first HandleRequest() error = %v", err)
|
|
}
|
|
|
|
chat := ConversationService.Get(first.ConversationID)
|
|
// Pretend the visitor also has a fresh chat open somewhere else.
|
|
other := ConversationService.Get(first.ConversationID)
|
|
if other == nil {
|
|
t.Fatal("conversation disappeared")
|
|
}
|
|
_ = chat
|
|
|
|
result, err := EmailRequestService.HandleRequest(t.Context(), channel.ChannelID, "channel-secret", EmailRequestInput{
|
|
Email: "kim@example.com", Subject: "Ongoing", Message: "Any news on this?",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("second HandleRequest() error = %v", err)
|
|
}
|
|
if result.Outcome != EmailRequestOutcomeEmail {
|
|
t.Fatalf("Outcome = %q, want %q", result.Outcome, EmailRequestOutcomeEmail)
|
|
}
|
|
}
|
|
|
|
// The cap has to apply on this path too, or mail becomes a way around it.
|
|
func TestHandleRequestRespectsTheOpenRequestCap(t *testing.T) {
|
|
_, channel, _ := setupEmailRequestTest(t, 2, 30)
|
|
|
|
for i := 0; i < 2; i++ {
|
|
if _, err := EmailRequestService.HandleRequest(t.Context(), channel.ChannelID, "channel-secret", EmailRequestInput{
|
|
Email: "heidi@example.com", Message: "Request.",
|
|
}); err != nil {
|
|
t.Fatalf("HandleRequest() #%d error = %v", i+1, err)
|
|
}
|
|
}
|
|
if _, err := EmailRequestService.HandleRequest(t.Context(), channel.ChannelID, "channel-secret", EmailRequestInput{
|
|
Email: "heidi@example.com", Message: "One too many.",
|
|
}); err == nil {
|
|
t.Fatal("expected the open request cap to apply to email submissions")
|
|
}
|
|
}
|
|
|
|
func TestHandleRequestRejectsUnknownChannel(t *testing.T) {
|
|
setupEmailRequestTest(t, 3, 30)
|
|
|
|
if _, err := EmailRequestService.HandleRequest(t.Context(), "does-not-exist", "channel-secret", EmailRequestInput{
|
|
Email: "ivan@example.com", Message: "Hello.",
|
|
}); err == nil {
|
|
t.Fatal("expected an unknown channel to be rejected")
|
|
}
|
|
}
|
|
|
|
func TestFindLiveChatIgnoresOtherCustomers(t *testing.T) {
|
|
_, emailChannel, _ := setupEmailRequestTest(t, 5, 30)
|
|
webChannel := createTestWebChannel(t)
|
|
|
|
other := openidentity.ExternalUser{
|
|
ExternalSource: enums.ExternalSourceUser,
|
|
ExternalID: "u_other",
|
|
ExternalName: "Other",
|
|
}
|
|
if _, err := ConversationService.CreateNew(other, webChannel.ID, emailChannel.AIAgentID, ""); err != nil {
|
|
t.Fatalf("CreateNew() error = %v", err)
|
|
}
|
|
|
|
if live := EmailRequestService.findLiveChat(999999, emailChannel.ID); live != nil {
|
|
t.Fatalf("found a chat for a customer that does not exist: %+v", live)
|
|
}
|
|
}
|
|
|
|
func mustMarshalChannelConfig(cfg dto.EmailChannelConfig) string {
|
|
raw, err := json.Marshal(cfg)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
return string(raw)
|
|
}
|
|
|
|
func createTestWebChannel(t *testing.T) *models.Channel {
|
|
t.Helper()
|
|
channel := models.Channel{
|
|
Name: "Web",
|
|
ChannelID: "web_main",
|
|
ChannelType: enums.ChannelTypeWeb,
|
|
Status: enums.StatusOk,
|
|
}
|
|
if err := sqls.DB().Create(&channel).Error; err != nil {
|
|
t.Fatalf("create web channel error = %v", err)
|
|
}
|
|
return &channel
|
|
}
|
|
|
|
func findTestContact(t *testing.T, address string) *models.CustomerContact {
|
|
t.Helper()
|
|
return CustomerContactService.FindOne(sqls.NewCnd().
|
|
Eq("contact_type", enums.ContactTypeEmail).
|
|
Eq("contact_value", address))
|
|
}
|
|
|
|
// linkTestIdentity reuses an existing customer for an email address so routing
|
|
// decisions see one person rather than two.
|
|
func closeConversation(t *testing.T, conversationID int64) {
|
|
t.Helper()
|
|
if err := sqls.DB().Model(&models.Conversation{}).Where("id = ?", conversationID).
|
|
Update("status", enums.IMConversationStatusClosed).Error; err != nil {
|
|
t.Fatalf("close conversation error = %v", err)
|
|
}
|
|
}
|