package quizzes_test import ( "context" "database/sql" "fmt" "net/url" "os" "path/filepath" "strings" "testing" "time" _ "git.golem15.com/golem15/sm-quizzes-plugin" _ "git.golem15.com/golem15/sm-user-plugin" usermodels "git.golem15.com/golem15/sm-user-plugin/models" "git.golem15.com/golem15/summercms/modules/backpack" "git.golem15.com/golem15/summercms/modules/bouncer" "git.golem15.com/golem15/summercms/modules/compass" "git.golem15.com/golem15/summercms/modules/lagoon" "git.golem15.com/golem15/summercms/modules/party" _ "github.com/jackc/pgx/v5/stdlib" "github.com/testcontainers/testcontainers-go" "github.com/testcontainers/testcontainers-go/modules/postgres" "gorm.io/gorm" ) const testSecret = "quizzes-test-only-hs256-secret-with-32-bytes" // The Docker tests run against a throwaway testcontainers Postgres, never a // developer or shared database. Under -short the container is not started // and the tests skip. var ( pgContainer *postgres.PostgresContainer pgAdmin *sql.DB pgDSN string ) func TestMain(m *testing.M) { if !isShort() { ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) err := startPostgres(ctx) cancel() if err != nil { fmt.Fprintf(os.Stderr, "quizzes: testcontainers postgres: %v\n", err) stopPostgres() os.Exit(1) } } code := m.Run() stopPostgres() os.Exit(code) } func isShort() bool { for _, a := range os.Args { if a == "-test.short" || a == "-test.short=true" { return true } } return false } func startPostgres(ctx context.Context) error { ctr, err := postgres.Run(ctx, "postgres:16-alpine", postgres.WithDatabase("quizzes"), postgres.WithUsername("quizzes"), postgres.WithPassword("quizzes"), postgres.BasicWaitStrategies(), ) if err != nil { return err } pgContainer = ctr dsn, err := ctr.ConnectionString(ctx, "sslmode=disable") if err != nil { return err } db, err := sql.Open("pgx", dsn) if err != nil { return err } if err := db.PingContext(ctx); err != nil { _ = db.Close() return err } pgAdmin, pgDSN = db, dsn return nil } func stopPostgres() { if pgAdmin != nil { _ = pgAdmin.Close() } if pgContainer != nil { _ = testcontainers.TerminateContainer(pgContainer) } } // testDatabase creates a database for one test and drops it when the test // ends. func testDatabase(t *testing.T) *sql.DB { t.Helper() if testing.Short() { t.Skip("requires testcontainers postgres") } if pgAdmin == nil { t.Fatal("postgres unavailable: the container was not started") } name := "quizzes_" + strings.ToLower(strings.NewReplacer("/", "_", "-", "_", " ", "_").Replace(t.Name())) if len(name) > 60 { name = name[:60] } quoted := `"` + strings.ReplaceAll(name, `"`, `""`) + `"` if _, err := pgAdmin.ExecContext(t.Context(), `CREATE DATABASE `+quoted+` TEMPLATE template0 ENCODING 'UTF8'`); err != nil { t.Fatalf("create %s: %v", name, err) } u, err := url.Parse(pgDSN) if err != nil { t.Fatal(err) } u.Path = "/" + name db, err := sql.Open("pgx", u.String()) if err != nil { t.Fatal(err) } t.Cleanup(func() { _ = db.Close() _, _ = pgAdmin.ExecContext(context.Background(), `DROP DATABASE IF EXISTS `+quoted+` WITH (FORCE)`) }) return db } // testConfig is an application config with the HTTP limits, the user JWT // secret and the admin secret the activated plugins need. func testConfig(t *testing.T) *compass.Config { t.Helper() dir := t.TempDir() httpYAML := "body_limits:\n default_bytes: 1048576\n upload_bytes: 10485760\n" if err := os.WriteFile(filepath.Join(dir, "http.yaml"), []byte(httpYAML), 0o644); err != nil { t.Fatal(err) } cfg, err := compass.Open(compass.Options{Dir: dir, Env: "testing", Environ: []string{ "SUMMER_GOLEM15__USER__JWT__SECRET=" + testSecret, }}) if err != nil { t.Fatal(err) } if err := cfg.Set("admin.jwt.secret", "test-only-secret-with-at-least-32-bytes"); err != nil { t.Fatal(err) } return cfg } // activate boots golem15.user and golem15.quizzes the way the generated // main does. With gdb it publishes the database first, as tests and serve do. func activate(t *testing.T, sqlDB *sql.DB, gdb *gorm.DB) (*backpack.App, []party.Plugin) { t.Helper() app := backpack.New(testConfig(t)) if gdb != nil { if err := lagoon.Publish(app, sqlDB, gdb); err != nil { t.Fatalf("lagoon.Publish: %v", err) } } plugins, err := party.Activate(app, []string{"golem15.user", "golem15.quizzes"}) if err != nil { t.Fatalf("Activate: %v", err) } return app, plugins } // migrated activates both plugins on a fresh, migrated database. func migrated(t *testing.T) (*backpack.App, []party.Plugin, *gorm.DB) { t.Helper() sqlDB := testDatabase(t) gdb, err := lagoon.Use(t.Context(), sqlDB) if err != nil { t.Fatalf("lagoon.Use: %v", err) } app, plugins := activate(t, sqlDB, gdb) if err := lagoon.Migrate(gdb, plugins); err != nil { t.Fatalf("lagoon.Migrate: %v", err) } return app, plugins, gdb } // newUser inserts an active user and returns it with a bearer token. func newUser(t *testing.T, gdb *gorm.DB, email string) (*usermodels.User, string) { t.Helper() u := &usermodels.User{Email: email, Password: "x", IsActivated: true} if err := gdb.Create(u).Error; err != nil { t.Fatalf("create user %s: %v", email, err) } token, _, err := bouncer.Mint(testSecret, fmt.Sprint(u.ID), "http://127.0.0.1/_user/api/v1/login", time.Hour) if err != nil { t.Fatal(err) } return u, token }