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("PGDATABASE='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 TestPostgresDumpCommandQuotesDatabaseName(t *testing.T) {
129 cmd := postgresDumpCommand("customer data")
130 assert.Contains(t, cmd, "PGDATABASE='customer data'")
131}
132
133func TestServiceStatusReturnsWhenContextCancelledDuringSemaphoreAcquire(t *testing.T) {
134 runtime := outmocks.NewMockContainerRuntime(t)
135 storage := outmocks.NewMockBackupStorage(t)
136 containerSvc := inmocks.NewMockContainerService(t)
137
138 containerSvc.EXPECT().List(mock.Anything).Return(map[string]*domain.Container{
139 "a.example.com": {},
140 "b.example.com": {},
141 "c.example.com": {},
142 "d.example.com": {},
143 "e.example.com": {},
144 })
145
146 unblock := make(chan struct{})
147 defer close(unblock)
148
149 var started int32
150 startedFour := make(chan struct{})
151 storage.EXPECT().List(mock.Anything, mock.Anything, (*domain.BackupSchedule)(nil)).RunAndReturn(
152 func(context.Context, string, *domain.BackupSchedule) ([]domain.BackupJob, error) {
153 if atomic.AddInt32(&started, 1) == 4 {
154 close(startedFour)
155 }
156 <-unblock
157 return nil, nil
158 },
159 ).Times(4)
160
161 svc := NewService(runtime, storage, containerSvc, domain.BackupConfig{}, zerowrap.Default())
162
163 ctx, cancel := context.WithCancel(context.Background())
164 errCh := make(chan error, 1)
165 go func() {
166 _, err := svc.Status(ctx)
167 errCh <- err
168 }()
169
170 <-startedFour
171 cancel()
172
173 select {
174 case err := <-errCh:
175 require.ErrorIs(t, err, context.Canceled)
176 case <-time.After(250 * time.Millisecond):
177 t.Fatal("Status did not return after context cancellation while waiting for semaphore")
178 }
179}
180
181func TestService_RunForSchedule_StoresTierAndAppliesRetention(t *testing.T) {
182 runtime := outmocks.NewMockContainerRuntime(t)
183 storage := outmocks.NewMockBackupStorage(t)
184 containerSvc := inmocks.NewMockContainerService(t)
185
186 containerSvc.EXPECT().List(mock.Anything).Return(map[string]*domain.Container{
187 "app.example.com": {},
188 })
189 containerSvc.EXPECT().ListAttachments(mock.Anything, "app.example.com").Return([]domain.Attachment{
190 {Name: "postgres", Image: "postgres:17", ContainerID: "db123", Status: "running"},
191 })
192
193 runtime.EXPECT().ExecInContainer(mock.Anything, "db123", mock.MatchedBy(func(cmd []string) bool {
194 if len(cmd) != 3 || cmd[0] != "sh" || cmd[1] != "-c" {
195 return false
196 }
197 return bytes.Contains([]byte(cmd[2]), []byte("pg_dump -Fc"))
198 })).Return(&outiface.ExecResult{ExitCode: 0, Stdout: []byte("backup-data")}, nil)
199
200 runtime.EXPECT().CopyFromContainer(mock.Anything, "db123", mock.MatchedBy(func(path string) bool {
201 return path != ""
202 })).Return(io.NopCloser(bytes.NewReader([]byte("backup-data"))), nil)
203
204 runtime.EXPECT().ExecInContainer(mock.Anything, "db123", mock.MatchedBy(func(cmd []string) bool {
205 return len(cmd) == 3 && cmd[0] == "sh" && cmd[1] == "-c" && bytes.Contains([]byte(cmd[2]), []byte("rm -f"))
206 })).Return(&outiface.ExecResult{ExitCode: 0}, nil)
207
208 storage.EXPECT().Store(
209 mock.Anything,
210 "app.example.com",
211 "postgres",
212 domain.ScheduleDaily,
213 mock.Anything,
214 mock.Anything,
215 ).Return("/tmp/daily-backup.bak", nil)
216
217 storage.EXPECT().ApplyRetention(mock.Anything, "app.example.com", domain.RetentionPolicy{Daily: 7}).Return(0, nil)
218
219 svc := NewService(runtime, storage, containerSvc, domain.BackupConfig{Retention: domain.RetentionPolicy{Daily: 7}}, zerowrap.Default())
220
221 err := svc.RunForSchedule(context.Background(), domain.ScheduleDaily)
222 require.NoError(t, err)
223}
224
225func TestService_RunForSchedule_RejectsInvalidSchedule(t *testing.T) {
226 runtime := outmocks.NewMockContainerRuntime(t)
227 storage := outmocks.NewMockBackupStorage(t)
228 containerSvc := inmocks.NewMockContainerService(t)
229
230 svc := NewService(runtime, storage, containerSvc, domain.BackupConfig{}, zerowrap.Default())
231
232 err := svc.RunForSchedule(context.Background(), domain.BackupSchedule("every-minute"))
233 require.Error(t, err)
234 assert.Contains(t, err.Error(), "invalid backup schedule")
235}