685 lines
25 KiB
Go
685 lines
25 KiB
Go
package database
|
|
|
|
import (
|
|
"errors"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"infinite-canvas/backend/internal/model"
|
|
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
func TestCurrentSchemaVersionMatchesMigrationPlan(t *testing.T) {
|
|
if len(schemaMigrations) == 0 {
|
|
t.Fatal("migration plan is empty")
|
|
}
|
|
latest := schemaMigrations[len(schemaMigrations)-1].version
|
|
if CurrentSchemaVersion != latest {
|
|
t.Fatalf("supported schema version %d does not match latest migration %d", CurrentSchemaVersion, latest)
|
|
}
|
|
}
|
|
|
|
func TestMigrateSchemaRecordsAndValidatesVersion(t *testing.T) {
|
|
db, err := Open(Config{Driver: "sqlite", DSN: "file:migration-version?mode=memory&cache=shared"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := MigrateSchema(db); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
status, err := ReadSchemaStatus(db)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !status.Ready || status.Current != CurrentSchemaVersion {
|
|
t.Fatalf("unexpected schema status: %#v", status)
|
|
}
|
|
if !db.Migrator().HasIndex(&schemaMigration{}, "idx_schema_migrations_applied_at") {
|
|
t.Fatal("schema migration v2 did not create the applied_at index")
|
|
}
|
|
if !db.Migrator().HasIndex(&model.ProjectAssetCandidate{}, "idx_project_asset_candidates_pending_identity") {
|
|
t.Fatal("schema migration v3 did not create candidate identity index")
|
|
}
|
|
if !db.Migrator().HasTable(&model.AgentProfile{}) || !db.Migrator().HasIndex(&model.AgentProfile{}, "idx_agent_profiles_scope") {
|
|
t.Fatal("schema migration v15 did not create scoped Agent profiles")
|
|
}
|
|
if !db.Migrator().HasTable(&model.AgentLesson{}) || !db.Migrator().HasIndex(&model.AgentLesson{}, "idx_agent_lessons_status") {
|
|
t.Fatal("schema migration v16 did not create Agent lessons")
|
|
}
|
|
if !db.Migrator().HasIndex(&model.AgentLesson{}, "idx_agent_lessons_author_status") {
|
|
t.Fatal("schema migration v17 did not create owner status index")
|
|
}
|
|
if !db.Migrator().HasTable(&model.AgentMemorySetting{}) {
|
|
t.Fatal("schema migration v18 did not create agent memory settings")
|
|
}
|
|
if !db.Migrator().HasColumn(&model.PaymentProviderConfig{}, "plugin_version") || !db.Migrator().HasColumn(&model.PaymentOrder{}, "plugin_version") {
|
|
t.Fatal("schema migration v19 did not add payment plugin version columns")
|
|
}
|
|
if !db.Migrator().HasTable(&model.BannerAnnouncement{}) {
|
|
t.Fatal("schema migration v20 did not create banner announcements")
|
|
}
|
|
if !db.Migrator().HasColumn(&model.BannerAnnouncement{}, "title_runs") {
|
|
t.Fatal("schema migration v21 did not create banner announcements title_runs")
|
|
}
|
|
if !db.Migrator().HasColumn(&model.BannerAnnouncement{}, "notice_type") {
|
|
t.Fatal("schema migration v22 did not create banner announcements notice_type")
|
|
}
|
|
if err := MigrateSchema(db); err != nil {
|
|
t.Fatalf("migration should be idempotent: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestMigrateSchemaV15UpgradesExistingDatabase(t *testing.T) {
|
|
db, err := Open(Config{Driver: "sqlite", DSN: "file:migration-agent-profiles-v15?mode=memory&cache=shared"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := MigrateSchema(db); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := db.Migrator().DropTable(&model.AgentProfile{}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := db.Where("version = ?", 15).Delete(&schemaMigration{}).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := MigrateSchema(db); err != nil {
|
|
t.Fatalf("upgrade from v14: %v", err)
|
|
}
|
|
if !db.Migrator().HasTable(&model.AgentProfile{}) || !db.Migrator().HasIndex(&model.AgentProfile{}, "idx_agent_profiles_scope") {
|
|
t.Fatal("v15 upgrade did not install Agent profile table and scope index")
|
|
}
|
|
status, err := ReadSchemaStatus(db)
|
|
if err != nil || !status.Ready || status.Current != CurrentSchemaVersion {
|
|
t.Fatalf("unexpected upgraded schema status: %+v, %v", status, err)
|
|
}
|
|
}
|
|
|
|
func TestMigrateSchemaV16UpgradesExistingDatabase(t *testing.T) {
|
|
db, err := Open(Config{Driver: "sqlite", DSN: "file:migration-agent-lessons-v16?mode=memory&cache=shared"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := MigrateSchema(db); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := db.Migrator().DropTable(&model.AgentLesson{}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := db.Where("version = ?", 16).Delete(&schemaMigration{}).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := MigrateSchema(db); err != nil {
|
|
t.Fatalf("upgrade from v15: %v", err)
|
|
}
|
|
if !db.Migrator().HasTable(&model.AgentLesson{}) || !db.Migrator().HasIndex(&model.AgentLesson{}, "idx_agent_lessons_status") {
|
|
t.Fatal("v16 upgrade did not install Agent lesson table and status index")
|
|
}
|
|
status, err := ReadSchemaStatus(db)
|
|
if err != nil || !status.Ready || status.Current != CurrentSchemaVersion {
|
|
t.Fatalf("unexpected upgraded schema status: %+v, %v", status, err)
|
|
}
|
|
}
|
|
|
|
func TestMigrateSchemaV17UpgradesExistingDatabase(t *testing.T) {
|
|
db, err := Open(Config{Driver: "sqlite", DSN: "file:migration-agent-lessons-v17?mode=memory&cache=shared"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := MigrateSchema(db); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := db.Migrator().DropIndex(&model.AgentLesson{}, "idx_agent_lessons_author_status"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := db.Where("version = ?", 17).Delete(&schemaMigration{}).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := MigrateSchema(db); err != nil {
|
|
t.Fatalf("upgrade from v16: %v", err)
|
|
}
|
|
if !db.Migrator().HasIndex(&model.AgentLesson{}, "idx_agent_lessons_author_status") {
|
|
t.Fatal("v17 upgrade did not install owner status index")
|
|
}
|
|
status, err := ReadSchemaStatus(db)
|
|
if err != nil || !status.Ready || status.Current != CurrentSchemaVersion {
|
|
t.Fatalf("unexpected upgraded schema status: %+v, %v", status, err)
|
|
}
|
|
}
|
|
|
|
func TestMigrateSchemaV18UpgradesExistingDatabase(t *testing.T) {
|
|
db, err := Open(Config{Driver: "sqlite", DSN: "file:migration-agent-memory-settings-v18?mode=memory&cache=shared"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := MigrateSchema(db); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := db.Migrator().DropTable(&model.AgentMemorySetting{}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := db.Where("version = ?", 18).Delete(&schemaMigration{}).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := MigrateSchema(db); err != nil {
|
|
t.Fatalf("upgrade from v17: %v", err)
|
|
}
|
|
if !db.Migrator().HasTable(&model.AgentMemorySetting{}) {
|
|
t.Fatal("v18 upgrade did not install agent memory settings")
|
|
}
|
|
status, err := ReadSchemaStatus(db)
|
|
if err != nil || !status.Ready || status.Current != CurrentSchemaVersion {
|
|
t.Fatalf("unexpected upgraded schema status: %+v, %v", status, err)
|
|
}
|
|
}
|
|
|
|
func TestMigrateSchemaV19AddsPaymentPluginVersion(t *testing.T) {
|
|
db, err := Open(Config{Driver: "sqlite", DSN: "file:migration-payment-plugin-version-v19?mode=memory&cache=shared"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := MigrateSchema(db); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := db.Migrator().DropColumn(&model.PaymentProviderConfig{}, "PluginVersion"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := db.Migrator().DropColumn(&model.PaymentOrder{}, "PluginVersion"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := db.Where("version = ?", 19).Delete(&schemaMigration{}).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := MigrateSchema(db); err != nil {
|
|
t.Fatalf("upgrade from v18: %v", err)
|
|
}
|
|
if !db.Migrator().HasColumn(&model.PaymentProviderConfig{}, "plugin_version") {
|
|
t.Fatal("v19 upgrade did not add payment_provider_configs.plugin_version")
|
|
}
|
|
if !db.Migrator().HasColumn(&model.PaymentOrder{}, "plugin_version") {
|
|
t.Fatal("v19 upgrade did not add payment_orders.plugin_version")
|
|
}
|
|
status, err := ReadSchemaStatus(db)
|
|
if err != nil || !status.Ready || status.Current != CurrentSchemaVersion {
|
|
t.Fatalf("unexpected upgraded schema status: %+v, %v", status, err)
|
|
}
|
|
}
|
|
|
|
func TestMigrateSchemaV20UpgradesExistingDatabaseWithBannerAnnouncements(t *testing.T) {
|
|
db, err := Open(Config{Driver: "sqlite", DSN: "file:migration-banner-announcements-v20?mode=memory&cache=shared"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := MigrateSchema(db); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !db.Migrator().HasTable(&model.BannerAnnouncement{}) {
|
|
t.Fatal("v20 migration did not create banner_announcements table")
|
|
}
|
|
// 模拟旧库升级:删表 + 删除 v20 记录,重跑迁移应能重建。
|
|
if err := db.Migrator().DropTable(&model.BannerAnnouncement{}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := db.Where("version = ?", 20).Delete(&schemaMigration{}).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := MigrateSchema(db); err != nil {
|
|
t.Fatalf("upgrade from v19: %v", err)
|
|
}
|
|
if !db.Migrator().HasTable(&model.BannerAnnouncement{}) {
|
|
t.Fatal("v20 upgrade did not reinstall banner_announcements table")
|
|
}
|
|
status, err := ReadSchemaStatus(db)
|
|
if err != nil || !status.Ready || status.Current != CurrentSchemaVersion {
|
|
t.Fatalf("unexpected upgraded schema status: %+v, %v", status, err)
|
|
}
|
|
}
|
|
|
|
func TestMigrateSchemaV21AddsBannerAnnouncementTitleRuns(t *testing.T) {
|
|
db, err := Open(Config{Driver: "sqlite", DSN: "file:migration-banner-title-runs-v21?mode=memory&cache=shared"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := MigrateSchema(db); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !db.Migrator().HasColumn(&model.BannerAnnouncement{}, "title_runs") {
|
|
t.Fatal("v21 migration did not add banner_announcements.title_runs")
|
|
}
|
|
legacy := &model.BannerAnnouncement{ID: "legacy-banner", Title: "旧库通知", Status: "active"}
|
|
if err := db.Create(legacy).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := db.Migrator().DropColumn(&model.BannerAnnouncement{}, "title_runs"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := db.Where("version = ?", 21).Delete(&schemaMigration{}).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := MigrateSchema(db); err != nil {
|
|
t.Fatalf("upgrade from v20: %v", err)
|
|
}
|
|
if !db.Migrator().HasColumn(&model.BannerAnnouncement{}, "title_runs") {
|
|
t.Fatal("v21 upgrade did not restore banner_announcements.title_runs")
|
|
}
|
|
var stored model.BannerAnnouncement
|
|
if err := db.First(&stored, "id = ?", "legacy-banner").Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if stored.Title != "旧库通知" {
|
|
t.Fatalf("legacy banner lost during upgrade: %+v", stored)
|
|
}
|
|
status, err := ReadSchemaStatus(db)
|
|
if err != nil || !status.Ready || status.Current != CurrentSchemaVersion {
|
|
t.Fatalf("unexpected upgraded schema status: %+v, %v", status, err)
|
|
}
|
|
}
|
|
|
|
func TestMigrateSchemaV22AddsBannerAnnouncementNoticeType(t *testing.T) {
|
|
db, err := Open(Config{Driver: "sqlite", DSN: "file:migration-banner-notice-type-v22?mode=memory&cache=shared"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := MigrateSchema(db); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !db.Migrator().HasColumn(&model.BannerAnnouncement{}, "notice_type") {
|
|
t.Fatal("v22 migration did not add banner_announcements.notice_type")
|
|
}
|
|
legacy := &model.BannerAnnouncement{ID: "legacy-banner-v21", Title: "旧库通知", TitleRunsJSON: `[{"text":"旧库通知"}]`, Status: "active"}
|
|
if err := db.Create(legacy).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := db.Migrator().DropColumn(&model.BannerAnnouncement{}, "notice_type"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := db.Where("version = ?", 22).Delete(&schemaMigration{}).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := MigrateSchema(db); err != nil {
|
|
t.Fatalf("upgrade from v21: %v", err)
|
|
}
|
|
if !db.Migrator().HasColumn(&model.BannerAnnouncement{}, "notice_type") {
|
|
t.Fatal("v22 upgrade did not restore banner_announcements.notice_type")
|
|
}
|
|
var stored model.BannerAnnouncement
|
|
if err := db.First(&stored, "id = ?", "legacy-banner-v21").Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if stored.Title != "旧库通知" || stored.TitleRunsJSON != `[{"text":"旧库通知"}]` {
|
|
t.Fatalf("legacy banner lost during upgrade: %+v", stored)
|
|
}
|
|
status, err := ReadSchemaStatus(db)
|
|
if err != nil || !status.Ready || status.Current != CurrentSchemaVersion {
|
|
t.Fatalf("unexpected upgraded schema status: %+v, %v", status, err)
|
|
}
|
|
}
|
|
|
|
func TestMigrateSchemaV23BackfillsCanvasRevisions(t *testing.T) {
|
|
db, err := Open(Config{Driver: "sqlite", DSN: "file:migration-canvas-revisions-v23?mode=memory&cache=shared"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := MigrateSchema(db); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := db.Migrator().DropTable(&model.CanvasSnapshotResource{}, &model.CanvasSnapshot{}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := db.Migrator().DropColumn(&model.CanvasProject{}, "Revision"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := db.Exec(`INSERT INTO canvas_projects (id, user_id, title, payload_json) VALUES ('legacy', 'owner', 'Existing canvas', '{"nodes":[]}')`).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := db.Where("version = ?", 23).Delete(&schemaMigration{}).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := MigrateSchema(db); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var project model.CanvasProject
|
|
if err := db.First(&project, "id = ?", "legacy").Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if project.Revision != 1 || project.PayloadJSON != `{"nodes":[]}` {
|
|
t.Fatalf("legacy canvas changed: %+v", project)
|
|
}
|
|
if !db.Migrator().HasTable(&model.CanvasSnapshot{}) || !db.Migrator().HasTable(&model.CanvasSnapshotResource{}) {
|
|
t.Fatal("history tables missing")
|
|
}
|
|
if err := MigrateSchema(db); err != nil {
|
|
t.Fatalf("migration not idempotent: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestMigrateSchemaV8AllowsReusingArchivedLogicalModelCode(t *testing.T) {
|
|
db, err := Open(Config{Driver: "sqlite", DSN: "file:migration-logical-model-active-code?mode=memory&cache=shared"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := db.Exec(`CREATE TABLE logical_models (id text PRIMARY KEY, code text NOT NULL, archived_at datetime)`).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := db.Exec(`CREATE UNIQUE INDEX idx_logical_models_code ON logical_models(code)`).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := db.Exec(`INSERT INTO logical_models(id, code, archived_at) VALUES ('archived', 'gpt-image-2', CURRENT_TIMESTAMP)`).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := migrateSchemaV8(db); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := db.Exec(`INSERT INTO logical_models(id, code, archived_at) VALUES ('active', 'gpt-image-2', NULL)`).Error; err != nil {
|
|
t.Fatalf("reusing archived code after migration: %v", err)
|
|
}
|
|
if err := db.Exec(`INSERT INTO logical_models(id, code, archived_at) VALUES ('duplicate', 'gpt-image-2', NULL)`).Error; err == nil {
|
|
t.Fatal("active logical model code must remain unique")
|
|
}
|
|
}
|
|
|
|
func TestMigrateSchemaV12AddsAgentTokenChargeLimit(t *testing.T) {
|
|
db, err := Open(Config{Driver: "sqlite", DSN: "file:migration-agent-token-charge-limit?mode=memory&cache=shared"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := db.Exec(`CREATE TABLE billing_orders (id text PRIMARY KEY)`).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := migrateSchemaV12(db); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !db.Migrator().HasColumn(&model.BillingOrder{}, "ChargeLimitMicrocredits") {
|
|
t.Fatal("migration v12 did not add Agent token charge limit")
|
|
}
|
|
}
|
|
|
|
func TestMigrateSchemaRejectsChecksumMismatch(t *testing.T) {
|
|
db, err := Open(Config{Driver: "sqlite", DSN: "file:migration-checksum?mode=memory&cache=shared"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := MigrateSchema(db); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := db.Model(&schemaMigration{}).Where("version = ?", CurrentSchemaVersion).Update("checksum", "changed").Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := MigrateSchema(db); err == nil || !strings.Contains(err.Error(), "校验和不一致") {
|
|
t.Fatalf("expected checksum mismatch, got %v", err)
|
|
}
|
|
if err := RequireSchemaVersion(db); err == nil || !strings.Contains(err.Error(), "校验和不一致") {
|
|
t.Fatalf("schema verification must reject checksum mismatch, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestMigrateSchemaV3NormalizesLegacyAccessoryCategory(t *testing.T) {
|
|
db, err := Open(Config{Driver: "sqlite", DSN: "file:migration-asset-taxonomy?mode=memory&cache=shared"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := db.AutoMigrate(&model.Asset{}, &model.ProjectAssetCandidate{}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
asset := model.Asset{ID: "asset-1", UserID: "user-1", Kind: "image", Category: model.AssetCategory("accessory"), Title: "旧配饰"}
|
|
candidate := model.ProjectAssetCandidate{ID: "candidate-1", ProjectID: "project-1", Name: "旧配饰候选", Category: model.AssetCategory("accessory"), Status: "pending_confirmation"}
|
|
if err := db.Create(&asset).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := db.Create(&candidate).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := migrateSchemaV3(db); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := db.First(&asset, "id = ?", asset.ID).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := db.First(&candidate, "id = ?", candidate.ID).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if asset.Category != model.AssetCategoryProp || candidate.Category != model.AssetCategoryProp {
|
|
t.Fatalf("legacy accessory categories = %q/%q, want prop/prop", asset.Category, candidate.Category)
|
|
}
|
|
if candidate.NameKey != model.AssetCandidateNameKey(candidate.Name) {
|
|
t.Fatalf("candidate name key = %q", candidate.NameKey)
|
|
}
|
|
}
|
|
|
|
func TestMigrateSchemaV4AddsResourceUploadKeyToExistingSchema(t *testing.T) {
|
|
db, err := Open(Config{Driver: "sqlite", DSN: "file:migration-resource-upload-key?mode=memory&cache=shared"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := db.Exec(`CREATE TABLE resources (id TEXT PRIMARY KEY, user_id TEXT NOT NULL)`).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := db.AutoMigrate(&model.ModelChannel{}, &model.ChannelModel{}, &model.ChannelModelPriceTier{}, &model.BillingOrder{}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := db.AutoMigrate(&schemaMigration{}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, item := range schemaMigrations[:3] {
|
|
if err := db.Create(&schemaMigration{Version: item.version, Name: item.name, Checksum: item.checksum, AppliedAt: time.Now().UTC()}).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
if err := MigrateSchema(db); err != nil {
|
|
t.Fatalf("migrate existing schema: %v", err)
|
|
}
|
|
if !db.Migrator().HasColumn(&model.Resource{}, "upload_key") {
|
|
t.Fatal("resource upload_key column was not added")
|
|
}
|
|
if !db.Migrator().HasIndex(&model.Resource{}, "idx_resources_user_upload_key") {
|
|
t.Fatal("resource upload key index was not added")
|
|
}
|
|
var status SchemaStatus
|
|
status, err = ReadSchemaStatus(db)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !status.Ready || status.Current != CurrentSchemaVersion {
|
|
t.Fatalf("unexpected schema status: %#v", status)
|
|
}
|
|
|
|
firstKey := "same-upload"
|
|
if err := db.Exec(`INSERT INTO resources (id, user_id, upload_key) VALUES (?, ?, ?)`, "resource-1", "user-1", firstKey).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := db.Exec(`INSERT INTO resources (id, user_id, upload_key) VALUES (?, ?, ?)`, "resource-2", "user-1", firstKey).Error; err == nil {
|
|
t.Fatal("duplicate resource upload key should be rejected")
|
|
}
|
|
}
|
|
|
|
func TestMigrateSchemaRepairsLegacyAssetFoldersMigrationOrder(t *testing.T) {
|
|
db, err := Open(Config{Driver: "sqlite", DSN: "file:migration-legacy-v6-order?mode=memory&cache=shared"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := db.AutoMigrate(&model.Resource{}, &model.Asset{}, &model.AssetFolder{}, &model.ModelChannel{}, &model.ChannelModel{}, &model.ChannelModelPriceTier{}, &model.BillingOrder{}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := db.AutoMigrate(&schemaMigration{}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, item := range schemaMigrations[:5] {
|
|
if err := db.Create(&schemaMigration{Version: item.version, Name: item.name, Checksum: item.checksum, AppliedAt: time.Now().UTC()}).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
if err := db.Create(&schemaMigration{Version: 6, Name: "asset_library_folders", Checksum: assetLibraryFoldersChecksum, AppliedAt: time.Now().UTC()}).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if err := MigrateSchema(db); err != nil {
|
|
t.Fatalf("repair legacy migration order: %v", err)
|
|
}
|
|
if !db.Migrator().HasColumn(&model.Resource{}, "playback_status") || !db.Migrator().HasColumn(&model.Resource{}, "playback_object_key") || !db.Migrator().HasColumn(&model.Resource{}, "playback_error") {
|
|
t.Fatal("legacy database did not receive resource playback columns")
|
|
}
|
|
status, err := ReadSchemaStatus(db)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !status.Ready || status.Current != CurrentSchemaVersion {
|
|
t.Fatalf("unexpected repaired schema status: %#v", status)
|
|
}
|
|
var applied schemaMigration
|
|
if err := db.First(&applied, "version = ?", 6).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if applied.Name != "asset_library_folders" || applied.Checksum != assetLibraryFoldersChecksum {
|
|
t.Fatalf("historical migration 6 must be preserved: %#v", applied)
|
|
}
|
|
var playback schemaMigration
|
|
if err := db.First(&playback, "version = ?", 7).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if playback.Name != "resource_playback_variant" || playback.Checksum != resourcePlaybackChecksum {
|
|
t.Fatalf("migration 7 must supply playback schema: %#v", playback)
|
|
}
|
|
}
|
|
|
|
func TestMigrateSchemaRollsBackFailedMigration(t *testing.T) {
|
|
db, err := Open(Config{Driver: "sqlite", DSN: "file:migration-rollback?mode=memory&cache=shared"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := MigrateSchema(db); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
original := schemaMigrations
|
|
schemaMigrations = append(append([]migration(nil), original...), migration{
|
|
version: CurrentSchemaVersion + 1,
|
|
name: "rollback_probe",
|
|
checksum: "sha256:rollback-probe",
|
|
apply: func(tx *gorm.DB) error {
|
|
if err := tx.Exec("CREATE TABLE migration_rollback_probe (id INTEGER PRIMARY KEY)").Error; err != nil {
|
|
return err
|
|
}
|
|
return errors.New("forced migration failure")
|
|
},
|
|
})
|
|
t.Cleanup(func() { schemaMigrations = original })
|
|
|
|
if err := MigrateSchema(db); err == nil || !strings.Contains(err.Error(), "forced migration failure") {
|
|
t.Fatalf("expected forced migration failure, got %v", err)
|
|
}
|
|
if db.Migrator().HasTable("migration_rollback_probe") {
|
|
t.Fatal("failed migration left a partial table behind")
|
|
}
|
|
var count int64
|
|
if err := db.Model(&schemaMigration{}).Where("version = ?", CurrentSchemaVersion+1).Count(&count).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if count != 0 {
|
|
t.Fatalf("failed migration was recorded: %d", count)
|
|
}
|
|
}
|
|
|
|
func TestRequireSchemaVersionRejectsUninitializedDatabase(t *testing.T) {
|
|
db, err := Open(Config{Driver: "sqlite", DSN: "file:migration-uninitialized?mode=memory&cache=shared"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := RequireSchemaVersion(db); err == nil || !strings.Contains(err.Error(), "请先执行 migrate-schema up") {
|
|
t.Fatalf("expected missing migration error, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestMigrateSchemaV13AddsCloudAgentCanvasMutation(t *testing.T) {
|
|
db, err := Open(Config{Driver: "sqlite", DSN: "file:migration-cloud-agent-canvas-mutation?mode=memory&cache=shared"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := MigrateSchema(db); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !db.Migrator().HasTable(&model.CloudAgentCanvasMutation{}) {
|
|
t.Fatal("migration v13 did not create cloud agent canvas mutation table")
|
|
}
|
|
for _, field := range []string{"RunID", "BeforeSnapshotHash", "AfterSnapshotHash", "BeforeJSON", "HasSubmittedTask", "Status"} {
|
|
if !db.Migrator().HasColumn(&model.CloudAgentCanvasMutation{}, field) {
|
|
t.Fatalf("migration v13 did not add %s", field)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestMigrateSchemaV30AddsBuiltinTools(t *testing.T) {
|
|
db, err := Open(Config{Driver: "sqlite", DSN: "file:migration-builtin-tools-v30?mode=memory&cache=shared"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := MigrateSchema(db); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !db.Migrator().HasTable(&model.Tool{}) {
|
|
t.Fatal("migration v30 did not create tools table")
|
|
}
|
|
}
|
|
|
|
func TestMigrateSchemaV31AddsToolUserActions(t *testing.T) {
|
|
db, err := Open(Config{Driver: "sqlite", DSN: "file:migration-tool-user-actions-v31?mode=memory&cache=shared"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := MigrateSchema(db); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !db.Migrator().HasTable(&model.ToolFavorite{}) {
|
|
t.Fatal("migration v31 did not create tool_favorites table")
|
|
}
|
|
}
|
|
|
|
func TestToolsUpgradeFromMain29PreservesMigrationChecksums(t *testing.T) {
|
|
db, err := Open(Config{Driver: "sqlite", DSN: "file:tools-main29-upgrade?mode=memory&cache=shared"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := db.AutoMigrate(&schemaMigration{}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, item := range schemaMigrations {
|
|
if item.version > 29 {
|
|
break
|
|
}
|
|
if err := item.apply(db); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := db.Create(&schemaMigration{Version: item.version, Name: item.name, Checksum: item.checksum, AppliedAt: time.Now()}).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
// The initial migration uses today's model registry; restore the actual v29
|
|
// boundary so this test proves that v30/v31 create the new tables.
|
|
if err := db.Migrator().DropTable(&model.ToolFavorite{}, &model.Tool{}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if db.Migrator().HasTable(&model.Tool{}) || db.Migrator().HasTable(&model.ToolFavorite{}) {
|
|
t.Fatal("tool tables must not exist before upgrading v29")
|
|
}
|
|
for range 2 {
|
|
if err := MigrateSchema(db); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
for _, version := range []int64{28, 29} {
|
|
var record schemaMigration
|
|
if err := db.First(&record, version).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
expected := map[int64]string{28: "sha256:agent-execution-journal-v28", 29: "sha256:agent-resource-leases-v29-20260919"}
|
|
if record.Checksum != expected[version] {
|
|
t.Fatal("main checksum changed")
|
|
}
|
|
}
|
|
if !db.Migrator().HasTable(&model.Tool{}) || !db.Migrator().HasTable(&model.ToolFavorite{}) {
|
|
t.Fatal("tools tables missing")
|
|
}
|
|
}
|