129 lines
5.3 KiB
Go
129 lines
5.3 KiB
Go
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)
|
||
}
|
||
})
|
||
})
|
||
}
|
||
}
|