This repository has been archived on 2026-04-03. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
core/server/handlers/upgrade_test.go
Jeffrey Smith 4e1f54b28d
All checks were successful
CI/CD / detect-changes (pull_request) Successful in 4s
CI/CD / test-frontend (pull_request) Successful in 5s
CI/CD / test-go-pg (pull_request) Successful in 2m35s
CI/CD / test-sqlite (pull_request) Successful in 2m51s
CI/CD / build-and-deploy (pull_request) Successful in 1m4s
Fix upgrade test bool literal for Postgres
Use `true` instead of `1` for BOOLEAN column insert in
TestUpgrade_SchemaMultiColumnAdd. Postgres BOOLEAN rejects integer
literals; `true` works on both PG and SQLite.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 20:42:36 +00:00

651 lines
19 KiB
Go

package handlers
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"net/http"
"testing"
"switchboard-core/database"
"switchboard-core/models"
"switchboard-core/store"
)
// ═══════════════════════════════════════════════
// Upgrade Tests — v0.5.5
//
// 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)
}
}