package tverdb_test import ( "bytes" "compress/gzip" "io" "os" "os/exec" "path/filepath" "strings" "testing" "time" ) func TestBackupDB(t *testing.T) { t.Run("success and retention boundary", func(t *testing.T) { env := newScriptEnv(t, "success") old := filepath.Join(env.backupDir, "tver_20260901_000000.sql.gz") boundary := filepath.Join(env.backupDir, "tver_20260920_000000.sql.gz") unrelated := []string{ filepath.Join(env.backupDir, "restore-test.log"), filepath.Join(env.backupDir, "other_20260901_000000.sql.gz"), filepath.Join(env.backupDir, "tver_20260901_000000.sql"), } writeFile(t, old, "old") writeFile(t, boundary, "boundary") for _, path := range unrelated { writeFile(t, path, "keep") } now := time.Now() oldTime := now.Add(-(15*24*time.Hour + time.Hour)) boundaryTime := now.Add(-(15*24*time.Hour - time.Hour)) if err := os.Chtimes(old, oldTime, oldTime); err != nil { t.Fatal(err) } if err := os.Chtimes(boundary, boundaryTime, boundaryTime); err != nil { t.Fatal(err) } for _, path := range unrelated { if err := os.Chtimes(path, oldTime, oldTime); err != nil { t.Fatal(err) } } output, err := env.run(t, "backup-db.sh", "STAMP=20261004_010203", "KEEP_DAYS=14", "CONTAINER=custom-postgres", "DB_USER=custom-user", "DB_NAME=custom-db") if err != nil { t.Fatalf("backup failed: %v\n%s", err, output) } created := filepath.Join(env.backupDir, "tver_20261004_010203.sql.gz") if got := readGzip(t, created); !strings.Contains(got, "CREATE TABLE example") { t.Fatalf("dump=%q", got) } if _, err := os.Stat(old); !os.IsNotExist(err) { t.Fatalf("old backup remains: %v", err) } if _, err := os.Stat(boundary); err != nil { t.Fatalf("14-day backup was deleted: %v", err) } for _, path := range unrelated { if got := readFile(t, path); got != "keep" { t.Fatalf("unrelated file was changed: %s content=%q", path, got) } } if !strings.Contains(output, "OK: "+created) || !strings.Contains(output, "bytes") { t.Fatalf("success output=%q", output) } if log := readFile(t, env.dockerLog); !strings.Contains(log, "exec custom-postgres pg_dump -U custom-user -d custom-db") { t.Fatalf("docker log=%q", log) } }) t.Run("creates missing backup directory", func(t *testing.T) { env := newScriptEnv(t, "success") if err := os.RemoveAll(env.backupDir); err != nil { t.Fatal(err) } output, err := env.run(t, "backup-db.sh", "STAMP=20261004_010203") if err != nil { t.Fatalf("backup failed: %v\n%s", err, output) } if got := readGzip(t, filepath.Join(env.backupDir, "tver_20261004_010203.sql.gz")); !strings.Contains(got, "CREATE TABLE example") { t.Fatalf("dump=%q", got) } }) t.Run("does not replace an existing completed backup", func(t *testing.T) { env := newScriptEnv(t, "success") completed := filepath.Join(env.backupDir, "tver_20261004_010203.sql.gz") writeGzip(t, completed, "existing") output, err := env.run(t, "backup-db.sh", "STAMP=20261004_010203") if err == nil { t.Fatalf("backup replaced an existing file: %s", output) } if got := readGzip(t, completed); got != "existing" { t.Fatalf("existing backup was changed: %q", got) } assertNoBackupTemps(t, env.backupDir) }) t.Run("same stamp concurrently allows exactly one backup", func(t *testing.T) { env := newScriptEnv(t, "concurrent") barrier := filepath.Join(env.dir, "barrier") extra := []string{"STAMP=20261004_010203", "FAKE_DOCKER_BARRIER=" + barrier} commands := make([]*exec.Cmd, 2) outputs := make([]bytes.Buffer, 2) for i := range commands { commands[i] = exec.Command("bash", "backup-db.sh") commands[i].Env = append(append(append([]string{}, os.Environ()...), env.baseEnv...), extra...) commands[i].Stdout = &outputs[i] commands[i].Stderr = &outputs[i] if err := commands[i].Start(); err != nil { t.Fatal(err) } } succeeded := 0 for i, command := range commands { if err := command.Wait(); err == nil { succeeded++ } else if _, ok := err.(*exec.ExitError); !ok { t.Fatalf("backup %d wait: %v output=%q", i, err, outputs[i].String()) } } if succeeded != 1 { t.Fatalf("concurrent successes=%d want=1 outputs=%q / %q", succeeded, outputs[0].String(), outputs[1].String()) } if got := readGzip(t, filepath.Join(env.backupDir, "tver_20261004_010203.sql.gz")); !strings.Contains(got, "CREATE TABLE example") { t.Fatalf("completed dump=%q", got) } assertNoBackupTemps(t, env.backupDir) }) for _, mode := range []string{"dump-fail", "dump-partial-fail", "empty"} { t.Run(mode, func(t *testing.T) { env := newScriptEnv(t, mode) output, err := env.run(t, "backup-db.sh", "STAMP=20261004_010203") if err == nil { t.Fatalf("backup succeeded unexpectedly: %s", output) } matches, globErr := filepath.Glob(filepath.Join(env.backupDir, "tver_*.sql.gz")) if globErr != nil || len(matches) != 0 { t.Fatalf("partial backups=%v err=%v", matches, globErr) } assertNoBackupTemps(t, env.backupDir) if mode == "empty" && !strings.Contains(output, "empty") { t.Fatalf("empty dump error missing: %q", output) } }) } for _, keepDays := range []string{"-1", "invalid"} { t.Run("invalid retention "+keepDays, func(t *testing.T) { env := newScriptEnv(t, "success") output, err := env.run(t, "backup-db.sh", "STAMP=20261004_010203", "KEEP_DAYS="+keepDays) if err == nil || !strings.Contains(output, "KEEP_DAYS") { t.Fatalf("invalid KEEP_DAYS: err=%v output=%q", err, output) } if _, err := os.Stat(env.dockerLog); !os.IsNotExist(err) { t.Fatalf("pg_dump was invoked: %v", err) } }) } } func TestRestoreTest(t *testing.T) { t.Run("restores latest dump and compares rows", func(t *testing.T) { env := newScriptEnv(t, "success") writeGzip(t, filepath.Join(env.backupDir, "tver_20261003_010203.sql.gz"), "older") writeGzip(t, filepath.Join(env.backupDir, "tver_20261004_010203.sql.gz"), "newest") output, err := env.run(t, "restore-test.sh") if err != nil { t.Fatalf("restore test failed: %v\n%s", err, output) } for _, table := range []string{"download_jobs", "subscriptions", "known_episodes", "schema_migrations"} { if !strings.Contains(output, "OK: "+table) { t.Errorf("missing result for %s: %s", table, output) } } if got := readFile(t, env.restoreInput); got != "newest" { t.Fatalf("restored dump=%q", got) } log := readFile(t, env.dockerLog) if !strings.Contains(log, "psql -q -o /dev/null -v ON_ERROR_STOP=1") { t.Fatalf("psql output suppression is missing: %q", log) } cleanupCommand := "dropdb --force --if-exists -U tver tver_restore_test" if strings.Count(log, cleanupCommand) != 2 || !strings.HasSuffix(strings.TrimSpace(log), cleanupCommand) { t.Fatalf("temporary DB cleanup log=%q", log) } for _, line := range strings.Split(strings.TrimSpace(log), "\n") { if (strings.Contains(line, " createdb ") || strings.Contains(line, " dropdb ")) && !strings.HasSuffix(line, "tver_restore_test") { t.Fatalf("production DB mutation was attempted: %q", line) } if strings.Contains(line, " psql ") && strings.Contains(line, " -d tver ") && !strings.Contains(line, "SELECT count(*)") { t.Fatalf("production DB received a non-count command: %q", line) } if strings.Contains(line, " ON_ERROR_STOP=1 ") && !strings.Contains(line, " -d tver_restore_test") { t.Fatalf("dump was restored into a non-temporary DB: %q", line) } } if restoreLog := readFile(t, filepath.Join(env.backupDir, "restore-test.log")); !strings.Contains(restoreLog, "復元確認に成功") || strings.Contains(restoreLog, "SET\n") || strings.Contains(restoreLog, "CREATE TABLE\n") { t.Fatalf("restore log=%q", restoreLog) } }) for _, mode := range []string{"restore-fail", "count-mismatch"} { t.Run(mode+" cleans temporary database", func(t *testing.T) { env := newScriptEnv(t, mode) writeGzip(t, filepath.Join(env.backupDir, "tver_20261004_010203.sql.gz"), "dump") output, err := env.run(t, "restore-test.sh") if err == nil { t.Fatalf("restore test succeeded unexpectedly: %s", output) } log := strings.TrimSpace(readFile(t, env.dockerLog)) if !strings.HasSuffix(log, "dropdb --force --if-exists -U tver tver_restore_test") { t.Fatalf("cleanup was not last command: %q", log) } if mode == "count-mismatch" && !strings.Contains(output, "ERROR: known_episodes") { t.Fatalf("mismatch not reported: %q", output) } if mode == "restore-fail" { if !strings.Contains(output, "restore failed") { t.Fatalf("restore error missing from output: %q", output) } if restoreLog := readFile(t, filepath.Join(env.backupDir, "restore-test.log")); !strings.Contains(restoreLog, "restore failed") { t.Fatalf("restore error missing from log: %q", restoreLog) } } }) } t.Run("rejects production database as restore target", func(t *testing.T) { env := newScriptEnv(t, "success") writeGzip(t, filepath.Join(env.backupDir, "tver_20261004_010203.sql.gz"), "dump") output, err := env.run(t, "restore-test.sh", "RESTORE_DB=tver") if err == nil || !strings.Contains(output, "RESTORE_DB must differ from DB_NAME") { t.Fatalf("production restore target was accepted: err=%v output=%q", err, output) } if _, err := os.Stat(env.dockerLog); !os.IsNotExist(err) { t.Fatalf("docker was invoked: %v", err) } if log := readFile(t, filepath.Join(env.backupDir, "restore-test.log")); !strings.Contains(log, "RESTORE_DB must differ from DB_NAME") { t.Fatalf("rejection was not logged: %q", log) } }) t.Run("missing dump fails and cleans temporary database", func(t *testing.T) { env := newScriptEnv(t, "success") output, err := env.run(t, "restore-test.sh") if err == nil || !strings.Contains(output, "backup dump was not found") { t.Fatalf("missing dump: err=%v output=%q", err, output) } log := strings.TrimSpace(readFile(t, env.dockerLog)) if log != "exec tver-postgres dropdb --force --if-exists -U tver tver_restore_test" { t.Fatalf("unexpected cleanup: %q", log) } if restoreLog := readFile(t, filepath.Join(env.backupDir, "restore-test.log")); !strings.Contains(restoreLog, "backup dump was not found") { t.Fatalf("failure was not logged: %q", restoreLog) } }) t.Run("honors database and container overrides", func(t *testing.T) { env := newScriptEnv(t, "success") writeGzip(t, filepath.Join(env.backupDir, "tver_20261004_010203.sql.gz"), "dump") output, err := env.run(t, "restore-test.sh", "CONTAINER=custom-postgres", "DB_USER=custom-user", "DB_NAME=production", "RESTORE_DB=temporary") if err != nil { t.Fatalf("restore failed: %v\n%s", err, output) } for _, line := range strings.Split(strings.TrimSpace(readFile(t, env.dockerLog)), "\n") { if !strings.Contains(line, " custom-postgres ") || strings.Contains(line, " tver-postgres ") || !strings.Contains(line, " -U custom-user ") { t.Fatalf("override missing: %q", line) } if (strings.Contains(line, " createdb ") || strings.Contains(line, " dropdb ")) && !strings.HasSuffix(line, "temporary") { t.Fatalf("wrong temporary DB: %q", line) } } }) t.Run("invalid gzip fails and cleans temporary database", func(t *testing.T) { env := newScriptEnv(t, "success") writeFile(t, filepath.Join(env.backupDir, "tver_20261004_010203.sql.gz"), "not gzip") output, err := env.run(t, "restore-test.sh") if err == nil { t.Fatalf("invalid gzip succeeded: %s", output) } log := strings.TrimSpace(readFile(t, env.dockerLog)) if !strings.HasSuffix(log, "dropdb --force --if-exists -U tver tver_restore_test") { t.Fatalf("cleanup was not last command: %q", log) } }) } type scriptEnv struct { dir, backupDir, dockerLog, restoreInput string baseEnv []string } func newScriptEnv(t *testing.T, mode string) scriptEnv { t.Helper() dir := t.TempDir() backupDir := filepath.Join(dir, "backups") if err := os.MkdirAll(backupDir, 0o755); err != nil { t.Fatal(err) } scriptDir, err := filepath.Abs(".") if err != nil { t.Fatal(err) } dockerDir := filepath.Join(scriptDir, "testdata") return scriptEnv{ dir: dir, backupDir: backupDir, dockerLog: filepath.Join(dir, "docker.log"), restoreInput: filepath.Join(dir, "restore.sql"), baseEnv: []string{ "PATH=" + dockerDir + string(os.PathListSeparator) + os.Getenv("PATH"), "BACKUP_DIR=" + backupDir, "LOG_FILE=" + filepath.Join(backupDir, "restore-test.log"), "FAKE_DOCKER_LOG=" + filepath.Join(dir, "docker.log"), "FAKE_RESTORE_INPUT=" + filepath.Join(dir, "restore.sql"), "FAKE_DOCKER_MODE=" + mode, }, } } func (e scriptEnv) run(t *testing.T, script string, extraEnv ...string) (string, error) { t.Helper() cmd := exec.Command("bash", script) cmd.Env = append(append(os.Environ(), e.baseEnv...), extraEnv...) var output bytes.Buffer cmd.Stdout = &output cmd.Stderr = &output err := cmd.Run() return output.String(), err } func writeFile(t *testing.T, path, content string) { t.Helper() if err := os.WriteFile(path, []byte(content), 0o600); err != nil { t.Fatal(err) } } func readFile(t *testing.T, path string) string { t.Helper() data, err := os.ReadFile(path) if err != nil { t.Fatal(err) } return string(data) } func writeGzip(t *testing.T, path, content string) { t.Helper() file, err := os.Create(path) if err != nil { t.Fatal(err) } writer := gzip.NewWriter(file) if _, err := io.WriteString(writer, content); err != nil { t.Fatal(err) } if err := writer.Close(); err != nil { t.Fatal(err) } if err := file.Close(); err != nil { t.Fatal(err) } } func readGzip(t *testing.T, path string) string { t.Helper() file, err := os.Open(path) if err != nil { t.Fatal(err) } defer file.Close() reader, err := gzip.NewReader(file) if err != nil { t.Fatal(err) } defer reader.Close() data, err := io.ReadAll(reader) if err != nil { t.Fatal(err) } return string(data) } func assertNoBackupTemps(t *testing.T, backupDir string) { t.Helper() entries, err := os.ReadDir(backupDir) if err != nil { t.Fatal(err) } for _, entry := range entries { name := entry.Name() if strings.HasPrefix(name, ".tver_") || strings.Contains(name, ".sql.gz.tmp.") { t.Errorf("temporary backup remains: %s", name) } } }