v2.5.0 b9b6391

autoroute_test.go RawHistoryFolder

1package container
2
3import (
4	"context"
5	"encoding/json"
6	"io"
7	"strings"
8	"testing"
9
10	"github.com/bnema/zerowrap"
11	"github.com/stretchr/testify/assert"
12	"github.com/stretchr/testify/mock"
13	"github.com/stretchr/testify/require"
14
15	inmocks "gordon/internal/boundaries/in/mocks"
16	"gordon/internal/boundaries/out/mocks"
17	"gordon/internal/domain"
18)
19
20// domainToEnvFileName tests
21
22func TestDomainToEnvFileName(t *testing.T) {
23	tests := []struct {
24		name     string
25		domain   string
26		expected string
27	}{
28		{
29			name:     "simple domain",
30			domain:   "app.example.com",
31			expected: "app_example_com.env",
32		},
33		{
34			name:     "domain with port",
35			domain:   "app.example.com:8080",
36			expected: "app_example_com_8080.env",
37		},
38		{
39			name:     "domain with path",
40			domain:   "app.example.com/api",
41			expected: "app_example_com_api.env",
42		},
43		{
44			name:     "complex domain",
45			domain:   "sub.app.example.com:443/v1",
46			expected: "sub_app_example_com_443_v1.env",
47		},
48		{
49			name:     "single word",
50			domain:   "localhost",
51			expected: "localhost.env",
52		},
53	}
54
55	for _, tt := range tests {
56		t.Run(tt.name, func(t *testing.T) {
57			result := domainToEnvFileName(tt.domain)
58			assert.Equal(t, tt.expected, result)
59		})
60	}
61}
62
63// parseEnvFile tests
64
65func TestParseEnvFile(t *testing.T) {
66	tests := []struct {
67		name     string
68		input    string
69		expected map[string]string
70		wantErr  bool
71	}{
72		{
73			name:  "simple key-value pairs",
74			input: "FOO=bar\nBAZ=qux",
75			expected: map[string]string{
76				"FOO": "bar",
77				"BAZ": "qux",
78			},
79		},
80		{
81			name:  "with comments and empty lines",
82			input: "# This is a comment\nFOO=bar\n\n# Another comment\nBAZ=qux\n",
83			expected: map[string]string{
84				"FOO": "bar",
85				"BAZ": "qux",
86			},
87		},
88		{
89			name:  "with quoted values - double quotes",
90			input: `FOO="bar baz"`,
91			expected: map[string]string{
92				"FOO": "bar baz",
93			},
94		},
95		{
96			name:  "with quoted values - single quotes",
97			input: `FOO='bar baz'`,
98			expected: map[string]string{
99				"FOO": "bar baz",
100			},
101		},
102		{
103			name:  "value with equals sign",
104			input: "DATABASE_URL=postgres://user:pass@host/db?option=value",
105			expected: map[string]string{
106				"DATABASE_URL": "postgres://user:pass@host/db?option=value",
107			},
108		},
109		{
110			name:  "empty value",
111			input: "EMPTY=",
112			expected: map[string]string{
113				"EMPTY": "",
114			},
115		},
116		{
117			name:     "empty file",
118			input:    "",
119			expected: map[string]string{},
120		},
121		{
122			name:     "only comments",
123			input:    "# comment 1\n# comment 2",
124			expected: map[string]string{},
125		},
126		{
127			name:     "malformed line without equals",
128			input:    "MALFORMED\nGOOD=value",
129			expected: map[string]string{"GOOD": "value"},
130		},
131		{
132			name:  "whitespace trimming",
133			input: "  FOO  =  bar  \n  BAZ=qux",
134			expected: map[string]string{
135				"FOO": "bar",
136				"BAZ": "qux",
137			},
138		},
139	}
140
141	for _, tt := range tests {
142		t.Run(tt.name, func(t *testing.T) {
143			result, err := parseEnvFile([]byte(tt.input))
144
145			if tt.wantErr {
146				assert.Error(t, err)
147				return
148			}
149
150			require.NoError(t, err)
151			assert.Equal(t, tt.expected, result)
152		})
153	}
154}
155
156// parseConfigDigest tests
157
158func TestParseConfigDigest(t *testing.T) {
159	tests := []struct {
160		name     string
161		manifest map[string]any
162		expected string
163		wantErr  bool
164	}{
165		{
166			name: "valid manifest v2",
167			manifest: map[string]any{
168				"schemaVersion": 2,
169				"mediaType":     "application/vnd.docker.distribution.manifest.v2+json",
170				"config": map[string]any{
171					"mediaType": "application/vnd.docker.container.image.v1+json",
172					"digest":    "sha256:abc123",
173					"size":      1234,
174				},
175			},
176			expected: "sha256:abc123",
177		},
178		{
179			name: "OCI manifest",
180			manifest: map[string]any{
181				"schemaVersion": 2,
182				"mediaType":     "application/vnd.oci.image.manifest.v1+json",
183				"config": map[string]any{
184					"mediaType": "application/vnd.oci.image.config.v1+json",
185					"digest":    "sha256:def456",
186					"size":      5678,
187				},
188			},
189			expected: "sha256:def456",
190		},
191		{
192			name: "empty config digest",
193			manifest: map[string]any{
194				"schemaVersion": 2,
195				"config": map[string]any{
196					"digest": "",
197				},
198			},
199			expected: "",
200		},
201	}
202
203	for _, tt := range tests {
204		t.Run(tt.name, func(t *testing.T) {
205			data, err := json.Marshal(tt.manifest)
206			require.NoError(t, err)
207
208			result, err := parseConfigDigest(data)
209
210			if tt.wantErr {
211				assert.Error(t, err)
212				return
213			}
214
215			require.NoError(t, err)
216			assert.Equal(t, tt.expected, result)
217		})
218	}
219}
220
221func TestParseConfigDigest_InvalidJSON(t *testing.T) {
222	_, err := parseConfigDigest([]byte("invalid json"))
223	assert.Error(t, err)
224}
225
226// parseImageLabels tests
227
228func TestParseImageLabels(t *testing.T) {
229	tests := []struct {
230		name     string
231		config   map[string]any
232		expected *domain.ImageLabels
233	}{
234		{
235			name: "single domain label",
236			config: map[string]any{
237				"config": map[string]any{
238					"Labels": map[string]any{
239						"gordon.domain": "app.example.com",
240					},
241				},
242			},
243			expected: &domain.ImageLabels{
244				Domain: "app.example.com",
245			},
246		},
247		{
248			name: "multiple domains label",
249			config: map[string]any{
250				"config": map[string]any{
251					"Labels": map[string]any{
252						"gordon.domains": "app1.example.com, app2.example.com",
253					},
254				},
255			},
256			expected: &domain.ImageLabels{
257				Domains: []string{"app1.example.com", "app2.example.com"},
258			},
259		},
260		{
261			name: "all gordon labels",
262			config: map[string]any{
263				"config": map[string]any{
264					"Labels": map[string]any{
265						"gordon.domain":   "app.example.com",
266						"gordon.health":   "/healthz",
267						"gordon.port":     "8080",
268						"gordon.env-file": ".env.prod",
269					},
270				},
271			},
272			expected: &domain.ImageLabels{
273				Domain:  "app.example.com",
274				Health:  "/healthz",
275				Port:    "8080",
276				EnvFile: ".env.prod",
277			},
278		},
279		{
280			name: "no labels",
281			config: map[string]any{
282				"config": map[string]any{
283					"Labels": nil,
284				},
285			},
286			expected: &domain.ImageLabels{},
287		},
288		{
289			name: "no gordon labels",
290			config: map[string]any{
291				"config": map[string]any{
292					"Labels": map[string]any{
293						"other.label": "value",
294					},
295				},
296			},
297			expected: &domain.ImageLabels{},
298		},
299		{
300			name: "whitespace in labels",
301			config: map[string]any{
302				"config": map[string]any{
303					"Labels": map[string]any{
304						"gordon.domain": "  app.example.com  ",
305					},
306				},
307			},
308			expected: &domain.ImageLabels{
309				Domain: "app.example.com",
310			},
311		},
312	}
313
314	for _, tt := range tests {
315		t.Run(tt.name, func(t *testing.T) {
316			data, err := json.Marshal(tt.config)
317			require.NoError(t, err)
318
319			result, err := parseImageLabels(data)
320			require.NoError(t, err)
321
322			assert.Equal(t, tt.expected.Domain, result.Domain)
323			assert.Equal(t, tt.expected.Domains, result.Domains)
324			assert.Equal(t, tt.expected.Health, result.Health)
325			assert.Equal(t, tt.expected.Port, result.Port)
326			assert.Equal(t, tt.expected.EnvFile, result.EnvFile)
327		})
328	}
329}
330
331func TestParseImageLabels_InvalidJSON(t *testing.T) {
332	_, err := parseImageLabels([]byte("invalid json"))
333	assert.Error(t, err)
334}
335
336// AutoRouteHandler tests
337
338func TestAutoRouteHandler_CanHandle(t *testing.T) {
339	ctx := zerowrap.WithCtx(context.Background(), zerowrap.Default())
340	configSvc := inmocks.NewMockConfigService(t)
341	containerSvc := inmocks.NewMockContainerService(t)
342	blobStorage := mocks.NewMockBlobStorage(t)
343
344	handler := NewAutoRouteHandler(ctx, configSvc, containerSvc, blobStorage, "registry.example.com")
345
346	assert.True(t, handler.CanHandle(domain.EventImagePushed))
347	assert.False(t, handler.CanHandle(domain.EventConfigReload))
348	assert.False(t, handler.CanHandle(domain.EventManualReload))
349}
350
351func TestAutoRouteHandler_Handle_DisabledAutoRoute(t *testing.T) {
352	ctx := zerowrap.WithCtx(context.Background(), zerowrap.Default())
353	configSvc := inmocks.NewMockConfigService(t)
354	containerSvc := inmocks.NewMockContainerService(t)
355	blobStorage := mocks.NewMockBlobStorage(t)
356
357	handler := NewAutoRouteHandler(ctx, configSvc, containerSvc, blobStorage, "registry.example.com")
358
359	configSvc.EXPECT().IsAutoRouteEnabled().Return(false)
360
361	event := domain.Event{
362		ID:   "event-123",
363		Type: domain.EventImagePushed,
364		Data: domain.ImagePushedPayload{
365			Name:      "myapp",
366			Reference: "latest",
367			Manifest:  []byte(`{"schemaVersion": 2}`),
368		},
369	}
370
371	err := handler.Handle(event)
372
373	assert.NoError(t, err)
374}
375
376func TestAutoRouteHandler_Handle_InvalidPayload(t *testing.T) {
377	ctx := zerowrap.WithCtx(context.Background(), zerowrap.Default())
378	configSvc := inmocks.NewMockConfigService(t)
379	containerSvc := inmocks.NewMockContainerService(t)
380	blobStorage := mocks.NewMockBlobStorage(t)
381
382	handler := NewAutoRouteHandler(ctx, configSvc, containerSvc, blobStorage, "registry.example.com")
383
384	configSvc.EXPECT().IsAutoRouteEnabled().Return(true)
385
386	event := domain.Event{
387		ID:   "event-123",
388		Type: domain.EventImagePushed,
389		Data: "invalid payload type",
390	}
391
392	err := handler.Handle(event)
393
394	// Handler skips invalid payload, doesn't error
395	assert.NoError(t, err)
396}
397
398func TestAutoRouteHandler_Handle_EmptyManifest(t *testing.T) {
399	ctx := zerowrap.WithCtx(context.Background(), zerowrap.Default())
400	configSvc := inmocks.NewMockConfigService(t)
401	containerSvc := inmocks.NewMockContainerService(t)
402	blobStorage := mocks.NewMockBlobStorage(t)
403
404	handler := NewAutoRouteHandler(ctx, configSvc, containerSvc, blobStorage, "registry.example.com")
405
406	configSvc.EXPECT().IsAutoRouteEnabled().Return(true)
407
408	event := domain.Event{
409		ID:   "event-123",
410		Type: domain.EventImagePushed,
411		Data: domain.ImagePushedPayload{
412			Name:      "myapp",
413			Reference: "latest",
414			Manifest:  []byte{},
415		},
416	}
417
418	err := handler.Handle(event)
419
420	assert.NoError(t, err)
421}
422
423func TestAutoRouteHandler_Handle_CreatesNewRoute(t *testing.T) {
424	ctx := zerowrap.WithCtx(context.Background(), zerowrap.Default())
425	configSvc := inmocks.NewMockConfigService(t)
426	containerSvc := inmocks.NewMockContainerService(t)
427	blobStorage := mocks.NewMockBlobStorage(t)
428
429	handler := NewAutoRouteHandler(ctx, configSvc, containerSvc, blobStorage, "registry.example.com")
430
431	// Build manifest with config digest
432	manifest := map[string]any{
433		"schemaVersion": 2,
434		"config": map[string]any{
435			"digest": "sha256:configdigest123",
436		},
437	}
438	manifestData, _ := json.Marshal(manifest)
439
440	// Build image config with gordon labels
441	imageConfig := map[string]any{
442		"config": map[string]any{
443			"Labels": map[string]any{
444				"gordon.domain": "app.example.com",
445			},
446		},
447	}
448	configData, _ := json.Marshal(imageConfig)
449
450	configSvc.EXPECT().IsAutoRouteEnabled().Return(true)
451	blobStorage.EXPECT().GetBlob("sha256:configdigest123").Return(io.NopCloser(strings.NewReader(string(configData))), nil)
452
453	// No existing routes
454	configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{})
455
456	// Expect route to be added
457	configSvc.EXPECT().AddRoute(mock.Anything, domain.Route{
458		Domain: "app.example.com",
459		Image:  "myapp:latest",
460	}).Return(nil)
461
462	// Expect deploy to be triggered
463	containerSvc.EXPECT().Deploy(mock.Anything, domain.Route{
464		Domain: "app.example.com",
465		Image:  "myapp:latest",
466	}).Return(&domain.Container{ID: "container-1"}, nil)
467
468	event := domain.Event{
469		ID:   "event-123",
470		Type: domain.EventImagePushed,
471		Data: domain.ImagePushedPayload{
472			Name:      "myapp",
473			Reference: "latest",
474			Manifest:  manifestData,
475		},
476	}
477
478	err := handler.Handle(event)
479
480	assert.NoError(t, err)
481}
482
483func TestAutoRouteHandler_Handle_UpdatesExistingRoute(t *testing.T) {
484	ctx := zerowrap.WithCtx(context.Background(), zerowrap.Default())
485	configSvc := inmocks.NewMockConfigService(t)
486	containerSvc := inmocks.NewMockContainerService(t)
487	blobStorage := mocks.NewMockBlobStorage(t)
488
489	handler := NewAutoRouteHandler(ctx, configSvc, containerSvc, blobStorage, "registry.example.com")
490
491	// Build manifest with config digest
492	manifest := map[string]any{
493		"schemaVersion": 2,
494		"config": map[string]any{
495			"digest": "sha256:configdigest123",
496		},
497	}
498	manifestData, _ := json.Marshal(manifest)
499
500	// Build image config with gordon labels
501	imageConfig := map[string]any{
502		"config": map[string]any{
503			"Labels": map[string]any{
504				"gordon.domain": "app.example.com",
505			},
506		},
507	}
508	configData, _ := json.Marshal(imageConfig)
509
510	configSvc.EXPECT().IsAutoRouteEnabled().Return(true)
511	blobStorage.EXPECT().GetBlob("sha256:configdigest123").Return(io.NopCloser(strings.NewReader(string(configData))), nil)
512
513	// Existing route with different image
514	configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{
515		{Domain: "app.example.com", Image: "myapp:v1"},
516	})
517
518	// Expect route to be updated
519	configSvc.EXPECT().UpdateRoute(mock.Anything, domain.Route{
520		Domain: "app.example.com",
521		Image:  "myapp:v2",
522	}).Return(nil)
523
524	// Expect deploy to be triggered
525	containerSvc.EXPECT().Deploy(mock.Anything, domain.Route{
526		Domain: "app.example.com",
527		Image:  "myapp:v2",
528	}).Return(&domain.Container{ID: "container-1"}, nil)
529
530	event := domain.Event{
531		ID:   "event-123",
532		Type: domain.EventImagePushed,
533		Data: domain.ImagePushedPayload{
534			Name:      "myapp",
535			Reference: "v2",
536			Manifest:  manifestData,
537		},
538	}
539
540	err := handler.Handle(event)
541
542	assert.NoError(t, err)
543}
544
545func TestAutoRouteHandler_Handle_NoUpdateIfSameImage(t *testing.T) {
546	ctx := zerowrap.WithCtx(context.Background(), zerowrap.Default())
547	configSvc := inmocks.NewMockConfigService(t)
548	containerSvc := inmocks.NewMockContainerService(t)
549	blobStorage := mocks.NewMockBlobStorage(t)
550
551	handler := NewAutoRouteHandler(ctx, configSvc, containerSvc, blobStorage, "registry.example.com")
552
553	// Build manifest with config digest
554	manifest := map[string]any{
555		"schemaVersion": 2,
556		"config": map[string]any{
557			"digest": "sha256:configdigest123",
558		},
559	}
560	manifestData, _ := json.Marshal(manifest)
561
562	// Build image config with gordon labels
563	imageConfig := map[string]any{
564		"config": map[string]any{
565			"Labels": map[string]any{
566				"gordon.domain": "app.example.com",
567			},
568		},
569	}
570	configData, _ := json.Marshal(imageConfig)
571
572	configSvc.EXPECT().IsAutoRouteEnabled().Return(true)
573	blobStorage.EXPECT().GetBlob("sha256:configdigest123").Return(io.NopCloser(strings.NewReader(string(configData))), nil)
574
575	// Existing route with same image - no update needed
576	configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{
577		{Domain: "app.example.com", Image: "myapp:latest"},
578	})
579
580	// No AddRoute, UpdateRoute, or Deploy calls expected
581
582	event := domain.Event{
583		ID:   "event-123",
584		Type: domain.EventImagePushed,
585		Data: domain.ImagePushedPayload{
586			Name:      "myapp",
587			Reference: "latest",
588			Manifest:  manifestData,
589		},
590	}
591
592	err := handler.Handle(event)
593
594	assert.NoError(t, err)
595}
596
597func TestAutoRouteHandler_Handle_NoDomainLabel(t *testing.T) {
598	ctx := zerowrap.WithCtx(context.Background(), zerowrap.Default())
599	configSvc := inmocks.NewMockConfigService(t)
600	containerSvc := inmocks.NewMockContainerService(t)
601	blobStorage := mocks.NewMockBlobStorage(t)
602
603	handler := NewAutoRouteHandler(ctx, configSvc, containerSvc, blobStorage, "registry.example.com")
604
605	// Build manifest with config digest
606	manifest := map[string]any{
607		"schemaVersion": 2,
608		"config": map[string]any{
609			"digest": "sha256:configdigest123",
610		},
611	}
612	manifestData, _ := json.Marshal(manifest)
613
614	// Build image config with no gordon labels
615	imageConfig := map[string]any{
616		"config": map[string]any{
617			"Labels": map[string]any{
618				"other.label": "value",
619			},
620		},
621	}
622	configData, _ := json.Marshal(imageConfig)
623
624	configSvc.EXPECT().IsAutoRouteEnabled().Return(true)
625	blobStorage.EXPECT().GetBlob("sha256:configdigest123").Return(io.NopCloser(strings.NewReader(string(configData))), nil)
626
627	// No route operations expected
628
629	event := domain.Event{
630		ID:   "event-123",
631		Type: domain.EventImagePushed,
632		Data: domain.ImagePushedPayload{
633			Name:      "myapp",
634			Reference: "latest",
635			Manifest:  manifestData,
636		},
637	}
638
639	err := handler.Handle(event)
640
641	assert.NoError(t, err)
642}
643
644func TestAutoRouteHandler_Handle_MultipleDomains(t *testing.T) {
645	ctx := zerowrap.WithCtx(context.Background(), zerowrap.Default())
646	configSvc := inmocks.NewMockConfigService(t)
647	containerSvc := inmocks.NewMockContainerService(t)
648	blobStorage := mocks.NewMockBlobStorage(t)
649
650	handler := NewAutoRouteHandler(ctx, configSvc, containerSvc, blobStorage, "registry.example.com")
651
652	// Build manifest with config digest
653	manifest := map[string]any{
654		"schemaVersion": 2,
655		"config": map[string]any{
656			"digest": "sha256:configdigest123",
657		},
658	}
659	manifestData, _ := json.Marshal(manifest)
660
661	// Build image config with multiple domains
662	imageConfig := map[string]any{
663		"config": map[string]any{
664			"Labels": map[string]any{
665				"gordon.domain":  "app.example.com",
666				"gordon.domains": "api.example.com, www.example.com",
667			},
668		},
669	}
670	configData, _ := json.Marshal(imageConfig)
671
672	configSvc.EXPECT().IsAutoRouteEnabled().Return(true)
673	blobStorage.EXPECT().GetBlob("sha256:configdigest123").Return(io.NopCloser(strings.NewReader(string(configData))), nil)
674
675	// No existing routes
676	configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{}).Times(3)
677
678	// Expect routes to be added for all three domains
679	configSvc.EXPECT().AddRoute(mock.Anything, domain.Route{
680		Domain: "app.example.com",
681		Image:  "myapp:latest",
682	}).Return(nil)
683	configSvc.EXPECT().AddRoute(mock.Anything, domain.Route{
684		Domain: "api.example.com",
685		Image:  "myapp:latest",
686	}).Return(nil)
687	configSvc.EXPECT().AddRoute(mock.Anything, domain.Route{
688		Domain: "www.example.com",
689		Image:  "myapp:latest",
690	}).Return(nil)
691
692	// Expect deploy for all three
693	containerSvc.EXPECT().Deploy(mock.Anything, mock.AnythingOfType("domain.Route")).Return(&domain.Container{ID: "container-1"}, nil).Times(3)
694
695	event := domain.Event{
696		ID:   "event-123",
697		Type: domain.EventImagePushed,
698		Data: domain.ImagePushedPayload{
699			Name:      "myapp",
700			Reference: "latest",
701			Manifest:  manifestData,
702		},
703	}
704
705	err := handler.Handle(event)
706
707	assert.NoError(t, err)
708}
709
710func TestAutoRouteHandler_TriggerDeploy_NilContainerService(t *testing.T) {
711	ctx := zerowrap.WithCtx(context.Background(), zerowrap.Default())
712	configSvc := inmocks.NewMockConfigService(t)
713	blobStorage := mocks.NewMockBlobStorage(t)
714
715	// Create handler without container service
716	handler := NewAutoRouteHandler(ctx, configSvc, nil, blobStorage, "registry.example.com")
717
718	route := domain.Route{
719		Domain: "app.example.com",
720		Image:  "myapp:latest",
721	}
722
723	// Should not panic, just log and skip
724	handler.triggerDeploy(ctx, route)
725}
726
727// collectDomains tests
728
729func TestAutoRouteHandler_CollectDomains(t *testing.T) {
730	ctx := zerowrap.WithCtx(context.Background(), zerowrap.Default())
731	configSvc := inmocks.NewMockConfigService(t)
732	containerSvc := inmocks.NewMockContainerService(t)
733	blobStorage := mocks.NewMockBlobStorage(t)
734
735	handler := NewAutoRouteHandler(ctx, configSvc, containerSvc, blobStorage, "registry.example.com")
736
737	tests := []struct {
738		name     string
739		labels   *domain.ImageLabels
740		expected []string
741	}{
742		{
743			name: "single domain",
744			labels: &domain.ImageLabels{
745				Domain: "app.example.com",
746			},
747			expected: []string{"app.example.com"},
748		},
749		{
750			name: "domains list only",
751			labels: &domain.ImageLabels{
752				Domains: []string{"api.example.com", "www.example.com"},
753			},
754			expected: []string{"api.example.com", "www.example.com"},
755		},
756		{
757			name: "both domain and domains",
758			labels: &domain.ImageLabels{
759				Domain:  "app.example.com",
760				Domains: []string{"api.example.com"},
761			},
762			expected: []string{"app.example.com", "api.example.com"},
763		},
764		{
765			name:     "empty labels",
766			labels:   &domain.ImageLabels{},
767			expected: nil,
768		},
769	}
770
771	for _, tt := range tests {
772		t.Run(tt.name, func(t *testing.T) {
773			result := handler.collectDomains(tt.labels)
774			assert.Equal(t, tt.expected, result)
775		})
776	}
777}
778
779// buildImageName tests
780
781func TestAutoRouteHandler_BuildImageName(t *testing.T) {
782	ctx := zerowrap.WithCtx(context.Background(), zerowrap.Default())
783	configSvc := inmocks.NewMockConfigService(t)
784	containerSvc := inmocks.NewMockContainerService(t)
785	blobStorage := mocks.NewMockBlobStorage(t)
786
787	handler := NewAutoRouteHandler(ctx, configSvc, containerSvc, blobStorage, "registry.example.com")
788
789	tests := []struct {
790		name      string
791		imageName string
792		reference string
793		expected  string
794	}{
795		{
796			name:      "with tag",
797			imageName: "myapp",
798			reference: "latest",
799			expected:  "myapp:latest",
800		},
801		{
802			name:      "with version tag",
803			imageName: "myapp",
804			reference: "v1.2.3",
805			expected:  "myapp:v1.2.3",
806		},
807		{
808			name:      "empty reference",
809			imageName: "myapp",
810			reference: "",
811			expected:  "myapp",
812		},
813		{
814			name:      "with digest",
815			imageName: "myapp",
816			reference: "sha256:abc123",
817			expected:  "myapp@sha256:abc123",
818		},
819	}
820
821	for _, tt := range tests {
822		t.Run(tt.name, func(t *testing.T) {
823			result := handler.buildImageName(tt.imageName, tt.reference)
824			assert.Equal(t, tt.expected, result)
825		})
826	}
827}
828
829// buildFullImageRef tests
830
831func TestAutoRouteHandler_BuildFullImageRef(t *testing.T) {
832	ctx := zerowrap.WithCtx(context.Background(), zerowrap.Default())
833	configSvc := inmocks.NewMockConfigService(t)
834	containerSvc := inmocks.NewMockContainerService(t)
835	blobStorage := mocks.NewMockBlobStorage(t)
836
837	tests := []struct {
838		name           string
839		registryDomain string
840		imageName      string
841		expected       string
842	}{
843		{
844			name:           "simple image with registry",
845			registryDomain: "registry.example.com",
846			imageName:      "myapp:latest",
847			expected:       "registry.example.com/myapp:latest",
848		},
849		{
850			name:           "already has registry prefix",
851			registryDomain: "registry.example.com",
852			imageName:      "registry.example.com/myapp:latest",
853			expected:       "registry.example.com/myapp:latest",
854		},
855		{
856			name:           "external registry reference",
857			registryDomain: "registry.example.com",
858			imageName:      "docker.io/library/nginx:latest",
859			expected:       "docker.io/library/nginx:latest",
860		},
861		{
862			name:           "digest reference",
863			registryDomain: "registry.example.com",
864			imageName:      "myapp@sha256:abc123def456",
865			expected:       "registry.example.com/myapp@sha256:abc123def456",
866		},
867		{
868			name:           "empty registry domain",
869			registryDomain: "",
870			imageName:      "myapp:latest",
871			expected:       "myapp:latest",
872		},
873		{
874			name:           "image without tag",
875			registryDomain: "registry.example.com",
876			imageName:      "myapp",
877			expected:       "registry.example.com/myapp",
878		},
879	}
880
881	for _, tt := range tests {
882		t.Run(tt.name, func(t *testing.T) {
883			handler := NewAutoRouteHandler(ctx, configSvc, containerSvc, blobStorage, tt.registryDomain)
884			result := handler.buildFullImageRef(tt.imageName)
885			assert.Equal(t, tt.expected, result)
886		})
887	}
888}

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