v2.1.0 0fb867d

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	// No existing containers
220	containerSvc.EXPECT().List(mock.Anything).Return(map[string]*domain.Container{})
221
222	// New route in config
223	configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{
224		{Domain: "newapp.example.com", Image: "newapp:latest"},
225	})
226
227	containerSvc.EXPECT().Deploy(mock.Anything, domain.Route{
228		Domain: "newapp.example.com",
229		Image:  "newapp:latest",
230	}).Return(&domain.Container{ID: "container-1"}, nil)
231
232	event := domain.Event{
233		ID:   "event-123",
234		Type: domain.EventConfigReload,
235	}
236
237	err := handler.Handle(event)
238
239	assert.NoError(t, err)
240}
241
242func TestConfigReloadHandler_Handle_StopsRemovedRoutes(t *testing.T) {
243	containerSvc := inmocks.NewMockContainerService(t)
244	configSvc := inmocks.NewMockConfigService(t)
245
246	handler := NewConfigReloadHandler(testCtx(), containerSvc, configSvc)
247
248	// Existing container for a route that's no longer configured
249	containerSvc.EXPECT().List(mock.Anything).Return(map[string]*domain.Container{
250		"removed.example.com": {
251			ID: "container-old",
252			Labels: map[string]string{
253				"gordon.route": "removed.example.com",
254				"gordon.image": "oldapp:latest",
255			},
256		},
257	})
258
259	// Empty routes - the route was removed
260	configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{})
261
262	containerSvc.EXPECT().Stop(mock.Anything, "container-old").Return(nil)
263	containerSvc.EXPECT().Remove(mock.Anything, "container-old", true).Return(nil)
264
265	event := domain.Event{
266		ID:   "event-123",
267		Type: domain.EventConfigReload,
268	}
269
270	err := handler.Handle(event)
271
272	assert.NoError(t, err)
273}
274
275func TestConfigReloadHandler_Handle_RedeploysChangedImage(t *testing.T) {
276	containerSvc := inmocks.NewMockContainerService(t)
277	configSvc := inmocks.NewMockConfigService(t)
278
279	handler := NewConfigReloadHandler(testCtx(), containerSvc, configSvc)
280
281	// Existing container with old image
282	containerSvc.EXPECT().List(mock.Anything).Return(map[string]*domain.Container{
283		"app.example.com": {
284			ID: "container-1",
285			Labels: map[string]string{
286				"gordon.route": "app.example.com",
287				"gordon.image": "myapp:v1",
288			},
289		},
290	})
291
292	// Config now has different image
293	configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{
294		{Domain: "app.example.com", Image: "myapp:v2"},
295	})
296
297	containerSvc.EXPECT().Deploy(mock.Anything, domain.Route{
298		Domain: "app.example.com",
299		Image:  "myapp:v2",
300	}).Return(&domain.Container{ID: "container-2"}, nil)
301
302	event := domain.Event{
303		ID:   "event-123",
304		Type: domain.EventConfigReload,
305	}
306
307	err := handler.Handle(event)
308
309	assert.NoError(t, err)
310}
311
312func TestConfigReloadHandler_Handle_NoChanges(t *testing.T) {
313	containerSvc := inmocks.NewMockContainerService(t)
314	configSvc := inmocks.NewMockConfigService(t)
315
316	handler := NewConfigReloadHandler(testCtx(), containerSvc, configSvc)
317
318	// Existing container matches config
319	containerSvc.EXPECT().List(mock.Anything).Return(map[string]*domain.Container{
320		"app.example.com": {
321			ID: "container-1",
322			Labels: map[string]string{
323				"gordon.route": "app.example.com",
324				"gordon.image": "myapp:latest",
325			},
326		},
327	})
328
329	configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{
330		{Domain: "app.example.com", Image: "myapp:latest"},
331	})
332
333	// No Deploy, Stop, or Remove calls expected
334
335	event := domain.Event{
336		ID:   "event-123",
337		Type: domain.EventConfigReload,
338	}
339
340	err := handler.Handle(event)
341
342	assert.NoError(t, err)
343}
344
345// ManualReloadHandler tests
346
347func TestManualReloadHandler_CanHandle(t *testing.T) {
348	containerSvc := inmocks.NewMockContainerService(t)
349	configSvc := inmocks.NewMockConfigService(t)
350
351	handler := NewManualReloadHandler(testCtx(), containerSvc, configSvc)
352
353	assert.True(t, handler.CanHandle(domain.EventManualReload))
354	assert.False(t, handler.CanHandle(domain.EventImagePushed))
355	assert.False(t, handler.CanHandle(domain.EventConfigReload))
356}
357
358func TestManualReloadHandler_Handle_RedeploysExistingContainers(t *testing.T) {
359	containerSvc := inmocks.NewMockContainerService(t)
360	configSvc := inmocks.NewMockConfigService(t)
361
362	handler := NewManualReloadHandler(testCtx(), containerSvc, configSvc)
363
364	// Routes in config
365	configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{
366		{Domain: "app1.example.com", Image: "app1:latest"},
367		{Domain: "app2.example.com", Image: "app2:latest"},
368	})
369
370	// Only app1 has a running container
371	containerSvc.EXPECT().Get(mock.Anything, "app1.example.com").Return(&domain.Container{ID: "container-1"}, true)
372	containerSvc.EXPECT().Get(mock.Anything, "app2.example.com").Return(nil, false)
373
374	// Only app1 should be redeployed (app2 has no container)
375	containerSvc.EXPECT().Deploy(mock.Anything, domain.Route{
376		Domain: "app1.example.com",
377		Image:  "app1:latest",
378	}).Return(&domain.Container{ID: "container-1-new"}, nil)
379
380	event := domain.Event{
381		ID:   "event-123",
382		Type: domain.EventManualReload,
383	}
384
385	err := handler.Handle(event)
386
387	assert.NoError(t, err)
388}
389
390func TestManualReloadHandler_Handle_NoContainersRunning(t *testing.T) {
391	containerSvc := inmocks.NewMockContainerService(t)
392	configSvc := inmocks.NewMockConfigService(t)
393
394	handler := NewManualReloadHandler(testCtx(), containerSvc, configSvc)
395
396	configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{
397		{Domain: "app.example.com", Image: "app:latest"},
398	})
399
400	containerSvc.EXPECT().Get(mock.Anything, "app.example.com").Return(nil, false)
401
402	// No Deploy calls - no container exists to reload
403
404	event := domain.Event{
405		ID:   "event-123",
406		Type: domain.EventManualReload,
407	}
408
409	err := handler.Handle(event)
410
411	assert.NoError(t, err)
412}
413
414func TestManualReloadHandler_Handle_DeployErrors(t *testing.T) {
415	containerSvc := inmocks.NewMockContainerService(t)
416	configSvc := inmocks.NewMockConfigService(t)
417
418	handler := NewManualReloadHandler(testCtx(), containerSvc, configSvc)
419
420	configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{
421		{Domain: "app1.example.com", Image: "app1:latest"},
422		{Domain: "app2.example.com", Image: "app2:latest"},
423	})
424
425	containerSvc.EXPECT().Get(mock.Anything, "app1.example.com").Return(&domain.Container{ID: "container-1"}, true)
426	containerSvc.EXPECT().Get(mock.Anything, "app2.example.com").Return(&domain.Container{ID: "container-2"}, true)
427
428	containerSvc.EXPECT().Deploy(mock.Anything, domain.Route{
429		Domain: "app1.example.com",
430		Image:  "app1:latest",
431	}).Return(nil, errors.New("deploy failed"))
432
433	containerSvc.EXPECT().Deploy(mock.Anything, domain.Route{
434		Domain: "app2.example.com",
435		Image:  "app2:latest",
436	}).Return(&domain.Container{ID: "container-2-new"}, nil)
437
438	event := domain.Event{
439		ID:   "event-123",
440		Type: domain.EventManualReload,
441	}
442
443	err := handler.Handle(event)
444
445	// Should return error indicating some failures
446	assert.Error(t, err)
447	assert.Contains(t, err.Error(), "1 errors")
448}

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