1package app
2
3import (
4 "testing"
5 "time"
6
7 "github.com/spf13/viper"
8 "github.com/stretchr/testify/assert"
9 "github.com/stretchr/testify/require"
10
11 "github.com/bnema/gordon/internal/domain"
12)
13
14func TestValidateBackupRetentionRejectsNegativeValues(t *testing.T) {
15 tests := []struct {
16 name string
17 set func(*Config)
18 err string
19 }{
20 {
21 name: "hourly",
22 set: func(cfg *Config) {
23 cfg.Backups.Databases.Retention.Hourly = -1
24 },
25 err: "backups.databases.retention.hourly",
26 },
27 {
28 name: "daily",
29 set: func(cfg *Config) {
30 cfg.Backups.Databases.Retention.Daily = -1
31 },
32 err: "backups.databases.retention.daily",
33 },
34 {
35 name: "weekly",
36 set: func(cfg *Config) {
37 cfg.Backups.Databases.Retention.Weekly = -1
38 },
39 err: "backups.databases.retention.weekly",
40 },
41 {
42 name: "monthly",
43 set: func(cfg *Config) {
44 cfg.Backups.Databases.Retention.Monthly = -1
45 },
46 err: "backups.databases.retention.monthly",
47 },
48 }
49
50 for _, tt := range tests {
51 t.Run(tt.name, func(t *testing.T) {
52 var cfg Config
53 tt.set(&cfg)
54
55 _, err := validateBackupRetention(cfg)
56 require.Error(t, err)
57 assert.Contains(t, err.Error(), tt.err)
58 })
59 }
60}
61
62func TestValidateBackupRetentionAcceptsZeroAndPositiveValues(t *testing.T) {
63 t.Run("positive values", func(t *testing.T) {
64 var cfg Config
65 cfg.Backups.Databases.Retention.Hourly = 1
66 cfg.Backups.Databases.Retention.Daily = 7
67 cfg.Backups.Databases.Retention.Weekly = 4
68 cfg.Backups.Databases.Retention.Monthly = 12
69
70 retention, err := validateBackupRetention(cfg)
71 require.NoError(t, err)
72 assert.Equal(t, 1, retention.Hourly)
73 assert.Equal(t, 7, retention.Daily)
74 assert.Equal(t, 4, retention.Weekly)
75 assert.Equal(t, 12, retention.Monthly)
76 })
77
78 t.Run("zero values", func(t *testing.T) {
79 var cfg Config
80 cfg.Backups.Databases.Retention.Hourly = 0
81 cfg.Backups.Databases.Retention.Daily = 0
82 cfg.Backups.Databases.Retention.Weekly = 0
83 cfg.Backups.Databases.Retention.Monthly = 0
84
85 retention, err := validateBackupRetention(cfg)
86 require.NoError(t, err)
87 assert.Equal(t, 0, retention.Hourly)
88 assert.Equal(t, 0, retention.Daily)
89 assert.Equal(t, 0, retention.Weekly)
90 assert.Equal(t, 0, retention.Monthly)
91 })
92}
93
94func TestValidateBackupRetentionAcceptsLegacyBackupsConfig(t *testing.T) {
95 var cfg Config
96 cfg.Backups.Retention.Daily = 3
97
98 retention, err := validateBackupRetention(cfg)
99
100 require.NoError(t, err)
101 assert.Equal(t, 3, retention.Daily)
102}
103
104func TestDatabaseBackupSettingsPreferEnabledLegacySchedule(t *testing.T) {
105 var cfg Config
106 cfg.Backups.Enabled = true
107 cfg.Backups.Schedule = string(domain.ScheduleHourly)
108 cfg.Backups.Databases.Schedule = string(domain.ScheduleDaily)
109
110 settings := databaseBackupSettings(cfg)
111
112 assert.True(t, settings.Enabled)
113 assert.Equal(t, string(domain.ScheduleHourly), settings.Schedule)
114}
115
116func TestValidateVolumeBackupConfig(t *testing.T) {
117 t.Run("disabled accepts defaults", func(t *testing.T) {
118 var cfg Config
119
120 volumeCfg, err := validateVolumeBackupConfig(cfg)
121 require.NoError(t, err)
122 assert.False(t, volumeCfg.Enabled)
123 assert.Equal(t, domain.VolumeBackupCompressionGzip, volumeCfg.Compression)
124 assert.Equal(t, 0, volumeCfg.Retention.Keep)
125 assert.Equal(t, 2, volumeCfg.MaxConcurrency)
126 assert.Equal(t, "alpine:3.20", volumeCfg.HelperImage)
127 assert.Equal(t, "gordon", volumeCfg.VolumePrefix)
128 })
129
130 t.Run("enabled requires bucket and region", func(t *testing.T) {
131 var cfg Config
132 cfg.Backups.Volumes.Enabled = true
133 cfg.Backups.Volumes.Retention.Keep = 14
134
135 _, err := validateVolumeBackupConfig(cfg)
136 require.Error(t, err)
137 assert.Contains(t, err.Error(), "backups.volumes.s3.bucket")
138
139 cfg.Backups.Volumes.S3.Bucket = "gordon-backups"
140 _, err = validateVolumeBackupConfig(cfg)
141 require.Error(t, err)
142 assert.Contains(t, err.Error(), "backups.volumes.s3.region")
143 })
144
145 t.Run("enabled accepts s3 config", func(t *testing.T) {
146 var cfg Config
147 cfg.Backups.Volumes.Enabled = true
148 cfg.Backups.Volumes.Schedule = "weekly"
149 cfg.Backups.Volumes.Time = "03:30"
150 cfg.Backups.Volumes.Timeout = "30m"
151 cfg.Backups.Volumes.Compression = "gzip"
152 cfg.Backups.Volumes.MaxConcurrency = 3
153 cfg.Backups.Volumes.HelperImage = "example/helper:1"
154 cfg.Volumes.Prefix = "acme"
155 cfg.Backups.Volumes.S3.Bucket = "gordon-backups"
156 cfg.Backups.Volumes.S3.Region = "eu-west-3"
157 cfg.Backups.Volumes.S3.Prefix = "prod/gordon"
158 cfg.Backups.Volumes.Retention.Keep = 7
159
160 volumeCfg, err := validateVolumeBackupConfig(cfg)
161 require.NoError(t, err)
162 assert.True(t, volumeCfg.Enabled)
163 assert.Equal(t, domain.ScheduleWeekly, volumeCfg.Schedule.Preset)
164 require.NotNil(t, volumeCfg.Schedule.At)
165 assert.Equal(t, domain.TimeOfDay{Hour: 3, Minute: 30}, *volumeCfg.Schedule.At)
166 assert.Equal(t, time.Local, volumeCfg.Schedule.Location)
167 assert.Equal(t, domain.VolumeBackupCompressionGzip, volumeCfg.Compression)
168 assert.Equal(t, 7, volumeCfg.Retention.Keep)
169 assert.Equal(t, 3, volumeCfg.MaxConcurrency)
170 assert.Equal(t, "acme", volumeCfg.VolumePrefix)
171 assert.Equal(t, "prod/gordon", volumeCfg.S3Prefix)
172 })
173
174 t.Run("schedule defaults to daily at the preset time", func(t *testing.T) {
175 var cfg Config
176
177 volumeCfg, err := validateVolumeBackupConfig(cfg)
178 require.NoError(t, err)
179 assert.Equal(t, domain.ScheduleDaily, volumeCfg.Schedule.Preset)
180 assert.Nil(t, volumeCfg.Schedule.At)
181 })
182
183 t.Run("schedule rejects invalid time", func(t *testing.T) {
184 for _, raw := range []string{"3h30", "24:00", "03:60", "3:5", "night"} {
185 var cfg Config
186 cfg.Backups.Volumes.Time = raw
187
188 _, err := validateVolumeBackupConfig(cfg)
189 require.Error(t, err, raw)
190 assert.Contains(t, err.Error(), "backups.volumes.time")
191 }
192 })
193
194 t.Run("schedule rejects unknown preset", func(t *testing.T) {
195 var cfg Config
196 cfg.Backups.Volumes.Schedule = "nightly"
197
198 _, err := validateVolumeBackupConfig(cfg)
199 require.Error(t, err)
200 assert.Contains(t, err.Error(), "backups.volumes.schedule")
201 })
202
203 t.Run("storage class is normalized and passed through", func(t *testing.T) {
204 var cfg Config
205 cfg.Backups.Volumes.S3.StorageClass = " onezone_ia "
206
207 volumeCfg, err := validateVolumeBackupConfig(cfg)
208 require.NoError(t, err)
209 assert.Equal(t, "ONEZONE_IA", volumeCfg.S3StorageClass)
210 })
211
212 t.Run("empty storage class keeps the bucket default", func(t *testing.T) {
213 var cfg Config
214
215 volumeCfg, err := validateVolumeBackupConfig(cfg)
216 require.NoError(t, err)
217 assert.Empty(t, volumeCfg.S3StorageClass)
218 })
219
220 t.Run("storage class rejects unsafe values", func(t *testing.T) {
221 var cfg Config
222 cfg.Backups.Volumes.S3.StorageClass = "one zone"
223
224 _, err := validateVolumeBackupConfig(cfg)
225 require.Error(t, err)
226 assert.Contains(t, err.Error(), "backups.volumes.s3.storage_class")
227 })
228
229 t.Run("zstd requires helper image with zstd", func(t *testing.T) {
230 var cfg Config
231 cfg.Backups.Volumes.Compression = "zstd"
232
233 _, err := validateVolumeBackupConfig(cfg)
234
235 require.Error(t, err)
236 assert.Contains(t, err.Error(), "helper_image")
237 })
238
239 t.Run("enabled rejects zero retention", func(t *testing.T) {
240 var cfg Config
241 cfg.Backups.Volumes.Enabled = true
242 cfg.Backups.Volumes.S3.Bucket = "gordon-backups"
243 cfg.Backups.Volumes.S3.Region = "eu-west-3"
244 cfg.Backups.Volumes.Retention.Keep = 0
245
246 _, err := validateVolumeBackupConfig(cfg)
247 require.Error(t, err)
248 assert.Contains(t, err.Error(), "backups.volumes.retention.keep")
249 })
250}
251
252func TestLoadConfigSetsBackupDefaults(t *testing.T) {
253 v := viper.New()
254
255 require.NoError(t, loadConfig(v, ""))
256
257 assert.Equal(t, string(domain.ScheduleDaily), v.GetString("backups.databases.schedule"))
258 assert.Equal(t, string(domain.ScheduleDaily), v.GetString("backups.volumes.schedule"))
259 assert.Empty(t, v.GetString("backups.volumes.time"))
260 assert.Empty(t, v.GetString("backups.databases.time"))
261 assert.Equal(t, string(domain.VolumeBackupCompressionGzip), v.GetString("backups.volumes.compression"))
262 assert.Equal(t, 14, v.GetInt("backups.volumes.retention.keep"))
263}
264
265func TestResolveBackupSchedule(t *testing.T) {
266 t.Run("valid values", func(t *testing.T) {
267 tests := []struct {
268 input string
269 want domain.BackupSchedule
270 }{
271 {input: "hourly", want: domain.ScheduleHourly},
272 {input: "daily", want: domain.ScheduleDaily},
273 {input: "weekly", want: domain.ScheduleWeekly},
274 {input: "monthly", want: domain.ScheduleMonthly},
275 {input: " DAILY ", want: domain.ScheduleDaily},
276 }
277
278 for _, tt := range tests {
279 t.Run(tt.input, func(t *testing.T) {
280 got, err := resolveCronSchedule(tt.input, "", "backups.databases")
281 require.NoError(t, err)
282 assert.Equal(t, tt.want, got.Preset)
283 })
284 }
285 })
286
287 t.Run("invalid value", func(t *testing.T) {
288 _, err := resolveCronSchedule("every-minute", "", "backups.databases")
289 require.Error(t, err)
290 assert.Contains(t, err.Error(), "backups.databases.schedule")
291 })
292
293 t.Run("time is read in server local time", func(t *testing.T) {
294 got, err := resolveCronSchedule("daily", "04:15", "backups.databases")
295 require.NoError(t, err)
296 require.NotNil(t, got.At)
297 assert.Equal(t, domain.TimeOfDay{Hour: 4, Minute: 15}, *got.At)
298 assert.Equal(t, time.Local, got.Location)
299 })
300
301 t.Run("invalid time names the database key", func(t *testing.T) {
302 _, err := resolveCronSchedule("daily", "25:00", "backups.databases")
303 require.Error(t, err)
304 assert.Contains(t, err.Error(), "backups.databases.time")
305 })
306}