package repository import ( "database/sql" "fmt" "os" "strings" "testing" "cmautobuy/admin/config" "cmautobuy/admin/spec" ) // TestMySQLMigrate_真实MySQL8 只在显式提供隔离测试库时运行。 // 库名必须以 _test 结尾,防止测试清理误碰生产库。 func TestMySQLMigrate_真实MySQL8(t *testing.T) { if os.Getenv("CMAUTOBUY_MYSQL_TEST") != "1" { t.Skip("未启用真实 MySQL 8 集成测试") } cfg, err := config.LoadDatabaseFromEnv() if err != nil { t.Fatal(err) } if !strings.HasSuffix(cfg.Name, "_test") { t.Fatalf("拒绝清理非测试数据库 %q:库名必须以 _test 结尾", cfg.Name) } db, err := OpenMySQL(cfg) if err != nil { t.Fatal(err) } defer db.Close() cleanMySQLTestSchema(t, db) defer cleanMySQLTestSchema(t, db) if err := MigrateMySQL(db); err != nil { t.Fatalf("首次建立 MySQL schema 失败: %v", err) } if err := MigrateMySQL(db); err != nil { t.Fatalf("重复迁移应该无副作用: %v", err) } var version int if err := db.QueryRow(`SELECT MAX(version) FROM schema_migrations`).Scan(&version); err != nil { t.Fatal(err) } if version != mysqlSchemaVersion { t.Fatalf("schema 版本=%d,期望 %d", version, mysqlSchemaVersion) } } func TestMySQLMigrate_V2升级V3并转换真实主链路(t *testing.T) { db := openMySQLMigrationTestDB(t) defer db.Close() cleanMySQLTestSchema(t, db) defer cleanMySQLTestSchema(t, db) prepareMySQLV2(t, db) now := "2026-08-10T03:00:00Z" mustExec(t, db, `INSERT INTO shopee_products (goods_id,title,created_at,updated_at) VALUES ('REPORT-1','报表商品',?,?)`, now, now) mustExec(t, db, `INSERT INTO shopee_skus (sku_id,goods_id,spec_raw,created_at,updated_at) VALUES ('SKU-1','REPORT-1','黑色, M',?,?)`, now, now) mustExec(t, db, `INSERT INTO sku_mappings (shopee_sku_id,pdd_goods_id,pdd_option_key,pdd_options,goods_id,mapped_at) VALUES ('SKU-1','PDD-1','color=黑色&size=M','color=黑色&size=M','REPORT-1',?)`, now) mustExec(t, db, `INSERT INTO syb_orders (syb_id,order_no,title,shopee_goods_id,product_spec,quantity,syb_data,created_at,updated_at) VALUES ('SYB-1','ORDER-1','报表商品','REPORT-1',' 黑色, M ',1,'{}',?,?), ('SYB-2','ORDER-2','骨架商品','SKELETON-1','白色,L',1,'{}',?,?), ('SYB-3','ORDER-3','空规格','EMPTY-1',NULL,1,'{}',?,?)`, now, now, now, now, now, now) if err := MigrateMySQL(db); err != nil { t.Fatalf("v2→v3 失败: %v", err) } var key string if err := db.QueryRow(`SELECT spec_key FROM syb_orders WHERE syb_id='SYB-1'`).Scan(&key); err != nil || key != "黑色, M" { t.Fatalf("规格回填=%q err=%v", key, err) } var empty sql.NullString if err := db.QueryRow(`SELECT spec_key FROM syb_orders WHERE syb_id='SYB-3'`).Scan(&empty); err != nil || empty.Valid { t.Fatalf("空规格必须保持 NULL: %+v err=%v", empty, err) } var source string if err := db.QueryRow(`SELECT source FROM shopee_products WHERE goods_id='SKELETON-1'`).Scan(&source); err != nil || source != "syb" { t.Fatalf("骨架来源=%q err=%v", source, err) } var mappingCount int if err := db.QueryRow(`SELECT COUNT(*) FROM spec_mappings WHERE shopee_goods_id='REPORT-1' AND spec_key='黑色, M' AND pdd_goods_id='PDD-1'`).Scan(&mappingCount); err != nil || mappingCount != 1 { t.Fatalf("旧映射转换数量=%d err=%v", mappingCount, err) } if exists, _ := mysqlTableExists(db, "sku_mappings"); exists { t.Fatal("旧 sku_mappings 应已删除") } if exists, _ := mysqlTableExists(db, "sku_mappings_v3_backup"); !exists { t.Fatal("删除旧表前必须保留受限备份表") } } func TestMySQLMigrate_V3三个中断点重跑收敛(t *testing.T) { for _, tc := range []struct { name string prepare func(*testing.T, *sql.DB) }{ {"DDL完成回填未开始", func(t *testing.T, db *sql.DB) { if err := ensureMySQLV3Columns(db); err != nil { t.Fatal(err) } mustExec(t, db, mysqlSchemaV3SpecMappings) }}, {"回填到一半", func(t *testing.T, db *sql.DB) { if err := ensureMySQLV3Columns(db); err != nil { t.Fatal(err) } mustExec(t, db, mysqlSchemaV3SpecMappings) key, _ := spec.SpecKey("黑色,M") mustExec(t, db, `UPDATE syb_orders SET spec_key=? WHERE syb_id='SYB-A'`, key) }}, {"全部完成版本未记录", func(t *testing.T, db *sql.DB) { if err := migrateMySQLV3(db); err != nil { t.Fatal(err) } }}, } { t.Run(tc.name, func(t *testing.T) { db := openMySQLMigrationTestDB(t) defer db.Close() cleanMySQLTestSchema(t, db) defer cleanMySQLTestSchema(t, db) prepareMySQLV2(t, db) now := "2026-08-10T03:00:00Z" mustExec(t, db, `INSERT INTO syb_orders (syb_id,order_no,title,shopee_goods_id,product_spec,quantity,syb_data,created_at,updated_at) VALUES ('SYB-A','A','A','G-A','黑色,M',1,'{}',?,?),('SYB-B','B','B','G-B','白色,L',1,'{}',?,?)`, now, now, now, now) tc.prepare(t, db) if err := MigrateMySQL(db); err != nil { t.Fatalf("重跑失败: %v", err) } if err := MigrateMySQL(db); err != nil { t.Fatalf("再次重跑失败: %v", err) } var count int if err := db.QueryRow(`SELECT COUNT(*) FROM syb_orders WHERE spec_key IS NOT NULL`).Scan(&count); err != nil || count != 2 { t.Fatalf("回填未收敛 count=%d err=%v", count, err) } }) } } func TestMySQLMigrate_V3字段存在但约束缺失可收敛(t *testing.T) { db := openMySQLMigrationTestDB(t) defer db.Close() cleanMySQLTestSchema(t, db) defer cleanMySQLTestSchema(t, db) prepareMySQLV2(t, db) mustExec(t, db, `INSERT INTO shopee_products(goods_id,title,created_at,updated_at) VALUES ('EXISTING-1','存量商品','2026-08-10T00:00:00Z','2026-08-10T00:00:00Z')`) mustExec(t, db, `ALTER TABLE shopee_products ADD COLUMN source VARCHAR(16) NULL`) if err := MigrateMySQL(db); err != nil { t.Fatal(err) } var existingSource string if err := db.QueryRow(`SELECT source FROM shopee_products WHERE goods_id='EXISTING-1'`).Scan(&existingSource); err != nil || existingSource != "report" { t.Fatalf("可空 source 中断点的存量行未收敛: source=%q err=%v", existingSource, err) } mustExec(t, db, `INSERT INTO shopee_products(goods_id,title,source,created_at,updated_at) VALUES ('CHECK-1','检查','report','2026-08-10T00:00:00Z','2026-08-10T00:00:00Z')`) if _, err := db.Exec(`UPDATE shopee_products SET source='typo'`); err == nil { t.Fatal("source CHECK 必须拒绝非法值") } } func TestMySQLMigrate_V3形状自检失败不记版本(t *testing.T) { db := openMySQLMigrationTestDB(t) defer db.Close() cleanMySQLTestSchema(t, db) defer cleanMySQLTestSchema(t, db) prepareMySQLV2(t, db) mustExec(t, db, `CREATE TABLE spec_mappings ( shopee_goods_id VARCHAR(191) NOT NULL, spec_key VARCHAR(191) NOT NULL, pdd_goods_id VARCHAR(191) NOT NULL, pdd_option_key VARCHAR(191) NOT NULL, pdd_options LONGTEXT NOT NULL, spec_raw TEXT NOT NULL, mapped_at VARCHAR(35) NOT NULL, PRIMARY KEY (shopee_goods_id, pdd_goods_id, spec_key) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`) if err := MigrateMySQL(db); err == nil { t.Fatal("错误主键顺序必须让 v3 形状自检失败") } var count int if err := db.QueryRow(`SELECT COUNT(*) FROM schema_migrations WHERE version=3`).Scan(&count); err != nil || count != 0 { t.Fatalf("自检失败时不得记录 v3: count=%d err=%v", count, err) } } func TestMySQLMigrate_V3升级V4且断点重跑(t *testing.T) { db := openMySQLMigrationTestDB(t) defer db.Close() cleanMySQLTestSchema(t, db) defer cleanMySQLTestSchema(t, db) prepareMySQLV2(t, db) if err := migrateMySQLV3(db); err != nil { t.Fatal(err) } mustExec(t, db, `INSERT INTO schema_migrations(version,applied_at) VALUES (3,'2026-08-10T00:00:00Z')`) // 模拟 DDL 已完成、版本未记录。 mustExec(t, db, mysqlSchemaV4Decisions) if err := MigrateMySQL(db); err != nil { t.Fatal(err) } if err := MigrateMySQL(db); err != nil { t.Fatalf("重跑失败: %v", err) } var versions int if err := db.QueryRow(`SELECT COUNT(*) FROM schema_migrations WHERE version=4`).Scan(&versions); err != nil || versions != 1 { t.Fatalf("v4=%d err=%v", versions, err) } mustExec(t, db, `INSERT INTO spec_mapping_decisions(shopee_goods_id,spec_key,pdd_goods_id,rules_version,chosen_option_key,accepted,decided_at) VALUES('S','K','P','rules_v1','O',1,'2026-08-10T00:00:00Z')`) if _, err := db.Exec(`UPDATE spec_mapping_decisions SET accepted=2`); err == nil { t.Fatal("accepted CHECK 必须拒绝 2") } } func TestMySQLMigrate_V4形状错误不记版本(t *testing.T) { db := openMySQLMigrationTestDB(t) defer db.Close() cleanMySQLTestSchema(t, db) defer cleanMySQLTestSchema(t, db) prepareMySQLV2(t, db) if err := migrateMySQLV3(db); err != nil { t.Fatal(err) } mustExec(t, db, `INSERT INTO schema_migrations(version,applied_at) VALUES (3,'2026-08-10T00:00:00Z')`) mustExec(t, db, strings.Replace(mysqlSchemaV4Decisions, "CHECK (accepted IN (0,1))", "CHECK (accepted IN (0,1,2))", 1)) if err := MigrateMySQL(db); err == nil { t.Fatal("错误 CHECK 必须阻止 v4") } var count int db.QueryRow(`SELECT COUNT(*) FROM schema_migrations WHERE version=4`).Scan(&count) if count != 0 { t.Fatal("自检失败不得记 v4") } } func openMySQLMigrationTestDB(t *testing.T) *sql.DB { t.Helper() if os.Getenv("CMAUTOBUY_MYSQL_TEST") != "1" { t.Skip("未启用真实 MySQL 8 集成测试") } cfg, err := config.LoadDatabaseFromEnv() if err != nil { t.Fatal(err) } if !strings.HasSuffix(cfg.Name, "_test") { t.Fatalf("拒绝使用非 _test 数据库 %q", cfg.Name) } db, err := OpenMySQL(cfg) if err != nil { t.Fatal(err) } return db } func prepareMySQLV2(t *testing.T, db *sql.DB) { t.Helper() mustExec(t, db, `CREATE TABLE schema_migrations (version INT PRIMARY KEY, applied_at VARCHAR(35) NOT NULL) ENGINE=InnoDB`) for _, statement := range mysqlSchemaV1 { mustExec(t, db, statement) } for _, statement := range mysqlSchemaV2 { mustExec(t, db, statement) } mustExec(t, db, `INSERT INTO schema_migrations(version,applied_at) VALUES (1,'2026-08-10T00:00:00Z'),(2,'2026-08-10T00:00:00Z')`) } func mustExec(t *testing.T, db *sql.DB, query string, args ...any) { t.Helper() if _, err := db.Exec(query, args...); err != nil { t.Fatalf("执行 SQL 失败: %v", err) } } func cleanMySQLTestSchema(t *testing.T, db *sql.DB) { t.Helper() rows, err := db.Query(`SELECT table_name FROM information_schema.tables WHERE table_schema = DATABASE() AND table_type = 'BASE TABLE'`) if err != nil { t.Fatal(err) } var tables []string for rows.Next() { var table string if err := rows.Scan(&table); err != nil { rows.Close() t.Fatal(err) } tables = append(tables, table) } if err := rows.Close(); err != nil { t.Fatal(err) } if _, err := db.Exec(`SET FOREIGN_KEY_CHECKS = 0`); err != nil { t.Fatal(err) } defer db.Exec(`SET FOREIGN_KEY_CHECKS = 1`) for _, table := range tables { // 表名只来自当前 _test 数据库的 information_schema,并对反引号转义。 quoted := "`" + strings.ReplaceAll(table, "`", "``") + "`" if _, err := db.Exec("DROP TABLE " + quoted); err != nil { t.Fatal(fmt.Errorf("清理 MySQL 测试表 %s 失败: %w", table, err)) } } }