package handlers import ( "context" "database/sql" "encoding/json" "fmt" "net/http" "testing" "switchboard-core/database" "switchboard-core/models" "switchboard-core/store" ) // ═══════════════════════════════════════════════ // Upgrade Tests // // Schema edge cases, settings migration, and // package compatibility across kernel upgrades. // ═══════════════════════════════════════════════ // ── Schema Edge Cases ───────────────────────── func TestUpgrade_SchemaAddIndex(t *testing.T) { stores := newTestStores(t) ctx := context.Background() // Install v1 with a table, no indexes seedPackage(t, stores, map[string]any{ "id": "idx-pkg", "title": "Index Test", "type": "surface", "version": "1.0.0", "db_tables": map[string]any{ "events": map[string]any{ "columns": map[string]any{"name": "text", "category": "text"}, }, }, }) tables, _ := ParseDBTables(map[string]any{ "db_tables": map[string]any{ "events": map[string]any{ "columns": map[string]any{"name": "text", "category": "text"}, }, }, }) CreateExtTables(ctx, database.TestDB, database.IsPostgres(), stores, "idx-pkg", tables) // Insert a row physical := extPhysicalTable("idx-pkg", "events") _, err := database.TestDB.ExecContext(ctx, fmt.Sprintf("INSERT INTO %s (id, name, category) VALUES ('row1', 'hello', 'test')", physical)) if err != nil { t.Fatal(err) } // Update to v2 with an index on category router := setupUpdateRouter(t, stores) pkg := buildPkgBytes(t, map[string]any{ "id": "idx-pkg", "title": "Index Test", "type": "surface", "version": "2.0.0", "db_tables": map[string]any{ "events": map[string]any{ "columns": map[string]any{"name": "text", "category": "text"}, "indexes": []any{[]any{"category"}}, }, }, }) w := doUpdate(router, "idx-pkg", pkg) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } // Verify data still intact var name string err = database.TestDB.QueryRowContext(ctx, fmt.Sprintf("SELECT name FROM %s WHERE id = 'row1'", physical)).Scan(&name) if err != nil { t.Fatal("data should survive index addition:", err) } if name != "hello" { t.Errorf("name = %q, want %q", name, "hello") } // Verify index exists by re-running the same update (idempotent) pkg2 := buildPkgBytes(t, map[string]any{ "id": "idx-pkg", "title": "Index Test", "type": "surface", "version": "3.0.0", "db_tables": map[string]any{ "events": map[string]any{ "columns": map[string]any{"name": "text", "category": "text"}, "indexes": []any{[]any{"category"}}, }, }, }) w2 := doUpdate(router, "idx-pkg", pkg2) if w2.Code != http.StatusOK { t.Fatalf("idempotent index re-apply failed: %d: %s", w2.Code, w2.Body.String()) } } func TestUpgrade_SchemaAddColumnIdempotent(t *testing.T) { stores := newTestStores(t) ctx := context.Background() // Install v1 with columns name + priority seedPackage(t, stores, map[string]any{ "id": "idem-pkg", "title": "Idempotent", "type": "surface", "version": "1.0.0", "db_tables": map[string]any{ "items": map[string]any{ "columns": map[string]any{"name": "text", "priority": "int"}, }, }, }) tables, _ := ParseDBTables(map[string]any{ "db_tables": map[string]any{ "items": map[string]any{ "columns": map[string]any{"name": "text", "priority": "int"}, }, }, }) CreateExtTables(ctx, database.TestDB, database.IsPostgres(), stores, "idem-pkg", tables) // Insert a row physical := extPhysicalTable("idem-pkg", "items") _, err := database.TestDB.ExecContext(ctx, fmt.Sprintf("INSERT INTO %s (id, name, priority) VALUES ('r1', 'test', 5)", physical)) if err != nil { t.Fatal(err) } // Update to v2 with same columns (no-op migration) router := setupUpdateRouter(t, stores) pkg := buildPkgBytes(t, map[string]any{ "id": "idem-pkg", "title": "Idempotent", "type": "surface", "version": "2.0.0", "db_tables": map[string]any{ "items": map[string]any{ "columns": map[string]any{"name": "text", "priority": "int"}, }, }, }) w := doUpdate(router, "idem-pkg", pkg) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } // Verify data intact var name string var priority int err = database.TestDB.QueryRowContext(ctx, fmt.Sprintf("SELECT name, priority FROM %s WHERE id = 'r1'", physical)).Scan(&name, &priority) if err != nil { t.Fatal(err) } if name != "test" || priority != 5 { t.Errorf("data changed: name=%q priority=%d", name, priority) } // Check response has no schema changes var resp map[string]any json.Unmarshal(w.Body.Bytes(), &resp) changes, _ := resp["changes"].([]any) if len(changes) != 0 { t.Errorf("expected no schema changes for identical columns, got %v", changes) } } func TestUpgrade_SchemaMultiColumnAdd(t *testing.T) { stores := newTestStores(t) ctx := context.Background() // Install v1 with one column seedPackage(t, stores, map[string]any{ "id": "multi-pkg", "title": "Multi", "type": "surface", "version": "1.0.0", "db_tables": map[string]any{ "records": map[string]any{ "columns": map[string]any{"name": "text"}, }, }, }) tables, _ := ParseDBTables(map[string]any{ "db_tables": map[string]any{ "records": map[string]any{ "columns": map[string]any{"name": "text"}, }, }, }) CreateExtTables(ctx, database.TestDB, database.IsPostgres(), stores, "multi-pkg", tables) // Insert a row physical := extPhysicalTable("multi-pkg", "records") _, err := database.TestDB.ExecContext(ctx, fmt.Sprintf("INSERT INTO %s (id, name) VALUES ('r1', 'hello')", physical)) if err != nil { t.Fatal(err) } // Update to v2 with three new columns router := setupUpdateRouter(t, stores) pkg := buildPkgBytes(t, map[string]any{ "id": "multi-pkg", "title": "Multi", "type": "surface", "version": "2.0.0", "db_tables": map[string]any{ "records": map[string]any{ "columns": map[string]any{ "name": "text", "priority": "int", "score": "real", "active": "bool", }, }, }, }) w := doUpdate(router, "multi-pkg", pkg) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } // Verify old row preserved with NULLs for new columns var name string var priority sql.NullInt64 var score sql.NullFloat64 var active sql.NullInt64 // bool is INTEGER on SQLite err = database.TestDB.QueryRowContext(ctx, fmt.Sprintf("SELECT name, priority, score, active FROM %s WHERE id = 'r1'", physical)). Scan(&name, &priority, &score, &active) if err != nil { t.Fatal("query failed after multi-column add:", err) } if name != "hello" { t.Errorf("name = %q, want %q", name, "hello") } if priority.Valid { t.Error("priority should be NULL for existing row") } if score.Valid { t.Error("score should be NULL for existing row") } // Verify new row can use all columns // Use 'true' for bool — works on both Postgres (BOOLEAN) and SQLite (INTEGER, coerced to 1) _, err = database.TestDB.ExecContext(ctx, fmt.Sprintf("INSERT INTO %s (id, name, priority, score, active) VALUES ('r2', 'new', 3, 4.5, true)", physical)) if err != nil { t.Fatal("insert with new columns failed:", err) } // Check response lists schema changes var resp map[string]any json.Unmarshal(w.Body.Bytes(), &resp) changes, _ := resp["changes"].([]any) if len(changes) < 3 { t.Errorf("expected at least 3 schema changes, got %d: %v", len(changes), changes) } } // ── Settings Migration ──────────────────────── func TestUpgrade_SettingsPreservedAcrossUpdate(t *testing.T) { stores := newTestStores(t) ctx := context.Background() // Seed a user and team (FK constraints require them) user := &models.User{ Username: "testuser", PasswordHash: "hash", IsActive: true, AuthSource: "builtin", } if err := stores.Users.Create(ctx, user); err != nil { t.Fatalf("seed user: %v", err) } userID := user.ID team := &models.Team{ Name: "Test Team", CreatedBy: userID, IsActive: true, } if err := stores.Teams.Create(ctx, team); err != nil { t.Fatalf("seed team: %v", err) } teamID := team.ID // Install with settings schema seedPackage(t, stores, map[string]any{ "id": "set-pkg", "title": "Settings", "type": "surface", "version": "1.0.0", "settings": []any{ map[string]any{"key": "theme", "type": "text", "default": "light"}, map[string]any{"key": "limit", "type": "int", "default": float64(10)}, }, }) // Set global settings globalSettings := json.RawMessage(`{"theme":"dark","limit":25}`) stores.Packages.SetPackageSettings(ctx, "set-pkg", globalSettings) // Set team settings teamSettings := json.RawMessage(`{"theme":"ocean"}`) if err := stores.Packages.SetTeamSettings(ctx, "set-pkg", teamID, teamSettings); err != nil { t.Fatalf("SetTeamSettings failed: %v", err) } // Set user settings if err := stores.Packages.SetUserSettings(ctx, &store.PackageUserSettings{ PackageID: "set-pkg", UserID: userID, Settings: json.RawMessage(`{"limit":50}`), IsEnabled: true, }); err != nil { t.Fatalf("SetUserSettings failed: %v", err) } // Update package to v2 with one new setting, one removed router := setupUpdateRouter(t, stores) pkg := buildPkgBytes(t, map[string]any{ "id": "set-pkg", "title": "Settings", "type": "surface", "version": "2.0.0", "settings": []any{ map[string]any{"key": "theme", "type": "text", "default": "light"}, // "limit" removed from schema map[string]any{"key": "font_size", "type": "int", "default": float64(14)}, }, }) w := doUpdate(router, "set-pkg", pkg) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } // Verify global settings: theme preserved, limit preserved (not deleted), font_size added raw, _ := stores.Packages.GetPackageSettings(ctx, "set-pkg") var global map[string]any json.Unmarshal(raw, &global) if global["theme"] != "dark" { t.Errorf("global theme = %v, want %q", global["theme"], "dark") } if global["limit"] != float64(25) { t.Errorf("global limit = %v, want %v (removed key should be preserved)", global["limit"], 25) } if global["font_size"] != float64(14) { t.Errorf("global font_size = %v, want %v (new key should get default)", global["font_size"], 14) } // Verify team settings: untouched by package update teamRaw, _ := stores.Packages.GetTeamSettings(ctx, "set-pkg", teamID) var teamSet map[string]any json.Unmarshal(teamRaw, &teamSet) if teamSet["theme"] != "ocean" { t.Errorf("team theme = %v, want %q (should survive package update)", teamSet["theme"], "ocean") } // Verify user settings: untouched by package update pus, _ := stores.Packages.GetUserSettings(ctx, "set-pkg", userID) if pus == nil { t.Fatal("user settings should survive package update") } var userSet map[string]any json.Unmarshal(pus.Settings, &userSet) if userSet["limit"] != float64(50) { t.Errorf("user limit = %v, want %v (should survive package update)", userSet["limit"], 50) } } func TestUpgrade_SettingsNewKeyGetsDefault(t *testing.T) { stores := newTestStores(t) ctx := context.Background() // Install with no settings seedPackage(t, stores, map[string]any{ "id": "newkey-pkg", "title": "NewKey", "type": "surface", "version": "1.0.0", }) // Update to v2 with settings router := setupUpdateRouter(t, stores) pkg := buildPkgBytes(t, map[string]any{ "id": "newkey-pkg", "title": "NewKey", "type": "surface", "version": "2.0.0", "settings": []any{ map[string]any{"key": "color", "type": "text", "default": "blue"}, map[string]any{"key": "count", "type": "int", "default": float64(5)}, }, }) w := doUpdate(router, "newkey-pkg", pkg) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } raw, _ := stores.Packages.GetPackageSettings(ctx, "newkey-pkg") var settings map[string]any json.Unmarshal(raw, &settings) if settings["color"] != "blue" { t.Errorf("color = %v, want %q", settings["color"], "blue") } if settings["count"] != float64(5) { t.Errorf("count = %v, want %v", settings["count"], 5) } } // ── Package Compatibility ───────────────────── func TestUpgrade_BundledSkipExistingOnRestart(t *testing.T) { stores := newTestStores(t) ctx := context.Background() bundledDir := t.TempDir() packagesDir := t.TempDir() // Build a bundled package at v1.0.0 buildTestPkg(t, bundledDir, map[string]any{ "id": "restart-pkg", "title": "Restart", "type": "surface", "version": "1.0.0", }) // First install InstallBundledPackages(bundledDir, packagesDir, "*", stores, nil) pkg, _ := stores.Packages.Get(ctx, "restart-pkg") if pkg == nil { t.Fatal("package should be installed on first run") } if pkg.Version != "1.0.0" { t.Errorf("version = %q, want %q", pkg.Version, "1.0.0") } // Simulate restart: bundled dir now has v2.0.0 buildTestPkg(t, bundledDir, map[string]any{ "id": "restart-pkg", "title": "Restart Updated", "type": "surface", "version": "2.0.0", }) // Second install — should skip because package exists InstallBundledPackages(bundledDir, packagesDir, "*", stores, nil) pkg, _ = stores.Packages.Get(ctx, "restart-pkg") if pkg.Version != "1.0.0" { t.Errorf("version = %q, want %q (should NOT be upgraded by re-run)", pkg.Version, "1.0.0") } if pkg.Title != "Restart" { t.Errorf("title = %q, want %q (should NOT be overwritten)", pkg.Title, "Restart") } } func TestUpgrade_PackageDormantOnUnmetRequires(t *testing.T) { stores := newTestStores(t) ctx := context.Background() bundledDir := t.TempDir() packagesDir := t.TempDir() // Package requires a capability that doesn't exist buildTestPkg(t, bundledDir, map[string]any{ "id": "future-pkg", "title": "Future", "type": "extension", "version": "1.0.0", "requires": []string{"kernel>=99.0.0"}, }) InstallBundledPackages(bundledDir, packagesDir, "*", stores, nil) pkg, _ := stores.Packages.Get(ctx, "future-pkg") if pkg == nil { t.Fatal("package should still be registered") } if pkg.Status != "dormant" { t.Errorf("status = %q, want %q", pkg.Status, "dormant") } if pkg.Enabled { t.Error("dormant package should not be enabled") } } func TestUpgrade_PackageRoutesAfterVersionBump(t *testing.T) { stores := newTestStores(t) ctx := context.Background() // Install a surface at v1 seedPackage(t, stores, map[string]any{ "id": "route-pkg", "title": "Route Test", "type": "surface", "version": "1.0.0", }) // Update to v2 router := setupUpdateRouter(t, stores) pkg := buildPkgBytes(t, map[string]any{ "id": "route-pkg", "title": "Route Test", "type": "surface", "version": "2.0.0", }) w := doUpdate(router, "route-pkg", pkg) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } // Verify package still enabled and type unchanged updated, _ := stores.Packages.Get(ctx, "route-pkg") if updated == nil { t.Fatal("package should exist after update") } if !updated.Enabled { t.Error("package should remain enabled after update") } if updated.Type != "surface" { t.Errorf("type = %q, want %q", updated.Type, "surface") } if updated.Version != "2.0.0" { t.Errorf("version = %q, want %q", updated.Version, "2.0.0") } } // ── MigrateExtTables direct tests ───────────── func TestUpgrade_MigrateExtTables_NewTable(t *testing.T) { stores := newTestStores(t) ctx := context.Background() // Register package with no tables initially seedPackage(t, stores, map[string]any{ "id": "migrate-new", "title": "Migrate", "type": "surface", "version": "1.0.0", }) // Migrate with a brand-new table newTables := map[string]TableDef{ "logs": { Columns: map[string]string{"message": "text", "level": "text"}, Indexes: [][]string{{"level"}}, }, } changes, err := MigrateExtTables(ctx, database.TestDB, database.IsPostgres(), stores, "migrate-new", newTables) if err != nil { t.Fatal(err) } if len(changes) == 0 { t.Error("expected at least 1 change for new table creation") } // Verify table works physical := extPhysicalTable("migrate-new", "logs") _, err = database.TestDB.ExecContext(ctx, fmt.Sprintf("INSERT INTO %s (id, message, level) VALUES ('1', 'test', 'info')", physical)) if err != nil { t.Fatal("new table should be usable:", err) } } func TestUpgrade_MigrateExtTables_AddColumnPreservesRows(t *testing.T) { stores := newTestStores(t) ctx := context.Background() // Create initial table with data seedPackage(t, stores, map[string]any{ "id": "migrate-col", "title": "Migrate", "type": "surface", "version": "1.0.0", "db_tables": map[string]any{ "records": map[string]any{ "columns": map[string]any{"title": "text"}, }, }, }) tables, _ := ParseDBTables(map[string]any{ "db_tables": map[string]any{ "records": map[string]any{ "columns": map[string]any{"title": "text"}, }, }, }) CreateExtTables(ctx, database.TestDB, database.IsPostgres(), stores, "migrate-col", tables) physical := extPhysicalTable("migrate-col", "records") _, err := database.TestDB.ExecContext(ctx, fmt.Sprintf("INSERT INTO %s (id, title) VALUES ('r1', 'original')", physical)) if err != nil { t.Fatal(err) } // Migrate adding a column newTables := map[string]TableDef{ "records": { Columns: map[string]string{"title": "text", "status": "text"}, }, } changes, err := MigrateExtTables(ctx, database.TestDB, database.IsPostgres(), stores, "migrate-col", newTables) if err != nil { t.Fatal(err) } found := false for _, c := range changes { if c == "added column 'status' to "+physical { found = true } } if !found { t.Errorf("expected change for 'status' column, got %v", changes) } // Verify old row intact var title string var status sql.NullString err = database.TestDB.QueryRowContext(ctx, fmt.Sprintf("SELECT title, status FROM %s WHERE id = 'r1'", physical)).Scan(&title, &status) if err != nil { t.Fatal(err) } if title != "original" { t.Errorf("title = %q, want %q", title, "original") } if status.Valid { t.Error("status should be NULL for existing row") } } func TestUpgrade_MigrateExtTables_IndexIdempotent(t *testing.T) { stores := newTestStores(t) ctx := context.Background() seedPackage(t, stores, map[string]any{ "id": "migrate-idx", "title": "Migrate", "type": "surface", "version": "1.0.0", "db_tables": map[string]any{ "items": map[string]any{ "columns": map[string]any{"name": "text", "category": "text"}, "indexes": []any{[]any{"category"}}, }, }, }) tables, _ := ParseDBTables(map[string]any{ "db_tables": map[string]any{ "items": map[string]any{ "columns": map[string]any{"name": "text", "category": "text"}, "indexes": []any{[]any{"category"}}, }, }, }) CreateExtTables(ctx, database.TestDB, database.IsPostgres(), stores, "migrate-idx", tables) // Migrate with same index — should not fail newTables := map[string]TableDef{ "items": { Columns: map[string]string{"name": "text", "category": "text"}, Indexes: [][]string{{"category"}}, }, } _, err := MigrateExtTables(ctx, database.TestDB, database.IsPostgres(), stores, "migrate-idx", newTables) if err != nil { t.Fatal("idempotent index migration should not fail:", err) } // Run again — still no error _, err = MigrateExtTables(ctx, database.TestDB, database.IsPostgres(), stores, "migrate-idx", newTables) if err != nil { t.Fatal("second idempotent index migration should not fail:", err) } }