detached d87f8ad

fix(config): match bare image names in FindRoutesByImage and return 200 for empty results

commit
d87f8adf6777b7f3413164c9f858c1c0aa71c68f
author
bnema · · 2026-02-09 20:50 UTC
parent
ab55833
signature
unsigned
3 files · +249 -5
M internal/adapters/in/http/admin/handler.go +0 -4
@@ -476,10 +476,6 @@ func (h *Handler) handleRoutesByImage(w http.ResponseWriter, r *http.Request, pa
476476 }
477477
478478 routes := h.configSvc.FindRoutesByImage(ctx, imageName)
479- if len(routes) == 0 {
480- h.sendError(w, http.StatusNotFound, fmt.Sprintf("no routes found for image '%s'", imageName))
481- return
482- }
483479
484480 response := make([]routeResponse, 0, len(routes))
485481 for _, route := range routes {
@@ -476,10 +476,6 @@ func (h *Handler) handleRoutesByImage(w http.ResponseWriter, r *http.Request, pa
476 }476 }
477 477
478 routes := h.configSvc.FindRoutesByImage(ctx, imageName)478 routes := h.configSvc.FindRoutesByImage(ctx, imageName)
479- if len(routes) == 0 {
480- h.sendError(w, http.StatusNotFound, fmt.Sprintf("no routes found for image '%s'", imageName))
481- return
482- }
483 479
484 response := make([]routeResponse, 0, len(routes))480 response := make([]routeResponse, 0, len(routes))
485 for _, route := range routes {481 for _, route := range routes {
M internal/usecase/config/service.go +22 -1
@@ -228,17 +228,30 @@ func (s *Service) GetRoute(_ context.Context, domainName string) (*domain.Route,
228228
229229 // FindRoutesByImage returns all routes whose image matches the given image name.
230230 // Image names are normalized by stripping the registry domain prefix before comparison.
231+// When the input has no tag, only the name portion is compared (i.e. "myapp" matches "myapp:latest").
231232 func (s *Service) FindRoutesByImage(_ context.Context, imageName string) []domain.Route {
232233 s.mu.RLock()
233234 defer s.mu.RUnlock()
234235
235236 registryDomain := s.config.RegistryDomain
236237 normalizedImage := NormalizeRegistryImage(imageName, registryDomain)
238+ inputName, inputHasTag := splitImageNameTag(normalizedImage)
237239
238240 var routes []domain.Route
239241 for domainName, image := range s.config.Routes {
240242 normalizedRouteImage := NormalizeRegistryImage(image, registryDomain)
241- if strings.EqualFold(normalizedRouteImage, normalizedImage) {
243+
244+ match := false
245+ if inputHasTag {
246+ // Exact match when the caller provided a tag (e.g. event handler).
247+ match = strings.EqualFold(normalizedRouteImage, normalizedImage)
248+ } else {
249+ // Name-only match when no tag was provided (e.g. CLI push).
250+ routeName, _ := splitImageNameTag(normalizedRouteImage)
251+ match = strings.EqualFold(routeName, inputName)
252+ }
253+
254+ if match {
242255 route := domain.Route{
243256 Image: image,
244257 HTTPS: true,
@@ -256,6 +269,14 @@ func (s *Service) FindRoutesByImage(_ context.Context, imageName string) []domai
256269 return routes
257270 }
258271
272+// splitImageNameTag splits "name:tag" into ("name", true) or ("name", false) when no tag is present.
273+func splitImageNameTag(image string) (name string, hasTag bool) {
274+ if idx := strings.LastIndex(image, ":"); idx != -1 {
275+ return image[:idx], true
276+ }
277+ return image, false
278+}
279+
259280 // NormalizeRegistryImage strips the registry domain prefix from an image name for comparison.
260281 func NormalizeRegistryImage(imageName, registryDomain string) string {
261282 registryDomain = strings.TrimSuffix(registryDomain, "/")
@@ -228,17 +228,30 @@ func (s *Service) GetRoute(_ context.Context, domainName string) (*domain.Route,
228 228
229 // FindRoutesByImage returns all routes whose image matches the given image name.229 // FindRoutesByImage returns all routes whose image matches the given image name.
230 // Image names are normalized by stripping the registry domain prefix before comparison.230 // Image names are normalized by stripping the registry domain prefix before comparison.
231+// When the input has no tag, only the name portion is compared (i.e. "myapp" matches "myapp:latest").
231 func (s *Service) FindRoutesByImage(_ context.Context, imageName string) []domain.Route {232 func (s *Service) FindRoutesByImage(_ context.Context, imageName string) []domain.Route {
232 s.mu.RLock()233 s.mu.RLock()
233 defer s.mu.RUnlock()234 defer s.mu.RUnlock()
234 235
235 registryDomain := s.config.RegistryDomain236 registryDomain := s.config.RegistryDomain
236 normalizedImage := NormalizeRegistryImage(imageName, registryDomain)237 normalizedImage := NormalizeRegistryImage(imageName, registryDomain)
238+ inputName, inputHasTag := splitImageNameTag(normalizedImage)
237 239
238 var routes []domain.Route240 var routes []domain.Route
239 for domainName, image := range s.config.Routes {241 for domainName, image := range s.config.Routes {
240 normalizedRouteImage := NormalizeRegistryImage(image, registryDomain)242 normalizedRouteImage := NormalizeRegistryImage(image, registryDomain)
241- if strings.EqualFold(normalizedRouteImage, normalizedImage) {243+
244+ match := false
245+ if inputHasTag {
246+ // Exact match when the caller provided a tag (e.g. event handler).
247+ match = strings.EqualFold(normalizedRouteImage, normalizedImage)
248+ } else {
249+ // Name-only match when no tag was provided (e.g. CLI push).
250+ routeName, _ := splitImageNameTag(normalizedRouteImage)
251+ match = strings.EqualFold(routeName, inputName)
252+ }
253+
254+ if match {
242 route := domain.Route{255 route := domain.Route{
243 Image: image,256 Image: image,
244 HTTPS: true,257 HTTPS: true,
@@ -256,6 +269,14 @@ func (s *Service) FindRoutesByImage(_ context.Context, imageName string) []domai
256 return routes269 return routes
257 }270 }
258 271
272+// splitImageNameTag splits "name:tag" into ("name", true) or ("name", false) when no tag is present.
273+func splitImageNameTag(image string) (name string, hasTag bool) {
274+ if idx := strings.LastIndex(image, ":"); idx != -1 {
275+ return image[:idx], true
276+ }
277+ return image, false
278+}
279+
259 // NormalizeRegistryImage strips the registry domain prefix from an image name for comparison.280 // NormalizeRegistryImage strips the registry domain prefix from an image name for comparison.
260 func NormalizeRegistryImage(imageName, registryDomain string) string {281 func NormalizeRegistryImage(imageName, registryDomain string) string {
261 registryDomain = strings.TrimSuffix(registryDomain, "/")282 registryDomain = strings.TrimSuffix(registryDomain, "/")
M internal/usecase/config/service_test.go +227 -0
@@ -864,6 +864,233 @@ func TestService_GetExternalRoutes_Empty(t *testing.T) {
864864 assert.Empty(t, routes)
865865 }
866866
867+func TestSplitImageNameTag(t *testing.T) {
868+ tests := []struct {
869+ name string
870+ image string
871+ wantName string
872+ wantHasTag bool
873+ }{
874+ {"with tag", "myapp:latest", "myapp", true},
875+ {"with version tag", "myapp:v1.2.0", "myapp", true},
876+ {"no tag", "myapp", "myapp", false},
877+ {"empty string", "", "", false},
878+ {"tag only colon", "myapp:", "myapp", true},
879+ {"registry prefixed with tag", "reg.example.com/myapp:latest", "reg.example.com/myapp", true},
880+ {"registry prefixed no tag", "reg.example.com/myapp", "reg.example.com/myapp", false},
881+ }
882+
883+ for _, tt := range tests {
884+ t.Run(tt.name, func(t *testing.T) {
885+ name, hasTag := splitImageNameTag(tt.image)
886+ assert.Equal(t, tt.wantName, name)
887+ assert.Equal(t, tt.wantHasTag, hasTag)
888+ })
889+ }
890+}
891+
892+func TestNormalizeRegistryImage(t *testing.T) {
893+ tests := []struct {
894+ name string
895+ imageName string
896+ registryDomain string
897+ expected string
898+ }{
899+ {"strips registry prefix", "reg.example.com/myapp:latest", "reg.example.com", "myapp:latest"},
900+ {"no prefix to strip", "myapp:latest", "reg.example.com", "myapp:latest"},
901+ {"empty registry domain", "myapp:latest", "", "myapp:latest"},
902+ {"registry with trailing slash", "reg.example.com/myapp:latest", "reg.example.com/", "myapp:latest"},
903+ {"partial match is not stripped", "reg.example.com.evil/myapp:latest", "reg.example.com", "reg.example.com.evil/myapp:latest"},
904+ {"bare name no registry", "myapp", "reg.example.com", "myapp"},
905+ {"exact registry without image", "reg.example.com/", "reg.example.com", ""},
906+ }
907+
908+ for _, tt := range tests {
909+ t.Run(tt.name, func(t *testing.T) {
910+ result := NormalizeRegistryImage(tt.imageName, tt.registryDomain)
911+ assert.Equal(t, tt.expected, result)
912+ })
913+ }
914+}
915+
916+func TestService_FindRoutesByImage(t *testing.T) {
917+ t.Run("exact match with tag", func(t *testing.T) {
918+ v := viper.New()
919+ v.Set("routes", map[string]interface{}{
920+ "app.example.com": "myapp:latest",
921+ })
922+
923+ eventBus := mocks.NewMockEventPublisher(t)
924+ svc := NewService(v, eventBus)
925+ ctx := testContext()
926+ _ = svc.Load(ctx)
927+
928+ routes := svc.FindRoutesByImage(ctx, "myapp:latest")
929+ assert.Len(t, routes, 1)
930+ assert.Equal(t, "app.example.com", routes[0].Domain)
931+ assert.Equal(t, "myapp:latest", routes[0].Image)
932+ })
933+
934+ t.Run("bare name matches route with tag", func(t *testing.T) {
935+ v := viper.New()
936+ v.Set("routes", map[string]interface{}{
937+ "app.example.com": "myapp:latest",
938+ })
939+
940+ eventBus := mocks.NewMockEventPublisher(t)
941+ svc := NewService(v, eventBus)
942+ ctx := testContext()
943+ _ = svc.Load(ctx)
944+
945+ routes := svc.FindRoutesByImage(ctx, "myapp")
946+ assert.Len(t, routes, 1)
947+ assert.Equal(t, "app.example.com", routes[0].Domain)
948+ })
949+
950+ t.Run("bare name matches route with version tag", func(t *testing.T) {
951+ v := viper.New()
952+ v.Set("routes", map[string]interface{}{
953+ "app.example.com": "myapp:v2.0.0",
954+ })
955+
956+ eventBus := mocks.NewMockEventPublisher(t)
957+ svc := NewService(v, eventBus)
958+ ctx := testContext()
959+ _ = svc.Load(ctx)
960+
961+ routes := svc.FindRoutesByImage(ctx, "myapp")
962+ assert.Len(t, routes, 1)
963+ assert.Equal(t, "app.example.com", routes[0].Domain)
964+ })
965+
966+ t.Run("tag mismatch does not match", func(t *testing.T) {
967+ v := viper.New()
968+ v.Set("routes", map[string]interface{}{
969+ "app.example.com": "myapp:v1",
970+ })
971+
972+ eventBus := mocks.NewMockEventPublisher(t)
973+ svc := NewService(v, eventBus)
974+ ctx := testContext()
975+ _ = svc.Load(ctx)
976+
977+ routes := svc.FindRoutesByImage(ctx, "myapp:v2")
978+ assert.Empty(t, routes)
979+ })
980+
981+ t.Run("no match", func(t *testing.T) {
982+ v := viper.New()
983+ v.Set("routes", map[string]interface{}{
984+ "app.example.com": "myapp:latest",
985+ })
986+
987+ eventBus := mocks.NewMockEventPublisher(t)
988+ svc := NewService(v, eventBus)
989+ ctx := testContext()
990+ _ = svc.Load(ctx)
991+
992+ routes := svc.FindRoutesByImage(ctx, "otherapp")
993+ assert.Empty(t, routes)
994+ })
995+
996+ t.Run("strips registry prefix before matching", func(t *testing.T) {
997+ v := viper.New()
998+ v.Set("server.registry_domain", "reg.example.com")
999+ v.Set("routes", map[string]interface{}{
1000+ "app.example.com": "reg.example.com/myapp:latest",
1001+ })
1002+
1003+ eventBus := mocks.NewMockEventPublisher(t)
1004+ svc := NewService(v, eventBus)
1005+ ctx := testContext()
1006+ _ = svc.Load(ctx)
1007+
1008+ routes := svc.FindRoutesByImage(ctx, "myapp")
1009+ assert.Len(t, routes, 1)
1010+ assert.Equal(t, "app.example.com", routes[0].Domain)
1011+ })
1012+
1013+ t.Run("strips registry prefix from input too", func(t *testing.T) {
1014+ v := viper.New()
1015+ v.Set("server.registry_domain", "reg.example.com")
1016+ v.Set("routes", map[string]interface{}{
1017+ "app.example.com": "reg.example.com/myapp:latest",
1018+ })
1019+
1020+ eventBus := mocks.NewMockEventPublisher(t)
1021+ svc := NewService(v, eventBus)
1022+ ctx := testContext()
1023+ _ = svc.Load(ctx)
1024+
1025+ routes := svc.FindRoutesByImage(ctx, "reg.example.com/myapp:latest")
1026+ assert.Len(t, routes, 1)
1027+ assert.Equal(t, "app.example.com", routes[0].Domain)
1028+ })
1029+
1030+ t.Run("multiple routes for same image", func(t *testing.T) {
1031+ v := viper.New()
1032+ v.Set("routes", map[string]interface{}{
1033+ "app1.example.com": "myapp:latest",
1034+ "app2.example.com": "myapp:latest",
1035+ "other.example.com": "otherapp:latest",
1036+ })
1037+
1038+ eventBus := mocks.NewMockEventPublisher(t)
1039+ svc := NewService(v, eventBus)
1040+ ctx := testContext()
1041+ _ = svc.Load(ctx)
1042+
1043+ routes := svc.FindRoutesByImage(ctx, "myapp")
1044+ assert.Len(t, routes, 2)
1045+
1046+ domains := []string{routes[0].Domain, routes[1].Domain}
1047+ assert.ElementsMatch(t, []string{"app1.example.com", "app2.example.com"}, domains)
1048+ })
1049+
1050+ t.Run("case insensitive match", func(t *testing.T) {
1051+ v := viper.New()
1052+ v.Set("routes", map[string]interface{}{
1053+ "app.example.com": "MyApp:latest",
1054+ })
1055+
1056+ eventBus := mocks.NewMockEventPublisher(t)
1057+ svc := NewService(v, eventBus)
1058+ ctx := testContext()
1059+ _ = svc.Load(ctx)
1060+
1061+ routes := svc.FindRoutesByImage(ctx, "myapp")
1062+ assert.Len(t, routes, 1)
1063+ })
1064+
1065+ t.Run("http prefix route sets HTTPS false", func(t *testing.T) {
1066+ v := viper.New()
1067+ v.Set("routes", map[string]interface{}{
1068+ "http://insecure.example.com": "myapp:latest",
1069+ })
1070+
1071+ eventBus := mocks.NewMockEventPublisher(t)
1072+ svc := NewService(v, eventBus)
1073+ ctx := testContext()
1074+ _ = svc.Load(ctx)
1075+
1076+ routes := svc.FindRoutesByImage(ctx, "myapp")
1077+ assert.Len(t, routes, 1)
1078+ assert.Equal(t, "insecure.example.com", routes[0].Domain)
1079+ assert.False(t, routes[0].HTTPS)
1080+ })
1081+
1082+ t.Run("empty routes", func(t *testing.T) {
1083+ v := viper.New()
1084+ eventBus := mocks.NewMockEventPublisher(t)
1085+ svc := NewService(v, eventBus)
1086+ ctx := testContext()
1087+ _ = svc.Load(ctx)
1088+
1089+ routes := svc.FindRoutesByImage(ctx, "myapp")
1090+ assert.Empty(t, routes)
1091+ })
1092+}
1093+
8671094 func TestExtractDomainFromImageName(t *testing.T) {
8681095 tests := []struct {
8691096 name string
@@ -864,6 +864,233 @@ func TestService_GetExternalRoutes_Empty(t *testing.T) {
864 assert.Empty(t, routes)864 assert.Empty(t, routes)
865 }865 }
866 866
867+func TestSplitImageNameTag(t *testing.T) {
868+ tests := []struct {
869+ name string
870+ image string
871+ wantName string
872+ wantHasTag bool
873+ }{
874+ {"with tag", "myapp:latest", "myapp", true},
875+ {"with version tag", "myapp:v1.2.0", "myapp", true},
876+ {"no tag", "myapp", "myapp", false},
877+ {"empty string", "", "", false},
878+ {"tag only colon", "myapp:", "myapp", true},
879+ {"registry prefixed with tag", "reg.example.com/myapp:latest", "reg.example.com/myapp", true},
880+ {"registry prefixed no tag", "reg.example.com/myapp", "reg.example.com/myapp", false},
881+ }
882+
883+ for _, tt := range tests {
884+ t.Run(tt.name, func(t *testing.T) {
885+ name, hasTag := splitImageNameTag(tt.image)
886+ assert.Equal(t, tt.wantName, name)
887+ assert.Equal(t, tt.wantHasTag, hasTag)
888+ })
889+ }
890+}
891+
892+func TestNormalizeRegistryImage(t *testing.T) {
893+ tests := []struct {
894+ name string
895+ imageName string
896+ registryDomain string
897+ expected string
898+ }{
899+ {"strips registry prefix", "reg.example.com/myapp:latest", "reg.example.com", "myapp:latest"},
900+ {"no prefix to strip", "myapp:latest", "reg.example.com", "myapp:latest"},
901+ {"empty registry domain", "myapp:latest", "", "myapp:latest"},
902+ {"registry with trailing slash", "reg.example.com/myapp:latest", "reg.example.com/", "myapp:latest"},
903+ {"partial match is not stripped", "reg.example.com.evil/myapp:latest", "reg.example.com", "reg.example.com.evil/myapp:latest"},
904+ {"bare name no registry", "myapp", "reg.example.com", "myapp"},
905+ {"exact registry without image", "reg.example.com/", "reg.example.com", ""},
906+ }
907+
908+ for _, tt := range tests {
909+ t.Run(tt.name, func(t *testing.T) {
910+ result := NormalizeRegistryImage(tt.imageName, tt.registryDomain)
911+ assert.Equal(t, tt.expected, result)
912+ })
913+ }
914+}
915+
916+func TestService_FindRoutesByImage(t *testing.T) {
917+ t.Run("exact match with tag", func(t *testing.T) {
918+ v := viper.New()
919+ v.Set("routes", map[string]interface{}{
920+ "app.example.com": "myapp:latest",
921+ })
922+
923+ eventBus := mocks.NewMockEventPublisher(t)
924+ svc := NewService(v, eventBus)
925+ ctx := testContext()
926+ _ = svc.Load(ctx)
927+
928+ routes := svc.FindRoutesByImage(ctx, "myapp:latest")
929+ assert.Len(t, routes, 1)
930+ assert.Equal(t, "app.example.com", routes[0].Domain)
931+ assert.Equal(t, "myapp:latest", routes[0].Image)
932+ })
933+
934+ t.Run("bare name matches route with tag", func(t *testing.T) {
935+ v := viper.New()
936+ v.Set("routes", map[string]interface{}{
937+ "app.example.com": "myapp:latest",
938+ })
939+
940+ eventBus := mocks.NewMockEventPublisher(t)
941+ svc := NewService(v, eventBus)
942+ ctx := testContext()
943+ _ = svc.Load(ctx)
944+
945+ routes := svc.FindRoutesByImage(ctx, "myapp")
946+ assert.Len(t, routes, 1)
947+ assert.Equal(t, "app.example.com", routes[0].Domain)
948+ })
949+
950+ t.Run("bare name matches route with version tag", func(t *testing.T) {
951+ v := viper.New()
952+ v.Set("routes", map[string]interface{}{
953+ "app.example.com": "myapp:v2.0.0",
954+ })
955+
956+ eventBus := mocks.NewMockEventPublisher(t)
957+ svc := NewService(v, eventBus)
958+ ctx := testContext()
959+ _ = svc.Load(ctx)
960+
961+ routes := svc.FindRoutesByImage(ctx, "myapp")
962+ assert.Len(t, routes, 1)
963+ assert.Equal(t, "app.example.com", routes[0].Domain)
964+ })
965+
966+ t.Run("tag mismatch does not match", func(t *testing.T) {
967+ v := viper.New()
968+ v.Set("routes", map[string]interface{}{
969+ "app.example.com": "myapp:v1",
970+ })
971+
972+ eventBus := mocks.NewMockEventPublisher(t)
973+ svc := NewService(v, eventBus)
974+ ctx := testContext()
975+ _ = svc.Load(ctx)
976+
977+ routes := svc.FindRoutesByImage(ctx, "myapp:v2")
978+ assert.Empty(t, routes)
979+ })
980+
981+ t.Run("no match", func(t *testing.T) {
982+ v := viper.New()
983+ v.Set("routes", map[string]interface{}{
984+ "app.example.com": "myapp:latest",
985+ })
986+
987+ eventBus := mocks.NewMockEventPublisher(t)
988+ svc := NewService(v, eventBus)
989+ ctx := testContext()
990+ _ = svc.Load(ctx)
991+
992+ routes := svc.FindRoutesByImage(ctx, "otherapp")
993+ assert.Empty(t, routes)
994+ })
995+
996+ t.Run("strips registry prefix before matching", func(t *testing.T) {
997+ v := viper.New()
998+ v.Set("server.registry_domain", "reg.example.com")
999+ v.Set("routes", map[string]interface{}{
1000+ "app.example.com": "reg.example.com/myapp:latest",
1001+ })
1002+
1003+ eventBus := mocks.NewMockEventPublisher(t)
1004+ svc := NewService(v, eventBus)
1005+ ctx := testContext()
1006+ _ = svc.Load(ctx)
1007+
1008+ routes := svc.FindRoutesByImage(ctx, "myapp")
1009+ assert.Len(t, routes, 1)
1010+ assert.Equal(t, "app.example.com", routes[0].Domain)
1011+ })
1012+
1013+ t.Run("strips registry prefix from input too", func(t *testing.T) {
1014+ v := viper.New()
1015+ v.Set("server.registry_domain", "reg.example.com")
1016+ v.Set("routes", map[string]interface{}{
1017+ "app.example.com": "reg.example.com/myapp:latest",
1018+ })
1019+
1020+ eventBus := mocks.NewMockEventPublisher(t)
1021+ svc := NewService(v, eventBus)
1022+ ctx := testContext()
1023+ _ = svc.Load(ctx)
1024+
1025+ routes := svc.FindRoutesByImage(ctx, "reg.example.com/myapp:latest")
1026+ assert.Len(t, routes, 1)
1027+ assert.Equal(t, "app.example.com", routes[0].Domain)
1028+ })
1029+
1030+ t.Run("multiple routes for same image", func(t *testing.T) {
1031+ v := viper.New()
1032+ v.Set("routes", map[string]interface{}{
1033+ "app1.example.com": "myapp:latest",
1034+ "app2.example.com": "myapp:latest",
1035+ "other.example.com": "otherapp:latest",
1036+ })
1037+
1038+ eventBus := mocks.NewMockEventPublisher(t)
1039+ svc := NewService(v, eventBus)
1040+ ctx := testContext()
1041+ _ = svc.Load(ctx)
1042+
1043+ routes := svc.FindRoutesByImage(ctx, "myapp")
1044+ assert.Len(t, routes, 2)
1045+
1046+ domains := []string{routes[0].Domain, routes[1].Domain}
1047+ assert.ElementsMatch(t, []string{"app1.example.com", "app2.example.com"}, domains)
1048+ })
1049+
1050+ t.Run("case insensitive match", func(t *testing.T) {
1051+ v := viper.New()
1052+ v.Set("routes", map[string]interface{}{
1053+ "app.example.com": "MyApp:latest",
1054+ })
1055+
1056+ eventBus := mocks.NewMockEventPublisher(t)
1057+ svc := NewService(v, eventBus)
1058+ ctx := testContext()
1059+ _ = svc.Load(ctx)
1060+
1061+ routes := svc.FindRoutesByImage(ctx, "myapp")
1062+ assert.Len(t, routes, 1)
1063+ })
1064+
1065+ t.Run("http prefix route sets HTTPS false", func(t *testing.T) {
1066+ v := viper.New()
1067+ v.Set("routes", map[string]interface{}{
1068+ "http://insecure.example.com": "myapp:latest",
1069+ })
1070+
1071+ eventBus := mocks.NewMockEventPublisher(t)
1072+ svc := NewService(v, eventBus)
1073+ ctx := testContext()
1074+ _ = svc.Load(ctx)
1075+
1076+ routes := svc.FindRoutesByImage(ctx, "myapp")
1077+ assert.Len(t, routes, 1)
1078+ assert.Equal(t, "insecure.example.com", routes[0].Domain)
1079+ assert.False(t, routes[0].HTTPS)
1080+ })
1081+
1082+ t.Run("empty routes", func(t *testing.T) {
1083+ v := viper.New()
1084+ eventBus := mocks.NewMockEventPublisher(t)
1085+ svc := NewService(v, eventBus)
1086+ ctx := testContext()
1087+ _ = svc.Load(ctx)
1088+
1089+ routes := svc.FindRoutesByImage(ctx, "myapp")
1090+ assert.Empty(t, routes)
1091+ })
1092+}
1093+
867 func TestExtractDomainFromImageName(t *testing.T) {1094 func TestExtractDomainFromImageName(t *testing.T) {
868 tests := []struct {1095 tests := []struct {
869 name string1096 name string

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 8

Tags 106

↑↓ moveenter openesc close