package testhelpers import ( "fmt" "os" "strings" "testing" "time" "gorm.io/driver/postgres" "gorm.io/driver/sqlite" "gorm.io/gorm" "gorm.io/gorm/logger" "github.com/gochat/gochat/internal/model" "github.com/gochat/gochat/internal/model/channel" ) // DSN returns a PostgreSQL connection string for testing. // Priority: GOCHAT_TEST_DB_URL env > default localhost PG. func testDSN() string { dsn := os.Getenv("GOCHAT_TEST_DB_URL") if dsn != "" { return dsn } return "host=localhost port=5432 user=postgres password=postgres dbname=gochat_test sslmode=disable" } // UsePostgres returns true when a PostgreSQL test database is reachable. // Tests that require PG-specific features (ILIKE, NULLS LAST, etc.) should // check this flag and skip gracefully when PG is unavailable. func UsePostgres() bool { return os.Getenv("GOCHAT_TEST_DB") != "sqlite" } // SetupTestDB creates an isolated test database. // When UsePostgres() is true, connects to the PG test instance (docker-compose.test.yml) // and creates a per-test isolated schema. Otherwise falls back to SQLite in-memory. // // The returned DB has all GoChat models auto-migrated. // Call t.Cleanup(func(){ ... }) for teardown if needed. func SetupTestDB(t *testing.T) *gorm.DB { t.Helper() if UsePostgres() { db := setupPostgresDB(t) autoMigrateAll(t, db) return db } return setupSQLiteDB(t) } // SetupIsolatedDB creates a truly isolated database per test. // For PG: creates a separate schema with random suffix. // For SQLite: creates a fresh in-memory database. func SetupIsolatedDB(t *testing.T) *gorm.DB { t.Helper() if UsePostgres() { db := setupPostgresIsolatedDB(t) autoMigrateAll(t, db) return db } return setupSQLiteDB(t) } func setupPostgresDB(t *testing.T) *gorm.DB { t.Helper() dsn := testDSN() db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{ Logger: logger.Default.LogMode(logger.Silent), PrepareStmt: true, DisableForeignKeyConstraintWhenMigrating: true, }) if err != nil { t.Fatalf("failed to connect to PostgreSQL test db: %v\nSet GOCHAT_TEST_DB=sqlite to fall back to SQLite", err) } sqlDB, err := db.DB() if err != nil { t.Fatalf("failed to get underlying sql.DB: %v", err) } sqlDB.SetMaxOpenConns(10) sqlDB.SetMaxIdleConns(5) sqlDB.SetConnMaxLifetime(5 * time.Minute) t.Cleanup(func() { // Clean all test data — drop and recreate tables for _, m := range allModels() { db.Exec(fmt.Sprintf("TRUNCATE TABLE %s CASCADE", tableName(m))) } sqlDB.Close() }) return db } func setupPostgresIsolatedDB(t *testing.T) *gorm.DB { t.Helper() schemaName := fmt.Sprintf("test_%s_%d", t.Name(), time.Now().UnixNano()) // PostgreSQL schema names can't contain / from subtests, sanitize schemaName = sanitizeSchemaName(schemaName) dsn := testDSN() db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{ Logger: logger.Default.LogMode(logger.Silent), PrepareStmt: true, DisableForeignKeyConstraintWhenMigrating: true, }) if err != nil { t.Fatalf("failed to connect to PostgreSQL test db: %v", err) } // Create isolated schema db.Exec(fmt.Sprintf("CREATE SCHEMA %s", schemaName)) db.Exec(fmt.Sprintf("SET search_path TO %s, public", schemaName)) t.Cleanup(func() { db.Exec(fmt.Sprintf("DROP SCHEMA %s CASCADE", schemaName)) sqlDB, _ := db.DB() sqlDB.Close() }) return db } func setupSQLiteDB(t *testing.T) *gorm.DB { t.Helper() db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{ Logger: logger.Default.LogMode(logger.Silent), }) if err != nil { t.Fatalf("failed to open SQLite in-memory db: %v", err) } autoMigrateAll(t, db) t.Cleanup(func() { sqlDB, _ := db.DB() sqlDB.Close() }) return db } func autoMigrateAll(t *testing.T, db *gorm.DB) { t.Helper() models := allModels() for _, m := range models { if err := db.AutoMigrate(m); err != nil { t.Fatalf("failed to auto-migrate %T: %v", m, err) } } } func allModels() []interface{} { return []interface{}{ // Core models &model.Account{}, &model.AccountUser{}, &model.User{}, &model.Contact{}, &model.ContactInbox{}, &model.Conversation{}, &model.Inbox{}, &model.InboxMember{}, &model.Message{}, &model.Attachment{}, &model.Notification{}, &model.NotificationPreference{}, &model.CustomRole{}, &model.PlatformApp{}, &model.DashboardApp{}, &model.ReportingEvent{}, &model.ReportingEventsRollup{}, // Captain (AI assistant) models &model.CaptainAssistant{}, &model.CaptainAssistantResponse{}, &model.CaptainCustomTool{}, &model.CaptainDocument{}, &model.CaptainInbox{}, &model.CaptainScenario{}, // Copilot models &model.CopilotMessage{}, &model.CopilotThread{}, // Channel models &channel.ChannelBase{}, &channel.ChannelFacebook{}, &channel.ChannelTelegram{}, &channel.ChannelWebWidget{}, &channel.ChannelWhatsApp{}, } } func tableName(m interface{}) string { // All GoChat models implement a TableName() method. // Use a type assertion via a local interface to call it directly — // avoids needing a live gorm.DB instance (which Statement.Parse requires). type tabler interface { TableName() string } if t, ok := m.(tabler); ok { return t.TableName() } // Fallback: pluralize the struct type name using GORM convention. // This should not normally be needed since all our models have TableName(). name := fmt.Sprintf("%T", m) // Strip pointer prefix and package path (e.g. "*model.Account" → "Account") if name[0] == '*' { name = name[1:] } if idx := strings.LastIndex(name, "."); idx >= 0 { name = name[idx+1:] } // Simple snake_case + pluralize approximation return toSnakeCase(name) + "s" } // toSnakeCase converts CamelCase to snake_case. func toSnakeCase(s string) string { var result strings.Builder for i, c := range s { if c >= 'A' && c <= 'Z' { if i > 0 { result.WriteRune('_') } result.WriteRune(c + ('a' - 'A')) } else { result.WriteRune(c) } } return result.String() } func sanitizeSchemaName(name string) string { // Replace non-alphanumeric chars with underscore result := make([]byte, 0, len(name)) for _, c := range name { if (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_' { result = append(result, byte(c)) } else { result = append(result, '_') } } // Ensure starts with letter if len(result) > 0 && result[0] >= '0' && result[0] <= '9' { result = append([]byte("t"), result...) } // Truncate to PostgreSQL max identifier length (63 chars) if len(result) > 63 { result = result[:63] } return string(result) }