diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c128a6e8..7830de07 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,6 +31,36 @@ jobs: working-directory: backend run: go vet ./... + backend-postgres: + name: Backend (PostgreSQL concurrency) + runs-on: ubuntu-latest + services: + postgres: + image: postgres:16-alpine + env: + POSTGRES_DB: gochat_test + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U postgres -d gochat_test" + --health-interval 5s + --health-timeout 5s + --health-retries 5 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: backend/go.mod + cache-dependency-path: backend/go.sum + - name: Test auto-assignment concurrency + working-directory: backend + env: + GOCHAT_TEST_DB: postgres + GOCHAT_TEST_DB_URL: postgres://postgres:postgres@localhost:5432/gochat_test?sslmode=disable + run: go test ./internal/autoassignment -run '^TestAssignmentServiceOnlyOneConcurrentWorkerWinsPostgres$' -count=3 + frontend: name: Frontend Build runs-on: ubuntu-latest diff --git a/backend/internal/autoassignment/coverage9_test.go b/backend/internal/autoassignment/coverage9_test.go index 957626b1..6a8052cf 100644 --- a/backend/internal/autoassignment/coverage9_test.go +++ b/backend/internal/autoassignment/coverage9_test.go @@ -3,6 +3,8 @@ package autoassignment import ( "context" "fmt" + "net/url" + "os" "strings" "sync" "sync/atomic" @@ -14,11 +16,23 @@ import ( "github.com/gochat/gochat/internal/model" "github.com/redis/go-redis/v9" "github.com/stretchr/testify/require" + "gorm.io/driver/postgres" "gorm.io/driver/sqlite" "gorm.io/gorm" "gorm.io/gorm/logger" ) +func migrateFullAADB_Cov9(t *testing.T, db *gorm.DB) { + t.Helper() + require.NoError(t, db.AutoMigrate( + &model.Account{}, &model.User{}, &model.AccountUser{}, + &model.Inbox{}, &model.InboxMember{}, &model.Contact{}, &model.ContactInbox{}, + &model.Conversation{}, &model.Message{}, + &AssignmentPolicy{}, &InboxAssignmentPolicy{}, + &model.AgentCapacityPolicy{}, &model.InboxCapacityLimit{}, + )) +} + func setupFullAADB_Cov9(t *testing.T) (*gorm.DB, *redis.Client) { t.Helper() db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared&_busy_timeout=5000", strings.ReplaceAll(t.Name(), "/", "_"))), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)}) @@ -26,13 +40,49 @@ func setupFullAADB_Cov9(t *testing.T) (*gorm.DB, *redis.Client) { sqlDB, err := db.DB() require.NoError(t, err) t.Cleanup(func() { _ = sqlDB.Close() }) - require.NoError(t, db.AutoMigrate( - &model.Account{}, &model.User{}, &model.AccountUser{}, - &model.Inbox{}, &model.InboxMember{}, &model.Contact{}, &model.ContactInbox{}, - &model.Conversation{}, &model.Message{}, - &AssignmentPolicy{}, &InboxAssignmentPolicy{}, - &model.AgentCapacityPolicy{}, &model.InboxCapacityLimit{}, - )) + migrateFullAADB_Cov9(t, db) + mr := miniredis.RunT(t) + rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()}) + t.Cleanup(func() { _ = rdb.Close() }) + return db, rdb +} + +func setupFullAAPostgresDB_Cov9(t *testing.T) (*gorm.DB, *redis.Client) { + t.Helper() + dsn := os.Getenv("GOCHAT_TEST_DB_URL") + if dsn == "" { + dsn = "postgres://postgres:postgres@localhost:5432/gochat_test?sslmode=disable" + } + admin, err := gorm.Open(postgres.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)}) + require.NoError(t, err) + adminDB, err := admin.DB() + require.NoError(t, err) + schema := fmt.Sprintf("autoassignment_%d", time.Now().UnixNano()) + require.NoError(t, admin.Exec("CREATE SCHEMA "+schema).Error) + t.Cleanup(func() { + _ = admin.Exec("DROP SCHEMA " + schema + " CASCADE").Error + _ = adminDB.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) + sqlDB, err := db.DB() + require.NoError(t, err) + t.Cleanup(func() { _ = sqlDB.Close() }) + migrateFullAADB_Cov9(t, db) mr := miniredis.RunT(t) rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()}) t.Cleanup(func() { _ = rdb.Close() }) @@ -168,6 +218,19 @@ func TestAssignmentServiceAutoAssignmentDoesNotOverwriteNewConversationState(t * func TestAssignmentServiceOnlyOneConcurrentWorkerWins(t *testing.T) { db, rdb := setupFullAADB_Cov9(t) + testAssignmentServiceOnlyOneConcurrentWorkerWins(t, db, rdb) +} + +func TestAssignmentServiceOnlyOneConcurrentWorkerWinsPostgres(t *testing.T) { + if os.Getenv("GOCHAT_TEST_DB") != "postgres" { + t.Skip("PostgreSQL concurrency test") + } + db, rdb := setupFullAAPostgresDB_Cov9(t) + testAssignmentServiceOnlyOneConcurrentWorkerWins(t, db, rdb) +} + +func testAssignmentServiceOnlyOneConcurrentWorkerWins(t *testing.T, db *gorm.DB, rdb *redis.Client) { + t.Helper() account, agent, inbox, conversation := seedAssignableConversation_Cov9(t, db) db = db.Session(&gorm.Session{SkipDefaultTransaction: true}) sqlDB, err := db.DB()