v2.30.0-pre 7341e0e

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	// Attachment config is propagated after sync
213	configSvc.EXPECT().GetAllAttachments(mock.Anything).Return(map[string][]string{})
214	containerSvc.EXPECT().UpdateAttachments(map[string][]string{}).Return()
215
216	// No existing containers
217	containerSvc.EXPECT().List(mock.Anything).Return(map[string]*domain.Container{})
218
219	// New route in config
220	configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{
221		{Domain: "newapp.example.com", Image: "newapp:latest"},
222	})
223
224	containerSvc.EXPECT().Deploy(mock.Anything, domain.Route{
225		Domain: "newapp.example.com",
226		Image:  "newapp:latest",
227	}).Return(&domain.Container{ID: "container-1"}, nil)
228
229	event := domain.Event{
230		ID:   "event-123",
231		Type: domain.EventConfigReload,
232	}
233
234	err := handler.Handle(context.Background(), event)
235
236	assert.NoError(t, err)
237}
238
239func TestConfigReloadHandler_Handle_StopsRemovedRoutes(t *testing.T) {
240	containerSvc := inmocks.NewMockContainerService(t)
241	configSvc := inmocks.NewMockConfigService(t)
242
243	handler := NewConfigReloadHandler(testCtx(), containerSvc, configSvc)
244
245	// SyncContainers is called first
246	containerSvc.EXPECT().SyncContainers(mock.Anything).Return(nil)
247
248	// Attachment config is propagated after sync
249	configSvc.EXPECT().GetAllAttachments(mock.Anything).Return(map[string][]string{})
250	containerSvc.EXPECT().UpdateAttachments(map[string][]string{}).Return()
251
252	// Existing container for a route that's no longer configured
253	containerSvc.EXPECT().List(mock.Anything).Return(map[string]*domain.Container{
254		"removed.example.com": {
255			ID: "container-old",
256			Labels: map[string]string{
257				"gordon.route": "removed.example.com",
258				"gordon.image": "oldapp:latest",
259			},
260		},
261	})
262
263	// Empty routes - the route was removed
264	configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{})
265
266	containerSvc.EXPECT().Stop(mock.Anything, "container-old").Return(nil)
267	containerSvc.EXPECT().Remove(mock.Anything, "container-old", true).Return(nil)
268
269	event := domain.Event{
270		ID:   "event-123",
271		Type: domain.EventConfigReload,
272	}
273
274	err := handler.Handle(context.Background(), event)
275
276	assert.NoError(t, err)
277}
278
279func TestConfigReloadHandler_Handle_RedeploysChangedImage(t *testing.T) {
280	containerSvc := inmocks.NewMockContainerService(t)
281	configSvc := inmocks.NewMockConfigService(t)
282
283	handler := NewConfigReloadHandler(testCtx(), containerSvc, configSvc)
284
285	// SyncContainers is called first
286	containerSvc.EXPECT().SyncContainers(mock.Anything).Return(nil)
287
288	// Attachment config is propagated after sync
289	configSvc.EXPECT().GetAllAttachments(mock.Anything).Return(map[string][]string{})
290	containerSvc.EXPECT().UpdateAttachments(map[string][]string{}).Return()
291
292	// Existing container with old image
293	containerSvc.EXPECT().List(mock.Anything).Return(map[string]*domain.Container{
294		"app.example.com": {
295			ID: "container-1",
296			Labels: map[string]string{
297				"gordon.route": "app.example.com",
298				"gordon.image": "myapp:v1",
299			},
300		},
301	})
302
303	// Config now has different image
304	configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{
305		{Domain: "app.example.com", Image: "myapp:v2"},
306	})
307
308	containerSvc.EXPECT().Deploy(mock.Anything, domain.Route{
309		Domain: "app.example.com",
310		Image:  "myapp:v2",
311	}).Return(&domain.Container{ID: "container-2"}, nil)
312
313	event := domain.Event{
314		ID:   "event-123",
315		Type: domain.EventConfigReload,
316	}
317
318	err := handler.Handle(context.Background(), event)
319
320	assert.NoError(t, err)
321}
322
323func TestConfigReloadHandler_Handle_NoChanges(t *testing.T) {
324	containerSvc := inmocks.NewMockContainerService(t)
325	configSvc := inmocks.NewMockConfigService(t)
326
327	handler := NewConfigReloadHandler(testCtx(), containerSvc, configSvc)
328
329	// SyncContainers is called first
330	containerSvc.EXPECT().SyncContainers(mock.Anything).Return(nil)
331
332	// Attachment config is propagated after sync
333	configSvc.EXPECT().GetAllAttachments(mock.Anything).Return(map[string][]string{})
334	containerSvc.EXPECT().UpdateAttachments(map[string][]string{}).Return()
335
336	// Existing container matches config
337	containerSvc.EXPECT().List(mock.Anything).Return(map[string]*domain.Container{
338		"app.example.com": {
339			ID: "container-1",
340			Labels: map[string]string{
341				"gordon.route": "app.example.com",
342				"gordon.image": "myapp:latest",
343			},
344		},
345	})
346
347	configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{
348		{Domain: "app.example.com", Image: "myapp:latest"},
349	})
350
351	// No Deploy, Stop, or Remove calls expected
352
353	event := domain.Event{
354		ID:   "event-123",
355		Type: domain.EventConfigReload,
356	}
357
358	err := handler.Handle(context.Background(), event)
359
360	assert.NoError(t, err)
361}
362
363// ManualReloadHandler tests
364
365func TestManualReloadHandler_CanHandle(t *testing.T) {
366	containerSvc := inmocks.NewMockContainerService(t)
367	configSvc := inmocks.NewMockConfigService(t)
368
369	handler := NewManualReloadHandler(testCtx(), containerSvc, configSvc)
370
371	assert.True(t, handler.CanHandle(domain.EventManualReload))
372	assert.False(t, handler.CanHandle(domain.EventImagePushed))
373	assert.False(t, handler.CanHandle(domain.EventConfigReload))
374}
375
376func TestManualReloadHandler_Handle_StartsOnlyMissingContainers(t *testing.T) {
377	containerSvc := inmocks.NewMockContainerService(t)
378	configSvc := inmocks.NewMockConfigService(t)
379
380	handler := NewManualReloadHandler(testCtx(), containerSvc, configSvc)
381
382	// SyncContainers is called first
383	containerSvc.EXPECT().SyncContainers(mock.Anything).Return(nil)
384
385	// Routes in config
386	configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{
387		{Domain: "app1.example.com", Image: "app1:latest"},
388		{Domain: "app2.example.com", Image: "app2:latest"},
389	})
390
391	// app1 has a running container, app2 does not
392	containerSvc.EXPECT().Get(mock.Anything, "app1.example.com").Return(&domain.Container{ID: "container-1"}, true)
393	containerSvc.EXPECT().Get(mock.Anything, "app2.example.com").Return(nil, false)
394
395	// Only app2 should be deployed (app1 is already running - never restart healthy containers)
396	containerSvc.EXPECT().Deploy(mock.Anything, domain.Route{
397		Domain: "app2.example.com",
398		Image:  "app2:latest",
399	}).Return(&domain.Container{ID: "container-2"}, nil)
400
401	event := domain.Event{
402		ID:   "event-123",
403		Type: domain.EventManualReload,
404	}
405
406	err := handler.Handle(context.Background(), event)
407
408	assert.NoError(t, err)
409}
410
411func TestManualReloadHandler_Handle_StartsMissingContainer(t *testing.T) {
412	containerSvc := inmocks.NewMockContainerService(t)
413	configSvc := inmocks.NewMockConfigService(t)
414
415	handler := NewManualReloadHandler(testCtx(), containerSvc, configSvc)
416
417	// SyncContainers is called first
418	containerSvc.EXPECT().SyncContainers(mock.Anything).Return(nil)
419
420	configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{
421		{Domain: "app.example.com", Image: "app:latest"},
422	})
423
424	containerSvc.EXPECT().Get(mock.Anything, "app.example.com").Return(nil, false)
425
426	// Deploy should be called to start the missing container
427	containerSvc.EXPECT().Deploy(mock.Anything, domain.Route{
428		Domain: "app.example.com",
429		Image:  "app:latest",
430	}).Return(&domain.Container{ID: "container-1"}, nil)
431
432	event := domain.Event{
433		ID:   "event-123",
434		Type: domain.EventManualReload,
435	}
436
437	err := handler.Handle(context.Background(), event)
438
439	assert.NoError(t, err)
440}
441
442func TestManualReloadHandler_Handle_DeployErrors(t *testing.T) {
443	containerSvc := inmocks.NewMockContainerService(t)
444	configSvc := inmocks.NewMockConfigService(t)
445
446	handler := NewManualReloadHandler(testCtx(), containerSvc, configSvc)
447
448	// SyncContainers is called first
449	containerSvc.EXPECT().SyncContainers(mock.Anything).Return(nil)
450
451	configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{
452		{Domain: "app1.example.com", Image: "app1:latest"},
453		{Domain: "app2.example.com", Image: "app2:latest"},
454	})
455
456	// Both containers are missing
457	containerSvc.EXPECT().Get(mock.Anything, "app1.example.com").Return(nil, false)
458	containerSvc.EXPECT().Get(mock.Anything, "app2.example.com").Return(nil, false)
459
460	// app1 fails to deploy, app2 succeeds
461	containerSvc.EXPECT().Deploy(mock.Anything, domain.Route{
462		Domain: "app1.example.com",
463		Image:  "app1:latest",
464	}).Return(nil, errors.New("deploy failed"))
465
466	containerSvc.EXPECT().Deploy(mock.Anything, domain.Route{
467		Domain: "app2.example.com",
468		Image:  "app2:latest",
469	}).Return(&domain.Container{ID: "container-2"}, nil)
470
471	event := domain.Event{
472		ID:   "event-123",
473		Type: domain.EventManualReload,
474	}
475
476	err := handler.Handle(context.Background(), event)
477
478	// Should return error indicating some failures
479	assert.Error(t, err)
480	assert.Contains(t, err.Error(), "1 errors")
481}
482
483func TestManualReloadHandler_Handle_DoesNotRestartRunningContainers(t *testing.T) {
484	containerSvc := inmocks.NewMockContainerService(t)
485	configSvc := inmocks.NewMockConfigService(t)
486
487	handler := NewManualReloadHandler(testCtx(), containerSvc, configSvc)
488
489	// SyncContainers is called first
490	containerSvc.EXPECT().SyncContainers(mock.Anything).Return(nil)
491
492	// Route with running container
493	configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{
494		{Domain: "app.example.com", Image: "app:latest"},
495	})
496
497	containerSvc.EXPECT().Get(mock.Anything, "app.example.com").Return(&domain.Container{ID: "container-1"}, true)
498
499	// NO Deploy call expected - container is already running, never restart healthy containers
500
501	event := domain.Event{
502		ID:   "event-123",
503		Type: domain.EventManualReload,
504	}
505
506	err := handler.Handle(context.Background(), event)
507
508	assert.NoError(t, err)
509}
510
511// ManualDeployHandler tests
512
513func TestConfigReloadHandlerUpdatesContainerConfig(t *testing.T) {
514	containerSvc := inmocks.NewMockContainerService(t)
515	configSvc := inmocks.NewMockConfigService(t)
516
517	handler := NewConfigReloadHandler(testCtx(), containerSvc, configSvc)
518
519	// SyncContainers is called first
520	containerSvc.EXPECT().SyncContainers(mock.Anything).Return(nil)
521
522	// No existing containers
523	containerSvc.EXPECT().List(mock.Anything).Return(map[string]*domain.Container{})
524
525	// New attachments from config
526	newAttachments := map[string][]string{
527		"app.example.com": {"postgres:15", "redis:7"},
528	}
529	configSvc.EXPECT().GetAllAttachments(mock.Anything).Return(newAttachments)
530
531	// Expect UpdateAttachments to be called with new attachment config
532	containerSvc.EXPECT().UpdateAttachments(newAttachments).Return()
533
534	// No routes configured
535	configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{})
536
537	event := domain.Event{
538		ID:   "event-123",
539		Type: domain.EventConfigReload,
540	}
541
542	err := handler.Handle(context.Background(), event)
543
544	assert.NoError(t, err)
545}
546
547func TestManualDeployHandler_CanHandle(t *testing.T) {
548	containerSvc := inmocks.NewMockContainerService(t)
549	configSvc := inmocks.NewMockConfigService(t)
550
551	handler := NewManualDeployHandler(testCtx(), containerSvc, configSvc)
552
553	assert.True(t, handler.CanHandle(domain.EventManualDeploy))
554	assert.False(t, handler.CanHandle(domain.EventImagePushed))
555	assert.False(t, handler.CanHandle(domain.EventManualReload))
556}
557
558func TestManualDeployHandler_Handle_DeploysSpecificRoute(t *testing.T) {
559	containerSvc := inmocks.NewMockContainerService(t)
560	configSvc := inmocks.NewMockConfigService(t)
561
562	handler := NewManualDeployHandler(testCtx(), containerSvc, configSvc)
563
564	// Configure routes
565	configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{
566		{Domain: "app1.example.com", Image: "app1:latest"},
567		{Domain: "app2.example.com", Image: "app2:latest"},
568	})
569
570	// Only the requested route should be deployed
571	containerSvc.EXPECT().Deploy(mock.Anything, domain.Route{
572		Domain: "app1.example.com",
573		Image:  "app1:latest",
574	}).Return(&domain.Container{ID: "container-1"}, nil)
575
576	event := domain.Event{
577		ID:   "event-123",
578		Type: domain.EventManualDeploy,
579		Data: &domain.ManualDeployPayload{Domain: "app1.example.com"},
580	}
581
582	err := handler.Handle(context.Background(), event)
583
584	assert.NoError(t, err)
585}
586
587func TestManualDeployHandler_Handle_RouteNotFound(t *testing.T) {
588	containerSvc := inmocks.NewMockContainerService(t)
589	configSvc := inmocks.NewMockConfigService(t)
590
591	handler := NewManualDeployHandler(testCtx(), containerSvc, configSvc)
592
593	// No matching routes
594	configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{
595		{Domain: "other.example.com", Image: "other:latest"},
596	})
597
598	event := domain.Event{
599		ID:   "event-123",
600		Type: domain.EventManualDeploy,
601		Data: &domain.ManualDeployPayload{Domain: "unknown.example.com"},
602	}
603
604	err := handler.Handle(context.Background(), event)
605
606	assert.Error(t, err)
607	assert.Contains(t, err.Error(), "route not found")
608}
609
610func TestManualDeployHandler_Handle_InvalidPayload(t *testing.T) {
611	containerSvc := inmocks.NewMockContainerService(t)
612	configSvc := inmocks.NewMockConfigService(t)
613
614	handler := NewManualDeployHandler(testCtx(), containerSvc, configSvc)
615
616	// Event with nil payload
617	event := domain.Event{
618		ID:   "event-123",
619		Type: domain.EventManualDeploy,
620		Data: nil,
621	}
622
623	err := handler.Handle(context.Background(), event)
624
625	assert.Error(t, err)
626	assert.Contains(t, err.Error(), "invalid manual deploy payload")
627}
628
629func TestManualDeployHandler_Handle_EmptyDomain(t *testing.T) {
630	containerSvc := inmocks.NewMockContainerService(t)
631	configSvc := inmocks.NewMockConfigService(t)
632
633	handler := NewManualDeployHandler(testCtx(), containerSvc, configSvc)
634
635	// Event with empty domain
636	event := domain.Event{
637		ID:   "event-123",
638		Type: domain.EventManualDeploy,
639		Data: &domain.ManualDeployPayload{Domain: ""},
640	}
641
642	err := handler.Handle(context.Background(), event)
643
644	assert.Error(t, err)
645	assert.Contains(t, err.Error(), "invalid manual deploy payload")
646}
647
648func TestManualDeployHandler_Handle_DeployError(t *testing.T) {
649	containerSvc := inmocks.NewMockContainerService(t)
650	configSvc := inmocks.NewMockConfigService(t)
651
652	handler := NewManualDeployHandler(testCtx(), containerSvc, configSvc)
653
654	configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{
655		{Domain: "app.example.com", Image: "app:latest"},
656	})
657
658	containerSvc.EXPECT().Deploy(mock.Anything, mock.Anything).Return(nil, errors.New("deploy failed"))
659
660	event := domain.Event{
661		ID:   "event-123",
662		Type: domain.EventManualDeploy,
663		Data: &domain.ManualDeployPayload{Domain: "app.example.com"},
664	}
665
666	err := handler.Handle(context.Background(), event)
667
668	assert.Error(t, err)
669	assert.Contains(t, err.Error(), "failed to deploy")
670}

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