1package backup
2
3import (
4 "bytes"
5 "context"
6 "io"
7 "sync/atomic"
8 "testing"
9 "time"
10
11 inmocks "github.com/bnema/gordon/internal/boundaries/in/mocks"
12 outiface "github.com/bnema/gordon/internal/boundaries/out"
13 outmocks "github.com/bnema/gordon/internal/boundaries/out/mocks"
14 "github.com/bnema/gordon/internal/domain"
15 "github.com/bnema/zerowrap"
16 "github.com/stretchr/testify/assert"
17 "github.com/stretchr/testify/mock"
18 "github.com/stretchr/testify/require"
19)
20
21func TestService_DetectDatabases_PostgresAttachment(t *testing.T) {
22 runtime := outmocks.NewMockContainerRuntime(t)
23 storage := outmocks.NewMockBackupStorage(t)
24 containerSvc := inmocks.NewMockContainerService(t)
25
26 containerSvc.EXPECT().ListAttachments(mock.Anything, "app.example.com").Return([]domain.Attachment{
27 {Name: "postgres", Image: "postgres:17", ContainerID: "db1", Status: "running"},
28 })
29
30 svc := NewService(runtime, storage, containerSvc, domain.BackupConfig{}, zerowrap.Default())
31
32 dbs, err := svc.DetectDatabases(context.Background(), "app.example.com")
33 require.NoError(t, err)
34 require.Len(t, dbs, 1)
35 assert.Equal(t, domain.DBTypePostgreSQL, dbs[0].Type)
36 assert.Equal(t, "db1", dbs[0].ContainerID)
37}
38
39func TestService_RunBackup_Postgres(t *testing.T) {
40 runtime := outmocks.NewMockContainerRuntime(t)
41 storage := outmocks.NewMockBackupStorage(t)
42 containerSvc := inmocks.NewMockContainerService(t)
43
44 containerSvc.EXPECT().ListAttachments(mock.Anything, "app.example.com").Return([]domain.Attachment{
45 {Name: "postgres", Image: "postgres:17", ContainerID: "db123", Status: "running"},
46 })
47
48 runtime.EXPECT().ExecInContainer(mock.Anything, "db123", mock.MatchedBy(func(cmd []string) bool {
49 if len(cmd) != 3 || cmd[0] != "sh" || cmd[1] != "-c" {
50 return false
51 }
52 return bytes.Contains([]byte(cmd[2]), []byte("pg_dump -Fc")) &&
53 bytes.Contains([]byte(cmd[2]), []byte("${POSTGRES_DB:-postgres}")) &&
54 bytes.Contains([]byte(cmd[2]), []byte(" > "))
55 })).Return(&outiface.ExecResult{ExitCode: 0, Stdout: []byte("backup-data")}, nil)
56
57 runtime.EXPECT().CopyFromContainer(mock.Anything, "db123", mock.MatchedBy(func(path string) bool {
58 return path != ""
59 })).Return(io.NopCloser(bytes.NewReader([]byte("backup-data"))), nil)
60
61 runtime.EXPECT().ExecInContainer(mock.Anything, "db123", mock.MatchedBy(func(cmd []string) bool {
62 return len(cmd) == 3 && cmd[0] == "sh" && cmd[1] == "-c" && bytes.Contains([]byte(cmd[2]), []byte("rm -f"))
63 })).Return(&outiface.ExecResult{ExitCode: 0}, nil)
64
65 storage.EXPECT().Store(
66 mock.Anything,
67 "app.example.com",
68 "postgres",
69 domain.BackupSchedule(""),
70 mock.Anything,
71 mock.MatchedBy(func(r io.Reader) bool {
72 data, _ := io.ReadAll(r)
73 return string(data) == "backup-data"
74 }),
75 ).Return("/tmp/backup.bak", nil)
76
77 svc := NewService(runtime, storage, containerSvc, domain.BackupConfig{}, zerowrap.Default())
78
79 result, err := svc.RunBackup(context.Background(), "app.example.com", "postgres")
80 require.NoError(t, err)
81 require.NotNil(t, result)
82 assert.Equal(t, domain.BackupStatusCompleted, result.Job.Status)
83 assert.Equal(t, "/tmp/backup.bak", result.Job.FilePath)
84 assert.Equal(t, int64(len("backup-data")), result.Job.SizeBytes)
85}
86
87func TestService_RunBackup_CleansUpDumpWhenPgDumpFails(t *testing.T) {
88 runtime := outmocks.NewMockContainerRuntime(t)
89 storage := outmocks.NewMockBackupStorage(t)
90 containerSvc := inmocks.NewMockContainerService(t)
91
92 containerSvc.EXPECT().ListAttachments(mock.Anything, "app.example.com").Return([]domain.Attachment{
93 {Name: "postgres", Image: "postgres:17", ContainerID: "db123", Status: "running"},
94 })
95
96 runtime.EXPECT().ExecInContainer(mock.Anything, "db123", mock.MatchedBy(func(cmd []string) bool {
97 return len(cmd) == 3 && cmd[0] == "sh" && cmd[1] == "-c" && bytes.Contains([]byte(cmd[2]), []byte("pg_dump -Fc"))
98 })).Return(&outiface.ExecResult{ExitCode: 2, Stderr: []byte("dump failed")}, nil)
99
100 runtime.EXPECT().ExecInContainer(mock.Anything, "db123", mock.MatchedBy(func(cmd []string) bool {
101 return len(cmd) == 3 && cmd[0] == "sh" && cmd[1] == "-c" && bytes.Contains([]byte(cmd[2]), []byte("rm -f"))
102 })).Return(&outiface.ExecResult{ExitCode: 0}, nil)
103
104 svc := NewService(runtime, storage, containerSvc, domain.BackupConfig{}, zerowrap.Default())
105
106 result, err := svc.RunBackup(context.Background(), "app.example.com", "postgres")
107 require.Error(t, err)
108 assert.Nil(t, result)
109 assert.Contains(t, err.Error(), "pg_dump failed")
110}
111
112func TestSelectDatabaseRequiresExplicitNameWhenMultipleDetected(t *testing.T) {
113 db, err := selectDatabase([]domain.DBInfo{
114 {Name: "postgres"},
115 {Name: "analytics"},
116 }, "")
117 require.Error(t, err)
118 assert.Contains(t, err.Error(), "multiple database attachments detected")
119 assert.Equal(t, domain.DBInfo{}, db)
120}
121
122func TestSelectDatabaseAutoSelectsOnlyDatabaseWhenUnspecified(t *testing.T) {
123 db, err := selectDatabase([]domain.DBInfo{{Name: "postgres"}}, "")
124 require.NoError(t, err)
125 assert.Equal(t, "postgres", db.Name)
126}
127
128func TestPostgresDumpCommandUsesEnvVar(t *testing.T) {
129 cmd := postgresDumpCommand("ignored")
130 assert.Contains(t, cmd, "${POSTGRES_DB:-postgres}")
131 assert.Contains(t, cmd, "${POSTGRES_USER:-postgres}")
132}
133
134func TestServiceStatusReturnsWhenContextCancelledDuringSemaphoreAcquire(t *testing.T) {
135 runtime := outmocks.NewMockContainerRuntime(t)
136 storage := outmocks.NewMockBackupStorage(t)
137 containerSvc := inmocks.NewMockContainerService(t)
138
139 containerSvc.EXPECT().List(mock.Anything).Return(map[string]*domain.Container{
140 "a.example.com": {},
141 "b.example.com": {},
142 "c.example.com": {},
143 "d.example.com": {},
144 "e.example.com": {},
145 })
146
147 unblock := make(chan struct{})
148 defer close(unblock)
149
150 var started int32
151 startedFour := make(chan struct{})
152 storage.EXPECT().List(mock.Anything, mock.Anything, (*domain.BackupSchedule)(nil)).RunAndReturn(
153 func(context.Context, string, *domain.BackupSchedule) ([]domain.BackupJob, error) {
154 if atomic.AddInt32(&started, 1) == 4 {
155 close(startedFour)
156 }
157 <-unblock
158 return nil, nil
159 },
160 ).Times(4)
161
162 svc := NewService(runtime, storage, containerSvc, domain.BackupConfig{}, zerowrap.Default())
163
164 ctx, cancel := context.WithCancel(context.Background())
165 errCh := make(chan error, 1)
166 go func() {
167 _, err := svc.Status(ctx)
168 errCh <- err
169 }()
170
171 <-startedFour
172 cancel()
173
174 select {
175 case err := <-errCh:
176 require.ErrorIs(t, err, context.Canceled)
177 case <-time.After(250 * time.Millisecond):
178 t.Fatal("Status did not return after context cancellation while waiting for semaphore")
179 }
180}
181
182func TestService_RunForSchedule_StoresTierAndAppliesRetention(t *testing.T) {
183 runtime := outmocks.NewMockContainerRuntime(t)
184 storage := outmocks.NewMockBackupStorage(t)
185 containerSvc := inmocks.NewMockContainerService(t)
186
187 containerSvc.EXPECT().List(mock.Anything).Return(map[string]*domain.Container{
188 "app.example.com": {},
189 })
190 containerSvc.EXPECT().ListAttachments(mock.Anything, "app.example.com").Return([]domain.Attachment{
191 {Name: "postgres", Image: "postgres:17", ContainerID: "db123", Status: "running"},
192 })
193
194 runtime.EXPECT().ExecInContainer(mock.Anything, "db123", mock.MatchedBy(func(cmd []string) bool {
195 if len(cmd) != 3 || cmd[0] != "sh" || cmd[1] != "-c" {
196 return false
197 }
198 return bytes.Contains([]byte(cmd[2]), []byte("pg_dump -Fc"))
199 })).Return(&outiface.ExecResult{ExitCode: 0, Stdout: []byte("backup-data")}, nil)
200
201 runtime.EXPECT().CopyFromContainer(mock.Anything, "db123", mock.MatchedBy(func(path string) bool {
202 return path != ""
203 })).Return(io.NopCloser(bytes.NewReader([]byte("backup-data"))), nil)
204
205 runtime.EXPECT().ExecInContainer(mock.Anything, "db123", mock.MatchedBy(func(cmd []string) bool {
206 return len(cmd) == 3 && cmd[0] == "sh" && cmd[1] == "-c" && bytes.Contains([]byte(cmd[2]), []byte("rm -f"))
207 })).Return(&outiface.ExecResult{ExitCode: 0}, nil)
208
209 storage.EXPECT().Store(
210 mock.Anything,
211 "app.example.com",
212 "postgres",
213 domain.ScheduleDaily,
214 mock.Anything,
215 mock.Anything,
216 ).Return("/tmp/daily-backup.bak", nil)
217
218 storage.EXPECT().ApplyRetention(mock.Anything, "app.example.com", domain.RetentionPolicy{Daily: 7}).Return(0, nil)
219
220 svc := NewService(runtime, storage, containerSvc, domain.BackupConfig{Retention: domain.RetentionPolicy{Daily: 7}}, zerowrap.Default())
221
222 err := svc.RunForSchedule(context.Background(), domain.ScheduleDaily)
223 require.NoError(t, err)
224}
225
226func TestService_RunForSchedule_RejectsInvalidSchedule(t *testing.T) {
227 runtime := outmocks.NewMockContainerRuntime(t)
228 storage := outmocks.NewMockBackupStorage(t)
229 containerSvc := inmocks.NewMockContainerService(t)
230
231 svc := NewService(runtime, storage, containerSvc, domain.BackupConfig{}, zerowrap.Default())
232
233 err := svc.RunForSchedule(context.Background(), domain.BackupSchedule("every-minute"))
234 require.Error(t, err)
235 assert.Contains(t, err.Error(), "invalid backup schedule")
236}