package repository import ( "crypto/sha256" "database/sql" "errors" "os" "path/filepath" "testing" "cmautobuy/admin/model" ) func TestMigrateSQLiteToMySQL_演练迁移核对和重复保护(t *testing.T) { sourcePath := newLegacyMigrationSource(t) before := fileDigest(t, sourcePath) source, err := OpenLegacySQLiteReadOnly(sourcePath) if err != nil { t.Fatal(err) } defer source.Close() target := newSybTestDB(t) dryRun, err := MigrateSQLiteToMySQL(source, target, true) if err != nil { t.Fatalf("演练失败: %v", err) } if !dryRun.DryRun || summaryCount(dryRun, "users") != 1 || summaryCount(dryRun, "tasks") != 1 { t.Fatalf("演练摘要不对: %+v", dryRun) } assertBusinessTablesEmpty(t, target) result, err := MigrateSQLiteToMySQL(source, target, false) if err != nil { t.Fatalf("迁移失败: %v", err) } if result.DryRun { t.Fatal("正式迁移摘要不应标记为 dry-run") } if _, err := VerifySQLiteToMySQL(source, target); err != nil { t.Fatalf("迁移后核对失败: %v", err) } if got := fileDigest(t, sourcePath); got != before { t.Fatal("迁移修改了源 SQLite 文件") } if _, err := MigrateSQLiteToMySQL(source, target, false); err == nil { t.Fatal("重复迁移到非空目标应该被拒绝") } } func TestMigrateSQLiteToMySQL_中途失败整体回滚(t *testing.T) { sourcePath := newLegacyMigrationSource(t) source, err := OpenLegacySQLiteReadOnly(sourcePath) if err != nil { t.Fatal(err) } defer source.Close() target := newSybTestDB(t) if _, err := target.Exec(`CREATE TRIGGER reject_tasks BEFORE INSERT ON tasks FOR EACH ROW SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'fixture rejection'`); err != nil { t.Fatal(err) } _, err = MigrateSQLiteToMySQL(source, target, false) var migrationErr *SQLiteMigrationError if !errors.As(err, &migrationErr) || migrationErr.Table != "tasks" { t.Fatalf("应该返回不含业务数据的 tasks 迁移错误,实际 %v", err) } assertBusinessTablesEmpty(t, target) } func TestMigrateSQLiteToMySQL_拒绝错误SQLite版本(t *testing.T) { dir := t.TempDir() db, err := Open(dir) if err != nil { t.Fatal(err) } if err := Migrate(db); err != nil { t.Fatal(err) } if _, err := db.Exec(`PRAGMA user_version = 7`); err != nil { t.Fatal(err) } db.Close() source, err := OpenLegacySQLiteReadOnly(filepath.Join(dir, "admin.db")) if err != nil { t.Fatal(err) } defer source.Close() target := newSybTestDB(t) if _, err := MigrateSQLiteToMySQL(source, target, true); err == nil { t.Fatal("错误 SQLite 版本应该被拒绝") } } func newLegacyMigrationSource(t *testing.T) string { t.Helper() dir := t.TempDir() db, err := Open(dir) if err != nil { t.Fatal(err) } if err := Migrate(db); err != nil { db.Close() t.Fatal(err) } now := model.NowISO() statements := []struct { query string args []any }{ {`INSERT INTO users (user_id,username,password_hash,role,status,password_changed_at,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?)`, []any{"USR-1", "admin", "bcrypt-fixture", "admin", "active", now, now, now}}, {`INSERT INTO clients (client_id,name,last_seen_at,created_at,updated_at) VALUES (?,?,?,?,?)`, []any{"CLIENT-1", "测试客户端", now, now, now}}, {`INSERT INTO shopee_products (goods_id,title,pdd_goods_url,pdd_goods_id,created_at,updated_at) VALUES (?,?,?,?,?,?)`, []any{"SP-1", "测试商品", "https://example.invalid/pdd", "PDD-1", now, now}}, {`INSERT INTO pdd_products (id,goods_id,url,title,skus_json,collect_status,created_at,updated_at) VALUES (?,?,?,?,?,'collected',?,?)`, []any{7, "PDD-1", "https://example.invalid/pdd", "PDD 商品", `{"skus":[]}`, now, now}}, {`INSERT INTO shopee_skus (sku_id,goods_id,spec_raw,color,size,parse_ok,created_at,updated_at) VALUES (?,?,?,?,?,1,?,?)`, []any{"SKU-1", "SP-1", "黑色,M", "黑色", "M", now, now}}, {`INSERT INTO syb_orders (syb_id,order_no,title,shopee_goods_id,shopee_sku_id,product_spec,quantity,syb_data,created_at,updated_at) VALUES (?,?,?,?,?,?,1,'{}',?,?)`, []any{"SYB-1", "ORDER-1", "测试货运单", "SP-1", "SKU-1", "黑色,M", now, now}}, {`INSERT INTO sku_mappings (shopee_sku_id,pdd_goods_id,pdd_option_key,pdd_options,goods_id,mapped_at,mapped_by) VALUES (?,?,?,?,?,?,?)`, []any{"SKU-1", "PDD-1", "color=黑色&size=M", `{"color":"黑色","size":"M"}`, "SP-1", now, "USR-1"}}, {`INSERT INTO tasks (task_id,task_type,status,assigned_client,syb_id,pdd_goods_url,pdd_goods_id,created_at,updated_at) VALUES (?,'collect','claimed',?,?,?,?,?,?)`, []any{"TASK-1", "CLIENT-1", "SYB-1", "https://example.invalid/pdd", "PDD-1", now, now}}, {`INSERT INTO task_claims (task_id,client_id,claimed_at) VALUES (?,?,?)`, []any{"TASK-1", "CLIENT-1", now}}, {`INSERT INTO idempotency_keys (key,request_hash,response_body,created_at) VALUES (?,?,?,?)`, []any{"IDEM-1", "hash", `{"accepted":true}`, now}}, {`INSERT INTO syb_session (username,cookies,expires_at,updated_at) VALUES (?,?,?,?)`, []any{"fixture", `[{"name":"session"}]`, now, now}}, {`INSERT INTO syb_sync_state (id,last_synced_at,updated_at) VALUES (1,?,?)`, []any{now, now}}, {`INSERT INTO web_sessions (session_hash,user_id,expires_at,created_at,last_seen_at) VALUES (?,?,?,?,?)`, []any{"SESSION-HASH", "USR-1", now, now, now}}, {`INSERT INTO client_user_assignments (assignment_id,client_id,user_id,started_at,assigned_by_user_id) VALUES (?,?,?,?,?)`, []any{"ASSIGN-1", "CLIENT-1", "USR-1", now, "USR-1"}}, {`INSERT INTO syb_sync_runs (run_id,user_id,date_from,date_to,status,started_at,finished_at) VALUES (?,?,?,?,'succeeded',?,?)`, []any{"RUN-1", "USR-1", "2026-08-09", "2026-08-09", now, now}}, } for _, statement := range statements { if _, err := db.Exec(statement.query, statement.args...); err != nil { db.Close() t.Fatalf("准备迁移 fixture 失败: %v", err) } } if err := db.Close(); err != nil { t.Fatal(err) } return filepath.Join(dir, "admin.db") } func assertBusinessTablesEmpty(t *testing.T, db *sql.DB) { t.Helper() for _, spec := range sqliteMigrationTables { var count int if err := db.QueryRow("SELECT COUNT(*) FROM " + quoteMySQLIdentifier(spec.name)).Scan(&count); err != nil { t.Fatal(err) } if count != 0 { t.Fatalf("表 %s 应为空,实际 %d", spec.name, count) } } } func summaryCount(summary *SQLiteMigrationSummary, name string) int64 { for _, table := range summary.Tables { if table.Name == name { return table.Count } } return -1 } func fileDigest(t *testing.T, path string) [32]byte { t.Helper() content, err := os.ReadFile(path) if err != nil { t.Fatal(err) } return sha256.Sum256(content) }