Feat v0.5.5 upgrade testing (#35)
All checks were successful
All checks were successful
Co-authored-by: Jeffrey Smith <jasafpro@gmail.com> Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
This commit was merged in pull request #35.
This commit is contained in:
@@ -224,9 +224,13 @@ func installBundledPackage(pkgPath, packagesDir string, stores store.Stores, run
|
||||
// Use direct SQL with NULL granted_by to satisfy FK constraint on users(id).
|
||||
if len(declaredPerms) > 0 {
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
_, err := database.DB.ExecContext(ctx,
|
||||
`UPDATE extension_permissions SET granted = 1, granted_by = NULL, granted_at = ? WHERE package_id = ? AND granted = 0`,
|
||||
now, pkgID)
|
||||
var grantSQL string
|
||||
if database.IsPostgres() {
|
||||
grantSQL = `UPDATE extension_permissions SET granted = true, granted_by = NULL, granted_at = $1 WHERE package_id = $2 AND granted = false`
|
||||
} else {
|
||||
grantSQL = `UPDATE extension_permissions SET granted = 1, granted_by = NULL, granted_at = ? WHERE package_id = ? AND granted = 0`
|
||||
}
|
||||
_, err := database.DB.ExecContext(ctx, grantSQL, now, pkgID)
|
||||
if err != nil {
|
||||
log.Printf("[bundled] Failed to auto-grant permissions for %s: %v", pkgID, err)
|
||||
}
|
||||
|
||||
650
server/handlers/upgrade_test.go
Normal file
650
server/handlers/upgrade_test.go
Normal file
@@ -0,0 +1,650 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user