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