All checks were successful
Co-authored-by: Jeffrey Smith <jasafpro@gmail.com> Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
652 lines
19 KiB
Go
652 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
|
|
//
|
|
// 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",
|
|
"hooks": []string{"on_install"},
|
|
"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)
|
|
}
|
|
}
|