| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176 |
- package repository
- import (
- "context"
- "errors"
- "fmt"
- "io"
- "os"
- "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 backupTestCommand(ctx, "success")
- })
- 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 backupTestCommand(ctx, "failure")
- })
- 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 backupTestCommand(ctx, "success")
- })
- 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 backupTestCommand(ctx context.Context, mode string) *exec.Cmd {
- cmd := exec.CommandContext(ctx, os.Args[0], "-test.run=^TestBackupHelperProcess$")
- cmd.Env = append(os.Environ(), "SUB2_BACKUP_TEST_PROCESS="+mode)
- return cmd
- }
- func TestBackupHelperProcess(t *testing.T) {
- switch os.Getenv("SUB2_BACKUP_TEST_PROCESS") {
- case "success":
- fmt.Print("backup-data")
- os.Exit(0)
- case "failure":
- fmt.Print("partial-backup")
- os.Exit(7)
- }
- }
- 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")
- }
|