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().Stop(mock.Anything, "container-old").Return(nil)
265 containerSvc.EXPECT().Remove(mock.Anything, "container-old", true).Return(nil)
266
267 event := domain.Event{
268 ID: "event-123",
269 Type: domain.EventConfigReload,
270 }
271
272 err := handler.Handle(context.Background(), event)
273
274 assert.NoError(t, err)
275}
276
277func TestConfigReloadHandler_Handle_RedeploysChangedImage(t *testing.T) {
278 containerSvc := inmocks.NewMockContainerService(t)
279 configSvc := inmocks.NewMockConfigService(t)
280
281 handler := NewConfigReloadHandler(testCtx(), containerSvc, configSvc)
282
283 // SyncContainers is called first
284 containerSvc.EXPECT().SyncContainers(mock.Anything).Return(nil)
285
286 // Attachment config is propagated after sync
287 configSvc.EXPECT().GetAllAttachments(mock.Anything).Return(map[string][]string{})
288 containerSvc.EXPECT().UpdateAttachments(map[string][]string{}).Return()
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(context.Background(), 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 // Attachment config is propagated after sync
331 configSvc.EXPECT().GetAllAttachments(mock.Anything).Return(map[string][]string{})
332 containerSvc.EXPECT().UpdateAttachments(map[string][]string{}).Return()
333
334 // Existing container matches config
335 containerSvc.EXPECT().List(mock.Anything).Return(map[string]*domain.Container{
336 "app.example.com": {
337 ID: "container-1",
338 Labels: map[string]string{
339 "gordon.route": "app.example.com",
340 "gordon.image": "myapp:latest",
341 },
342 },
343 })
344
345 configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{
346 {Domain: "app.example.com", Image: "myapp:latest"},
347 })
348
349 // No Deploy, Stop, or Remove calls expected
350
351 event := domain.Event{
352 ID: "event-123",
353 Type: domain.EventConfigReload,
354 }
355
356 err := handler.Handle(context.Background(), event)
357
358 assert.NoError(t, err)
359}
360
361// ManualDeployHandler tests
362
363func TestConfigReloadHandlerUpdatesContainerConfig(t *testing.T) {
364 containerSvc := inmocks.NewMockContainerService(t)
365 configSvc := inmocks.NewMockConfigService(t)
366
367 handler := NewConfigReloadHandler(testCtx(), containerSvc, configSvc)
368
369 // SyncContainers is called first
370 containerSvc.EXPECT().SyncContainers(mock.Anything).Return(nil)
371
372 // No existing containers
373 containerSvc.EXPECT().List(mock.Anything).Return(map[string]*domain.Container{})
374
375 // New attachments from config
376 newAttachments := map[string][]string{
377 "app.example.com": {"postgres:15", "redis:7"},
378 }
379 configSvc.EXPECT().GetAllAttachments(mock.Anything).Return(newAttachments)
380
381 // Expect UpdateAttachments to be called with new attachment config
382 containerSvc.EXPECT().UpdateAttachments(newAttachments).Return()
383
384 // No routes configured
385 configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{})
386
387 event := domain.Event{
388 ID: "event-123",
389 Type: domain.EventConfigReload,
390 }
391
392 err := handler.Handle(context.Background(), event)
393
394 assert.NoError(t, err)
395}
396
397func TestManualDeployHandler_CanHandle(t *testing.T) {
398 containerSvc := inmocks.NewMockContainerService(t)
399 configSvc := inmocks.NewMockConfigService(t)
400
401 handler := NewManualDeployHandler(testCtx(), containerSvc, configSvc)
402
403 assert.True(t, handler.CanHandle(domain.EventManualDeploy))
404 assert.False(t, handler.CanHandle(domain.EventImagePushed))
405}
406
407func TestManualDeployHandler_Handle_DeploysSpecificRoute(t *testing.T) {
408 containerSvc := inmocks.NewMockContainerService(t)
409 configSvc := inmocks.NewMockConfigService(t)
410
411 handler := NewManualDeployHandler(testCtx(), containerSvc, configSvc)
412
413 // Configure routes
414 configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{
415 {Domain: "app1.example.com", Image: "app1:latest"},
416 {Domain: "app2.example.com", Image: "app2:latest"},
417 })
418
419 // Only the requested route should be deployed
420 containerSvc.EXPECT().Deploy(mock.Anything, domain.Route{
421 Domain: "app1.example.com",
422 Image: "app1:latest",
423 }).Return(&domain.Container{ID: "container-1"}, nil)
424
425 event := domain.Event{
426 ID: "event-123",
427 Type: domain.EventManualDeploy,
428 Data: &domain.ManualDeployPayload{Domain: "app1.example.com"},
429 }
430
431 err := handler.Handle(context.Background(), event)
432
433 assert.NoError(t, err)
434}
435
436func TestManualDeployHandler_Handle_RouteNotFound(t *testing.T) {
437 containerSvc := inmocks.NewMockContainerService(t)
438 configSvc := inmocks.NewMockConfigService(t)
439
440 handler := NewManualDeployHandler(testCtx(), containerSvc, configSvc)
441
442 // No matching routes
443 configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{
444 {Domain: "other.example.com", Image: "other:latest"},
445 })
446
447 event := domain.Event{
448 ID: "event-123",
449 Type: domain.EventManualDeploy,
450 Data: &domain.ManualDeployPayload{Domain: "unknown.example.com"},
451 }
452
453 err := handler.Handle(context.Background(), event)
454
455 assert.Error(t, err)
456 assert.Contains(t, err.Error(), "route not found")
457}
458
459func TestManualDeployHandler_Handle_InvalidPayload(t *testing.T) {
460 containerSvc := inmocks.NewMockContainerService(t)
461 configSvc := inmocks.NewMockConfigService(t)
462
463 handler := NewManualDeployHandler(testCtx(), containerSvc, configSvc)
464
465 // Event with nil payload
466 event := domain.Event{
467 ID: "event-123",
468 Type: domain.EventManualDeploy,
469 Data: nil,
470 }
471
472 err := handler.Handle(context.Background(), event)
473
474 assert.Error(t, err)
475 assert.Contains(t, err.Error(), "invalid manual deploy payload")
476}
477
478func TestManualDeployHandler_Handle_EmptyDomain(t *testing.T) {
479 containerSvc := inmocks.NewMockContainerService(t)
480 configSvc := inmocks.NewMockConfigService(t)
481
482 handler := NewManualDeployHandler(testCtx(), containerSvc, configSvc)
483
484 // Event with empty domain
485 event := domain.Event{
486 ID: "event-123",
487 Type: domain.EventManualDeploy,
488 Data: &domain.ManualDeployPayload{Domain: ""},
489 }
490
491 err := handler.Handle(context.Background(), event)
492
493 assert.Error(t, err)
494 assert.Contains(t, err.Error(), "invalid manual deploy payload")
495}
496
497func TestManualDeployHandler_Handle_DeployError(t *testing.T) {
498 containerSvc := inmocks.NewMockContainerService(t)
499 configSvc := inmocks.NewMockConfigService(t)
500
501 handler := NewManualDeployHandler(testCtx(), containerSvc, configSvc)
502
503 configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{
504 {Domain: "app.example.com", Image: "app:latest"},
505 })
506
507 containerSvc.EXPECT().Deploy(mock.Anything, mock.Anything).Return(nil, errors.New("deploy failed"))
508
509 event := domain.Event{
510 ID: "event-123",
511 Type: domain.EventManualDeploy,
512 Data: &domain.ManualDeployPayload{Domain: "app.example.com"},
513 }
514
515 err := handler.Handle(context.Background(), event)
516
517 assert.Error(t, err)
518 assert.Contains(t, err.Error(), "failed to deploy")
519}