v2.2.6 c872d45

events_test.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close