137 lines
5.7 KiB
Go
137 lines
5.7 KiB
Go
package dbmigration
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.pcux.cn/xuans/Smail/config"
|
|
)
|
|
|
|
// 集成测试只创建随机命名的新库,不修改现有业务库,也不自动删除测试库。
|
|
// 必须显式提供专用测试服务连接;缺少连接时跳过,不冒充实库验收成功。
|
|
func TestPortableSQLIntegration(t *testing.T) {
|
|
for _, item := range []struct {
|
|
kind config.DatabaseType
|
|
env string
|
|
}{
|
|
{config.DatabaseTypeMySQL, "SMAIL_TEST_MIGRATION_MYSQL"},
|
|
{config.DatabaseTypePostgreSQL, "SMAIL_TEST_MIGRATION_POSTGRES"},
|
|
} {
|
|
t.Run(string(item.kind), func(t *testing.T) {
|
|
input := portableIntegrationInput(t, item.env, item.kind)
|
|
runPortableIntegrationChain(t, []TargetInput{input, input})
|
|
})
|
|
}
|
|
t.Run("mysql_postgresql", func(t *testing.T) {
|
|
mysql := portableIntegrationInput(t, "SMAIL_TEST_MIGRATION_MYSQL", config.DatabaseTypeMySQL)
|
|
postgres := portableIntegrationInput(t, "SMAIL_TEST_MIGRATION_POSTGRES", config.DatabaseTypePostgreSQL)
|
|
runPortableIntegrationChain(t, []TargetInput{mysql, postgres, mysql})
|
|
})
|
|
}
|
|
|
|
func portableIntegrationInput(t *testing.T, name string, kind config.DatabaseType) TargetInput {
|
|
t.Helper()
|
|
raw := strings.TrimSpace(os.Getenv(name))
|
|
if raw == "" {
|
|
t.Skip(name + " is not configured")
|
|
}
|
|
var input TargetInput
|
|
decoder := json.NewDecoder(strings.NewReader(raw))
|
|
decoder.DisallowUnknownFields()
|
|
if err := decoder.Decode(&input); err != nil || input.Type != kind || input.Name != "" || input.LocalRef != "" {
|
|
t.Fatal("integration connection must specify the expected type without a database name or local path")
|
|
}
|
|
return input
|
|
}
|
|
|
|
func runPortableIntegrationChain(t *testing.T, inputs []TargetInput) {
|
|
t.Helper()
|
|
root, source := portableFixture(t)
|
|
manager := portableIntegrationManager(t, root, source)
|
|
open, err := NewRuntimeSQLConnectionFactory(root)
|
|
if err != nil {
|
|
t.Fatal("integration connection factory unavailable")
|
|
}
|
|
// 使用二进制历史记录检查跨库复制,不输出配置原文或凭据。
|
|
payload := []byte{0, 1, 127, 128, 255}
|
|
digest := fmt.Sprintf("%x", sha256.Sum256(payload))
|
|
seed := &PortableExecutor{root: root, open: open}
|
|
database, err := seed.connect(context.Background(), source)
|
|
if err != nil {
|
|
t.Fatal("integration history source unavailable")
|
|
}
|
|
_, err = database.Exec("INSERT INTO config_records(kind,id,payload,digest) VALUES ('integration','integration-history',?,?)", payload, digest)
|
|
_ = database.Close()
|
|
if err != nil {
|
|
t.Fatal("integration history seed failed")
|
|
}
|
|
inputs = append(inputs, TargetInput{Type: config.DatabaseTypeSQLite, LocalRef: "roundtrip.db"})
|
|
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
|
|
defer cancel()
|
|
for index, input := range inputs {
|
|
if input.Type != config.DatabaseTypeSQLite {
|
|
input.Name, err = randomID("smail_migration_test_")
|
|
if err != nil {
|
|
t.Fatal("integration name generation failed")
|
|
}
|
|
}
|
|
target, err := input.Prepare(root)
|
|
if err != nil {
|
|
t.Fatal("integration connection configuration is invalid")
|
|
}
|
|
plan, err := buildTargetPlan(source, target)
|
|
if err != nil {
|
|
t.Fatal("integration plan validation failed")
|
|
}
|
|
plan.expectedGeneration = manager.Snapshot().Generation
|
|
plan.actor = config.AuditActor{UserID: "40", Role: "super_admin", SessionID: "integration-session", RecentAuth: true, RecoveryCodeConfigured: true}
|
|
executor := &PortableExecutor{root: root, open: open, switchConfig: manager.SwitchDatabase}
|
|
// 路径只记录随机测试库名,不记录连接账号、密码或底层驱动错误。
|
|
t.Logf("hop %d: %s -> %s, retained database: %s%s", index+1, source.Type, target.Type, target.Name, target.LocalRef)
|
|
if err := executor.Run(ctx, plan, func(Status) error { return nil }); err != nil {
|
|
executor.Close()
|
|
t.Fatalf("migration failed: %s", migrationErrorCode(err))
|
|
}
|
|
executor.Close()
|
|
if status := manager.Status(); len(status.PendingRestart) != 1 || status.PendingRestart[0] != config.ServiceDatabase {
|
|
t.Fatal("database restart requirement was not recorded")
|
|
}
|
|
manager = reopenPortableManager(t, root)
|
|
if snapshot := manager.Snapshot(); snapshot.Generation != plan.expectedGeneration+1 || snapshot.Root.Database != target {
|
|
t.Fatal("configuration transaction did not survive manager restart")
|
|
}
|
|
connection, err := executor.connect(ctx, target)
|
|
if err != nil {
|
|
t.Fatal("migrated target could not be reopened")
|
|
}
|
|
var subject string
|
|
queryErr := connection.QueryRowContext(ctx, "SELECT subject FROM email WHERE id=82").Scan(&subject)
|
|
var logPayload string
|
|
logErr := connection.QueryRowContext(ctx, "SELECT payload FROM mail_log WHERE id='migration-log'").Scan(&logPayload)
|
|
var historyPayload []byte
|
|
var historyDigest string
|
|
historyErr := connection.QueryRowContext(ctx, "SELECT payload,digest FROM config_records WHERE kind='integration' AND id='integration-history'").Scan(&historyPayload, &historyDigest)
|
|
var backups int
|
|
backupErr := connection.QueryRowContext(ctx, "SELECT COUNT(*) FROM config_records WHERE kind='backup'").Scan(&backups)
|
|
_, insertErr := connection.ExecContext(ctx, "INSERT INTO "+quoteDatabaseIdentifier(target.Type, "user")+" (account,name,password) VALUES ('next_hop_"+string(rune('a'+index))+"','sequence','test-only')")
|
|
_ = connection.Close()
|
|
if queryErr != nil || subject != "中文主题" || logErr != nil || logPayload != `{"message":"中文日志"}` || insertErr != nil {
|
|
t.Fatal("target content or identity sequence was not preserved")
|
|
}
|
|
if historyErr != nil || !bytes.Equal(historyPayload, payload) || historyDigest != digest {
|
|
t.Fatal("configuration history bytes or digest were not preserved")
|
|
}
|
|
if backupErr != nil || backups == 0 {
|
|
t.Fatal("real configuration backup did not survive migration")
|
|
}
|
|
source = target
|
|
}
|
|
}
|