1package container
2
3import (
4 "context"
5 "errors"
6 "testing"
7
8 "github.com/bnema/zerowrap"
9 "github.com/stretchr/testify/assert"
10 "github.com/stretchr/testify/mock"
11
12 inmocks "gordon/internal/boundaries/in/mocks"
13 "gordon/internal/domain"
14)
15
16func testCtx() context.Context {
17 return zerowrap.WithCtx(context.Background(), zerowrap.Default())
18}
19
20// ImagePushedHandler tests
21
22func TestImagePushedHandler_CanHandle(t *testing.T) {
23 containerSvc := inmocks.NewMockContainerService(t)
24 configSvc := inmocks.NewMockConfigService(t)
25
26 handler := NewImagePushedHandler(testCtx(), containerSvc, configSvc)
27
28 assert.True(t, handler.CanHandle(domain.EventImagePushed))
29 assert.False(t, handler.CanHandle(domain.EventConfigReload))
30 assert.False(t, handler.CanHandle(domain.EventManualReload))
31}
32
33func TestImagePushedHandler_Handle_DeploysMatchingRoutes(t *testing.T) {
34 containerSvc := inmocks.NewMockContainerService(t)
35 configSvc := inmocks.NewMockConfigService(t)
36
37 handler := NewImagePushedHandler(testCtx(), containerSvc, configSvc)
38
39 configSvc.EXPECT().GetRegistryDomain().Return("")
40
41 // Configure routes that match the pushed image
42 configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{
43 {Domain: "app1.example.com", Image: "myapp:latest"},
44 {Domain: "app2.example.com", Image: "myapp:latest"},
45 {Domain: "other.example.com", Image: "otherapp:latest"},
46 })
47
48 // Expect Deploy to be called for both matching routes
49 containerSvc.EXPECT().Deploy(mock.Anything, domain.Route{
50 Domain: "app1.example.com",
51 Image: "myapp:latest",
52 }).Return(&domain.Container{ID: "container-1"}, nil)
53
54 containerSvc.EXPECT().Deploy(mock.Anything, domain.Route{
55 Domain: "app2.example.com",
56 Image: "myapp:latest",
57 }).Return(&domain.Container{ID: "container-2"}, nil)
58
59 event := domain.Event{
60 ID: "event-123",
61 Type: domain.EventImagePushed,
62 ImageName: "myapp",
63 Tag: "latest",
64 }
65
66 err := handler.Handle(event)
67
68 assert.NoError(t, err)
69}
70
71func TestImagePushedHandler_Handle_NoMatchingRoutes(t *testing.T) {
72 containerSvc := inmocks.NewMockContainerService(t)
73 configSvc := inmocks.NewMockConfigService(t)
74
75 handler := NewImagePushedHandler(testCtx(), containerSvc, configSvc)
76
77 configSvc.EXPECT().GetRegistryDomain().Return("")
78
79 configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{
80 {Domain: "other.example.com", Image: "otherapp:latest"},
81 })
82
83 // No Deploy calls expected
84
85 event := domain.Event{
86 ID: "event-123",
87 Type: domain.EventImagePushed,
88 ImageName: "myapp",
89 Tag: "latest",
90 }
91
92 err := handler.Handle(event)
93
94 assert.NoError(t, err)
95}
96
97func TestImagePushedHandler_Handle_EmptyImageName(t *testing.T) {
98 containerSvc := inmocks.NewMockContainerService(t)
99 configSvc := inmocks.NewMockConfigService(t)
100
101 handler := NewImagePushedHandler(testCtx(), containerSvc, configSvc)
102
103 event := domain.Event{
104 ID: "event-123",
105 Type: domain.EventImagePushed,
106 ImageName: "",
107 Tag: "latest",
108 }
109
110 err := handler.Handle(event)
111
112 assert.ErrorIs(t, err, domain.ErrInvalidImageFormat)
113}
114
115func TestImagePushedHandler_Handle_DefaultTag(t *testing.T) {
116 containerSvc := inmocks.NewMockContainerService(t)
117 configSvc := inmocks.NewMockConfigService(t)
118
119 handler := NewImagePushedHandler(testCtx(), containerSvc, configSvc)
120
121 configSvc.EXPECT().GetRegistryDomain().Return("")
122
123 // Image name "myapp" with empty tag should become "myapp:latest"
124 configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{
125 {Domain: "app.example.com", Image: "myapp:latest"},
126 })
127
128 containerSvc.EXPECT().Deploy(mock.Anything, domain.Route{
129 Domain: "app.example.com",
130 Image: "myapp:latest",
131 }).Return(&domain.Container{ID: "container-1"}, nil)
132
133 event := domain.Event{
134 ID: "event-123",
135 Type: domain.EventImagePushed,
136 ImageName: "myapp",
137 Tag: "", // Empty tag should default to "latest"
138 }
139
140 err := handler.Handle(event)
141
142 assert.NoError(t, err)
143}
144
145func TestImagePushedHandler_Handle_DeployError(t *testing.T) {
146 containerSvc := inmocks.NewMockContainerService(t)
147 configSvc := inmocks.NewMockConfigService(t)
148
149 handler := NewImagePushedHandler(testCtx(), containerSvc, configSvc)
150
151 configSvc.EXPECT().GetRegistryDomain().Return("")
152
153 configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{
154 {Domain: "app.example.com", Image: "myapp:latest"},
155 })
156
157 containerSvc.EXPECT().Deploy(mock.Anything, mock.Anything).Return(nil, errors.New("deploy failed"))
158
159 event := domain.Event{
160 ID: "event-123",
161 Type: domain.EventImagePushed,
162 ImageName: "myapp",
163 Tag: "latest",
164 }
165
166 // Handler logs error but doesn't fail
167 err := handler.Handle(event)
168
169 assert.NoError(t, err)
170}
171
172func TestImagePushedHandler_Handle_StripsRegistryDomain(t *testing.T) {
173 containerSvc := inmocks.NewMockContainerService(t)
174 configSvc := inmocks.NewMockConfigService(t)
175
176 handler := NewImagePushedHandler(testCtx(), containerSvc, configSvc)
177
178 configSvc.EXPECT().GetRegistryDomain().Return("registry.example.com")
179 configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{
180 {Domain: "app.example.com", Image: "registry.example.com/docker.io/library/nginx:latest"},
181 })
182
183 containerSvc.EXPECT().Deploy(mock.Anything, domain.Route{
184 Domain: "app.example.com",
185 Image: "registry.example.com/docker.io/library/nginx:latest",
186 }).Return(&domain.Container{ID: "container-1"}, nil)
187
188 event := domain.Event{
189 ID: "event-123",
190 Type: domain.EventImagePushed,
191 ImageName: "docker.io/library/nginx",
192 Tag: "latest",
193 }
194
195 err := handler.Handle(event)
196
197 assert.NoError(t, err)
198}
199
200// ConfigReloadHandler tests
201
202func TestConfigReloadHandler_CanHandle(t *testing.T) {
203 containerSvc := inmocks.NewMockContainerService(t)
204 configSvc := inmocks.NewMockConfigService(t)
205
206 handler := NewConfigReloadHandler(testCtx(), containerSvc, configSvc)
207
208 assert.True(t, handler.CanHandle(domain.EventConfigReload))
209 assert.False(t, handler.CanHandle(domain.EventImagePushed))
210 assert.False(t, handler.CanHandle(domain.EventManualReload))
211}
212
213func TestConfigReloadHandler_Handle_DeploysNewRoutes(t *testing.T) {
214 containerSvc := inmocks.NewMockContainerService(t)
215 configSvc := inmocks.NewMockConfigService(t)
216
217 handler := NewConfigReloadHandler(testCtx(), containerSvc, configSvc)
218
219 // No existing containers
220 containerSvc.EXPECT().List(mock.Anything).Return(map[string]*domain.Container{})
221
222 // New route in config
223 configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{
224 {Domain: "newapp.example.com", Image: "newapp:latest"},
225 })
226
227 containerSvc.EXPECT().Deploy(mock.Anything, domain.Route{
228 Domain: "newapp.example.com",
229 Image: "newapp:latest",
230 }).Return(&domain.Container{ID: "container-1"}, nil)
231
232 event := domain.Event{
233 ID: "event-123",
234 Type: domain.EventConfigReload,
235 }
236
237 err := handler.Handle(event)
238
239 assert.NoError(t, err)
240}
241
242func TestConfigReloadHandler_Handle_StopsRemovedRoutes(t *testing.T) {
243 containerSvc := inmocks.NewMockContainerService(t)
244 configSvc := inmocks.NewMockConfigService(t)
245
246 handler := NewConfigReloadHandler(testCtx(), containerSvc, configSvc)
247
248 // Existing container for a route that's no longer configured
249 containerSvc.EXPECT().List(mock.Anything).Return(map[string]*domain.Container{
250 "removed.example.com": {
251 ID: "container-old",
252 Labels: map[string]string{
253 "gordon.route": "removed.example.com",
254 "gordon.image": "oldapp:latest",
255 },
256 },
257 })
258
259 // Empty routes - the route was removed
260 configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{})
261
262 containerSvc.EXPECT().Stop(mock.Anything, "container-old").Return(nil)
263 containerSvc.EXPECT().Remove(mock.Anything, "container-old", true).Return(nil)
264
265 event := domain.Event{
266 ID: "event-123",
267 Type: domain.EventConfigReload,
268 }
269
270 err := handler.Handle(event)
271
272 assert.NoError(t, err)
273}
274
275func TestConfigReloadHandler_Handle_RedeploysChangedImage(t *testing.T) {
276 containerSvc := inmocks.NewMockContainerService(t)
277 configSvc := inmocks.NewMockConfigService(t)
278
279 handler := NewConfigReloadHandler(testCtx(), containerSvc, configSvc)
280
281 // Existing container with old image
282 containerSvc.EXPECT().List(mock.Anything).Return(map[string]*domain.Container{
283 "app.example.com": {
284 ID: "container-1",
285 Labels: map[string]string{
286 "gordon.route": "app.example.com",
287 "gordon.image": "myapp:v1",
288 },
289 },
290 })
291
292 // Config now has different image
293 configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{
294 {Domain: "app.example.com", Image: "myapp:v2"},
295 })
296
297 containerSvc.EXPECT().Deploy(mock.Anything, domain.Route{
298 Domain: "app.example.com",
299 Image: "myapp:v2",
300 }).Return(&domain.Container{ID: "container-2"}, nil)
301
302 event := domain.Event{
303 ID: "event-123",
304 Type: domain.EventConfigReload,
305 }
306
307 err := handler.Handle(event)
308
309 assert.NoError(t, err)
310}
311
312func TestConfigReloadHandler_Handle_NoChanges(t *testing.T) {
313 containerSvc := inmocks.NewMockContainerService(t)
314 configSvc := inmocks.NewMockConfigService(t)
315
316 handler := NewConfigReloadHandler(testCtx(), containerSvc, configSvc)
317
318 // Existing container matches config
319 containerSvc.EXPECT().List(mock.Anything).Return(map[string]*domain.Container{
320 "app.example.com": {
321 ID: "container-1",
322 Labels: map[string]string{
323 "gordon.route": "app.example.com",
324 "gordon.image": "myapp:latest",
325 },
326 },
327 })
328
329 configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{
330 {Domain: "app.example.com", Image: "myapp:latest"},
331 })
332
333 // No Deploy, Stop, or Remove calls expected
334
335 event := domain.Event{
336 ID: "event-123",
337 Type: domain.EventConfigReload,
338 }
339
340 err := handler.Handle(event)
341
342 assert.NoError(t, err)
343}
344
345// ManualReloadHandler tests
346
347func TestManualReloadHandler_CanHandle(t *testing.T) {
348 containerSvc := inmocks.NewMockContainerService(t)
349 configSvc := inmocks.NewMockConfigService(t)
350
351 handler := NewManualReloadHandler(testCtx(), containerSvc, configSvc)
352
353 assert.True(t, handler.CanHandle(domain.EventManualReload))
354 assert.False(t, handler.CanHandle(domain.EventImagePushed))
355 assert.False(t, handler.CanHandle(domain.EventConfigReload))
356}
357
358func TestManualReloadHandler_Handle_RedeploysExistingContainers(t *testing.T) {
359 containerSvc := inmocks.NewMockContainerService(t)
360 configSvc := inmocks.NewMockConfigService(t)
361
362 handler := NewManualReloadHandler(testCtx(), containerSvc, configSvc)
363
364 // Routes in config
365 configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{
366 {Domain: "app1.example.com", Image: "app1:latest"},
367 {Domain: "app2.example.com", Image: "app2:latest"},
368 })
369
370 // Only app1 has a running container
371 containerSvc.EXPECT().Get(mock.Anything, "app1.example.com").Return(&domain.Container{ID: "container-1"}, true)
372 containerSvc.EXPECT().Get(mock.Anything, "app2.example.com").Return(nil, false)
373
374 // Only app1 should be redeployed (app2 has no container)
375 containerSvc.EXPECT().Deploy(mock.Anything, domain.Route{
376 Domain: "app1.example.com",
377 Image: "app1:latest",
378 }).Return(&domain.Container{ID: "container-1-new"}, nil)
379
380 event := domain.Event{
381 ID: "event-123",
382 Type: domain.EventManualReload,
383 }
384
385 err := handler.Handle(event)
386
387 assert.NoError(t, err)
388}
389
390func TestManualReloadHandler_Handle_NoContainersRunning(t *testing.T) {
391 containerSvc := inmocks.NewMockContainerService(t)
392 configSvc := inmocks.NewMockConfigService(t)
393
394 handler := NewManualReloadHandler(testCtx(), containerSvc, configSvc)
395
396 configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{
397 {Domain: "app.example.com", Image: "app:latest"},
398 })
399
400 containerSvc.EXPECT().Get(mock.Anything, "app.example.com").Return(nil, false)
401
402 // No Deploy calls - no container exists to reload
403
404 event := domain.Event{
405 ID: "event-123",
406 Type: domain.EventManualReload,
407 }
408
409 err := handler.Handle(event)
410
411 assert.NoError(t, err)
412}
413
414func TestManualReloadHandler_Handle_DeployErrors(t *testing.T) {
415 containerSvc := inmocks.NewMockContainerService(t)
416 configSvc := inmocks.NewMockConfigService(t)
417
418 handler := NewManualReloadHandler(testCtx(), containerSvc, configSvc)
419
420 configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{
421 {Domain: "app1.example.com", Image: "app1:latest"},
422 {Domain: "app2.example.com", Image: "app2:latest"},
423 })
424
425 containerSvc.EXPECT().Get(mock.Anything, "app1.example.com").Return(&domain.Container{ID: "container-1"}, true)
426 containerSvc.EXPECT().Get(mock.Anything, "app2.example.com").Return(&domain.Container{ID: "container-2"}, true)
427
428 containerSvc.EXPECT().Deploy(mock.Anything, domain.Route{
429 Domain: "app1.example.com",
430 Image: "app1:latest",
431 }).Return(nil, errors.New("deploy failed"))
432
433 containerSvc.EXPECT().Deploy(mock.Anything, domain.Route{
434 Domain: "app2.example.com",
435 Image: "app2:latest",
436 }).Return(&domain.Container{ID: "container-2-new"}, nil)
437
438 event := domain.Event{
439 ID: "event-123",
440 Type: domain.EventManualReload,
441 }
442
443 err := handler.Handle(event)
444
445 // Should return error indicating some failures
446 assert.Error(t, err)
447 assert.Contains(t, err.Error(), "1 errors")
448}