v2.6.0 fe25b4f

service_test.go RawHistoryFolder

1package config
2
3import (
4	"context"
5	"os"
6	"path/filepath"
7	"testing"
8
9	"github.com/bnema/zerowrap"
10	"github.com/spf13/viper"
11	"github.com/stretchr/testify/assert"
12	"github.com/stretchr/testify/require"
13
14	"gordon/internal/boundaries/out/mocks"
15	"gordon/internal/domain"
16)
17
18func testContext() context.Context {
19	return zerowrap.WithCtx(context.Background(), zerowrap.Default())
20}
21
22func TestService_Load(t *testing.T) {
23	v := viper.New()
24	v.Set("server.port", 8080)
25	v.Set("server.registry_port", 5000)
26	v.Set("server.registry_domain", "registry.example.com")
27	v.Set("server.data_dir", "/var/gordon")
28	v.Set("auto_route.enabled", true)
29	v.Set("network_isolation.enabled", true)
30	v.Set("network_isolation.network_prefix", "gordon")
31	v.Set("auth.enabled", true)
32	v.Set("auth.username", "admin")
33	v.Set("auth.password", "secret")
34	v.Set("volumes.auto_create", true)
35	v.Set("volumes.prefix", "gordon")
36	v.Set("volumes.preserve", false)
37	v.Set("routes", map[string]interface{}{
38		"app.example.com": "myapp:latest",
39	})
40
41	eventBus := mocks.NewMockEventPublisher(t)
42	svc := NewService(v, eventBus)
43	ctx := testContext()
44
45	err := svc.Load(ctx)
46
47	assert.NoError(t, err)
48	assert.Equal(t, 8080, svc.GetServerPort())
49	assert.Equal(t, 5000, svc.GetRegistryPort())
50	assert.Equal(t, "registry.example.com", svc.GetRegistryDomain())
51	assert.Equal(t, "/var/gordon", svc.GetDataDir())
52	assert.True(t, svc.IsAutoRouteEnabled())
53	assert.True(t, svc.IsNetworkIsolationEnabled())
54	assert.Equal(t, "gordon", svc.GetNetworkPrefix())
55
56	enabled, username, password := svc.GetRegistryAuthConfig()
57	assert.True(t, enabled)
58	assert.Equal(t, "admin", username)
59	assert.Equal(t, "secret", password)
60
61	autoCreate, prefix, preserve := svc.GetVolumeConfig()
62	assert.True(t, autoCreate)
63	assert.Equal(t, "gordon", prefix)
64	assert.False(t, preserve)
65}
66
67func TestService_GetRoutes(t *testing.T) {
68	v := viper.New()
69	v.Set("routes", map[string]interface{}{
70		"app1.example.com":        "myapp:latest",
71		"app2.example.com":        "otherapp:v2",
72		"http://insecure.example": "insecureapp:latest",
73	})
74
75	eventBus := mocks.NewMockEventPublisher(t)
76	svc := NewService(v, eventBus)
77	ctx := testContext()
78
79	_ = svc.Load(ctx)
80
81	routes := svc.GetRoutes(ctx)
82
83	assert.Len(t, routes, 3)
84
85	// Check routes (order is not guaranteed)
86	routeMap := make(map[string]domain.Route)
87	for _, r := range routes {
88		routeMap[r.Domain] = r
89	}
90
91	assert.Equal(t, "myapp:latest", routeMap["app1.example.com"].Image)
92	assert.True(t, routeMap["app1.example.com"].HTTPS)
93
94	assert.Equal(t, "otherapp:v2", routeMap["app2.example.com"].Image)
95	assert.True(t, routeMap["app2.example.com"].HTTPS)
96
97	assert.Equal(t, "insecureapp:latest", routeMap["insecure.example"].Image)
98	assert.False(t, routeMap["insecure.example"].HTTPS)
99}
100
101func TestService_GetRoute(t *testing.T) {
102	v := viper.New()
103	v.Set("routes", map[string]interface{}{
104		"app.example.com":         "myapp:latest",
105		"http://insecure.example": "insecureapp:latest",
106	})
107
108	eventBus := mocks.NewMockEventPublisher(t)
109	svc := NewService(v, eventBus)
110	ctx := testContext()
111
112	_ = svc.Load(ctx)
113
114	t.Run("existing route", func(t *testing.T) {
115		route, err := svc.GetRoute(ctx, "app.example.com")
116		require.NoError(t, err)
117		assert.Equal(t, "app.example.com", route.Domain)
118		assert.Equal(t, "myapp:latest", route.Image)
119		assert.True(t, route.HTTPS)
120	})
121
122	t.Run("non-existent route", func(t *testing.T) {
123		route, err := svc.GetRoute(ctx, "notfound.example.com")
124		assert.ErrorIs(t, err, domain.ErrRouteNotFound)
125		assert.Nil(t, route)
126	})
127}
128
129func TestService_AddRoute(t *testing.T) {
130	t.Run("success", func(t *testing.T) {
131		// Create temp config file for Save() to work
132		tmpDir := t.TempDir()
133		configFile := filepath.Join(tmpDir, "gordon.toml")
134		err := os.WriteFile(configFile, []byte("[routes]\n"), 0600)
135		require.NoError(t, err)
136
137		v := viper.New()
138		v.SetConfigFile(configFile)
139		eventBus := mocks.NewMockEventPublisher(t)
140		svc := NewService(v, eventBus)
141		ctx := testContext()
142
143		_ = svc.Load(ctx)
144
145		route := domain.Route{
146			Domain: "new.example.com",
147			Image:  "newapp:latest",
148		}
149
150		err = svc.AddRoute(ctx, route)
151
152		assert.NoError(t, err)
153
154		// Verify route was added
155		config := svc.GetConfig()
156		assert.Equal(t, "newapp:latest", config.Routes["new.example.com"])
157	})
158
159	t.Run("empty domain", func(t *testing.T) {
160		v := viper.New()
161		eventBus := mocks.NewMockEventPublisher(t)
162		svc := NewService(v, eventBus)
163		ctx := testContext()
164
165		route := domain.Route{
166			Domain: "",
167			Image:  "myapp:latest",
168		}
169
170		err := svc.AddRoute(ctx, route)
171		assert.ErrorIs(t, err, domain.ErrRouteDomainEmpty)
172	})
173
174	t.Run("empty image", func(t *testing.T) {
175		v := viper.New()
176		eventBus := mocks.NewMockEventPublisher(t)
177		svc := NewService(v, eventBus)
178		ctx := testContext()
179
180		route := domain.Route{
181			Domain: "example.com",
182			Image:  "",
183		}
184
185		err := svc.AddRoute(ctx, route)
186		assert.ErrorIs(t, err, domain.ErrRouteImageEmpty)
187	})
188}
189
190func TestService_UpdateRoute(t *testing.T) {
191	t.Run("success", func(t *testing.T) {
192		// Create temp config file for Save() to work
193		tmpDir := t.TempDir()
194		configFile := filepath.Join(tmpDir, "gordon.toml")
195		err := os.WriteFile(configFile, []byte("[routes]\n\"app.example.com\" = \"myapp:v1\"\n"), 0600)
196		require.NoError(t, err)
197
198		v := viper.New()
199		v.SetConfigFile(configFile)
200		v.Set("routes", map[string]any{
201			"app.example.com": "myapp:v1",
202		})
203
204		eventBus := mocks.NewMockEventPublisher(t)
205		svc := NewService(v, eventBus)
206		ctx := testContext()
207
208		_ = svc.Load(ctx)
209
210		route := domain.Route{
211			Domain: "app.example.com",
212			Image:  "myapp:v2",
213		}
214
215		err = svc.UpdateRoute(ctx, route)
216
217		assert.NoError(t, err)
218
219		config := svc.GetConfig()
220		assert.Equal(t, "myapp:v2", config.Routes["app.example.com"])
221	})
222
223	t.Run("not found", func(t *testing.T) {
224		v := viper.New()
225		eventBus := mocks.NewMockEventPublisher(t)
226		svc := NewService(v, eventBus)
227		ctx := testContext()
228
229		_ = svc.Load(ctx)
230
231		route := domain.Route{
232			Domain: "nonexistent.example.com",
233			Image:  "myapp:latest",
234		}
235
236		err := svc.UpdateRoute(ctx, route)
237		assert.ErrorIs(t, err, domain.ErrRouteNotFound)
238	})
239
240	t.Run("empty domain", func(t *testing.T) {
241		v := viper.New()
242		eventBus := mocks.NewMockEventPublisher(t)
243		svc := NewService(v, eventBus)
244		ctx := testContext()
245
246		route := domain.Route{
247			Domain: "",
248			Image:  "myapp:latest",
249		}
250
251		err := svc.UpdateRoute(ctx, route)
252		assert.ErrorIs(t, err, domain.ErrRouteDomainEmpty)
253	})
254
255	t.Run("empty image", func(t *testing.T) {
256		v := viper.New()
257		eventBus := mocks.NewMockEventPublisher(t)
258		svc := NewService(v, eventBus)
259		ctx := testContext()
260
261		route := domain.Route{
262			Domain: "example.com",
263			Image:  "",
264		}
265
266		err := svc.UpdateRoute(ctx, route)
267		assert.ErrorIs(t, err, domain.ErrRouteImageEmpty)
268	})
269}
270
271func TestService_RemoveRoute(t *testing.T) {
272	// Create temp config file for Save() to work
273	tmpDir := t.TempDir()
274	configFile := filepath.Join(tmpDir, "gordon.toml")
275	err := os.WriteFile(configFile, []byte("[routes]\n\"app.example.com\" = \"myapp:latest\"\n"), 0600)
276	require.NoError(t, err)
277
278	v := viper.New()
279	v.SetConfigFile(configFile)
280	v.Set("routes", map[string]interface{}{
281		"app.example.com": "myapp:latest",
282	})
283
284	eventBus := mocks.NewMockEventPublisher(t)
285	svc := NewService(v, eventBus)
286	ctx := testContext()
287
288	_ = svc.Load(ctx)
289
290	err = svc.RemoveRoute(ctx, "app.example.com")
291
292	assert.NoError(t, err)
293
294	config := svc.GetConfig()
295	_, exists := config.Routes["app.example.com"]
296	assert.False(t, exists)
297}
298
299func TestService_RemoveRoute_NotFound(t *testing.T) {
300	v := viper.New()
301	eventBus := mocks.NewMockEventPublisher(t)
302	svc := NewService(v, eventBus)
303	ctx := testContext()
304
305	_ = svc.Load(ctx)
306
307	err := svc.RemoveRoute(ctx, "nonexistent.example.com")
308
309	assert.ErrorIs(t, err, domain.ErrRouteNotFound)
310}
311
312func TestService_GetNetworkGroups(t *testing.T) {
313	v := viper.New()
314	v.Set("network_groups", map[string]interface{}{
315		"frontend": []interface{}{"app1.example.com", "app2.example.com"},
316		"backend":  []interface{}{"api.example.com", "db.example.com"},
317	})
318
319	eventBus := mocks.NewMockEventPublisher(t)
320	svc := NewService(v, eventBus)
321	ctx := testContext()
322
323	_ = svc.Load(ctx)
324
325	groups := svc.GetNetworkGroups()
326
327	assert.Len(t, groups, 2)
328	assert.ElementsMatch(t, []string{"app1.example.com", "app2.example.com"}, groups["frontend"])
329	assert.ElementsMatch(t, []string{"api.example.com", "db.example.com"}, groups["backend"])
330}
331
332func TestService_GetAttachments(t *testing.T) {
333	v := viper.New()
334	v.Set("attachments", map[string]interface{}{
335		"app.example.com": []interface{}{"redis:latest", "postgres:18"},
336	})
337
338	eventBus := mocks.NewMockEventPublisher(t)
339	svc := NewService(v, eventBus)
340	ctx := testContext()
341
342	_ = svc.Load(ctx)
343
344	attachments := svc.GetAttachments()
345
346	assert.Len(t, attachments, 1)
347	assert.ElementsMatch(t, []string{"redis:latest", "postgres:18"}, attachments["app.example.com"])
348}
349
350func TestService_GetAllAttachments(t *testing.T) {
351	t.Run("returns all attachments", func(t *testing.T) {
352		v := viper.New()
353		v.Set("attachments", map[string]interface{}{
354			"app.example.com": []interface{}{"redis:latest", "postgres:18"},
355			"api.example.com": []interface{}{"rabbitmq:3"},
356		})
357
358		eventBus := mocks.NewMockEventPublisher(t)
359		svc := NewService(v, eventBus)
360		ctx := testContext()
361
362		_ = svc.Load(ctx)
363
364		attachments := svc.GetAllAttachments(ctx)
365
366		assert.Len(t, attachments, 2)
367		assert.ElementsMatch(t, []string{"redis:latest", "postgres:18"}, attachments["app.example.com"])
368		assert.ElementsMatch(t, []string{"rabbitmq:3"}, attachments["api.example.com"])
369	})
370
371	t.Run("returns empty map when no attachments", func(t *testing.T) {
372		v := viper.New()
373		eventBus := mocks.NewMockEventPublisher(t)
374		svc := NewService(v, eventBus)
375		ctx := testContext()
376
377		_ = svc.Load(ctx)
378
379		attachments := svc.GetAllAttachments(ctx)
380
381		assert.Empty(t, attachments)
382	})
383
384	t.Run("returns copy not reference", func(t *testing.T) {
385		v := viper.New()
386		v.Set("attachments", map[string]interface{}{
387			"app.example.com": []interface{}{"redis:latest"},
388		})
389
390		eventBus := mocks.NewMockEventPublisher(t)
391		svc := NewService(v, eventBus)
392		ctx := testContext()
393
394		_ = svc.Load(ctx)
395
396		attachments := svc.GetAllAttachments(ctx)
397		// Modify the returned map
398		attachments["app.example.com"] = append(attachments["app.example.com"], "postgres:18")
399
400		// Original should be unchanged
401		original := svc.GetAllAttachments(ctx)
402		assert.Len(t, original["app.example.com"], 1)
403	})
404}
405
406func TestService_GetAttachmentsFor(t *testing.T) {
407	t.Run("existing domain", func(t *testing.T) {
408		v := viper.New()
409		v.Set("attachments", map[string]interface{}{
410			"app.example.com": []interface{}{"redis:latest", "postgres:18"},
411		})
412
413		eventBus := mocks.NewMockEventPublisher(t)
414		svc := NewService(v, eventBus)
415		ctx := testContext()
416
417		_ = svc.Load(ctx)
418
419		images, err := svc.GetAttachmentsFor(ctx, "app.example.com")
420
421		require.NoError(t, err)
422		assert.ElementsMatch(t, []string{"redis:latest", "postgres:18"}, images)
423	})
424
425	t.Run("existing network group", func(t *testing.T) {
426		v := viper.New()
427		v.Set("attachments", map[string]interface{}{
428			"backend": []interface{}{"rabbitmq:3", "redis:latest"},
429		})
430
431		eventBus := mocks.NewMockEventPublisher(t)
432		svc := NewService(v, eventBus)
433		ctx := testContext()
434
435		_ = svc.Load(ctx)
436
437		images, err := svc.GetAttachmentsFor(ctx, "backend")
438
439		require.NoError(t, err)
440		assert.ElementsMatch(t, []string{"rabbitmq:3", "redis:latest"}, images)
441	})
442
443	t.Run("non-existent target", func(t *testing.T) {
444		v := viper.New()
445		eventBus := mocks.NewMockEventPublisher(t)
446		svc := NewService(v, eventBus)
447		ctx := testContext()
448
449		_ = svc.Load(ctx)
450
451		images, err := svc.GetAttachmentsFor(ctx, "notfound.example.com")
452
453		assert.ErrorIs(t, err, domain.ErrAttachmentNotFound)
454		assert.Nil(t, images)
455	})
456
457	t.Run("returns copy not reference", func(t *testing.T) {
458		v := viper.New()
459		v.Set("attachments", map[string]interface{}{
460			"app.example.com": []interface{}{"redis:latest"},
461		})
462
463		eventBus := mocks.NewMockEventPublisher(t)
464		svc := NewService(v, eventBus)
465		ctx := testContext()
466
467		_ = svc.Load(ctx)
468
469		images, err := svc.GetAttachmentsFor(ctx, "app.example.com")
470		require.NoError(t, err)
471
472		// Modify the returned slice (use _ to satisfy linter)
473		_ = append(images, "postgres:18")
474
475		// Original should be unchanged
476		original, _ := svc.GetAttachmentsFor(ctx, "app.example.com")
477		assert.Len(t, original, 1)
478	})
479}
480
481func TestService_AddAttachment(t *testing.T) {
482	t.Run("success - new target", func(t *testing.T) {
483		tmpDir := t.TempDir()
484		configFile := filepath.Join(tmpDir, "gordon.toml")
485		err := os.WriteFile(configFile, []byte("[attachments]\n"), 0600)
486		require.NoError(t, err)
487
488		v := viper.New()
489		v.SetConfigFile(configFile)
490		eventBus := mocks.NewMockEventPublisher(t)
491		svc := NewService(v, eventBus)
492		ctx := testContext()
493
494		_ = svc.Load(ctx)
495
496		err = svc.AddAttachment(ctx, "app.example.com", "postgres:18")
497
498		assert.NoError(t, err)
499
500		// Verify attachment was added
501		config := svc.GetConfig()
502		assert.Contains(t, config.Attachments["app.example.com"], "postgres:18")
503	})
504
505	t.Run("success - existing target", func(t *testing.T) {
506		tmpDir := t.TempDir()
507		configFile := filepath.Join(tmpDir, "gordon.toml")
508		err := os.WriteFile(configFile, []byte("[attachments]\n\"app.example.com\" = [\"redis:latest\"]\n"), 0600)
509		require.NoError(t, err)
510
511		v := viper.New()
512		v.SetConfigFile(configFile)
513		v.Set("attachments", map[string]interface{}{
514			"app.example.com": []interface{}{"redis:latest"},
515		})
516		eventBus := mocks.NewMockEventPublisher(t)
517		svc := NewService(v, eventBus)
518		ctx := testContext()
519
520		_ = svc.Load(ctx)
521
522		err = svc.AddAttachment(ctx, "app.example.com", "postgres:18")
523
524		assert.NoError(t, err)
525
526		// Verify attachment was added alongside existing
527		config := svc.GetConfig()
528		assert.ElementsMatch(t, []string{"redis:latest", "postgres:18"}, config.Attachments["app.example.com"])
529	})
530
531	t.Run("duplicate attachment", func(t *testing.T) {
532		v := viper.New()
533		v.Set("attachments", map[string]interface{}{
534			"app.example.com": []interface{}{"redis:latest"},
535		})
536		eventBus := mocks.NewMockEventPublisher(t)
537		svc := NewService(v, eventBus)
538		ctx := testContext()
539
540		_ = svc.Load(ctx)
541
542		err := svc.AddAttachment(ctx, "app.example.com", "redis:latest")
543
544		assert.ErrorIs(t, err, domain.ErrAttachmentExists)
545	})
546
547	t.Run("empty target", func(t *testing.T) {
548		v := viper.New()
549		eventBus := mocks.NewMockEventPublisher(t)
550		svc := NewService(v, eventBus)
551		ctx := testContext()
552
553		err := svc.AddAttachment(ctx, "", "postgres:18")
554
555		assert.ErrorIs(t, err, domain.ErrAttachmentTargetEmpty)
556	})
557
558	t.Run("empty image", func(t *testing.T) {
559		v := viper.New()
560		eventBus := mocks.NewMockEventPublisher(t)
561		svc := NewService(v, eventBus)
562		ctx := testContext()
563
564		err := svc.AddAttachment(ctx, "app.example.com", "")
565
566		assert.ErrorIs(t, err, domain.ErrAttachmentImageEmpty)
567	})
568
569	t.Run("network group target", func(t *testing.T) {
570		tmpDir := t.TempDir()
571		configFile := filepath.Join(tmpDir, "gordon.toml")
572		err := os.WriteFile(configFile, []byte("[attachments]\n"), 0600)
573		require.NoError(t, err)
574
575		v := viper.New()
576		v.SetConfigFile(configFile)
577		eventBus := mocks.NewMockEventPublisher(t)
578		svc := NewService(v, eventBus)
579		ctx := testContext()
580
581		_ = svc.Load(ctx)
582
583		err = svc.AddAttachment(ctx, "backend", "rabbitmq:3")
584
585		assert.NoError(t, err)
586
587		config := svc.GetConfig()
588		assert.Contains(t, config.Attachments["backend"], "rabbitmq:3")
589	})
590}
591
592func TestService_RemoveAttachment(t *testing.T) {
593	t.Run("success - single attachment", func(t *testing.T) {
594		tmpDir := t.TempDir()
595		configFile := filepath.Join(tmpDir, "gordon.toml")
596		err := os.WriteFile(configFile, []byte("[attachments]\n\"app.example.com\" = [\"redis:latest\"]\n"), 0600)
597		require.NoError(t, err)
598
599		v := viper.New()
600		v.SetConfigFile(configFile)
601		v.Set("attachments", map[string]interface{}{
602			"app.example.com": []interface{}{"redis:latest"},
603		})
604		eventBus := mocks.NewMockEventPublisher(t)
605		svc := NewService(v, eventBus)
606		ctx := testContext()
607
608		_ = svc.Load(ctx)
609
610		err = svc.RemoveAttachment(ctx, "app.example.com", "redis:latest")
611
612		assert.NoError(t, err)
613
614		// Verify target was removed (no attachments left)
615		config := svc.GetConfig()
616		_, exists := config.Attachments["app.example.com"]
617		assert.False(t, exists)
618	})
619
620	t.Run("success - multiple attachments", func(t *testing.T) {
621		tmpDir := t.TempDir()
622		configFile := filepath.Join(tmpDir, "gordon.toml")
623		err := os.WriteFile(configFile, []byte("[attachments]\n\"app.example.com\" = [\"redis:latest\", \"postgres:18\"]\n"), 0600)
624		require.NoError(t, err)
625
626		v := viper.New()
627		v.SetConfigFile(configFile)
628		v.Set("attachments", map[string]interface{}{
629			"app.example.com": []interface{}{"redis:latest", "postgres:18"},
630		})
631		eventBus := mocks.NewMockEventPublisher(t)
632		svc := NewService(v, eventBus)
633		ctx := testContext()
634
635		_ = svc.Load(ctx)
636
637		err = svc.RemoveAttachment(ctx, "app.example.com", "redis:latest")
638
639		assert.NoError(t, err)
640
641		// Verify only redis was removed
642		config := svc.GetConfig()
643		assert.Equal(t, []string{"postgres:18"}, config.Attachments["app.example.com"])
644	})
645
646	t.Run("target not found", func(t *testing.T) {
647		v := viper.New()
648		eventBus := mocks.NewMockEventPublisher(t)
649		svc := NewService(v, eventBus)
650		ctx := testContext()
651
652		_ = svc.Load(ctx)
653
654		err := svc.RemoveAttachment(ctx, "notfound.example.com", "redis:latest")
655
656		assert.ErrorIs(t, err, domain.ErrAttachmentNotFound)
657	})
658
659	t.Run("image not found in target", func(t *testing.T) {
660		v := viper.New()
661		v.Set("attachments", map[string]interface{}{
662			"app.example.com": []interface{}{"redis:latest"},
663		})
664		eventBus := mocks.NewMockEventPublisher(t)
665		svc := NewService(v, eventBus)
666		ctx := testContext()
667
668		_ = svc.Load(ctx)
669
670		err := svc.RemoveAttachment(ctx, "app.example.com", "postgres:18")
671
672		assert.ErrorIs(t, err, domain.ErrAttachmentNotFound)
673	})
674
675	t.Run("empty target", func(t *testing.T) {
676		v := viper.New()
677		eventBus := mocks.NewMockEventPublisher(t)
678		svc := NewService(v, eventBus)
679		ctx := testContext()
680
681		err := svc.RemoveAttachment(ctx, "", "redis:latest")
682
683		assert.ErrorIs(t, err, domain.ErrAttachmentTargetEmpty)
684	})
685
686	t.Run("empty image", func(t *testing.T) {
687		v := viper.New()
688		eventBus := mocks.NewMockEventPublisher(t)
689		svc := NewService(v, eventBus)
690		ctx := testContext()
691
692		err := svc.RemoveAttachment(ctx, "app.example.com", "")
693
694		assert.ErrorIs(t, err, domain.ErrAttachmentImageEmpty)
695	})
696
697	t.Run("network group target", func(t *testing.T) {
698		tmpDir := t.TempDir()
699		configFile := filepath.Join(tmpDir, "gordon.toml")
700		err := os.WriteFile(configFile, []byte("[attachments]\n\"backend\" = [\"rabbitmq:3\"]\n"), 0600)
701		require.NoError(t, err)
702
703		v := viper.New()
704		v.SetConfigFile(configFile)
705		v.Set("attachments", map[string]interface{}{
706			"backend": []interface{}{"rabbitmq:3"},
707		})
708		eventBus := mocks.NewMockEventPublisher(t)
709		svc := NewService(v, eventBus)
710		ctx := testContext()
711
712		_ = svc.Load(ctx)
713
714		err = svc.RemoveAttachment(ctx, "backend", "rabbitmq:3")
715
716		assert.NoError(t, err)
717
718		config := svc.GetConfig()
719		_, exists := config.Attachments["backend"]
720		assert.False(t, exists)
721	})
722}
723
724func TestService_GetExternalRoutes(t *testing.T) {
725	v := viper.New()
726	v.Set("external_routes", map[string]interface{}{
727		"reg.example.com":   "localhost:5000",
728		"cache.example.com": "127.0.0.1:6379",
729	})
730
731	eventBus := mocks.NewMockEventPublisher(t)
732	svc := NewService(v, eventBus)
733	ctx := testContext()
734
735	_ = svc.Load(ctx)
736
737	routes := svc.GetExternalRoutes()
738
739	assert.Len(t, routes, 2)
740	assert.Equal(t, "localhost:5000", routes["reg.example.com"])
741	assert.Equal(t, "127.0.0.1:6379", routes["cache.example.com"])
742}
743
744func TestService_GetExternalRoutes_Empty(t *testing.T) {
745	v := viper.New()
746
747	eventBus := mocks.NewMockEventPublisher(t)
748	svc := NewService(v, eventBus)
749	ctx := testContext()
750
751	_ = svc.Load(ctx)
752
753	routes := svc.GetExternalRoutes()
754
755	assert.Empty(t, routes)
756}
757
758func TestExtractDomainFromImageName(t *testing.T) {
759	tests := []struct {
760		name          string
761		imageName     string
762		expectedDom   string
763		expectedFound bool
764	}{
765		{
766			name:          "domain like image",
767			imageName:     "myapp.example.com:latest",
768			expectedDom:   "myapp.example.com",
769			expectedFound: true,
770		},
771		{
772			name:          "domain like image without tag",
773			imageName:     "api.backend.io",
774			expectedDom:   "api.backend.io",
775			expectedFound: true,
776		},
777		{
778			name:          "simple image name",
779			imageName:     "nginx:latest",
780			expectedDom:   "",
781			expectedFound: false,
782		},
783		{
784			name:          "registry path",
785			imageName:     "gcr.io/project/image:tag",
786			expectedDom:   "gcr.io/project/image",
787			expectedFound: true,
788		},
789		{
790			name:          "leading dot",
791			imageName:     ".invalid:latest",
792			expectedDom:   "",
793			expectedFound: false,
794		},
795		{
796			name:          "trailing dot",
797			imageName:     "invalid.:latest",
798			expectedDom:   "",
799			expectedFound: false,
800		},
801	}
802
803	for _, tt := range tests {
804		t.Run(tt.name, func(t *testing.T) {
805			domain, found := ExtractDomainFromImageName(tt.imageName)
806			assert.Equal(t, tt.expectedDom, domain)
807			assert.Equal(t, tt.expectedFound, found)
808		})
809	}
810}
811
812func TestLoadStringMap(t *testing.T) {
813	tests := []struct {
814		name     string
815		input    any
816		expected map[string]string
817	}{
818		{
819			name:     "nil input",
820			input:    nil,
821			expected: map[string]string{},
822		},
823		{
824			name: "valid map",
825			input: map[string]any{
826				"key1": "value1",
827				"key2": "value2",
828			},
829			expected: map[string]string{
830				"key1": "value1",
831				"key2": "value2",
832			},
833		},
834		{
835			name:     "invalid type",
836			input:    "not a map",
837			expected: map[string]string{},
838		},
839		{
840			name: "mixed values",
841			input: map[string]any{
842				"string": "value",
843				"int":    123,
844			},
845			expected: map[string]string{
846				"string": "value",
847			},
848		},
849	}
850
851	for _, tt := range tests {
852		t.Run(tt.name, func(t *testing.T) {
853			result := loadStringMap(tt.input)
854			assert.Equal(t, tt.expected, result)
855		})
856	}
857}
858
859func TestLoadStringArrayMap(t *testing.T) {
860	tests := []struct {
861		name     string
862		input    any
863		expected map[string][]string
864	}{
865		{
866			name:     "nil input",
867			input:    nil,
868			expected: map[string][]string{},
869		},
870		{
871			name: "valid map",
872			input: map[string]any{
873				"group1": []any{"a", "b"},
874				"group2": []any{"c", "d", "e"},
875			},
876			expected: map[string][]string{
877				"group1": {"a", "b"},
878				"group2": {"c", "d", "e"},
879			},
880		},
881		{
882			name:     "invalid type",
883			input:    "not a map",
884			expected: map[string][]string{},
885		},
886		{
887			name: "non-array value",
888			input: map[string]any{
889				"array":  []any{"a", "b"},
890				"string": "not an array",
891			},
892			expected: map[string][]string{
893				"array": {"a", "b"},
894			},
895		},
896	}
897
898	for _, tt := range tests {
899		t.Run(tt.name, func(t *testing.T) {
900			result := loadStringArrayMap(tt.input)
901			assert.Equal(t, tt.expected, result)
902		})
903	}
904}

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