* H-300: wire Captain Skills into Web runtime * H-300: enforce effective model and conservative skill budget * H-300: fix CI gosec step * ci: extend golangci-lint timeout * fix lint findings across backend * fix(push): resolve delivery protocol blockers * test(repository): close SQLite test databases * test(repository): reuse SQLite schema per package * H-307: restore backend Go cache in CI * H-307: prefetch modules before cold lint * H-307: resolve govulncheck security gate * H-307: build lint with patched Go toolchain * H-307: clear remaining security scan findings --------- Co-authored-by: Rogee <rogee@ipao.vip>
161 lines
5.7 KiB
Go
161 lines
5.7 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"net/url"
|
|
"os"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/gochat/gochat/internal/model"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"gorm.io/driver/postgres"
|
|
"gorm.io/gorm"
|
|
"gorm.io/gorm/logger"
|
|
)
|
|
|
|
func TestWidgetConversationRollsBackCaptainBindingOnCreateFailure(t *testing.T) {
|
|
db, svc := setupWidgetServiceTest(t)
|
|
account, inbox := seedWidgetInbox(t, db)
|
|
contact := &model.Contact{AccountID: account.ID, Name: "Atomic visitor"}
|
|
require.NoError(t, db.Create(contact).Error)
|
|
contactInbox := &model.ContactInbox{ContactID: contact.ID, InboxID: inbox.ID, SourceID: "atomic"}
|
|
require.NoError(t, db.Create(contactInbox).Error)
|
|
assistant := &model.CaptainAssistant{AccountID: account.ID, Name: "Atomic", Status: model.AssistantStatusActive}
|
|
require.NoError(t, db.Create(assistant).Error)
|
|
require.NoError(t, db.Create(&model.CaptainInbox{AccountID: account.ID, InboxID: inbox.ID, AssistantID: assistant.ID}).Error)
|
|
require.NoError(t, db.Create(&model.CaptainPreference{AccountID: account.ID, AutoReplyEnabled: true}).Error)
|
|
require.NoError(t, db.Callback().Create().Before("gorm:create").Register("test:fail_conversation_create", func(tx *gorm.DB) {
|
|
if tx.Statement.Table == "conversations" {
|
|
require.ErrorContains(t, tx.AddError(errors.New("conversation create failed")), "conversation create failed")
|
|
}
|
|
}))
|
|
t.Cleanup(func() { _ = db.Callback().Create().Remove("test:fail_conversation_create") })
|
|
|
|
_, err := svc.createWidgetConversation(context.Background(), contactInbox, nil, nil)
|
|
require.ErrorContains(t, err, "conversation create failed")
|
|
for _, value := range []any{&model.Conversation{}, &model.AgentBot{}, &model.AgentBotInbox{}} {
|
|
var count int64
|
|
require.NoError(t, db.Model(value).Count(&count).Error)
|
|
assert.Zero(t, count)
|
|
}
|
|
}
|
|
|
|
func TestEnsureCaptainAgentBotBindingConcurrentCallsStayUnique(t *testing.T) {
|
|
if os.Getenv("GOCHAT_TEST_DB") == "sqlite" {
|
|
t.Skip("requires PostgreSQL conflict handling")
|
|
}
|
|
dsn := os.Getenv("GOCHAT_TEST_DB_URL")
|
|
if dsn == "" {
|
|
dsn = "host=localhost port=5432 user=postgres password=postgres dbname=gochat_test sslmode=disable"
|
|
}
|
|
admin, err := gorm.Open(postgres.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
|
|
require.NoError(t, err)
|
|
schema := fmt.Sprintf("captain_binding_%d", time.Now().UnixNano())
|
|
require.NoError(t, admin.Exec("CREATE SCHEMA "+schema).Error)
|
|
t.Cleanup(func() {
|
|
_ = admin.Exec("DROP SCHEMA " + schema + " CASCADE").Error
|
|
if sqlDB, dbErr := admin.DB(); dbErr == nil {
|
|
_ = sqlDB.Close()
|
|
}
|
|
})
|
|
if strings.HasPrefix(dsn, "postgres://") || strings.HasPrefix(dsn, "postgresql://") {
|
|
dsnURL, parseErr := url.Parse(dsn)
|
|
require.NoError(t, parseErr)
|
|
query := dsnURL.Query()
|
|
query.Set("search_path", schema)
|
|
dsnURL.RawQuery = query.Encode()
|
|
dsn = dsnURL.String()
|
|
} else {
|
|
dsn += " search_path=" + schema
|
|
}
|
|
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{
|
|
Logger: logger.Default.LogMode(logger.Silent),
|
|
DisableForeignKeyConstraintWhenMigrating: true,
|
|
})
|
|
require.NoError(t, err)
|
|
require.NoError(t, db.AutoMigrate(&model.CaptainAssistant{}, &model.AgentBot{}, &model.AgentBotInbox{}))
|
|
sqlDB, err := db.DB()
|
|
require.NoError(t, err)
|
|
const calls = 8
|
|
sqlDB.SetMaxOpenConns(calls)
|
|
t.Cleanup(func() { _ = sqlDB.Close() })
|
|
assistant := &model.CaptainAssistant{AccountID: 1, Name: "Concurrent", Status: model.AssistantStatusActive}
|
|
require.NoError(t, db.Create(assistant).Error)
|
|
|
|
errs := make(chan error, calls)
|
|
ids := make(chan uint, calls)
|
|
ready := make(chan struct{}, calls)
|
|
start := make(chan struct{})
|
|
var wg sync.WaitGroup
|
|
for range calls {
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
var bot *model.AgentBot
|
|
err := db.Transaction(func(tx *gorm.DB) error {
|
|
ready <- struct{}{}
|
|
<-start
|
|
var bindErr error
|
|
bot, bindErr = ensureCaptainAgentBotBinding(context.Background(), tx, assistant, 42)
|
|
return bindErr
|
|
})
|
|
errs <- err
|
|
if bot != nil {
|
|
ids <- bot.ID
|
|
}
|
|
}()
|
|
}
|
|
for range calls {
|
|
<-ready
|
|
}
|
|
close(start)
|
|
wg.Wait()
|
|
close(errs)
|
|
close(ids)
|
|
for err := range errs {
|
|
require.NoError(t, err)
|
|
}
|
|
var first uint
|
|
for id := range ids {
|
|
if first == 0 {
|
|
first = id
|
|
}
|
|
assert.Equal(t, first, id)
|
|
}
|
|
for _, value := range []any{&model.AgentBot{}, &model.AgentBotInbox{}} {
|
|
var count int64
|
|
require.NoError(t, db.Model(value).Count(&count).Error)
|
|
assert.Equal(t, int64(1), count)
|
|
}
|
|
}
|
|
|
|
func TestDissociateInboxRollsBackCaptainDeleteWhenBindingDeleteFails(t *testing.T) {
|
|
db, _, svc := setupCov7CaptainAssistant(t)
|
|
account := seedCov7Account(t, db)
|
|
inbox := seedCov7Inbox(t, db, account.ID)
|
|
assistant, err := svc.Create(context.Background(), account.ID, &CreateAssistantRequest{Name: "Rollback", Description: "test"})
|
|
require.NoError(t, err)
|
|
_, err = svc.AssociateInbox(context.Background(), assistant.ID, inbox.ID, account.ID)
|
|
require.NoError(t, err)
|
|
require.NoError(t, db.Callback().Delete().Before("gorm:delete").Register("test:fail_binding_delete", func(tx *gorm.DB) {
|
|
if tx.Statement.Table == "agent_bot_inboxes" {
|
|
require.ErrorContains(t, tx.AddError(errors.New("binding delete failed")), "binding delete failed")
|
|
}
|
|
}))
|
|
t.Cleanup(func() { _ = db.Callback().Delete().Remove("test:fail_binding_delete") })
|
|
|
|
err = svc.DissociateInbox(context.Background(), account.ID, assistant.ID, inbox.ID)
|
|
require.ErrorContains(t, err, "binding delete failed")
|
|
var captainInboxCount, bindingCount int64
|
|
require.NoError(t, db.Model(&model.CaptainInbox{}).Where("inbox_id = ?", inbox.ID).Count(&captainInboxCount).Error)
|
|
require.NoError(t, db.Model(&model.AgentBotInbox{}).Where("inbox_id = ?", inbox.ID).Count(&bindingCount).Error)
|
|
assert.Equal(t, int64(1), captainInboxCount)
|
|
assert.Equal(t, int64(1), bindingCount)
|
|
}
|