mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-12 16:26:36 +08:00
227 lines
7.7 KiB
Go
227 lines
7.7 KiB
Go
package service
|
|
|
|
import (
|
|
"compress/gzip"
|
|
"context"
|
|
"os"
|
|
"path/filepath"
|
|
"reflect"
|
|
"testing"
|
|
|
|
"go.uber.org/zap"
|
|
|
|
"github.com/truewhile/MeBox/internal/config"
|
|
"github.com/truewhile/MeBox/internal/model"
|
|
)
|
|
|
|
func newBackupTestService(t *testing.T) (*BackupService, *config.Config) {
|
|
t.Helper()
|
|
cfg := &config.Config{}
|
|
cfg.App.DataDir = t.TempDir()
|
|
db := newServiceTestDB(t)
|
|
svc := &BackupService{cfg: cfg, log: zap.NewNop(), db: db}
|
|
// Create 会先建目录;直接调各方言的 create* 助手时需要它已存在。
|
|
if err := os.MkdirAll(svc.backupDir(), 0o750); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return svc, cfg
|
|
}
|
|
|
|
// TestPostgresLogicalBackupRoundTrip 验证「备份 → 改库 → 恢复」后整库回到备份时的
|
|
// 样子,包括软删除行与 json:"-" 的列。
|
|
//
|
|
// 逻辑备份/恢复本身是方言无关的 GORM 代码(PostgreSQL 专有的只有文件后缀与
|
|
// isPostgres() 分派),所以可以直接在 SQLite 上跑。
|
|
func TestPostgresLogicalBackupRoundTrip(t *testing.T) {
|
|
svc, _ := newBackupTestService(t)
|
|
ctx := context.Background()
|
|
db := svc.db
|
|
|
|
lib := model.Library{Base: model.Base{ID: "lib-1"}, Name: "Movies", Path: "/media/movies", Type: "movie", Enabled: true}
|
|
if err := db.Create(&lib).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := db.Create(&model.LibraryRoot{Base: model.Base{ID: "root-1"}, LibraryID: lib.ID, Path: "/media/movies"}).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := db.Create(&model.Media{Base: model.Base{ID: "m-keep"}, LibraryID: lib.ID, Title: "Keep", Path: "/media/movies/keep.mkv"}).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := db.Create(&model.Media{Base: model.Base{ID: "m-gone"}, LibraryID: lib.ID, Title: "Gone", Path: "/media/movies/gone.mkv"}).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := db.Delete(&model.Media{}, "id = ?", "m-gone").Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := db.Create(&model.Setting{Key: "k", Value: "v"}).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// 下面这些列都带 json:"-":走结构体自带的 JSON 序列化会被整列丢掉,
|
|
// 恢复之后用户权限、置顶库、加密后的密钥会一起消失。
|
|
user := model.User{
|
|
Base: model.Base{ID: "u-1"},
|
|
Username: "alice",
|
|
PasswordHash: "hash",
|
|
Role: "user",
|
|
AllowedLibraryIDs: `["lib-1"]`,
|
|
PinnedLibraryIDs: `["lib-1"]`,
|
|
HideAdult: true,
|
|
IsActive: true,
|
|
DanmakuEnabled: true,
|
|
PlayerVr360GuideSeen: true,
|
|
}
|
|
if err := db.Create(&user).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := db.Create(&model.APIConfig{Base: model.Base{ID: "cfg-1"}, Provider: "tmdb", APIKey: "ciphertext", Enabled: true}).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
dump, err := svc.createPostgresBackup(ctx, "20260101_000000")
|
|
if err != nil {
|
|
t.Fatalf("createPostgresBackup: %v", err)
|
|
}
|
|
if dump == "" {
|
|
t.Fatal("createPostgresBackup returned an empty path")
|
|
}
|
|
|
|
// 破坏当前数据:加一条备份里没有的、硬删一条备份里有的。
|
|
if err := db.Create(&model.Media{Base: model.Base{ID: "m-extra"}, LibraryID: lib.ID, Title: "Extra", Path: "/media/movies/extra.mkv"}).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := db.Unscoped().Delete(&model.Media{}, "id = ?", "m-keep").Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if err := svc.restorePostgresBackup(ctx, dump); err != nil {
|
|
t.Fatalf("restorePostgresBackup: %v", err)
|
|
}
|
|
|
|
var ids []string
|
|
if err := db.Unscoped().Model(&model.Media{}).Order("id asc").Pluck("id", &ids).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if want := []string{"m-gone", "m-keep"}; !reflect.DeepEqual(ids, want) {
|
|
t.Fatalf("media ids after restore = %v, want %v", ids, want)
|
|
}
|
|
|
|
// 软删除状态必须原样还原,而不是被「复活」。
|
|
var gone model.Media
|
|
if err := db.Unscoped().First(&gone, "id = ?", "m-gone").Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !gone.DeletedAt.Valid {
|
|
t.Fatal("soft-deleted media was revived by restore")
|
|
}
|
|
var live int64
|
|
if err := db.Model(&model.Media{}).Where("id = ?", "m-keep").Count(&live).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if live != 1 {
|
|
t.Fatalf("live media count = %d, want 1", live)
|
|
}
|
|
|
|
var value string
|
|
if err := db.Model(&model.Setting{}).Where("key = ?", "k").Select("value").Scan(&value).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if value != "v" {
|
|
t.Fatalf("setting value = %q, want v", value)
|
|
}
|
|
var libCount int64
|
|
if err := db.Model(&model.Library{}).Count(&libCount).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if libCount != 1 {
|
|
t.Fatalf("library count = %d, want 1", libCount)
|
|
}
|
|
var rootCount int64
|
|
if err := db.Model(&model.LibraryRoot{}).Count(&rootCount).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if rootCount != 1 {
|
|
t.Fatalf("library root count = %d, want 1", rootCount)
|
|
}
|
|
|
|
// json:"-" 的列必须同样被备份:它们是权限与凭据,丢了比丢一条媒体严重得多。
|
|
var restoredUser model.User
|
|
if err := db.First(&restoredUser, "id = ?", "u-1").Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if restoredUser.PasswordHash != "hash" {
|
|
t.Fatalf("password hash = %q, want hash", restoredUser.PasswordHash)
|
|
}
|
|
if restoredUser.AllowedLibraryIDs != `["lib-1"]` {
|
|
t.Fatalf(`allowed library ids = %q, want ["lib-1"]`, restoredUser.AllowedLibraryIDs)
|
|
}
|
|
if restoredUser.PinnedLibraryIDs != `["lib-1"]` {
|
|
t.Fatalf(`pinned library ids = %q, want ["lib-1"]`, restoredUser.PinnedLibraryIDs)
|
|
}
|
|
if !restoredUser.HideAdult || !restoredUser.IsActive || !restoredUser.DanmakuEnabled || !restoredUser.PlayerVr360GuideSeen {
|
|
t.Fatalf("boolean columns lost: %+v", restoredUser)
|
|
}
|
|
|
|
var restoredConfig model.APIConfig
|
|
if err := db.First(&restoredConfig, "id = ?", "cfg-1").Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if restoredConfig.APIKey != "ciphertext" {
|
|
t.Fatalf("api key = %q, want ciphertext", restoredConfig.APIKey)
|
|
}
|
|
}
|
|
|
|
// TestRestorePostgresBackupKeepsDataOnCorruptFile 验证损坏的备份不会在回滚后留下
|
|
// 半清空的库:恢复是先清空再插入,必须整体在一个事务里,否则中途失败就把库清掉了。
|
|
func TestRestorePostgresBackupKeepsDataOnCorruptFile(t *testing.T) {
|
|
svc, _ := newBackupTestService(t)
|
|
ctx := context.Background()
|
|
if err := svc.db.Create(&model.Setting{Key: "keep", Value: "me"}).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
corrupt := filepath.Join(t.TempDir(), "broken.json.gz")
|
|
file, err := os.Create(corrupt)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
zw := gzip.NewWriter(file)
|
|
// 头部完整、正文半截:解码在清空表之后才会失败。
|
|
if _, err := zw.Write([]byte(
|
|
`{"format":"mebox-postgres-logical","version":1,"created_at":"2026-01-01T00:00:00Z"}` + "\n" +
|
|
`{"table":"media","rows":[`)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := zw.Close(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := file.Close(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if err := svc.restorePostgresBackup(ctx, corrupt); err == nil {
|
|
t.Fatal("corrupt backup should fail to restore")
|
|
}
|
|
|
|
var value string
|
|
if err := svc.db.Model(&model.Setting{}).Where("key = ?", "keep").Select("value").Scan(&value).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if value != "me" {
|
|
t.Fatalf("a failed restore wiped the database: settings keep = %q", value)
|
|
}
|
|
}
|
|
|
|
// TestRestoreRejectsBackupFromOtherDialect 验证备份文件与当前方言不匹配时给出明确
|
|
// 错误,而不是把 SQLite 文件写到 PostgreSQL 部署的库路径上(那是原实现的隐患)。
|
|
func TestRestoreRejectsBackupFromOtherDialect(t *testing.T) {
|
|
svc, _ := newBackupTestService(t)
|
|
pgStyle := filepath.Join(svc.backupDir(), "mebox_20260101_000000.json.gz")
|
|
if err := os.WriteFile(pgStyle, []byte("not-gzip"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// SQLite 路径下不允许恢复 PostgreSQL 逻辑备份。
|
|
if err := svc.Restore(context.Background(), filepath.Base(pgStyle)); err == nil {
|
|
t.Fatal("SQLite restore accepted a PostgreSQL logical backup")
|
|
}
|
|
}
|