Files
MyGoNavi/internal/app/database_sql_import_gtid_test.go
Syngnat bd5fde990d 🐛 fix(data-import): 统一外部 SQL 导入并处理 GTID 冲突
- 将数据库右键入口统一到导入工作台,保留失败后继续选项并规避 macOS 闪退
- 导入前检测 GTID_PURGED 与目标 GTID_EXECUTED,冲突时默认零写入阻断
- 支持跳过 GTID 语句或按 MySQL 版本重置 GTID 历史
- 补齐 Wails 绑定、六种语言文案和前后端回归测试
2026-08-11 15:42:18 +08:00

226 lines
7.3 KiB
Go

package app
import (
"os"
"path/filepath"
"strings"
"testing"
"GoNavi-Wails/internal/connection"
"GoNavi-Wails/internal/db"
)
type fakeMySQLGTIDImportDB struct {
*fakeSQLFileBatchDB
gtidExecuted string
serverVersion string
queryCalls []string
}
func (database *fakeMySQLGTIDImportDB) Query(query string) ([]map[string]interface{}, []string, error) {
database.queryCalls = append(database.queryCalls, query)
return []map[string]interface{}{{
"gtid_executed": database.gtidExecuted,
"server_version": database.serverVersion,
}}, []string{"gtid_executed", "server_version"}, nil
}
func writeMySQLGTIDImportFixture(t *testing.T) string {
t.Helper()
filePath := filepath.Join(t.TempDir(), "mysqldump.sql")
content := strings.Join([]string{
"SET @OLD_SQL_MODE=@@SQL_MODE;",
"SET @@GLOBAL.GTID_PURGED=/*!80000 '+'*/ 'c289d954-7f57-11f1-99ab-fa163e2df103:1-618405';",
"CREATE TABLE demo(id INT);",
}, "\n")
if err := os.WriteFile(filePath, []byte(content), 0o600); err != nil {
t.Fatalf("write GTID import fixture: %v", err)
}
return filePath
}
func newMySQLGTIDImportTestApp(t *testing.T, database *fakeMySQLGTIDImportDB) *App {
t.Helper()
originalNewDatabaseFunc := newDatabaseFunc
t.Cleanup(func() { newDatabaseFunc = originalNewDatabaseFunc })
newDatabaseFunc = func(string) (db.Database, error) { return database, nil }
app := NewApp()
app.configDir = t.TempDir()
return app
}
func TestIsMySQLGTIDPurgedStatement(t *testing.T) {
tests := []struct {
name string
statement string
want bool
}{
{
name: "mysqldump assignment with version expression",
statement: "SET @@GLOBAL.GTID_PURGED=/*!80000 '+'*/ 'server:1-9'",
want: true,
},
{
name: "global assignment with spaces",
statement: "/* header */ SET GLOBAL GTID_PURGED := 'server:1-9'",
want: true,
},
{
name: "executable version comment",
statement: "/*!80000 SET @@GLOBAL.GTID_PURGED='server:1-9' */",
want: true,
},
{
name: "string literal is not an assignment",
statement: "SELECT 'SET @@GLOBAL.GTID_PURGED=server:1-9'",
want: false,
},
{
name: "session variable is unrelated",
statement: "SET @GTID_PURGED='server:1-9'",
want: false,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
if got := isMySQLGTIDPurgedStatement(test.statement); got != test.want {
t.Fatalf("isMySQLGTIDPurgedStatement(%q) = %t, want %t", test.statement, got, test.want)
}
})
}
}
func TestPreflightDatabaseSQLImportReportsGTIDDecisionBeforeExecution(t *testing.T) {
database := &fakeMySQLGTIDImportDB{
fakeSQLFileBatchDB: &fakeSQLFileBatchDB{},
gtidExecuted: "existing-server:1-10",
serverVersion: "8.0.39",
}
app := newMySQLGTIDImportTestApp(t, database)
result := app.PreflightDatabaseSQLImport(
connection.ConnectionConfig{Type: "mysql"},
"app",
writeMySQLGTIDImportFixture(t),
)
if !result.Success {
t.Fatalf("preflight failed: %#v", result)
}
payload, ok := result.Data.(map[string]interface{})
if !ok {
t.Fatalf("preflight payload type = %T, want map[string]interface{}", result.Data)
}
if payload["containsMySQLGTIDPurged"] != true || payload["targetGTIDExecutedNonEmpty"] != true || payload["requiresGTIDDecision"] != true {
t.Fatalf("unexpected GTID preflight payload: %#v", payload)
}
if database.execCalls != 0 || database.batchCalls != 0 {
t.Fatalf("preflight executed database statements: exec=%d batch=%d", database.execCalls, database.batchCalls)
}
}
func TestImportDatabaseSQLRejectsGTIDConflictBeforeAnyStatementByDefault(t *testing.T) {
database := &fakeMySQLGTIDImportDB{
fakeSQLFileBatchDB: &fakeSQLFileBatchDB{},
gtidExecuted: "existing-server:1-10",
serverVersion: "8.0.39",
}
app := newMySQLGTIDImportTestApp(t, database)
result := app.ImportDatabaseSQL(
connection.ConnectionConfig{Type: "mysql"},
"app",
writeMySQLGTIDImportFixture(t),
"gtid-default-reject",
false,
)
if result.Success {
t.Fatalf("GTID-conflicting import unexpectedly succeeded: %#v", result)
}
payload, ok := result.Data.(map[string]interface{})
if !ok || payload["requiresGTIDDecision"] != true {
t.Fatalf("unexpected conflict payload: %#v", result.Data)
}
if database.execCalls != 0 || database.batchCalls != 0 {
t.Fatalf("conflicting import had side effects before prompting: exec=%d batch=%d queries=%#v", database.execCalls, database.batchCalls, database.execQueries)
}
}
func TestImportDatabaseSQLWithGTIDSkipOmitsPurgedAssignment(t *testing.T) {
database := &fakeMySQLGTIDImportDB{
fakeSQLFileBatchDB: &fakeSQLFileBatchDB{},
gtidExecuted: "existing-server:1-10",
serverVersion: "8.0.39",
}
app := newMySQLGTIDImportTestApp(t, database)
result := app.ImportDatabaseSQLWithOptions(
connection.ConnectionConfig{Type: "mysql"},
"app",
writeMySQLGTIDImportFixture(t),
"gtid-skip",
false,
"skip",
)
if !result.Success {
t.Fatalf("skip-GTID import failed: %#v", result)
}
for _, query := range database.execQueries {
if strings.Contains(strings.ToUpper(query), "GTID_PURGED") {
t.Fatalf("skip mode executed GTID_PURGED: %#v", database.execQueries)
}
}
if strings.Join(database.execQueries, "\n") != "SET @OLD_SQL_MODE=@@SQL_MODE\nCREATE TABLE demo(id INT)" {
t.Fatalf("unexpected statements after GTID skip: %#v", database.execQueries)
}
}
func TestImportDatabaseSQLWithGTIDResetRunsVersionCompatibleResetFirst(t *testing.T) {
tests := []struct {
name string
serverVersion string
resetSQL string
}{
{name: "MySQL 8.0", serverVersion: "8.0.39", resetSQL: "RESET MASTER"},
{name: "MySQL 8.4", serverVersion: "8.4.3", resetSQL: "RESET BINARY LOGS AND GTIDS"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
database := &fakeMySQLGTIDImportDB{
fakeSQLFileBatchDB: &fakeSQLFileBatchDB{},
gtidExecuted: "existing-server:1-10",
serverVersion: test.serverVersion,
}
app := newMySQLGTIDImportTestApp(t, database)
result := app.ImportDatabaseSQLWithOptions(
connection.ConnectionConfig{Type: "mysql"},
"app",
writeMySQLGTIDImportFixture(t),
"gtid-reset-"+strings.ReplaceAll(test.serverVersion, ".", "-"),
false,
"reset",
)
if !result.Success {
t.Fatalf("reset-GTID import failed: %#v", result)
}
if len(database.execQueries) < 2 || database.execQueries[0] != test.resetSQL {
t.Fatalf("reset was not the first database statement: %#v", database.execQueries)
}
if !strings.Contains(strings.ToUpper(strings.Join(database.execQueries[1:], "\n")), "GTID_PURGED") {
t.Fatalf("reset mode did not execute GTID_PURGED after reset: %#v", database.execQueries)
}
})
}
}
func TestSQLImportOptionsHashSeparatesMySQLGTIDModes(t *testing.T) {
reject := buildSQLImportOptionsHashWithGTIDMode(false, DefaultSQLImportMaxStatementBytes, sqlFileTransactionModeOff, mysqlGTIDImportModeReject)
skip := buildSQLImportOptionsHashWithGTIDMode(false, DefaultSQLImportMaxStatementBytes, sqlFileTransactionModeOff, mysqlGTIDImportModeSkip)
reset := buildSQLImportOptionsHashWithGTIDMode(false, DefaultSQLImportMaxStatementBytes, sqlFileTransactionModeOff, mysqlGTIDImportModeReset)
if reject == skip || reject == reset || skip == reset {
t.Fatalf("GTID import modes produced identical options hashes: reject=%s skip=%s reset=%s", reject, skip, reset)
}
}