backup_pg_dumper_test.go 5.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176
  1. package repository
  2. import (
  3. "context"
  4. "errors"
  5. "fmt"
  6. "io"
  7. "os"
  8. "os/exec"
  9. "regexp"
  10. "testing"
  11. "github.com/DATA-DOG/go-sqlmock"
  12. "github.com/Wei-Shaw/sub2api/internal/config"
  13. "github.com/stretchr/testify/require"
  14. )
  15. func newTestPgDumper(t *testing.T, commandContext func(context.Context, string, ...string) *exec.Cmd) (*PgDumper, sqlmock.Sqlmock) {
  16. t.Helper()
  17. db, mock, err := sqlmock.New()
  18. require.NoError(t, err)
  19. t.Cleanup(func() { _ = db.Close() })
  20. return &PgDumper{
  21. cfg: &config.DatabaseConfig{
  22. Host: "db.example.test",
  23. Port: 5432,
  24. User: "sub2api",
  25. Password: "secret",
  26. DBName: "sub2api",
  27. SSLMode: "require",
  28. },
  29. db: db,
  30. commandContext: commandContext,
  31. }, mock
  32. }
  33. func expectBackupMigrationLock(mock sqlmock.Sqlmock) {
  34. mock.ExpectQuery(regexp.QuoteMeta("SELECT pg_try_advisory_lock($1)")).
  35. WithArgs(migrationsAdvisoryLockID).
  36. WillReturnRows(sqlmock.NewRows([]string{"pg_try_advisory_lock"}).AddRow(true))
  37. }
  38. func expectBackupMigrationUnlock(mock sqlmock.Sqlmock) {
  39. mock.ExpectExec(regexp.QuoteMeta("SELECT pg_advisory_unlock($1)")).
  40. WithArgs(migrationsAdvisoryLockID).
  41. WillReturnResult(sqlmock.NewResult(0, 1))
  42. }
  43. func TestPgDumperHoldsMigrationLockThroughReaderClose(t *testing.T) {
  44. var mock sqlmock.Sqlmock
  45. commandCreated := false
  46. dumper, createdMock := newTestPgDumper(t, func(ctx context.Context, name string, args ...string) *exec.Cmd {
  47. commandCreated = true
  48. require.Equal(t, "pg_dump", name)
  49. require.Contains(t, args, "--clean")
  50. require.NoError(t, mock.ExpectationsWereMet(), "migration lock must be acquired before pg_dump is created")
  51. return backupTestCommand(ctx, "success")
  52. })
  53. mock = createdMock
  54. expectBackupMigrationLock(mock)
  55. reader, err := dumper.Dump(context.Background())
  56. require.NoError(t, err)
  57. require.True(t, commandCreated)
  58. data, err := io.ReadAll(reader)
  59. require.NoError(t, err)
  60. require.Equal(t, "backup-data", string(data))
  61. expectBackupMigrationUnlock(mock)
  62. require.Error(t, mock.ExpectationsWereMet(), "migration lock was released before the reader closed")
  63. require.NoError(t, reader.Close())
  64. require.NoError(t, mock.ExpectationsWereMet())
  65. require.NoError(t, reader.Close(), "reader close must be idempotent")
  66. }
  67. func TestPgDumperReleasesMigrationLockWhenStdoutPipeSetupFails(t *testing.T) {
  68. dumper, mock := newTestPgDumper(t, func(ctx context.Context, _ string, _ ...string) *exec.Cmd {
  69. cmd := exec.CommandContext(ctx, "sh", "-c", "true")
  70. cmd.Stdout = io.Discard
  71. return cmd
  72. })
  73. expectBackupMigrationLock(mock)
  74. expectBackupMigrationUnlock(mock)
  75. reader, err := dumper.Dump(context.Background())
  76. require.Nil(t, reader)
  77. require.ErrorContains(t, err, "create stdout pipe")
  78. require.NoError(t, mock.ExpectationsWereMet())
  79. }
  80. func TestPgDumperReleasesMigrationLockWhenProcessStartFails(t *testing.T) {
  81. dumper, mock := newTestPgDumper(t, func(ctx context.Context, _ string, _ ...string) *exec.Cmd {
  82. return exec.CommandContext(ctx, "/path/that/does/not/exist/pg_dump")
  83. })
  84. expectBackupMigrationLock(mock)
  85. expectBackupMigrationUnlock(mock)
  86. reader, err := dumper.Dump(context.Background())
  87. require.Nil(t, reader)
  88. require.ErrorContains(t, err, "start pg_dump")
  89. require.NoError(t, mock.ExpectationsWereMet())
  90. }
  91. func TestPgDumperReleasesMigrationLockWhenProcessFails(t *testing.T) {
  92. dumper, mock := newTestPgDumper(t, func(ctx context.Context, _ string, _ ...string) *exec.Cmd {
  93. return backupTestCommand(ctx, "failure")
  94. })
  95. expectBackupMigrationLock(mock)
  96. reader, err := dumper.Dump(context.Background())
  97. require.NoError(t, err)
  98. data, err := io.ReadAll(reader)
  99. require.NoError(t, err)
  100. require.Equal(t, "partial-backup", string(data))
  101. expectBackupMigrationUnlock(mock)
  102. require.ErrorContains(t, reader.Close(), "pg_dump exited with error")
  103. require.NoError(t, mock.ExpectationsWereMet())
  104. }
  105. func TestPgDumperReportsUnlockFailureAndDiscardsConnection(t *testing.T) {
  106. dumper, mock := newTestPgDumper(t, func(ctx context.Context, _ string, _ ...string) *exec.Cmd {
  107. return backupTestCommand(ctx, "success")
  108. })
  109. expectBackupMigrationLock(mock)
  110. reader, err := dumper.Dump(context.Background())
  111. require.NoError(t, err)
  112. _, err = io.ReadAll(reader)
  113. require.NoError(t, err)
  114. mock.ExpectExec(regexp.QuoteMeta("SELECT pg_advisory_unlock($1)")).
  115. WithArgs(migrationsAdvisoryLockID).
  116. WillReturnError(errors.New("unlock unavailable"))
  117. require.ErrorContains(t, reader.Close(), "release backup migration lock")
  118. require.NoError(t, mock.ExpectationsWereMet())
  119. }
  120. func backupTestCommand(ctx context.Context, mode string) *exec.Cmd {
  121. cmd := exec.CommandContext(ctx, os.Args[0], "-test.run=^TestBackupHelperProcess$")
  122. cmd.Env = append(os.Environ(), "SUB2_BACKUP_TEST_PROCESS="+mode)
  123. return cmd
  124. }
  125. func TestBackupHelperProcess(t *testing.T) {
  126. switch os.Getenv("SUB2_BACKUP_TEST_PROCESS") {
  127. case "success":
  128. fmt.Print("backup-data")
  129. os.Exit(0)
  130. case "failure":
  131. fmt.Print("partial-backup")
  132. os.Exit(7)
  133. }
  134. }
  135. func TestPgDumperDoesNotStartProcessWhenMigrationLockFails(t *testing.T) {
  136. commandCreated := false
  137. dumper, mock := newTestPgDumper(t, func(ctx context.Context, _ string, _ ...string) *exec.Cmd {
  138. commandCreated = true
  139. return exec.CommandContext(ctx, "sh", "-c", "true")
  140. })
  141. mock.ExpectQuery(regexp.QuoteMeta("SELECT pg_try_advisory_lock($1)")).
  142. WithArgs(migrationsAdvisoryLockID).
  143. WillReturnError(errors.New("database unavailable"))
  144. reader, err := dumper.Dump(context.Background())
  145. require.Nil(t, reader)
  146. require.ErrorContains(t, err, "acquire backup migration lock")
  147. require.False(t, commandCreated)
  148. require.NoError(t, mock.ExpectationsWereMet())
  149. }
  150. func TestPgDumperRejectsNilDatabase(t *testing.T) {
  151. dumper := &PgDumper{cfg: &config.DatabaseConfig{}}
  152. reader, err := dumper.Dump(context.Background())
  153. require.Nil(t, reader)
  154. require.ErrorContains(t, err, "nil sql db")
  155. }