171 lines
6.5 KiB
Go
171 lines
6.5 KiB
Go
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)
|
||
|
|
}
|