Scert/backend/internal/cli/mysql_integration_test.go
xuans 7bfd400345 修复MySQL恢复会话切换与精确数值备份错误
固定恢复连接,按DECIMAL列类型保留精确文本。新增双版本实库备份恢复、错误停止、取消以及正式命令失败回滚回归;不将同进程命令测试视为独立发行验收。
2026-09-27 19:32:46 +08:00

129 lines
5.3 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package cli
import (
"bytes"
"context"
"database/sql"
"os"
"path/filepath"
"testing"
"git.pcux.cn/xuans/Scert/backend/internal/database"
"git.pcux.cn/xuans/Scert/backend/internal/testsupport/mysqltest"
)
func TestMySQLRealBackupRestore(t *testing.T) {
server := mysqltest.New(t)
t.Run("session_identity", func(t *testing.T) {
cfg := server.Database(t)
cfg.MaxIdleConns = 0
input := filepath.Join(server.Root(), "session.sql")
script := "CREATE TABLE session_probe (id BIGINT NOT NULL, original_id BIGINT NOT NULL); SET @restore_connection = CONNECTION_ID(); INSERT INTO session_probe VALUES (CONNECTION_ID(), @restore_connection);"
if err := os.WriteFile(input, []byte(script), 0o600); err != nil {
t.Fatal(err)
}
if err := restoreMySQL(t.Context(), cfg, input); err != nil {
t.Fatalf("恢复必须保留会话变量:%v", err)
}
db, err := database.Open(t.Context(), cfg)
if err != nil {
t.Fatal(err)
}
defer db.Close()
var same bool
if err := db.DB.QueryRowContext(t.Context(), "SELECT id = original_id FROM session_probe").Scan(&same); err != nil || !same {
t.Fatalf("恢复过程发生连接切换:%v", err)
}
})
for _, scenario := range []struct {
name string
idle int
}{
{name: "without_idle_connections", idle: 0},
{name: "with_idle_connections", idle: 1},
} {
t.Run(scenario.name, func(t *testing.T) {
cfg := server.Database(t)
cfg.MaxIdleConns = scenario.idle
db, err := database.Open(t.Context(), cfg)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = db.Close() })
exec := func(statement string, args ...any) sql.Result {
t.Helper()
result, err := db.DB.ExecContext(t.Context(), statement, args...)
if err != nil {
t.Fatal(err)
}
return result
}
// 子表排在父表前面,恢复需要在同一会话内暂时关闭外键检查。
exec("CREATE TABLE z_parent (id BIGINT PRIMARY KEY AUTO_INCREMENT, label VARCHAR(100) NOT NULL) ENGINE=InnoDB")
exec("CREATE TABLE a_child (id BIGINT PRIMARY KEY, parent_id BIGINT NOT NULL, payload BLOB, amount DECIMAL(20,6), optional_value TEXT NULL, created_at DATETIME(6), FOREIGN KEY (parent_id) REFERENCES z_parent(id)) ENGINE=InnoDB")
label := "证书恢复;单引号'与反斜线\\\n"
payload := []byte{0, 1, 39, 59, 92, 255}
exec("INSERT INTO z_parent (id, label) VALUES (7, ?)", label)
exec("INSERT INTO a_child VALUES (1, 7, ?, 123456789.123456, NULL, '2026-09-27 12:34:56.123456')", payload)
root := filepath.Join(server.Root(), scenario.name)
if err := os.MkdirAll(root, 0o700); err != nil {
t.Fatal(err)
}
backup := filepath.Join(root, "backup.sql")
if err := backupMySQLDatabase(t.Context(), cfg, backup); err != nil {
t.Fatal(err)
}
exec("DELETE FROM a_child")
exec("DELETE FROM z_parent")
exec("INSERT INTO z_parent (id, label) VALUES (8, 'changed')")
exec("INSERT INTO a_child (id, parent_id) VALUES (2, 8)")
if err := restoreMySQL(t.Context(), cfg, backup); err != nil {
t.Fatalf("恢复真实备份失败:%v", err)
}
var gotLabel, amount, created string
var gotPayload []byte
var optional sql.NullString
if err := db.DB.QueryRowContext(t.Context(), "SELECT p.label, c.payload, CAST(c.amount AS CHAR), c.optional_value, DATE_FORMAT(c.created_at, '%Y-%m-%d %H:%i:%s.%f') FROM a_child c JOIN z_parent p ON p.id = c.parent_id WHERE c.id = 1 AND p.id = 7").Scan(&gotLabel, &gotPayload, &amount, &optional, &created); err != nil {
t.Fatal(err)
}
if gotLabel != label || !bytes.Equal(gotPayload, payload) || amount != "123456789.123456" || optional.Valid || created != "2026-09-27 12:34:56.123456" {
t.Fatal("恢复后的文本、二进制、精确数值、空值或时间不一致")
}
id, err := exec("INSERT INTO z_parent (label) VALUES ('next')").LastInsertId()
if err != nil || id != 8 {
t.Fatalf("恢复后的自增序列不正确:%d,%v", id, err)
}
if _, err := db.DB.ExecContext(t.Context(), "INSERT INTO a_child (id, parent_id) VALUES (99, 999)"); err == nil {
t.Fatal("恢复后仍须强制检查外键约束")
}
t.Run("stop_on_error", func(t *testing.T) {
input := filepath.Join(root, "invalid.sql")
// 第一条写入已经成功时,不能隐式重放或继续执行失败后的语句。
script := "SET FOREIGN_KEY_CHECKS=0; INSERT INTO z_parent (id, label) VALUES (20, 'before'); INSERT INTO absent_table VALUES (1); INSERT INTO z_parent (id, label) VALUES (21, 'after');"
if err := os.WriteFile(input, []byte(script), 0o600); err != nil {
t.Fatal(err)
}
if err := restoreMySQL(t.Context(), cfg, input); err == nil {
t.Fatal("无效恢复语句应返回错误")
}
var count int
if err := db.DB.QueryRowContext(t.Context(), "SELECT COUNT(*) FROM z_parent WHERE id IN (20, 21)").Scan(&count); err != nil || count != 1 {
t.Fatalf("失败后不应继续执行或隐式重放:%d,%v", count, err)
}
})
t.Run("cancel_before_restore", func(t *testing.T) {
ctx, cancel := context.WithCancel(t.Context())
cancel()
if err := restoreMySQL(ctx, cfg, backup); err == nil {
t.Fatal("已取消的恢复不得执行")
}
var count int
if err := db.DB.QueryRowContext(t.Context(), "SELECT COUNT(*) FROM z_parent WHERE id = 20").Scan(&count); err != nil || count != 1 {
t.Fatalf("取消恢复不应修改数据:%d,%v", count, err)
}
})
})
}
}