v2.16.0 ebd2ab3

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 "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}

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