329 lines
9.6 KiB
Go
329 lines
9.6 KiB
Go
package test
|
|
|
|
import (
|
|
"RenewCMS/internal/infrastructure/persistence/database"
|
|
"bytes"
|
|
"errors"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
type fakeDumpTable struct {
|
|
create string
|
|
columns []database.DumpColumn
|
|
rows [][][]byte
|
|
}
|
|
|
|
type fakeDumpSource struct {
|
|
order []string
|
|
tables map[string]fakeDumpTable
|
|
failOn string
|
|
closed bool
|
|
rowsError error
|
|
}
|
|
|
|
var _ database.DumpSource = &fakeDumpSource{}
|
|
|
|
type fakeDumpRows struct {
|
|
table fakeDumpTable
|
|
index int
|
|
failed error
|
|
}
|
|
|
|
var _ database.DumpRows = &fakeDumpRows{}
|
|
|
|
func (s *fakeDumpSource) Tables() ([]string, error) { return s.order, nil }
|
|
|
|
func (s *fakeDumpSource) CreateStatement(table string) (string, error) {
|
|
if s.failOn == table {
|
|
return "", errors.New("show create failed")
|
|
}
|
|
return s.tables[table].create, nil
|
|
}
|
|
|
|
func (s *fakeDumpSource) Rows(table string) (database.DumpRows, error) {
|
|
return &fakeDumpRows{table: s.tables[table], index: -1, failed: s.rowsError}, nil
|
|
}
|
|
|
|
func (s *fakeDumpSource) Close() error {
|
|
s.closed = true
|
|
return nil
|
|
}
|
|
|
|
func (r *fakeDumpRows) Columns() ([]database.DumpColumn, error) { return r.table.columns, nil }
|
|
|
|
func (r *fakeDumpRows) Next() bool {
|
|
r.index++
|
|
return r.index < len(r.table.rows)
|
|
}
|
|
|
|
func (r *fakeDumpRows) Values() ([][]byte, error) { return r.table.rows[r.index], nil }
|
|
func (r *fakeDumpRows) Err() error { return r.failed }
|
|
func (r *fakeDumpRows) Close() error { return nil }
|
|
|
|
func newFakeDumpSource() *fakeDumpSource {
|
|
return &fakeDumpSource{
|
|
order: []string{"notes", "plain"},
|
|
tables: map[string]fakeDumpTable{
|
|
"notes": {
|
|
create: "CREATE TABLE `notes` (\n `id` int unsigned NOT NULL,\n `body` longtext,\n `blob` longblob,\n `at` datetime(3),\n `ok` tinyint(1)\n)",
|
|
columns: []database.DumpColumn{
|
|
{Name: "id", Type: "UNSIGNED INT"},
|
|
{Name: "body", Type: "LONGTEXT"},
|
|
{Name: "blob", Type: "LONGBLOB"},
|
|
{Name: "at", Type: "DATETIME"},
|
|
{Name: "ok", Type: "TINYINT"},
|
|
},
|
|
rows: [][][]byte{
|
|
{[]byte("1"), []byte("it's a \"test\"\nline\\two\r\x00\x1a"), []byte{0x00, 0xff, 0x10}, []byte("2026-01-02 03:04:05.123000"), []byte("1")},
|
|
{[]byte("2"), nil, []byte{}, []byte("0000-00-00 00:00:00"), []byte("0")},
|
|
},
|
|
},
|
|
"plain": {
|
|
create: "CREATE TABLE `plain` (\n `a` int\n)",
|
|
columns: []database.DumpColumn{{Name: "a", Type: "INT"}},
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
func TestDumpWriter(t *testing.T) {
|
|
t.Run("the dump holds the schema and the rows of every table", func(t *testing.T) {
|
|
var output bytes.Buffer
|
|
|
|
err := database.WriteDump(&output, newFakeDumpSource())
|
|
|
|
if err != nil {
|
|
t.Fatalf("write: %v", err)
|
|
}
|
|
dump := output.String()
|
|
for _, expected := range []string{
|
|
"CREATE TABLE `notes` (",
|
|
"CREATE TABLE `plain` (",
|
|
"INSERT INTO `notes` (`id`,`body`,`blob`,`at`,`ok`) VALUES (1,'it\\'s a \"test\"\\nline\\\\two\\r\\0\\Z',0x00ff10,'2026-01-02 03:04:05.123000',1);",
|
|
"INSERT INTO `notes` (`id`,`body`,`blob`,`at`,`ok`) VALUES (2,NULL,'','0000-00-00 00:00:00',0);",
|
|
"SET sql_mode = 'NO_AUTO_VALUE_ON_ZERO';",
|
|
} {
|
|
if !strings.Contains(dump, expected) {
|
|
t.Errorf("the dump must contain %q, got:\n%s", expected, dump)
|
|
}
|
|
}
|
|
if strings.Contains(dump, "INSERT INTO `plain`") {
|
|
t.Error("an empty table has no rows to insert")
|
|
}
|
|
})
|
|
|
|
t.Run("every statement after the schema stays on one line", func(t *testing.T) {
|
|
var output bytes.Buffer
|
|
_ = database.WriteDump(&output, newFakeDumpSource())
|
|
|
|
for _, line := range strings.Split(output.String(), "\n") {
|
|
if strings.HasPrefix(line, "INSERT") && !strings.HasSuffix(line, ");") {
|
|
t.Errorf("insert split over several lines: %q", line)
|
|
}
|
|
}
|
|
})
|
|
|
|
t.Run("a failing table stops the dump with the error", func(t *testing.T) {
|
|
source := newFakeDumpSource()
|
|
source.failOn = "plain"
|
|
|
|
err := database.WriteDump(&bytes.Buffer{}, source)
|
|
|
|
if err == nil || err.Error() != "show create failed" {
|
|
t.Errorf("unexpected error %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("a row error stops the dump with the error", func(t *testing.T) {
|
|
source := newFakeDumpSource()
|
|
source.rowsError = errors.New("connection lost")
|
|
|
|
err := database.WriteDump(&bytes.Buffer{}, source)
|
|
|
|
if err == nil || err.Error() != "connection lost" {
|
|
t.Errorf("unexpected error %v", err)
|
|
}
|
|
})
|
|
}
|
|
|
|
type dumpBackupRig struct {
|
|
directory string
|
|
source *fakeDumpSource
|
|
clock time.Time
|
|
backup *database.DumpBackup
|
|
}
|
|
|
|
func newDumpBackupRig(t *testing.T) *dumpBackupRig {
|
|
t.Helper()
|
|
rig := &dumpBackupRig{
|
|
directory: filepath.Join(t.TempDir(), "nested", "backups"),
|
|
source: newFakeDumpSource(),
|
|
clock: time.Date(2026, 3, 4, 5, 6, 7, 0, time.UTC),
|
|
}
|
|
rig.backup = database.NewDumpBackup(func() (database.DumpSource, error) { return rig.source, nil }, rig.directory, func() time.Time {
|
|
rig.clock = rig.clock.Add(time.Second)
|
|
return rig.clock
|
|
})
|
|
return rig
|
|
}
|
|
|
|
func (r *dumpBackupRig) names(t *testing.T) []string {
|
|
t.Helper()
|
|
entries, err := os.ReadDir(r.directory)
|
|
if err != nil {
|
|
t.Fatalf("read backups: %v", err)
|
|
}
|
|
names := make([]string, 0, len(entries))
|
|
for _, entry := range entries {
|
|
names = append(names, entry.Name())
|
|
}
|
|
return names
|
|
}
|
|
|
|
func TestDumpBackup(t *testing.T) {
|
|
t.Run("a file renewcms-<time>.sql exists in the backups directory", func(t *testing.T) {
|
|
rig := newDumpBackupRig(t)
|
|
|
|
path, err := rig.backup.Backup()
|
|
|
|
if err != nil {
|
|
t.Fatalf("backup: %v", err)
|
|
}
|
|
name := filepath.Base(path)
|
|
if filepath.Dir(path) != rig.directory || !strings.HasPrefix(name, "renewcms-20260304T050608") || !strings.HasSuffix(name, ".sql") {
|
|
t.Errorf("unexpected backup path %q", path)
|
|
}
|
|
content, err := os.ReadFile(path)
|
|
if err != nil || !strings.Contains(string(content), "INSERT INTO `notes`") {
|
|
t.Errorf("unexpected content %q err %v", content, err)
|
|
}
|
|
if !rig.source.closed {
|
|
t.Error("the source must be released")
|
|
}
|
|
})
|
|
|
|
t.Run("only the 3 newest backups are kept", func(t *testing.T) {
|
|
rig := newDumpBackupRig(t)
|
|
var paths []string
|
|
for range 5 {
|
|
path, err := rig.backup.Backup()
|
|
if err != nil {
|
|
t.Fatalf("backup: %v", err)
|
|
}
|
|
paths = append(paths, path)
|
|
}
|
|
|
|
names := rig.names(t)
|
|
|
|
if len(names) != 3 {
|
|
t.Fatalf("expected 3 backups, got %v", names)
|
|
}
|
|
for _, newest := range paths[2:] {
|
|
if _, err := os.Stat(newest); err != nil {
|
|
t.Errorf("the newest backup %q must remain: %v", newest, err)
|
|
}
|
|
}
|
|
})
|
|
|
|
t.Run("a failure returns an error and leaves no file behind", func(t *testing.T) {
|
|
rig := newDumpBackupRig(t)
|
|
rig.source.failOn = "plain"
|
|
|
|
path, err := rig.backup.Backup()
|
|
|
|
if err == nil || path != "" {
|
|
t.Fatalf("expected a failure, got path %q err %v", path, err)
|
|
}
|
|
if names := rig.names(t); len(names) != 0 {
|
|
t.Errorf("no partial file may remain, got %v", names)
|
|
}
|
|
})
|
|
|
|
t.Run("a source that cannot be opened is a failure", func(t *testing.T) {
|
|
directory := filepath.Join(t.TempDir(), "backups")
|
|
backup := database.NewDumpBackup(func() (database.DumpSource, error) { return nil, errors.New("no connection") }, directory, time.Now)
|
|
|
|
path, err := backup.Backup()
|
|
|
|
if err == nil || path != "" {
|
|
t.Errorf("expected a failure, got path %q err %v", path, err)
|
|
}
|
|
if entries, _ := os.ReadDir(directory); len(entries) != 0 {
|
|
t.Errorf("no file may remain, got %d entries", len(entries))
|
|
}
|
|
})
|
|
|
|
t.Run("a backup taken at the same instant is refused instead of overwritten", func(t *testing.T) {
|
|
directory := filepath.Join(t.TempDir(), "backups")
|
|
fixed := func() time.Time { return time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) }
|
|
backup := database.NewDumpBackup(func() (database.DumpSource, error) { return newFakeDumpSource(), nil }, directory, fixed)
|
|
if _, err := backup.Backup(); err != nil {
|
|
t.Fatalf("first backup: %v", err)
|
|
}
|
|
|
|
_, err := backup.Backup()
|
|
|
|
if err == nil {
|
|
t.Error("expected the second backup to fail")
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestDumpBackupFiles(t *testing.T) {
|
|
t.Run("backups sharing a modification time are pruned by name, oldest name first", func(t *testing.T) {
|
|
rig := newDumpBackupRig(t)
|
|
if err := os.MkdirAll(rig.directory, os.ModePerm); err != nil {
|
|
t.Fatalf("mkdir: %v", err)
|
|
}
|
|
shared := time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC)
|
|
for _, name := range []string{"renewcms-1.sql", "renewcms-2.sql", "renewcms-3.sql", "renewcms-4.sql"} {
|
|
path := filepath.Join(rig.directory, name)
|
|
if err := os.WriteFile(path, []byte("x"), 0o600); err != nil {
|
|
t.Fatalf("write: %v", err)
|
|
}
|
|
if err := os.Chtimes(path, shared, shared); err != nil {
|
|
t.Fatalf("chtimes: %v", err)
|
|
}
|
|
}
|
|
|
|
if _, err := rig.backup.Backup(); err != nil {
|
|
t.Fatalf("backup: %v", err)
|
|
}
|
|
|
|
names := rig.names(t)
|
|
if len(names) != 3 || !strings.HasPrefix(names[0], "renewcms-2026") || names[1] != "renewcms-3.sql" || names[2] != "renewcms-4.sql" {
|
|
t.Errorf("the new backup and the two highest names must survive, got %v", names)
|
|
}
|
|
})
|
|
|
|
t.Run("a target that cannot be renamed into is a failure and leaves no partial file", func(t *testing.T) {
|
|
rig := newDumpBackupRig(t)
|
|
blocker := ""
|
|
rig.backup = database.NewDumpBackup(func() (database.DumpSource, error) {
|
|
blocker = filepath.Join(rig.directory, "renewcms-"+rig.clock.UTC().Format("20060102T150405.000000000Z")+".sql")
|
|
if err := os.MkdirAll(filepath.Join(blocker, "inner"), os.ModePerm); err != nil {
|
|
t.Fatalf("block target: %v", err)
|
|
}
|
|
return rig.source, nil
|
|
}, rig.directory, func() time.Time {
|
|
rig.clock = rig.clock.Add(time.Second)
|
|
return rig.clock
|
|
})
|
|
|
|
path, err := rig.backup.Backup()
|
|
|
|
if err == nil || path != "" {
|
|
t.Fatalf("expected a failure, got path %q err %v", path, err)
|
|
}
|
|
for _, name := range rig.names(t) {
|
|
if strings.HasSuffix(name, ".partial") {
|
|
t.Errorf("the partial file %q must be removed", name)
|
|
}
|
|
}
|
|
})
|
|
}
|