Files
sub2api/backend/internal/repository/backup_pg_dumper_test.go
T

158 lines
5.3 KiB
Go

package repository
import (
"context"
"errors"
"io"
"os/exec"
"regexp"
"testing"
"github.com/DATA-DOG/go-sqlmock"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/stretchr/testify/require"
)
func newTestPgDumper(t *testing.T, commandContext func(context.Context, string, ...string) *exec.Cmd) (*PgDumper, sqlmock.Sqlmock) {
t.Helper()
db, mock, err := sqlmock.New()
require.NoError(t, err)
t.Cleanup(func() { _ = db.Close() })
return &PgDumper{
cfg: &config.DatabaseConfig{
Host: "db.example.test",
Port: 5432,
User: "sub2api",
Password: "secret",
DBName: "sub2api",
SSLMode: "require",
},
db: db,
commandContext: commandContext,
}, mock
}
func expectBackupMigrationLock(mock sqlmock.Sqlmock) {
mock.ExpectQuery(regexp.QuoteMeta("SELECT pg_try_advisory_lock($1)")).
WithArgs(migrationsAdvisoryLockID).
WillReturnRows(sqlmock.NewRows([]string{"pg_try_advisory_lock"}).AddRow(true))
}
func expectBackupMigrationUnlock(mock sqlmock.Sqlmock) {
mock.ExpectExec(regexp.QuoteMeta("SELECT pg_advisory_unlock($1)")).
WithArgs(migrationsAdvisoryLockID).
WillReturnResult(sqlmock.NewResult(0, 1))
}
func TestPgDumperHoldsMigrationLockThroughReaderClose(t *testing.T) {
var mock sqlmock.Sqlmock
commandCreated := false
dumper, createdMock := newTestPgDumper(t, func(ctx context.Context, name string, args ...string) *exec.Cmd {
commandCreated = true
require.Equal(t, "pg_dump", name)
require.Contains(t, args, "--clean")
require.NoError(t, mock.ExpectationsWereMet(), "migration lock must be acquired before pg_dump is created")
return exec.CommandContext(ctx, "sh", "-c", "printf backup-data")
})
mock = createdMock
expectBackupMigrationLock(mock)
reader, err := dumper.Dump(context.Background())
require.NoError(t, err)
require.True(t, commandCreated)
data, err := io.ReadAll(reader)
require.NoError(t, err)
require.Equal(t, "backup-data", string(data))
expectBackupMigrationUnlock(mock)
require.Error(t, mock.ExpectationsWereMet(), "migration lock was released before the reader closed")
require.NoError(t, reader.Close())
require.NoError(t, mock.ExpectationsWereMet())
require.NoError(t, reader.Close(), "reader close must be idempotent")
}
func TestPgDumperReleasesMigrationLockWhenStdoutPipeSetupFails(t *testing.T) {
dumper, mock := newTestPgDumper(t, func(ctx context.Context, _ string, _ ...string) *exec.Cmd {
cmd := exec.CommandContext(ctx, "sh", "-c", "true")
cmd.Stdout = io.Discard
return cmd
})
expectBackupMigrationLock(mock)
expectBackupMigrationUnlock(mock)
reader, err := dumper.Dump(context.Background())
require.Nil(t, reader)
require.ErrorContains(t, err, "create stdout pipe")
require.NoError(t, mock.ExpectationsWereMet())
}
func TestPgDumperReleasesMigrationLockWhenProcessStartFails(t *testing.T) {
dumper, mock := newTestPgDumper(t, func(ctx context.Context, _ string, _ ...string) *exec.Cmd {
return exec.CommandContext(ctx, "/path/that/does/not/exist/pg_dump")
})
expectBackupMigrationLock(mock)
expectBackupMigrationUnlock(mock)
reader, err := dumper.Dump(context.Background())
require.Nil(t, reader)
require.ErrorContains(t, err, "start pg_dump")
require.NoError(t, mock.ExpectationsWereMet())
}
func TestPgDumperReleasesMigrationLockWhenProcessFails(t *testing.T) {
dumper, mock := newTestPgDumper(t, func(ctx context.Context, _ string, _ ...string) *exec.Cmd {
return exec.CommandContext(ctx, "sh", "-c", "printf partial-backup; exit 7")
})
expectBackupMigrationLock(mock)
reader, err := dumper.Dump(context.Background())
require.NoError(t, err)
data, err := io.ReadAll(reader)
require.NoError(t, err)
require.Equal(t, "partial-backup", string(data))
expectBackupMigrationUnlock(mock)
require.ErrorContains(t, reader.Close(), "pg_dump exited with error")
require.NoError(t, mock.ExpectationsWereMet())
}
func TestPgDumperReportsUnlockFailureAndDiscardsConnection(t *testing.T) {
dumper, mock := newTestPgDumper(t, func(ctx context.Context, _ string, _ ...string) *exec.Cmd {
return exec.CommandContext(ctx, "sh", "-c", "printf backup-data")
})
expectBackupMigrationLock(mock)
reader, err := dumper.Dump(context.Background())
require.NoError(t, err)
_, err = io.ReadAll(reader)
require.NoError(t, err)
mock.ExpectExec(regexp.QuoteMeta("SELECT pg_advisory_unlock($1)")).
WithArgs(migrationsAdvisoryLockID).
WillReturnError(errors.New("unlock unavailable"))
require.ErrorContains(t, reader.Close(), "release backup migration lock")
require.NoError(t, mock.ExpectationsWereMet())
}
func TestPgDumperDoesNotStartProcessWhenMigrationLockFails(t *testing.T) {
commandCreated := false
dumper, mock := newTestPgDumper(t, func(ctx context.Context, _ string, _ ...string) *exec.Cmd {
commandCreated = true
return exec.CommandContext(ctx, "sh", "-c", "true")
})
mock.ExpectQuery(regexp.QuoteMeta("SELECT pg_try_advisory_lock($1)")).
WithArgs(migrationsAdvisoryLockID).
WillReturnError(errors.New("database unavailable"))
reader, err := dumper.Dump(context.Background())
require.Nil(t, reader)
require.ErrorContains(t, err, "acquire backup migration lock")
require.False(t, commandCreated)
require.NoError(t, mock.ExpectationsWereMet())
}
func TestPgDumperRejectsNilDatabase(t *testing.T) {
dumper := &PgDumper{cfg: &config.DatabaseConfig{}}
reader, err := dumper.Dump(context.Background())
require.Nil(t, reader)
require.ErrorContains(t, err, "nil sql db")
}