v2.31.0-dev10 f49cffc

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().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}

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