mirror of
https://github.com/Awuqing/BackupX.git
synced 2026-08-29 12:06:49 +08:00
Remove obsolete implementations, centralize background task ownership and terminal-state recovery, consolidate frontend routing and log streaming, and enforce project-wide verification in CI.
219 lines
7.8 KiB
Go
219 lines
7.8 KiB
Go
package database
|
|
|
|
import (
|
|
"fmt"
|
|
"path/filepath"
|
|
"testing"
|
|
"time"
|
|
|
|
"backupx/server/internal/config"
|
|
"backupx/server/internal/logger"
|
|
"backupx/server/internal/model"
|
|
)
|
|
|
|
func TestOpenConfiguresSQLiteForSingleMasterConcurrency(t *testing.T) {
|
|
log, err := logger.New(config.LogConfig{Level: "error"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
db, err := Open(config.DatabaseConfig{Path: filepath.Join(t.TempDir(), "backupx.db")}, log)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
sqlDB, err := db.DB()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { _ = sqlDB.Close() })
|
|
|
|
var journalMode string
|
|
if err := db.Raw("PRAGMA journal_mode").Scan(&journalMode).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if journalMode != "delete" {
|
|
t.Fatalf("journal_mode = %q, want delete", journalMode)
|
|
}
|
|
var busyTimeout int
|
|
if err := db.Raw("PRAGMA busy_timeout").Scan(&busyTimeout).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if busyTimeout != 5000 {
|
|
t.Fatalf("busy_timeout = %d, want 5000", busyTimeout)
|
|
}
|
|
}
|
|
|
|
func TestReconcileInterruptedOperations(t *testing.T) {
|
|
log, err := logger.New(config.LogConfig{Level: "error"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
db, err := Open(config.DatabaseConfig{Path: filepath.Join(t.TempDir(), "reconcile.db")}, log)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
sqlDB, err := db.DB()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() {
|
|
if err := sqlDB.Close(); err != nil {
|
|
t.Errorf("close database: %v", err)
|
|
}
|
|
})
|
|
|
|
startedAt := time.Now().UTC().Add(-time.Minute)
|
|
task := model.BackupTask{Name: "interrupted", Type: model.BackupTaskTypeFile, LastStatus: model.BackupTaskStatusRunning}
|
|
if err := db.Create(&task).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
items := []any{
|
|
&model.BackupRecord{TaskID: task.ID, Status: model.BackupRecordStatusRunning, StartedAt: startedAt},
|
|
&model.RestoreRecord{TaskID: task.ID, Status: model.RestoreRecordStatusRunning, StartedAt: startedAt},
|
|
&model.VerificationRecord{TaskID: task.ID, Status: model.VerificationRecordStatusRunning, StartedAt: startedAt},
|
|
&model.ReplicationRecord{TaskID: task.ID, Status: model.ReplicationStatusRunning, StartedAt: startedAt},
|
|
}
|
|
for _, item := range items {
|
|
if err := db.Create(item).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
completedAt := time.Now().UTC()
|
|
count, err := reconcileInterruptedOperations(db, completedAt)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if count != 5 {
|
|
t.Fatalf("reconciled records = %d, want 5", count)
|
|
}
|
|
|
|
var runningRecords int64
|
|
for _, entity := range []any{&model.BackupRecord{}, &model.RestoreRecord{}, &model.VerificationRecord{}, &model.ReplicationRecord{}} {
|
|
if err := db.Model(entity).Where("status = ?", "running").Count(&runningRecords).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if runningRecords != 0 {
|
|
t.Fatalf("%T still has %d running records", entity, runningRecords)
|
|
}
|
|
}
|
|
if err := db.First(&task, task.ID).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if task.LastStatus != model.BackupTaskStatusFailed {
|
|
t.Fatalf("task last status = %q, want failed", task.LastStatus)
|
|
}
|
|
var backupRecord model.BackupRecord
|
|
if err := db.Where("task_id = ?", task.ID).First(&backupRecord).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if backupRecord.DurationSeconds <= 0 {
|
|
t.Fatalf("backup duration = %d, want positive duration", backupRecord.DurationSeconds)
|
|
}
|
|
}
|
|
|
|
func TestReconcileInterruptedOperationsPreservesRemoteAgentWork(t *testing.T) {
|
|
log, err := logger.New(config.LogConfig{Level: "error"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
db, err := Open(config.DatabaseConfig{Path: filepath.Join(t.TempDir(), "remote-reconcile.db")}, log)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
sqlDB, err := db.DB()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() {
|
|
if err := sqlDB.Close(); err != nil {
|
|
t.Errorf("close database: %v", err)
|
|
}
|
|
})
|
|
|
|
localNode := model.Node{Name: "local", Token: "local-token", Status: model.NodeStatusOnline, IsLocal: true}
|
|
remoteNode := model.Node{Name: "remote", Token: "remote-token", Status: model.NodeStatusOnline, IsLocal: false}
|
|
if err := db.Create(&localNode).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := db.Create(&remoteNode).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
localTask := model.BackupTask{Name: "local-interrupted", Type: model.BackupTaskTypeFile, LastStatus: model.BackupTaskStatusRunning}
|
|
remoteTask := model.BackupTask{Name: "remote-still-running", Type: model.BackupTaskTypeFile, LastStatus: model.BackupTaskStatusRunning}
|
|
orphanRemoteTask := model.BackupTask{Name: "remote-without-command", Type: model.BackupTaskTypeFile, LastStatus: model.BackupTaskStatusRunning}
|
|
if err := db.Create(&localTask).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := db.Create(&remoteTask).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := db.Create(&orphanRemoteTask).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
startedAt := time.Now().UTC().Add(-time.Minute)
|
|
localBackup := &model.BackupRecord{TaskID: localTask.ID, NodeID: localNode.ID, Status: model.BackupRecordStatusRunning, StartedAt: startedAt}
|
|
localRestore := &model.RestoreRecord{TaskID: localTask.ID, NodeID: localNode.ID, Status: model.RestoreRecordStatusRunning, StartedAt: startedAt}
|
|
remoteBackup := &model.BackupRecord{TaskID: remoteTask.ID, NodeID: remoteNode.ID, Status: model.BackupRecordStatusRunning, StartedAt: startedAt}
|
|
remoteRestore := &model.RestoreRecord{TaskID: remoteTask.ID, NodeID: remoteNode.ID, Status: model.RestoreRecordStatusRunning, StartedAt: startedAt}
|
|
orphanRemoteBackup := &model.BackupRecord{TaskID: orphanRemoteTask.ID, NodeID: remoteNode.ID, Status: model.BackupRecordStatusRunning, StartedAt: startedAt}
|
|
items := []any{localBackup, localRestore, remoteBackup, remoteRestore, orphanRemoteBackup}
|
|
for _, item := range items {
|
|
if err := db.Create(item).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
activeCommands := []model.AgentCommand{
|
|
{NodeID: remoteNode.ID, Type: model.AgentCommandTypeRunTask, Status: model.AgentCommandStatusDispatched, Payload: fmt.Sprintf(`{"recordId":%d}`, remoteBackup.ID)},
|
|
{NodeID: remoteNode.ID, Type: model.AgentCommandTypeRestoreRecord, Status: model.AgentCommandStatusPending, Payload: fmt.Sprintf(`{"restoreRecordId":%d}`, remoteRestore.ID)},
|
|
}
|
|
if err := db.Create(&activeCommands).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
count, err := reconcileInterruptedOperations(db, time.Now().UTC())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if count != 5 {
|
|
t.Fatalf("reconciled rows = %d, want local and unlinked remote records plus their tasks", count)
|
|
}
|
|
|
|
for _, entity := range []any{&model.BackupRecord{}, &model.RestoreRecord{}} {
|
|
var remoteRunning int64
|
|
if err := db.Model(entity).Where("task_id = ? AND status = ?", remoteTask.ID, "running").Count(&remoteRunning).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if remoteRunning != 1 {
|
|
t.Fatalf("%T remote running records = %d, want 1", entity, remoteRunning)
|
|
}
|
|
var localRunning int64
|
|
if err := db.Model(entity).Where("task_id = ? AND status = ?", localTask.ID, "running").Count(&localRunning).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if localRunning != 0 {
|
|
t.Fatalf("%T local running records = %d, want 0", entity, localRunning)
|
|
}
|
|
}
|
|
if err := db.First(&localTask, localTask.ID).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := db.First(&remoteTask, remoteTask.ID).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := db.First(&orphanRemoteTask, orphanRemoteTask.ID).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if localTask.LastStatus != model.BackupTaskStatusFailed || remoteTask.LastStatus != model.BackupTaskStatusRunning {
|
|
t.Fatalf("task statuses = local:%q remote:%q", localTask.LastStatus, remoteTask.LastStatus)
|
|
}
|
|
if orphanRemoteTask.LastStatus != model.BackupTaskStatusFailed {
|
|
t.Fatalf("unlinked remote task status = %q, want failed", orphanRemoteTask.LastStatus)
|
|
}
|
|
if err := db.First(orphanRemoteBackup, orphanRemoteBackup.ID).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if orphanRemoteBackup.Status != model.BackupRecordStatusFailed {
|
|
t.Fatalf("unlinked remote backup status = %q, want failed", orphanRemoteBackup.Status)
|
|
}
|
|
}
|