package db_test import ( "context" "errors" "net/url" "path/filepath" "strings" "testing" "time" "go.uber.org/fx/fxtest" "sneak.berlin/go/neoirc/internal/config" "sneak.berlin/go/neoirc/internal/db" "sneak.berlin/go/neoirc/internal/globals" "sneak.berlin/go/neoirc/internal/logger" ) const wantBusyTimeoutMS = 5000 const ( // endlessQuery counts the rows of a recursive query that never // stops, so it runs until its context ends. endlessQuery = `WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x + 1 FROM n) SELECT COUNT(*) FROM n` endlessQueryTimeout = 100 * time.Millisecond ) // TestDefaultDBURLSetsWALAndBusyTimeout opens a file database // with the default URL's parameters the way the server does, // and checks the journal mode and busy timeout it ends up with. func TestDefaultDBURLSetsWALAndBusyTimeout(t *testing.T) { t.Parallel() _, params, _ := strings.Cut(config.DefaultDBURL, "?") dbURL := "file://" + filepath.Join(t.TempDir(), "state.db") + "?" + params lifecycle := fxtest.NewLifecycle(t) log, err := logger.New(lifecycle, logger.Params{ Globals: &globals.Globals{}, }) if err != nil { t.Fatal(err) } database, err := db.New(lifecycle, db.Params{ Logger: log, Config: &config.Config{DBURL: dbURL}, }) if err != nil { t.Fatal(err) } lifecycle.RequireStart() t.Cleanup(lifecycle.RequireStop) var journalMode string err = database.GetDB().QueryRowContext( t.Context(), "PRAGMA journal_mode", ).Scan(&journalMode) if err != nil { t.Fatal(err) } if journalMode != "wal" { t.Errorf("journal_mode = %q, want %q", journalMode, "wal") } var busyTimeout int err = database.GetDB().QueryRowContext( t.Context(), "PRAGMA busy_timeout", ).Scan(&busyTimeout) if err != nil { t.Fatal(err) } if busyTimeout != wantBusyTimeoutMS { t.Errorf( "busy_timeout = %d, want %d", busyTimeout, wantBusyTimeoutMS, ) } } // TestForeignKeysOnAfterCancelledQuery cancels a query while it // runs, which makes the pool close its only connection and open a // new one. A TEMP table lasts only as long as its connection, so // its absence shows the connection was replaced. The test then // checks that the file database keeps its tables and that the new // connection has foreign keys on. func TestForeignKeysOnAfterCancelledQuery(t *testing.T) { t.Parallel() _, defaultParams, _ := strings.Cut(config.DefaultDBURL, "?") urlParams := map[string]string{ "default parameters": "?" + defaultParams, "no parameters": "", } for name, params := range urlParams { t.Run(name, func(t *testing.T) { t.Parallel() dbURL := "file://" + filepath.Join(t.TempDir(), "state.db") + params database, err := startDatabase(t, dbURL) if err != nil { t.Fatal(err) } _, err = database.GetDB().ExecContext( t.Context(), "CREATE TEMP TABLE first_connection (x)", ) if err != nil { t.Fatal(err) } ctx, cancel := context.WithTimeout( t.Context(), endlessQueryTimeout, ) defer cancel() _, err = database.GetDB().ExecContext(ctx, endlessQuery) if !errors.Is(err, context.DeadlineExceeded) { t.Fatalf("endless query: err = %v", err) } tempTables := queryInt( t, database, "SELECT COUNT(*) FROM sqlite_temp_schema", ) if tempTables != 0 { t.Fatal("the pool kept its first connection") } migrations := queryInt( t, database, "SELECT COUNT(*) FROM schema_migrations", ) if migrations == 0 { t.Error("schema_migrations is empty") } foreignKeys := queryInt(t, database, "PRAGMA foreign_keys") if foreignKeys != 1 { t.Errorf("foreign_keys = %d, want 1", foreignKeys) } }) } } // TestDBURLSettingForeignKeysFailsStartup checks that the database // refuses to start when DBURL sets foreign_keys itself, however it // is spelled. func TestDBURLSettingForeignKeysFailsStartup(t *testing.T) { t.Parallel() pragmas := []string{ "foreign_keys(0)", "foreign_keys(off)", "foreign_keys=0", "FOREIGN_KEYS(FALSE)", " Foreign_Keys = 1", } for _, pragma := range pragmas { t.Run(pragma, func(t *testing.T) { t.Parallel() dbURL := "file://" + filepath.Join(t.TempDir(), "state.db") + "?_pragma=" + url.QueryEscape(pragma) _, err := startDatabase(t, dbURL) if err == nil { t.Fatal("the database started") } if !strings.Contains(err.Error(), "DBURL") { t.Errorf("error %q does not name DBURL", err) } }) } } // startDatabase starts a Database for dbURL the way the server does. // It returns the error that stopped it from starting, if any. func startDatabase(t *testing.T, dbURL string) (*db.Database, error) { t.Helper() lifecycle := fxtest.NewLifecycle(t) log, err := logger.New(lifecycle, logger.Params{ Globals: &globals.Globals{}, }) if err != nil { t.Fatal(err) } database, err := db.New(lifecycle, db.Params{ Logger: log, Config: &config.Config{DBURL: dbURL}, }) if err != nil { t.Fatal(err) } t.Cleanup(lifecycle.RequireStop) return database, lifecycle.Start(t.Context()) } // queryInt runs a query that returns a single number. func queryInt(t *testing.T, database *db.Database, query string) int { t.Helper() var result int err := database.GetDB().QueryRowContext(t.Context(), query).Scan(&result) if err != nil { t.Fatal(err) } return result }