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) }