Files
gochat/backend/tests/helpers/pg_helper.go
T
rogee aeddedf2a3 Reorganize repo: backend/, deploy/, docs/ layout + AGENTS.md
Restructure the monorepo into clear top-level directories:
- backend/: Go module root (cmd, internal, pkg, configs, migrations,
  docs/swagger, scripts, tests, go.mod, Makefile, .air.toml)
- deploy/: Docker (Dockerfile, docker-compose*), quickstart, fluentd
- docs/: project documentation + reports/ (moved from repo root)
- AGENTS.md: new AI coding-agent guide at repo root

Update all references to the new layout:
- Dockerfile: COPY backend/go.mod, COPY backend/ (context = repo root)
- docker-compose files: context ../.., dockerfile deploy/docker/Dockerfile,
  env_file ../../.env, volume mounts ../../backend:/app
- deploy/quickstart/compose.yaml: dockerfile deploy/docker/Dockerfile
- CI: working-directory: backend for go commands, file deploy/docker/Dockerfile,
  coverage path backend/coverage.out, health_check backend/scripts/
- backend/Makefile: docker target uses -f ../deploy/docker/Dockerfile ../
- README: architecture tree, quickstart, config paths updated

Move root stray scripts (rename_models.*, run_m11_tests.sh, verify_build.sh,
gorm_bool_main.go) to backend/scripts/legacy/. All moves via git mv to
preserve history. Build, vet, SQLite tests, and docker compose config verified.
2026-07-07 14:44:12 +08:00

260 lines
6.5 KiB
Go

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