package api_test import ( "database/sql" "fmt" "net/url" "os" "strings" "testing" "git.ryuvia.com/niklas/terdut-server/internal/db" ) // Tests run against a real Postgres, because the server does. SQLite's // ":memory:" gave every test a private database for free; Postgres has no // equivalent, so isolation is bought with a schema per test. // // A schema rather than a database: CREATE DATABASE copies a template on disk and // costs a hundred milliseconds or so each time, while CREATE SCHEMA plus the one // baseline migration is a few, and the suite runs a few hundred of them. Each // test's pool is pinned to its own schema through search_path, so two tests // cannot see each other's rows even though they share a server. // // TERDUT_TEST_DSN must point at a database the test role may create schemas in: // // postgres://terdut:terdut@localhost:5432/terdut_test?sslmode=disable // // `make test-db` starts one locally; ci.yaml runs one as a service container. // An unset DSN fails rather than skips, deliberately — a suite that quietly // tests nothing is worse than one that does not run. const testDSNEnv = "TERDUT_TEST_DSN" // defaultTeam is the team migration 003 creates and the bootstrap user owns, as // a path segment. Every test that does not say otherwise works inside it. const defaultTeam = "1" var schemaSeq int // newTestDB returns a migrated database private to this test, and drops it // afterwards. func newTestDB(t *testing.T) *sql.DB { t.Helper() dsn := os.Getenv(testDSNEnv) if dsn == "" { t.Fatalf("%s is not set: these tests need Postgres.\n"+ "Run `make test-db` for a local one, then\n"+ " export %s=postgres://terdut:terdut@localhost:5432/terdut_test?sslmode=disable", testDSNEnv, testDSNEnv) } schemaSeq++ schema := fmt.Sprintf("test_%d_%d", os.Getpid(), schemaSeq) admin, err := sql.Open("pgx", dsn) if err != nil { t.Fatalf("connect to %s: %v", testDSNEnv, err) } defer admin.Close() if _, err := admin.Exec("CREATE SCHEMA " + schema); err != nil { t.Fatalf("create schema %s: %v", schema, err) } database, err := db.Open(withSearchPath(dsn, schema)) if err != nil { t.Fatalf("open db: %v", err) } if err := db.Migrate(database); err != nil { t.Fatalf("migrate: %v", err) } t.Cleanup(func() { database.Close() cleanup, err := sql.Open("pgx", dsn) if err != nil { return } defer cleanup.Close() if _, err := cleanup.Exec("DROP SCHEMA " + schema + " CASCADE"); err != nil { t.Logf("drop schema %s: %v", schema, err) } }) return database } // withSearchPath pins a DSN to one schema, so every connection the pool opens // lands there and nothing has to qualify a table name. // // Handles both DSN spellings: a postgres:// URL, and libpq's keyword/value form. func withSearchPath(dsn, schema string) string { opt := "-csearch_path=" + schema if strings.HasPrefix(dsn, "postgres://") || strings.HasPrefix(dsn, "postgresql://") { u, err := url.Parse(dsn) if err == nil { q := u.Query() q.Set("options", opt) u.RawQuery = q.Encode() return u.String() } } return dsn + " options='" + opt + "'" }