v2.31.0-dev22 8d342c4

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

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