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 // SyncContainers is called first
220 containerSvc.EXPECT().SyncContainers(mock.Anything).Return(nil)
221
222 // No existing containers
223 containerSvc.EXPECT().List(mock.Anything).Return(map[string]*domain.Container{})
224
225 // New route in config
226 configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{
227 {Domain: "newapp.example.com", Image: "newapp:latest"},
228 })
229
230 containerSvc.EXPECT().Deploy(mock.Anything, domain.Route{
231 Domain: "newapp.example.com",
232 Image: "newapp:latest",
233 }).Return(&domain.Container{ID: "container-1"}, nil)
234
235 event := domain.Event{
236 ID: "event-123",
237 Type: domain.EventConfigReload,
238 }
239
240 err := handler.Handle(event)
241
242 assert.NoError(t, err)
243}
244
245func TestConfigReloadHandler_Handle_StopsRemovedRoutes(t *testing.T) {
246 containerSvc := inmocks.NewMockContainerService(t)
247 configSvc := inmocks.NewMockConfigService(t)
248
249 handler := NewConfigReloadHandler(testCtx(), containerSvc, configSvc)
250
251 // SyncContainers is called first
252 containerSvc.EXPECT().SyncContainers(mock.Anything).Return(nil)
253
254 // Existing container for a route that's no longer configured
255 containerSvc.EXPECT().List(mock.Anything).Return(map[string]*domain.Container{
256 "removed.example.com": {
257 ID: "container-old",
258 Labels: map[string]string{
259 "gordon.route": "removed.example.com",
260 "gordon.image": "oldapp:latest",
261 },
262 },
263 })
264
265 // Empty routes - the route was removed
266 configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{})
267
268 containerSvc.EXPECT().Stop(mock.Anything, "container-old").Return(nil)
269 containerSvc.EXPECT().Remove(mock.Anything, "container-old", true).Return(nil)
270
271 event := domain.Event{
272 ID: "event-123",
273 Type: domain.EventConfigReload,
274 }
275
276 err := handler.Handle(event)
277
278 assert.NoError(t, err)
279}
280
281func TestConfigReloadHandler_Handle_RedeploysChangedImage(t *testing.T) {
282 containerSvc := inmocks.NewMockContainerService(t)
283 configSvc := inmocks.NewMockConfigService(t)
284
285 handler := NewConfigReloadHandler(testCtx(), containerSvc, configSvc)
286
287 // SyncContainers is called first
288 containerSvc.EXPECT().SyncContainers(mock.Anything).Return(nil)
289
290 // Existing container with old image
291 containerSvc.EXPECT().List(mock.Anything).Return(map[string]*domain.Container{
292 "app.example.com": {
293 ID: "container-1",
294 Labels: map[string]string{
295 "gordon.route": "app.example.com",
296 "gordon.image": "myapp:v1",
297 },
298 },
299 })
300
301 // Config now has different image
302 configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{
303 {Domain: "app.example.com", Image: "myapp:v2"},
304 })
305
306 containerSvc.EXPECT().Deploy(mock.Anything, domain.Route{
307 Domain: "app.example.com",
308 Image: "myapp:v2",
309 }).Return(&domain.Container{ID: "container-2"}, nil)
310
311 event := domain.Event{
312 ID: "event-123",
313 Type: domain.EventConfigReload,
314 }
315
316 err := handler.Handle(event)
317
318 assert.NoError(t, err)
319}
320
321func TestConfigReloadHandler_Handle_NoChanges(t *testing.T) {
322 containerSvc := inmocks.NewMockContainerService(t)
323 configSvc := inmocks.NewMockConfigService(t)
324
325 handler := NewConfigReloadHandler(testCtx(), containerSvc, configSvc)
326
327 // SyncContainers is called first
328 containerSvc.EXPECT().SyncContainers(mock.Anything).Return(nil)
329
330 // Existing container matches config
331 containerSvc.EXPECT().List(mock.Anything).Return(map[string]*domain.Container{
332 "app.example.com": {
333 ID: "container-1",
334 Labels: map[string]string{
335 "gordon.route": "app.example.com",
336 "gordon.image": "myapp:latest",
337 },
338 },
339 })
340
341 configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{
342 {Domain: "app.example.com", Image: "myapp:latest"},
343 })
344
345 // No Deploy, Stop, or Remove calls expected
346
347 event := domain.Event{
348 ID: "event-123",
349 Type: domain.EventConfigReload,
350 }
351
352 err := handler.Handle(event)
353
354 assert.NoError(t, err)
355}
356
357// ManualReloadHandler tests
358
359func TestManualReloadHandler_CanHandle(t *testing.T) {
360 containerSvc := inmocks.NewMockContainerService(t)
361 configSvc := inmocks.NewMockConfigService(t)
362
363 handler := NewManualReloadHandler(testCtx(), containerSvc, configSvc)
364
365 assert.True(t, handler.CanHandle(domain.EventManualReload))
366 assert.False(t, handler.CanHandle(domain.EventImagePushed))
367 assert.False(t, handler.CanHandle(domain.EventConfigReload))
368}
369
370func TestManualReloadHandler_Handle_StartsOnlyMissingContainers(t *testing.T) {
371 containerSvc := inmocks.NewMockContainerService(t)
372 configSvc := inmocks.NewMockConfigService(t)
373
374 handler := NewManualReloadHandler(testCtx(), containerSvc, configSvc)
375
376 // SyncContainers is called first
377 containerSvc.EXPECT().SyncContainers(mock.Anything).Return(nil)
378
379 // Routes in config
380 configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{
381 {Domain: "app1.example.com", Image: "app1:latest"},
382 {Domain: "app2.example.com", Image: "app2:latest"},
383 })
384
385 // app1 has a running container, app2 does not
386 containerSvc.EXPECT().Get(mock.Anything, "app1.example.com").Return(&domain.Container{ID: "container-1"}, true)
387 containerSvc.EXPECT().Get(mock.Anything, "app2.example.com").Return(nil, false)
388
389 // Only app2 should be deployed (app1 is already running - never restart healthy containers)
390 containerSvc.EXPECT().Deploy(mock.Anything, domain.Route{
391 Domain: "app2.example.com",
392 Image: "app2:latest",
393 }).Return(&domain.Container{ID: "container-2"}, nil)
394
395 event := domain.Event{
396 ID: "event-123",
397 Type: domain.EventManualReload,
398 }
399
400 err := handler.Handle(event)
401
402 assert.NoError(t, err)
403}
404
405func TestManualReloadHandler_Handle_StartsMissingContainer(t *testing.T) {
406 containerSvc := inmocks.NewMockContainerService(t)
407 configSvc := inmocks.NewMockConfigService(t)
408
409 handler := NewManualReloadHandler(testCtx(), containerSvc, configSvc)
410
411 // SyncContainers is called first
412 containerSvc.EXPECT().SyncContainers(mock.Anything).Return(nil)
413
414 configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{
415 {Domain: "app.example.com", Image: "app:latest"},
416 })
417
418 containerSvc.EXPECT().Get(mock.Anything, "app.example.com").Return(nil, false)
419
420 // Deploy should be called to start the missing container
421 containerSvc.EXPECT().Deploy(mock.Anything, domain.Route{
422 Domain: "app.example.com",
423 Image: "app:latest",
424 }).Return(&domain.Container{ID: "container-1"}, nil)
425
426 event := domain.Event{
427 ID: "event-123",
428 Type: domain.EventManualReload,
429 }
430
431 err := handler.Handle(event)
432
433 assert.NoError(t, err)
434}
435
436func TestManualReloadHandler_Handle_DeployErrors(t *testing.T) {
437 containerSvc := inmocks.NewMockContainerService(t)
438 configSvc := inmocks.NewMockConfigService(t)
439
440 handler := NewManualReloadHandler(testCtx(), containerSvc, configSvc)
441
442 // SyncContainers is called first
443 containerSvc.EXPECT().SyncContainers(mock.Anything).Return(nil)
444
445 configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{
446 {Domain: "app1.example.com", Image: "app1:latest"},
447 {Domain: "app2.example.com", Image: "app2:latest"},
448 })
449
450 // Both containers are missing
451 containerSvc.EXPECT().Get(mock.Anything, "app1.example.com").Return(nil, false)
452 containerSvc.EXPECT().Get(mock.Anything, "app2.example.com").Return(nil, false)
453
454 // app1 fails to deploy, app2 succeeds
455 containerSvc.EXPECT().Deploy(mock.Anything, domain.Route{
456 Domain: "app1.example.com",
457 Image: "app1:latest",
458 }).Return(nil, errors.New("deploy failed"))
459
460 containerSvc.EXPECT().Deploy(mock.Anything, domain.Route{
461 Domain: "app2.example.com",
462 Image: "app2:latest",
463 }).Return(&domain.Container{ID: "container-2"}, nil)
464
465 event := domain.Event{
466 ID: "event-123",
467 Type: domain.EventManualReload,
468 }
469
470 err := handler.Handle(event)
471
472 // Should return error indicating some failures
473 assert.Error(t, err)
474 assert.Contains(t, err.Error(), "1 errors")
475}
476
477func TestManualReloadHandler_Handle_DoesNotRestartRunningContainers(t *testing.T) {
478 containerSvc := inmocks.NewMockContainerService(t)
479 configSvc := inmocks.NewMockConfigService(t)
480
481 handler := NewManualReloadHandler(testCtx(), containerSvc, configSvc)
482
483 // SyncContainers is called first
484 containerSvc.EXPECT().SyncContainers(mock.Anything).Return(nil)
485
486 // Route with running container
487 configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{
488 {Domain: "app.example.com", Image: "app:latest"},
489 })
490
491 containerSvc.EXPECT().Get(mock.Anything, "app.example.com").Return(&domain.Container{ID: "container-1"}, true)
492
493 // NO Deploy call expected - container is already running, never restart healthy containers
494
495 event := domain.Event{
496 ID: "event-123",
497 Type: domain.EventManualReload,
498 }
499
500 err := handler.Handle(event)
501
502 assert.NoError(t, err)
503}
504
505// ManualDeployHandler tests
506
507func TestManualDeployHandler_CanHandle(t *testing.T) {
508 containerSvc := inmocks.NewMockContainerService(t)
509 configSvc := inmocks.NewMockConfigService(t)
510
511 handler := NewManualDeployHandler(testCtx(), containerSvc, configSvc)
512
513 assert.True(t, handler.CanHandle(domain.EventManualDeploy))
514 assert.False(t, handler.CanHandle(domain.EventImagePushed))
515 assert.False(t, handler.CanHandle(domain.EventManualReload))
516}
517
518func TestManualDeployHandler_Handle_DeploysSpecificRoute(t *testing.T) {
519 containerSvc := inmocks.NewMockContainerService(t)
520 configSvc := inmocks.NewMockConfigService(t)
521
522 handler := NewManualDeployHandler(testCtx(), containerSvc, configSvc)
523
524 // Configure routes
525 configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{
526 {Domain: "app1.example.com", Image: "app1:latest"},
527 {Domain: "app2.example.com", Image: "app2:latest"},
528 })
529
530 // Only the requested route should be deployed
531 containerSvc.EXPECT().Deploy(mock.Anything, domain.Route{
532 Domain: "app1.example.com",
533 Image: "app1:latest",
534 }).Return(&domain.Container{ID: "container-1"}, nil)
535
536 event := domain.Event{
537 ID: "event-123",
538 Type: domain.EventManualDeploy,
539 Data: &domain.ManualDeployPayload{Domain: "app1.example.com"},
540 }
541
542 err := handler.Handle(event)
543
544 assert.NoError(t, err)
545}
546
547func TestManualDeployHandler_Handle_RouteNotFound(t *testing.T) {
548 containerSvc := inmocks.NewMockContainerService(t)
549 configSvc := inmocks.NewMockConfigService(t)
550
551 handler := NewManualDeployHandler(testCtx(), containerSvc, configSvc)
552
553 // No matching routes
554 configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{
555 {Domain: "other.example.com", Image: "other:latest"},
556 })
557
558 event := domain.Event{
559 ID: "event-123",
560 Type: domain.EventManualDeploy,
561 Data: &domain.ManualDeployPayload{Domain: "unknown.example.com"},
562 }
563
564 err := handler.Handle(event)
565
566 assert.Error(t, err)
567 assert.Contains(t, err.Error(), "route not found")
568}
569
570func TestManualDeployHandler_Handle_InvalidPayload(t *testing.T) {
571 containerSvc := inmocks.NewMockContainerService(t)
572 configSvc := inmocks.NewMockConfigService(t)
573
574 handler := NewManualDeployHandler(testCtx(), containerSvc, configSvc)
575
576 // Event with nil payload
577 event := domain.Event{
578 ID: "event-123",
579 Type: domain.EventManualDeploy,
580 Data: nil,
581 }
582
583 err := handler.Handle(event)
584
585 assert.Error(t, err)
586 assert.Contains(t, err.Error(), "invalid manual deploy payload")
587}
588
589func TestManualDeployHandler_Handle_EmptyDomain(t *testing.T) {
590 containerSvc := inmocks.NewMockContainerService(t)
591 configSvc := inmocks.NewMockConfigService(t)
592
593 handler := NewManualDeployHandler(testCtx(), containerSvc, configSvc)
594
595 // Event with empty domain
596 event := domain.Event{
597 ID: "event-123",
598 Type: domain.EventManualDeploy,
599 Data: &domain.ManualDeployPayload{Domain: ""},
600 }
601
602 err := handler.Handle(event)
603
604 assert.Error(t, err)
605 assert.Contains(t, err.Error(), "invalid manual deploy payload")
606}
607
608func TestManualDeployHandler_Handle_DeployError(t *testing.T) {
609 containerSvc := inmocks.NewMockContainerService(t)
610 configSvc := inmocks.NewMockConfigService(t)
611
612 handler := NewManualDeployHandler(testCtx(), containerSvc, configSvc)
613
614 configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{
615 {Domain: "app.example.com", Image: "app:latest"},
616 })
617
618 containerSvc.EXPECT().Deploy(mock.Anything, mock.Anything).Return(nil, errors.New("deploy failed"))
619
620 event := domain.Event{
621 ID: "event-123",
622 Type: domain.EventManualDeploy,
623 Data: &domain.ManualDeployPayload{Domain: "app.example.com"},
624 }
625
626 err := handler.Handle(event)
627
628 assert.Error(t, err)
629 assert.Contains(t, err.Error(), "failed to deploy")
630}