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 "github.com/bnema/gordon/internal/boundaries/in/mocks"
13 "github.com/bnema/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 // FindRoutesByImage returns matching routes directly
40 configSvc.EXPECT().FindRoutesByImage(mock.Anything, "myapp:latest").Return([]domain.Route{
41 {Domain: "app1.example.com", Image: "myapp:latest"},
42 {Domain: "app2.example.com", Image: "myapp:latest"},
43 })
44
45 // Expect Deploy to be called for both matching routes
46 containerSvc.EXPECT().Deploy(mock.Anything, domain.Route{
47 Domain: "app1.example.com",
48 Image: "myapp:latest",
49 }).Return(&domain.Container{ID: "container-1"}, nil)
50
51 containerSvc.EXPECT().Deploy(mock.Anything, domain.Route{
52 Domain: "app2.example.com",
53 Image: "myapp:latest",
54 }).Return(&domain.Container{ID: "container-2"}, nil)
55
56 event := domain.Event{
57 ID: "event-123",
58 Type: domain.EventImagePushed,
59 ImageName: "myapp",
60 Tag: "latest",
61 }
62
63 err := handler.Handle(context.Background(), event)
64
65 assert.NoError(t, err)
66}
67
68func TestImagePushedHandler_Handle_NoMatchingRoutes(t *testing.T) {
69 containerSvc := inmocks.NewMockContainerService(t)
70 configSvc := inmocks.NewMockConfigService(t)
71
72 handler := NewImagePushedHandler(testCtx(), containerSvc, configSvc)
73
74 // FindRoutesByImage returns no matching routes
75 configSvc.EXPECT().FindRoutesByImage(mock.Anything, "myapp:latest").Return([]domain.Route{})
76
77 // No Deploy calls expected
78
79 event := domain.Event{
80 ID: "event-123",
81 Type: domain.EventImagePushed,
82 ImageName: "myapp",
83 Tag: "latest",
84 }
85
86 err := handler.Handle(context.Background(), event)
87
88 assert.NoError(t, err)
89}
90
91func TestImagePushedHandler_Handle_EmptyImageName(t *testing.T) {
92 containerSvc := inmocks.NewMockContainerService(t)
93 configSvc := inmocks.NewMockConfigService(t)
94
95 handler := NewImagePushedHandler(testCtx(), containerSvc, configSvc)
96
97 event := domain.Event{
98 ID: "event-123",
99 Type: domain.EventImagePushed,
100 ImageName: "",
101 Tag: "latest",
102 }
103
104 err := handler.Handle(context.Background(), event)
105
106 assert.ErrorIs(t, err, domain.ErrInvalidImageFormat)
107}
108
109func TestImagePushedHandler_Handle_DefaultTag(t *testing.T) {
110 containerSvc := inmocks.NewMockContainerService(t)
111 configSvc := inmocks.NewMockConfigService(t)
112
113 handler := NewImagePushedHandler(testCtx(), containerSvc, configSvc)
114
115 // Image name "myapp" with empty tag should become "myapp:latest"
116 configSvc.EXPECT().FindRoutesByImage(mock.Anything, "myapp:latest").Return([]domain.Route{
117 {Domain: "app.example.com", Image: "myapp:latest"},
118 })
119
120 containerSvc.EXPECT().Deploy(mock.Anything, domain.Route{
121 Domain: "app.example.com",
122 Image: "myapp:latest",
123 }).Return(&domain.Container{ID: "container-1"}, nil)
124
125 event := domain.Event{
126 ID: "event-123",
127 Type: domain.EventImagePushed,
128 ImageName: "myapp",
129 Tag: "", // Empty tag should default to "latest"
130 }
131
132 err := handler.Handle(context.Background(), event)
133
134 assert.NoError(t, err)
135}
136
137func TestImagePushedHandler_Handle_DeployError(t *testing.T) {
138 containerSvc := inmocks.NewMockContainerService(t)
139 configSvc := inmocks.NewMockConfigService(t)
140
141 handler := NewImagePushedHandler(testCtx(), containerSvc, configSvc)
142
143 configSvc.EXPECT().FindRoutesByImage(mock.Anything, "myapp:latest").Return([]domain.Route{
144 {Domain: "app.example.com", Image: "myapp:latest"},
145 })
146
147 containerSvc.EXPECT().Deploy(mock.Anything, mock.Anything).Return(nil, errors.New("deploy failed"))
148
149 event := domain.Event{
150 ID: "event-123",
151 Type: domain.EventImagePushed,
152 ImageName: "myapp",
153 Tag: "latest",
154 }
155
156 // Handler logs error but doesn't fail
157 err := handler.Handle(context.Background(), event)
158
159 assert.NoError(t, err)
160}
161
162func TestImagePushedHandler_Handle_StripsRegistryDomain(t *testing.T) {
163 containerSvc := inmocks.NewMockContainerService(t)
164 configSvc := inmocks.NewMockConfigService(t)
165
166 handler := NewImagePushedHandler(testCtx(), containerSvc, configSvc)
167
168 // FindRoutesByImage handles registry domain stripping internally
169 configSvc.EXPECT().FindRoutesByImage(mock.Anything, "docker.io/library/nginx:latest").Return([]domain.Route{
170 {Domain: "app.example.com", Image: "registry.example.com/docker.io/library/nginx:latest"},
171 })
172
173 containerSvc.EXPECT().Deploy(mock.Anything, domain.Route{
174 Domain: "app.example.com",
175 Image: "registry.example.com/docker.io/library/nginx:latest",
176 }).Return(&domain.Container{ID: "container-1"}, nil)
177
178 event := domain.Event{
179 ID: "event-123",
180 Type: domain.EventImagePushed,
181 ImageName: "docker.io/library/nginx",
182 Tag: "latest",
183 }
184
185 err := handler.Handle(context.Background(), event)
186
187 assert.NoError(t, err)
188}
189
190// ConfigReloadHandler tests
191
192func TestConfigReloadHandler_CanHandle(t *testing.T) {
193 containerSvc := inmocks.NewMockContainerService(t)
194 configSvc := inmocks.NewMockConfigService(t)
195
196 handler := NewConfigReloadHandler(testCtx(), containerSvc, configSvc)
197
198 assert.True(t, handler.CanHandle(domain.EventConfigReload))
199 assert.False(t, handler.CanHandle(domain.EventImagePushed))
200 assert.False(t, handler.CanHandle(domain.EventManualReload))
201}
202
203func TestConfigReloadHandler_Handle_DeploysNewRoutes(t *testing.T) {
204 containerSvc := inmocks.NewMockContainerService(t)
205 configSvc := inmocks.NewMockConfigService(t)
206
207 handler := NewConfigReloadHandler(testCtx(), containerSvc, configSvc)
208
209 // SyncContainers is called first
210 containerSvc.EXPECT().SyncContainers(mock.Anything).Return(nil)
211
212 // No existing containers
213 containerSvc.EXPECT().List(mock.Anything).Return(map[string]*domain.Container{})
214
215 // New route in config
216 configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{
217 {Domain: "newapp.example.com", Image: "newapp:latest"},
218 })
219
220 containerSvc.EXPECT().Deploy(mock.Anything, domain.Route{
221 Domain: "newapp.example.com",
222 Image: "newapp:latest",
223 }).Return(&domain.Container{ID: "container-1"}, nil)
224
225 event := domain.Event{
226 ID: "event-123",
227 Type: domain.EventConfigReload,
228 }
229
230 err := handler.Handle(context.Background(), event)
231
232 assert.NoError(t, err)
233}
234
235func TestConfigReloadHandler_Handle_StopsRemovedRoutes(t *testing.T) {
236 containerSvc := inmocks.NewMockContainerService(t)
237 configSvc := inmocks.NewMockConfigService(t)
238
239 handler := NewConfigReloadHandler(testCtx(), containerSvc, configSvc)
240
241 // SyncContainers is called first
242 containerSvc.EXPECT().SyncContainers(mock.Anything).Return(nil)
243
244 // Existing container for a route that's no longer configured
245 containerSvc.EXPECT().List(mock.Anything).Return(map[string]*domain.Container{
246 "removed.example.com": {
247 ID: "container-old",
248 Labels: map[string]string{
249 "gordon.route": "removed.example.com",
250 "gordon.image": "oldapp:latest",
251 },
252 },
253 })
254
255 // Empty routes - the route was removed
256 configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{})
257
258 containerSvc.EXPECT().Stop(mock.Anything, "container-old").Return(nil)
259 containerSvc.EXPECT().Remove(mock.Anything, "container-old", true).Return(nil)
260
261 event := domain.Event{
262 ID: "event-123",
263 Type: domain.EventConfigReload,
264 }
265
266 err := handler.Handle(context.Background(), event)
267
268 assert.NoError(t, err)
269}
270
271func TestConfigReloadHandler_Handle_RedeploysChangedImage(t *testing.T) {
272 containerSvc := inmocks.NewMockContainerService(t)
273 configSvc := inmocks.NewMockConfigService(t)
274
275 handler := NewConfigReloadHandler(testCtx(), containerSvc, configSvc)
276
277 // SyncContainers is called first
278 containerSvc.EXPECT().SyncContainers(mock.Anything).Return(nil)
279
280 // Existing container with old image
281 containerSvc.EXPECT().List(mock.Anything).Return(map[string]*domain.Container{
282 "app.example.com": {
283 ID: "container-1",
284 Labels: map[string]string{
285 "gordon.route": "app.example.com",
286 "gordon.image": "myapp:v1",
287 },
288 },
289 })
290
291 // Config now has different image
292 configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{
293 {Domain: "app.example.com", Image: "myapp:v2"},
294 })
295
296 containerSvc.EXPECT().Deploy(mock.Anything, domain.Route{
297 Domain: "app.example.com",
298 Image: "myapp:v2",
299 }).Return(&domain.Container{ID: "container-2"}, nil)
300
301 event := domain.Event{
302 ID: "event-123",
303 Type: domain.EventConfigReload,
304 }
305
306 err := handler.Handle(context.Background(), event)
307
308 assert.NoError(t, err)
309}
310
311func TestConfigReloadHandler_Handle_NoChanges(t *testing.T) {
312 containerSvc := inmocks.NewMockContainerService(t)
313 configSvc := inmocks.NewMockConfigService(t)
314
315 handler := NewConfigReloadHandler(testCtx(), containerSvc, configSvc)
316
317 // SyncContainers is called first
318 containerSvc.EXPECT().SyncContainers(mock.Anything).Return(nil)
319
320 // Existing container matches config
321 containerSvc.EXPECT().List(mock.Anything).Return(map[string]*domain.Container{
322 "app.example.com": {
323 ID: "container-1",
324 Labels: map[string]string{
325 "gordon.route": "app.example.com",
326 "gordon.image": "myapp:latest",
327 },
328 },
329 })
330
331 configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{
332 {Domain: "app.example.com", Image: "myapp:latest"},
333 })
334
335 // No Deploy, Stop, or Remove calls expected
336
337 event := domain.Event{
338 ID: "event-123",
339 Type: domain.EventConfigReload,
340 }
341
342 err := handler.Handle(context.Background(), event)
343
344 assert.NoError(t, err)
345}
346
347// ManualReloadHandler tests
348
349func TestManualReloadHandler_CanHandle(t *testing.T) {
350 containerSvc := inmocks.NewMockContainerService(t)
351 configSvc := inmocks.NewMockConfigService(t)
352
353 handler := NewManualReloadHandler(testCtx(), containerSvc, configSvc)
354
355 assert.True(t, handler.CanHandle(domain.EventManualReload))
356 assert.False(t, handler.CanHandle(domain.EventImagePushed))
357 assert.False(t, handler.CanHandle(domain.EventConfigReload))
358}
359
360func TestManualReloadHandler_Handle_StartsOnlyMissingContainers(t *testing.T) {
361 containerSvc := inmocks.NewMockContainerService(t)
362 configSvc := inmocks.NewMockConfigService(t)
363
364 handler := NewManualReloadHandler(testCtx(), containerSvc, configSvc)
365
366 // SyncContainers is called first
367 containerSvc.EXPECT().SyncContainers(mock.Anything).Return(nil)
368
369 // Routes in config
370 configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{
371 {Domain: "app1.example.com", Image: "app1:latest"},
372 {Domain: "app2.example.com", Image: "app2:latest"},
373 })
374
375 // app1 has a running container, app2 does not
376 containerSvc.EXPECT().Get(mock.Anything, "app1.example.com").Return(&domain.Container{ID: "container-1"}, true)
377 containerSvc.EXPECT().Get(mock.Anything, "app2.example.com").Return(nil, false)
378
379 // Only app2 should be deployed (app1 is already running - never restart healthy containers)
380 containerSvc.EXPECT().Deploy(mock.Anything, domain.Route{
381 Domain: "app2.example.com",
382 Image: "app2:latest",
383 }).Return(&domain.Container{ID: "container-2"}, nil)
384
385 event := domain.Event{
386 ID: "event-123",
387 Type: domain.EventManualReload,
388 }
389
390 err := handler.Handle(context.Background(), event)
391
392 assert.NoError(t, err)
393}
394
395func TestManualReloadHandler_Handle_StartsMissingContainer(t *testing.T) {
396 containerSvc := inmocks.NewMockContainerService(t)
397 configSvc := inmocks.NewMockConfigService(t)
398
399 handler := NewManualReloadHandler(testCtx(), containerSvc, configSvc)
400
401 // SyncContainers is called first
402 containerSvc.EXPECT().SyncContainers(mock.Anything).Return(nil)
403
404 configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{
405 {Domain: "app.example.com", Image: "app:latest"},
406 })
407
408 containerSvc.EXPECT().Get(mock.Anything, "app.example.com").Return(nil, false)
409
410 // Deploy should be called to start the missing container
411 containerSvc.EXPECT().Deploy(mock.Anything, domain.Route{
412 Domain: "app.example.com",
413 Image: "app:latest",
414 }).Return(&domain.Container{ID: "container-1"}, nil)
415
416 event := domain.Event{
417 ID: "event-123",
418 Type: domain.EventManualReload,
419 }
420
421 err := handler.Handle(context.Background(), event)
422
423 assert.NoError(t, err)
424}
425
426func TestManualReloadHandler_Handle_DeployErrors(t *testing.T) {
427 containerSvc := inmocks.NewMockContainerService(t)
428 configSvc := inmocks.NewMockConfigService(t)
429
430 handler := NewManualReloadHandler(testCtx(), containerSvc, configSvc)
431
432 // SyncContainers is called first
433 containerSvc.EXPECT().SyncContainers(mock.Anything).Return(nil)
434
435 configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{
436 {Domain: "app1.example.com", Image: "app1:latest"},
437 {Domain: "app2.example.com", Image: "app2:latest"},
438 })
439
440 // Both containers are missing
441 containerSvc.EXPECT().Get(mock.Anything, "app1.example.com").Return(nil, false)
442 containerSvc.EXPECT().Get(mock.Anything, "app2.example.com").Return(nil, false)
443
444 // app1 fails to deploy, app2 succeeds
445 containerSvc.EXPECT().Deploy(mock.Anything, domain.Route{
446 Domain: "app1.example.com",
447 Image: "app1:latest",
448 }).Return(nil, errors.New("deploy failed"))
449
450 containerSvc.EXPECT().Deploy(mock.Anything, domain.Route{
451 Domain: "app2.example.com",
452 Image: "app2:latest",
453 }).Return(&domain.Container{ID: "container-2"}, nil)
454
455 event := domain.Event{
456 ID: "event-123",
457 Type: domain.EventManualReload,
458 }
459
460 err := handler.Handle(context.Background(), event)
461
462 // Should return error indicating some failures
463 assert.Error(t, err)
464 assert.Contains(t, err.Error(), "1 errors")
465}
466
467func TestManualReloadHandler_Handle_DoesNotRestartRunningContainers(t *testing.T) {
468 containerSvc := inmocks.NewMockContainerService(t)
469 configSvc := inmocks.NewMockConfigService(t)
470
471 handler := NewManualReloadHandler(testCtx(), containerSvc, configSvc)
472
473 // SyncContainers is called first
474 containerSvc.EXPECT().SyncContainers(mock.Anything).Return(nil)
475
476 // Route with running container
477 configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{
478 {Domain: "app.example.com", Image: "app:latest"},
479 })
480
481 containerSvc.EXPECT().Get(mock.Anything, "app.example.com").Return(&domain.Container{ID: "container-1"}, true)
482
483 // NO Deploy call expected - container is already running, never restart healthy containers
484
485 event := domain.Event{
486 ID: "event-123",
487 Type: domain.EventManualReload,
488 }
489
490 err := handler.Handle(context.Background(), event)
491
492 assert.NoError(t, err)
493}
494
495// ManualDeployHandler tests
496
497func TestManualDeployHandler_CanHandle(t *testing.T) {
498 containerSvc := inmocks.NewMockContainerService(t)
499 configSvc := inmocks.NewMockConfigService(t)
500
501 handler := NewManualDeployHandler(testCtx(), containerSvc, configSvc)
502
503 assert.True(t, handler.CanHandle(domain.EventManualDeploy))
504 assert.False(t, handler.CanHandle(domain.EventImagePushed))
505 assert.False(t, handler.CanHandle(domain.EventManualReload))
506}
507
508func TestManualDeployHandler_Handle_DeploysSpecificRoute(t *testing.T) {
509 containerSvc := inmocks.NewMockContainerService(t)
510 configSvc := inmocks.NewMockConfigService(t)
511
512 handler := NewManualDeployHandler(testCtx(), containerSvc, configSvc)
513
514 // Configure routes
515 configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{
516 {Domain: "app1.example.com", Image: "app1:latest"},
517 {Domain: "app2.example.com", Image: "app2:latest"},
518 })
519
520 // Only the requested route should be deployed
521 containerSvc.EXPECT().Deploy(mock.Anything, domain.Route{
522 Domain: "app1.example.com",
523 Image: "app1:latest",
524 }).Return(&domain.Container{ID: "container-1"}, nil)
525
526 event := domain.Event{
527 ID: "event-123",
528 Type: domain.EventManualDeploy,
529 Data: &domain.ManualDeployPayload{Domain: "app1.example.com"},
530 }
531
532 err := handler.Handle(context.Background(), event)
533
534 assert.NoError(t, err)
535}
536
537func TestManualDeployHandler_Handle_RouteNotFound(t *testing.T) {
538 containerSvc := inmocks.NewMockContainerService(t)
539 configSvc := inmocks.NewMockConfigService(t)
540
541 handler := NewManualDeployHandler(testCtx(), containerSvc, configSvc)
542
543 // No matching routes
544 configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{
545 {Domain: "other.example.com", Image: "other:latest"},
546 })
547
548 event := domain.Event{
549 ID: "event-123",
550 Type: domain.EventManualDeploy,
551 Data: &domain.ManualDeployPayload{Domain: "unknown.example.com"},
552 }
553
554 err := handler.Handle(context.Background(), event)
555
556 assert.Error(t, err)
557 assert.Contains(t, err.Error(), "route not found")
558}
559
560func TestManualDeployHandler_Handle_InvalidPayload(t *testing.T) {
561 containerSvc := inmocks.NewMockContainerService(t)
562 configSvc := inmocks.NewMockConfigService(t)
563
564 handler := NewManualDeployHandler(testCtx(), containerSvc, configSvc)
565
566 // Event with nil payload
567 event := domain.Event{
568 ID: "event-123",
569 Type: domain.EventManualDeploy,
570 Data: nil,
571 }
572
573 err := handler.Handle(context.Background(), event)
574
575 assert.Error(t, err)
576 assert.Contains(t, err.Error(), "invalid manual deploy payload")
577}
578
579func TestManualDeployHandler_Handle_EmptyDomain(t *testing.T) {
580 containerSvc := inmocks.NewMockContainerService(t)
581 configSvc := inmocks.NewMockConfigService(t)
582
583 handler := NewManualDeployHandler(testCtx(), containerSvc, configSvc)
584
585 // Event with empty domain
586 event := domain.Event{
587 ID: "event-123",
588 Type: domain.EventManualDeploy,
589 Data: &domain.ManualDeployPayload{Domain: ""},
590 }
591
592 err := handler.Handle(context.Background(), event)
593
594 assert.Error(t, err)
595 assert.Contains(t, err.Error(), "invalid manual deploy payload")
596}
597
598func TestManualDeployHandler_Handle_DeployError(t *testing.T) {
599 containerSvc := inmocks.NewMockContainerService(t)
600 configSvc := inmocks.NewMockConfigService(t)
601
602 handler := NewManualDeployHandler(testCtx(), containerSvc, configSvc)
603
604 configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{
605 {Domain: "app.example.com", Image: "app:latest"},
606 })
607
608 containerSvc.EXPECT().Deploy(mock.Anything, mock.Anything).Return(nil, errors.New("deploy failed"))
609
610 event := domain.Event{
611 ID: "event-123",
612 Type: domain.EventManualDeploy,
613 Data: &domain.ManualDeployPayload{Domain: "app.example.com"},
614 }
615
616 err := handler.Handle(context.Background(), event)
617
618 assert.Error(t, err)
619 assert.Contains(t, err.Error(), "failed to deploy")
620}