From 1e7cd0934e51e0634db213c3e5db57ab491039ec Mon Sep 17 00:00:00 2001 From: LordMathis Date: Sun, 24 Nov 2024 00:17:08 +0100 Subject: [PATCH] Add migrations tests --- .vscode/settings.json | 4 +- server/internal/db/migrations.go | 3 + server/internal/db/migrations_test.go | 156 ++++++++++++++++++++++++++ server/internal/db/testing.go | 30 +++++ 4 files changed, 192 insertions(+), 1 deletion(-) create mode 100644 server/internal/db/migrations_test.go create mode 100644 server/internal/db/testing.go diff --git a/.vscode/settings.json b/.vscode/settings.json index 4d7334d..34f5162 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -14,6 +14,7 @@ "go.lintTool": "golangci-lint", "go.lintOnSave": "package", "go.formatTool": "goimports", + "go.testFlags": ["-tags=test"], "[go]": { "editor.formatOnSave": true, "editor.codeActionsOnSave": { @@ -23,6 +24,7 @@ }, "gopls": { "usePlaceholders": true, - "staticcheck": true + "staticcheck": true, + "buildFlags": ["-tags", "test"] } } diff --git a/server/internal/db/migrations.go b/server/internal/db/migrations.go index f59b20f..6ecb3fb 100644 --- a/server/internal/db/migrations.go +++ b/server/internal/db/migrations.go @@ -49,6 +49,9 @@ var migrations = []Migration{ { Version: 2, SQL: ` + -- Enable foreign key constraints + PRAGMA foreign_keys = ON; + -- Create sessions table for authentication CREATE TABLE IF NOT EXISTS sessions ( id TEXT PRIMARY KEY, diff --git a/server/internal/db/migrations_test.go b/server/internal/db/migrations_test.go new file mode 100644 index 0000000..6d1a5fb --- /dev/null +++ b/server/internal/db/migrations_test.go @@ -0,0 +1,156 @@ +package db_test + +import ( + "testing" + + "novamd/internal/db" + + _ "github.com/mattn/go-sqlite3" +) + +type mockSecrets struct{} + +func (m *mockSecrets) Encrypt(s string) (string, error) { return s, nil } +func (m *mockSecrets) Decrypt(s string) (string, error) { return s, nil } + +func TestMigrate(t *testing.T) { + database, err := db.NewTestDB(":memory:", &mockSecrets{}) + if err != nil { + t.Fatalf("failed to initialize database: %v", err) + } + defer database.Close() + + t.Run("migrations are applied in order", func(t *testing.T) { + if err := database.Migrate(); err != nil { + t.Fatalf("failed to run initial migrations: %v", err) + } + + // Check migration version + var version int + err := database.TestDB().QueryRow("SELECT MAX(version) FROM migrations").Scan(&version) + if err != nil { + t.Fatalf("failed to get migration version: %v", err) + } + + if version != 2 { // Current number of migrations in production code + t.Errorf("expected migration version 2, got %d", version) + } + + // Verify number of migration entries matches versions applied + var count int + err = database.TestDB().QueryRow("SELECT COUNT(*) FROM migrations").Scan(&count) + if err != nil { + t.Fatalf("failed to count migrations: %v", err) + } + + if count != 2 { + t.Errorf("expected 2 migration entries, got %d", count) + } + }) + + t.Run("migrations create expected schema", func(t *testing.T) { + // Verify tables exist + tables := []string{"users", "workspaces", "sessions", "system_settings", "migrations"} + for _, table := range tables { + if !tableExists(t, database, table) { + t.Errorf("table %q does not exist", table) + } + } + + // Verify indexes + indexes := []struct { + table string + name string + }{ + {"sessions", "idx_sessions_user_id"}, + {"sessions", "idx_sessions_expires_at"}, + {"sessions", "idx_sessions_refresh_token"}, + } + + for _, idx := range indexes { + if !indexExists(t, database, idx.table, idx.name) { + t.Errorf("index %q on table %q does not exist", idx.name, idx.table) + } + } + }) + + t.Run("migrations are idempotent", func(t *testing.T) { + // Run migrations again + if err := database.Migrate(); err != nil { + t.Fatalf("failed to re-run migrations: %v", err) + } + + // Verify migration count hasn't changed + var count int + err = database.TestDB().QueryRow("SELECT COUNT(*) FROM migrations").Scan(&count) + if err != nil { + t.Fatalf("failed to count migrations: %v", err) + } + + if count != 2 { + t.Errorf("expected 2 migration entries, got %d", count) + } + }) + + t.Run("rollback on migration failure", func(t *testing.T) { + // Create a test table that would conflict with a failing migration + _, err := database.TestDB().Exec("CREATE TABLE test_rollback (id INTEGER PRIMARY KEY)") + if err != nil { + t.Fatalf("failed to create test table: %v", err) + } + + // Start transaction + tx, err := database.Begin() + if err != nil { + t.Fatalf("failed to start transaction: %v", err) + } + + // Try operations that should fail and rollback + _, err = tx.Exec(` + CREATE TABLE test_rollback (id INTEGER PRIMARY KEY); + INSERT INTO nonexistent_table VALUES (1); + `) + if err == nil { + tx.Rollback() + t.Fatal("expected migration to fail") + } + tx.Rollback() + + // Verify the migration version hasn't changed + var version int + err = database.TestDB().QueryRow("SELECT MAX(version) FROM migrations").Scan(&version) + if err != nil { + t.Fatalf("failed to get migration version: %v", err) + } + + if version != 2 { + t.Errorf("expected migration version to remain at 2, got %d", version) + } + }) +} + +func tableExists(t *testing.T, database db.TestDatabase, tableName string) bool { + t.Helper() + + var name string + err := database.TestDB().QueryRow(` + SELECT name FROM sqlite_master + WHERE type='table' AND name=?`, + tableName, + ).Scan(&name) + + return err == nil +} + +func indexExists(t *testing.T, database db.TestDatabase, tableName, indexName string) bool { + t.Helper() + + var name string + err := database.TestDB().QueryRow(` + SELECT name FROM sqlite_master + WHERE type='index' AND tbl_name=? AND name=?`, + tableName, indexName, + ).Scan(&name) + + return err == nil +} diff --git a/server/internal/db/testing.go b/server/internal/db/testing.go new file mode 100644 index 0000000..41a63d0 --- /dev/null +++ b/server/internal/db/testing.go @@ -0,0 +1,30 @@ +//go:build test + +package db + +import ( + "database/sql" + "novamd/internal/secrets" +) + +type TestDatabase interface { + Database + TestDB() *sql.DB +} + +func NewTestDB(dbPath string, secretsService secrets.Service) (TestDatabase, error) { + db, err := Init(dbPath, secretsService) + if err != nil { + return nil, err + } + + return &testDatabase{db.(*database)}, nil +} + +type testDatabase struct { + *database +} + +func (td *testDatabase) TestDB() *sql.DB { + return td.DB +}