Files
MyGoNavi/internal/sync/fallback_safety_test.go
Syngnat bdd84db52c 🐛 fix(data-sync): 修复跨库同步静默失败与数据安全问题
- 聚合表级失败并保留已确认提交计数,避免任务误报成功

- 绑定分析上下文与实际同步模式,阻止陈旧或无效配置执行

- 加固全量覆盖及同端点分页写入,补充跨库与失败回归测试

Refs #846
2026-08-05 23:55:44 +08:00

232 lines
8.8 KiB
Go

package sync
import (
"strings"
"testing"
"GoNavi-Wails/internal/connection"
)
type staticRowsSyncSourceDB struct {
fakeMigrationDB
rows []map[string]interface{}
}
func (f *staticRowsSyncSourceDB) Query(query string) ([]map[string]interface{}, []string, error) {
f.queryLog = append(f.queryLog, query)
return f.rows, nil, nil
}
type recordingNonBatchSyncTargetDB struct {
fakeMigrationDB
execLog []string
}
func (f *recordingNonBatchSyncTargetDB) Exec(query string) (int64, error) {
f.execLog = append(f.execLog, query)
return 0, nil
}
func containsDestructiveClear(queries []string) bool {
for _, query := range queries {
upper := strings.ToUpper(query)
if strings.Contains(upper, "TRUNCATE TABLE") || strings.Contains(upper, "DELETE FROM") {
return true
}
}
return false
}
func TestRunSyncFallbackFullOverwriteValidatesColumnsBeforeClear(t *testing.T) {
columns := []connection.ColumnDefinition{{Name: "id", Type: "bigint", Nullable: "NO", Key: "PRI"}}
sourceDB := &staticRowsSyncSourceDB{
fakeMigrationDB: fakeMigrationDB{columns: map[string][]connection.ColumnDefinition{"app.users": columns}},
rows: []map[string]interface{}{{"id": 1, "dynamic_column": "value"}},
}
targetDB := &recordingExecSyncTargetDB{fakeQuerySyncTargetDB: fakeQuerySyncTargetDB{fakeMigrationDB: fakeMigrationDB{
columns: map[string][]connection.ColumnDefinition{"app.users": columns},
}}}
useSyncDatabaseFactorySequence(t,
syncDatabaseFactoryStep{db: sourceDB},
syncDatabaseFactoryStep{db: targetDB},
)
result := NewSyncEngine(Reporter{}).RunSync(SyncConfig{
SourceConfig: connection.ConnectionConfig{Type: "mysql", Host: "mysql.local", Port: 3306, Database: "app"},
TargetConfig: connection.ConnectionConfig{Type: "mysql", Host: "mysql.local", Port: 3306, Database: "app"},
SourceDatabase: "app",
TargetDatabase: "app",
Tables: []string{"users"},
Content: "data",
Mode: "full_overwrite",
AutoAddColumns: true,
})
if result.Success || !strings.Contains(result.Message, "dynamic_column") {
t.Fatalf("expected field validation failure, got %+v", result)
}
if containsDestructiveClear(targetDB.execLog) {
t.Fatalf("target was cleared before field validation completed: %v", targetDB.execLog)
}
if len(targetDB.appliedChanges.Inserts) != 0 {
t.Fatalf("rows were applied after field validation failed: %+v", targetDB.appliedChanges)
}
}
func TestRunSyncFallbackFullOverwriteChecksApplierBeforeClear(t *testing.T) {
columns := []connection.ColumnDefinition{{Name: "id", Type: "bigint", Nullable: "NO", Key: "PRI"}}
sourceDB := &staticRowsSyncSourceDB{
fakeMigrationDB: fakeMigrationDB{columns: map[string][]connection.ColumnDefinition{"source_db.users": columns}},
rows: []map[string]interface{}{{"id": 1}},
}
targetDB := &recordingNonBatchSyncTargetDB{fakeMigrationDB: fakeMigrationDB{
columns: map[string][]connection.ColumnDefinition{"target_db.users": columns},
}}
useSyncDatabaseFactorySequence(t,
syncDatabaseFactoryStep{db: sourceDB},
syncDatabaseFactoryStep{db: targetDB},
)
result := NewSyncEngine(Reporter{}).RunSync(SyncConfig{
SourceConfig: connection.ConnectionConfig{Type: "oracle", Database: "source_db"},
TargetConfig: connection.ConnectionConfig{Type: "mysql", Database: "target_db"},
SourceDatabase: "source_db",
TargetDatabase: "target_db",
Tables: []string{"users"},
Content: "data",
Mode: "full_overwrite",
})
if result.Success || !strings.Contains(result.Message, "ApplyChanges") {
t.Fatalf("expected unsupported applier failure, got %+v", result)
}
if containsDestructiveClear(targetDB.execLog) {
t.Fatalf("target was cleared before applier capability check: %v", targetDB.execLog)
}
}
func TestRunSyncFallbackFullOverwriteAllowsEmptySourceToClearWithoutApplier(t *testing.T) {
columns := []connection.ColumnDefinition{{Name: "id", Type: "bigint", Nullable: "NO", Key: "PRI"}}
sourceDB := &staticRowsSyncSourceDB{
fakeMigrationDB: fakeMigrationDB{columns: map[string][]connection.ColumnDefinition{"source_db.users": columns}},
}
targetDB := &recordingNonBatchSyncTargetDB{fakeMigrationDB: fakeMigrationDB{
columns: map[string][]connection.ColumnDefinition{"target_db.users": columns},
}}
useSyncDatabaseFactorySequence(t,
syncDatabaseFactoryStep{db: sourceDB},
syncDatabaseFactoryStep{db: targetDB},
)
result := NewSyncEngine(Reporter{}).RunSync(SyncConfig{
SourceConfig: connection.ConnectionConfig{Type: "oracle", Database: "source_db"},
TargetConfig: connection.ConnectionConfig{Type: "mysql", Database: "target_db"},
SourceDatabase: "source_db",
TargetDatabase: "target_db",
Tables: []string{"users"},
Content: "data",
Mode: "full_overwrite",
})
if !result.Success || result.TablesSynced != 1 {
t.Fatalf("expected empty-source full overwrite to clear successfully without an applier: %+v", result)
}
if !containsDestructiveClear(targetDB.execLog) {
t.Fatalf("expected target clear for empty source, exec=%v", targetDB.execLog)
}
}
func TestRunSyncSourceQueryFallbackChecksApplierBeforeClear(t *testing.T) {
columns := []connection.ColumnDefinition{{Name: "id", Type: "bigint", Nullable: "NO", Key: "PRI"}}
sourceDB := &staticRowsSyncSourceDB{rows: []map[string]interface{}{{"id": 1}}}
targetDB := &recordingNonBatchSyncTargetDB{fakeMigrationDB: fakeMigrationDB{
columns: map[string][]connection.ColumnDefinition{"target_db.users": columns},
}}
useSyncDatabaseFactorySequence(t,
syncDatabaseFactoryStep{db: sourceDB},
syncDatabaseFactoryStep{db: targetDB},
)
result := NewSyncEngine(Reporter{}).RunSync(SyncConfig{
SourceConfig: connection.ConnectionConfig{Type: "oracle", Database: "source_db"},
TargetConfig: connection.ConnectionConfig{Type: "mysql", Database: "target_db"},
TargetDatabase: "target_db",
Tables: []string{"users"},
SourceQuery: "SELECT id FROM source_users",
Mode: "full_overwrite",
})
if result.Success || !strings.Contains(result.Message, "ApplyChanges") {
t.Fatalf("expected source-query unsupported applier failure, got %+v", result)
}
if containsDestructiveClear(targetDB.execLog) {
t.Fatalf("source-query fallback cleared before applier capability check: %v", targetDB.execLog)
}
}
func TestRunSyncInsertUpdateNoOpStillCompletesCreatedStructureAndIndexes(t *testing.T) {
columns := []connection.ColumnDefinition{
{Name: "id", Type: "bigint", Nullable: "NO", Key: "PRI"},
{Name: "email", Type: "varchar(255)", Nullable: "YES"},
}
sourceDB := &fakeMigrationDB{
columns: map[string][]connection.ColumnDefinition{"source_db.users": columns},
indexes: map[string][]connection.IndexDefinition{"source_db.users": {
{Name: "idx_users_email", ColumnName: "email", NonUnique: 1, SeqInIndex: 1, IndexType: "BTREE"},
}},
}
targetDB := &recordingNonBatchSyncTargetDB{}
useSyncDatabaseFactorySequence(t,
syncDatabaseFactoryStep{db: sourceDB},
syncDatabaseFactoryStep{db: targetDB},
)
result := NewSyncEngine(Reporter{}).RunSync(SyncConfig{
SourceConfig: connection.ConnectionConfig{Type: "mysql", Database: "source_db"},
TargetConfig: connection.ConnectionConfig{Type: "mysql", Database: "target_db"},
SourceDatabase: "source_db",
TargetDatabase: "target_db",
Tables: []string{"users"},
Content: "both",
Mode: "insert_update",
TargetTableStrategy: "auto_create_if_missing",
CreateIndexes: true,
TableOptions: map[string]TableOptions{
"users": {},
},
})
if !result.Success || result.TablesSynced != 1 {
t.Fatalf("expected structure-only work in insert_update no-op to succeed: %+v", result)
}
executed := strings.Join(targetDB.execLog, "\n")
if !strings.Contains(executed, "CREATE TABLE") || !strings.Contains(executed, "CREATE INDEX") {
t.Fatalf("expected table and post-data index creation, exec=%v", targetDB.execLog)
}
}
func TestRunSyncSourceQueryInsertUpdateWithoutOperationsIsSuccessfulNoOp(t *testing.T) {
sourceDB := &fakeMigrationDB{}
targetDB := &recordingNonBatchSyncTargetDB{}
useSyncDatabaseFactorySequence(t,
syncDatabaseFactoryStep{db: sourceDB},
syncDatabaseFactoryStep{db: targetDB},
)
result := NewSyncEngine(Reporter{}).RunSync(SyncConfig{
SourceConfig: connection.ConnectionConfig{Type: "mysql", Database: "source_db"},
TargetConfig: connection.ConnectionConfig{Type: "mysql", Database: "target_db"},
Tables: []string{"users"},
SourceQuery: "SELECT id FROM source_users",
Mode: "insert_update",
TableOptions: map[string]TableOptions{"users": {}},
})
if !result.Success || result.TablesSynced != 1 {
t.Fatalf("expected source-query insert_update no-op to succeed: %+v", result)
}
if len(sourceDB.queryLog) != 0 || len(targetDB.execLog) != 0 {
t.Fatalf("no-op must not read or write data, sourceQueries=%v targetExec=%v", sourceDB.queryLog, targetDB.execLog)
}
}