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.
260 lines
6.5 KiB
Go
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)
|
|
} |