detached ac8d28a

fix: address code review issues

- Fix unreachable code in truncateImage/truncateNetwork functions
- Optimize N+1 query in ListRoutesWithDetails by batching attachment lookup
- Restore removed registry handler tests (NotFound, NestedName, PutManifest, GetBlob)
- Add unit tests for truncation functions
commit
ac8d28ae6635c7567334ff117b4cbe79e9cc4fa5
author
brice · · 2026-01-17 13:38 UTC
parent
256e074
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
5 files · +392 -32
M internal/adapters/in/cli/routes.go +19 -31
@@ -34,33 +34,24 @@ func truncateImage(image string, maxLen int) string {
3434 digest = digest[:12]
3535 }
3636 short := fmt.Sprintf("%s@sha256:%s", name, digest)
37- if len(short) > maxLen {
38- // Truncate from the end to maintain valid reference format
39- if maxLen <= 3 {
40- if len(short) < maxLen {
41- return short
42- }
43- return short[:maxLen]
44- }
45- if len(short) <= maxLen {
46- return short
47- }
48- return short[:maxLen-3] + "..."
37+ if len(short) <= maxLen {
38+ return short
4939 }
50- return short
40+ // Truncate from the end to maintain valid reference format
41+ if maxLen <= 3 {
42+ return short[:maxLen]
43+ }
44+ return short[:maxLen-3] + "..."
5145 }
5246
5347 // Regular tag: truncate if needed
54- if len(image) > maxLen {
55- if maxLen <= 3 {
56- if len(image) < maxLen {
57- return image
58- }
59- return image[:maxLen]
60- }
61- return image[:maxLen-3] + "..."
48+ if len(image) <= maxLen {
49+ return image
50+ }
51+ if maxLen <= 3 {
52+ return image[:maxLen]
6253 }
63- return image
54+ return image[:maxLen-3] + "..."
6455 }
6556
6657 func truncateNetwork(network string, maxLen int) string {
@@ -70,16 +61,13 @@ func truncateNetwork(network string, maxLen int) string {
7061 if maxLen <= 0 {
7162 return ""
7263 }
73- if len(network) > maxLen {
74- if maxLen <= 3 {
75- if len(network) < maxLen {
76- return network
77- }
78- return network[:maxLen]
79- }
80- return network[:maxLen-3] + "..."
64+ if len(network) <= maxLen {
65+ return network
66+ }
67+ if maxLen <= 3 {
68+ return network[:maxLen]
8169 }
82- return network
70+ return network[:maxLen-3] + "..."
8371 }
8472
8573 // newRoutesCmd creates the routes command group.
@@ -34,33 +34,24 @@ func truncateImage(image string, maxLen int) string {
34 digest = digest[:12]34 digest = digest[:12]
35 }35 }
36 short := fmt.Sprintf("%s@sha256:%s", name, digest)36 short := fmt.Sprintf("%s@sha256:%s", name, digest)
37- if len(short) > maxLen {37+ if len(short) <= maxLen {
38- // Truncate from the end to maintain valid reference format38+ return short
39- if maxLen <= 3 {
40- if len(short) < maxLen {
41- return short
42- }
43- return short[:maxLen]
44- }
45- if len(short) <= maxLen {
46- return short
47- }
48- return short[:maxLen-3] + "..."
49 }39 }
50- return short40+ // Truncate from the end to maintain valid reference format
41+ if maxLen <= 3 {
42+ return short[:maxLen]
43+ }
44+ return short[:maxLen-3] + "..."
51 }45 }
52 46
53 // Regular tag: truncate if needed47 // Regular tag: truncate if needed
54- if len(image) > maxLen {48+ if len(image) <= maxLen {
55- if maxLen <= 3 {49+ return image
56- if len(image) < maxLen {50+ }
57- return image51+ if maxLen <= 3 {
58- }52+ return image[:maxLen]
59- return image[:maxLen]
60- }
61- return image[:maxLen-3] + "..."
62 }53 }
63- return image54+ return image[:maxLen-3] + "..."
64 }55 }
65 56
66 func truncateNetwork(network string, maxLen int) string {57 func truncateNetwork(network string, maxLen int) string {
@@ -70,16 +61,13 @@ func truncateNetwork(network string, maxLen int) string {
70 if maxLen <= 0 {61 if maxLen <= 0 {
71 return ""62 return ""
72 }63 }
73- if len(network) > maxLen {64+ if len(network) <= maxLen {
74- if maxLen <= 3 {65+ return network
75- if len(network) < maxLen {66+ }
76- return network67+ if maxLen <= 3 {
77- }68+ return network[:maxLen]
78- return network[:maxLen]
79- }
80- return network[:maxLen-3] + "..."
81 }69 }
82- return network70+ return network[:maxLen-3] + "..."
83 }71 }
84 72
85 // newRoutesCmd creates the routes command group.73 // newRoutesCmd creates the routes command group.
A internal/adapters/in/cli/routes_test.go +181 -0
@@ -0,0 +1,181 @@
1+package cli
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/assert"
7+)
8+
9+func TestTruncateImage(t *testing.T) {
10+ tests := []struct {
11+ name string
12+ image string
13+ maxLen int
14+ expected string
15+ }{
16+ // Basic cases - no truncation needed
17+ {
18+ name: "short image fits",
19+ image: "nginx:latest",
20+ maxLen: 20,
21+ expected: "nginx:latest",
22+ },
23+ {
24+ name: "exact fit",
25+ image: "nginx:latest",
26+ maxLen: 12,
27+ expected: "nginx:latest",
28+ },
29+
30+ // Regular tag truncation
31+ {
32+ name: "truncate long tag with ellipsis",
33+ image: "registry.test.com/test:v1234567890",
34+ maxLen: 30,
35+ expected: "registry.test.com/test:v123...",
36+ },
37+ {
38+ name: "truncate very long image",
39+ image: "registry.example.com/organization/project/image:v1.2.3-beta.4",
40+ maxLen: 35,
41+ expected: "registry.example.com/organizatio...",
42+ },
43+
44+ // Digest truncation
45+ {
46+ name: "digest shortened to 12 chars",
47+ image: "myapp@sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4",
48+ maxLen: 50,
49+ expected: "myapp@sha256:a3ed95caeb02",
50+ },
51+ {
52+ name: "digest truncated with ellipsis when too long",
53+ image: "registry.example.com/org/app@sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4",
54+ maxLen: 35,
55+ expected: "registry.example.com/org/app@sha...",
56+ },
57+ {
58+ name: "short digest fits",
59+ image: "app@sha256:abc123",
60+ maxLen: 30,
61+ expected: "app@sha256:abc123",
62+ },
63+
64+ // Edge cases
65+ {
66+ name: "maxLen zero returns empty",
67+ image: "nginx:latest",
68+ maxLen: 0,
69+ expected: "",
70+ },
71+ {
72+ name: "maxLen negative returns empty",
73+ image: "nginx:latest",
74+ maxLen: -1,
75+ expected: "",
76+ },
77+ {
78+ name: "maxLen 3 or less - no ellipsis",
79+ image: "nginx:latest",
80+ maxLen: 3,
81+ expected: "ngi",
82+ },
83+ {
84+ name: "maxLen 4 - truncate with ellipsis",
85+ image: "nginx:latest",
86+ maxLen: 4,
87+ expected: "n...",
88+ },
89+ {
90+ name: "empty image",
91+ image: "",
92+ maxLen: 10,
93+ expected: "",
94+ },
95+ }
96+
97+ for _, tt := range tests {
98+ t.Run(tt.name, func(t *testing.T) {
99+ result := truncateImage(tt.image, tt.maxLen)
100+ assert.Equal(t, tt.expected, result)
101+ })
102+ }
103+}
104+
105+func TestTruncateNetwork(t *testing.T) {
106+ tests := []struct {
107+ name string
108+ network string
109+ maxLen int
110+ expected string
111+ }{
112+ // Basic cases
113+ {
114+ name: "short network fits",
115+ network: "gordon-net",
116+ maxLen: 20,
117+ expected: "gordon-net",
118+ },
119+ {
120+ name: "exact fit",
121+ network: "gordon-net",
122+ maxLen: 10,
123+ expected: "gordon-net",
124+ },
125+
126+ // Truncation
127+ {
128+ name: "truncate long network with ellipsis",
129+ network: "gordon-my-very-long-network-name",
130+ maxLen: 20,
131+ expected: "gordon-my-very-lo...",
132+ },
133+
134+ // Special values
135+ {
136+ name: "empty returns dash",
137+ network: "",
138+ maxLen: 10,
139+ expected: "-",
140+ },
141+ {
142+ name: "dash returns dash",
143+ network: "-",
144+ maxLen: 10,
145+ expected: "-",
146+ },
147+
148+ // Edge cases
149+ {
150+ name: "maxLen zero returns empty",
151+ network: "gordon-net",
152+ maxLen: 0,
153+ expected: "",
154+ },
155+ {
156+ name: "maxLen negative returns empty",
157+ network: "gordon-net",
158+ maxLen: -1,
159+ expected: "",
160+ },
161+ {
162+ name: "maxLen 3 or less - no ellipsis",
163+ network: "gordon-net",
164+ maxLen: 3,
165+ expected: "gor",
166+ },
167+ {
168+ name: "maxLen 4 - truncate with ellipsis",
169+ network: "gordon-net",
170+ maxLen: 4,
171+ expected: "g...",
172+ },
173+ }
174+
175+ for _, tt := range tests {
176+ t.Run(tt.name, func(t *testing.T) {
177+ result := truncateNetwork(tt.network, tt.maxLen)
178+ assert.Equal(t, tt.expected, result)
179+ })
180+ }
181+}
@@ -0,0 +1,181 @@
1+package cli
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/assert"
7+)
8+
9+func TestTruncateImage(t *testing.T) {
10+ tests := []struct {
11+ name string
12+ image string
13+ maxLen int
14+ expected string
15+ }{
16+ // Basic cases - no truncation needed
17+ {
18+ name: "short image fits",
19+ image: "nginx:latest",
20+ maxLen: 20,
21+ expected: "nginx:latest",
22+ },
23+ {
24+ name: "exact fit",
25+ image: "nginx:latest",
26+ maxLen: 12,
27+ expected: "nginx:latest",
28+ },
29+
30+ // Regular tag truncation
31+ {
32+ name: "truncate long tag with ellipsis",
33+ image: "registry.test.com/test:v1234567890",
34+ maxLen: 30,
35+ expected: "registry.test.com/test:v123...",
36+ },
37+ {
38+ name: "truncate very long image",
39+ image: "registry.example.com/organization/project/image:v1.2.3-beta.4",
40+ maxLen: 35,
41+ expected: "registry.example.com/organizatio...",
42+ },
43+
44+ // Digest truncation
45+ {
46+ name: "digest shortened to 12 chars",
47+ image: "myapp@sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4",
48+ maxLen: 50,
49+ expected: "myapp@sha256:a3ed95caeb02",
50+ },
51+ {
52+ name: "digest truncated with ellipsis when too long",
53+ image: "registry.example.com/org/app@sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4",
54+ maxLen: 35,
55+ expected: "registry.example.com/org/app@sha...",
56+ },
57+ {
58+ name: "short digest fits",
59+ image: "app@sha256:abc123",
60+ maxLen: 30,
61+ expected: "app@sha256:abc123",
62+ },
63+
64+ // Edge cases
65+ {
66+ name: "maxLen zero returns empty",
67+ image: "nginx:latest",
68+ maxLen: 0,
69+ expected: "",
70+ },
71+ {
72+ name: "maxLen negative returns empty",
73+ image: "nginx:latest",
74+ maxLen: -1,
75+ expected: "",
76+ },
77+ {
78+ name: "maxLen 3 or less - no ellipsis",
79+ image: "nginx:latest",
80+ maxLen: 3,
81+ expected: "ngi",
82+ },
83+ {
84+ name: "maxLen 4 - truncate with ellipsis",
85+ image: "nginx:latest",
86+ maxLen: 4,
87+ expected: "n...",
88+ },
89+ {
90+ name: "empty image",
91+ image: "",
92+ maxLen: 10,
93+ expected: "",
94+ },
95+ }
96+
97+ for _, tt := range tests {
98+ t.Run(tt.name, func(t *testing.T) {
99+ result := truncateImage(tt.image, tt.maxLen)
100+ assert.Equal(t, tt.expected, result)
101+ })
102+ }
103+}
104+
105+func TestTruncateNetwork(t *testing.T) {
106+ tests := []struct {
107+ name string
108+ network string
109+ maxLen int
110+ expected string
111+ }{
112+ // Basic cases
113+ {
114+ name: "short network fits",
115+ network: "gordon-net",
116+ maxLen: 20,
117+ expected: "gordon-net",
118+ },
119+ {
120+ name: "exact fit",
121+ network: "gordon-net",
122+ maxLen: 10,
123+ expected: "gordon-net",
124+ },
125+
126+ // Truncation
127+ {
128+ name: "truncate long network with ellipsis",
129+ network: "gordon-my-very-long-network-name",
130+ maxLen: 20,
131+ expected: "gordon-my-very-lo...",
132+ },
133+
134+ // Special values
135+ {
136+ name: "empty returns dash",
137+ network: "",
138+ maxLen: 10,
139+ expected: "-",
140+ },
141+ {
142+ name: "dash returns dash",
143+ network: "-",
144+ maxLen: 10,
145+ expected: "-",
146+ },
147+
148+ // Edge cases
149+ {
150+ name: "maxLen zero returns empty",
151+ network: "gordon-net",
152+ maxLen: 0,
153+ expected: "",
154+ },
155+ {
156+ name: "maxLen negative returns empty",
157+ network: "gordon-net",
158+ maxLen: -1,
159+ expected: "",
160+ },
161+ {
162+ name: "maxLen 3 or less - no ellipsis",
163+ network: "gordon-net",
164+ maxLen: 3,
165+ expected: "gor",
166+ },
167+ {
168+ name: "maxLen 4 - truncate with ellipsis",
169+ network: "gordon-net",
170+ maxLen: 4,
171+ expected: "g...",
172+ },
173+ }
174+
175+ for _, tt := range tests {
176+ t.Run(tt.name, func(t *testing.T) {
177+ result := truncateNetwork(tt.network, tt.maxLen)
178+ assert.Equal(t, tt.expected, result)
179+ })
180+ }
181+}
M internal/adapters/in/http/registry/handler_test.go +142 -0
@@ -5,6 +5,7 @@ import (
55 "encoding/json"
66 "net/http"
77 "net/http/httptest"
8+ "os"
89 "testing"
910
1011 "github.com/bnema/zerowrap"
@@ -33,6 +34,19 @@ func TestHandler_Base(t *testing.T) {
3334 assert.Equal(t, "registry/2.0", rec.Header().Get("Docker-Distribution-API-Version"))
3435 }
3536
37+func TestHandler_NotFound(t *testing.T) {
38+ registrySvc := inmocks.NewMockRegistryService(t)
39+
40+ handler := NewHandler(registrySvc, testLogger())
41+
42+ req := httptest.NewRequest("GET", "/v2/unknown/path", nil)
43+ rec := httptest.NewRecorder()
44+
45+ handler.ServeHTTP(rec, req)
46+
47+ assert.Equal(t, http.StatusNotFound, rec.Code)
48+}
49+
3650 func TestHandler_GetManifest_Success(t *testing.T) {
3751 registrySvc := inmocks.NewMockRegistryService(t)
3852
@@ -96,6 +110,134 @@ func TestHandler_GetManifest_NotFound(t *testing.T) {
96110 assert.Equal(t, "application/json", rec.Header().Get("Content-Type"))
97111 }
98112
113+func TestHandler_GetManifest_NestedName(t *testing.T) {
114+ registrySvc := inmocks.NewMockRegistryService(t)
115+
116+ handler := NewHandler(registrySvc, testLogger())
117+
118+ manifestData := []byte(`{"schemaVersion": 2}`)
119+ registrySvc.EXPECT().GetManifest(mock.Anything, "org/project/app", "v1.0").Return(&domain.Manifest{
120+ Name: "org/project/app",
121+ Reference: "v1.0",
122+ ContentType: "application/vnd.docker.distribution.manifest.v2+json",
123+ Data: manifestData,
124+ }, nil)
125+
126+ req := httptest.NewRequest("GET", "/v2/org/project/app/manifests/v1.0", nil)
127+ rec := httptest.NewRecorder()
128+
129+ handler.ServeHTTP(rec, req)
130+
131+ assert.Equal(t, http.StatusOK, rec.Code)
132+}
133+
134+func TestHandler_PutManifest_Success(t *testing.T) {
135+ registrySvc := inmocks.NewMockRegistryService(t)
136+
137+ handler := NewHandler(registrySvc, testLogger())
138+
139+ manifestData := []byte(`{"schemaVersion": 2}`)
140+ registrySvc.EXPECT().PutManifest(mock.Anything, mock.MatchedBy(func(m *domain.Manifest) bool {
141+ return m.Name == "myapp" && m.Reference == "latest" && m.ContentType == "application/vnd.docker.distribution.manifest.v2+json"
142+ })).Return("sha256:abc123", nil)
143+
144+ req := httptest.NewRequest("PUT", "/v2/myapp/manifests/latest", bytes.NewReader(manifestData))
145+ req.Header.Set("Content-Type", "application/vnd.docker.distribution.manifest.v2+json")
146+ rec := httptest.NewRecorder()
147+
148+ handler.ServeHTTP(rec, req)
149+
150+ assert.Equal(t, http.StatusCreated, rec.Code)
151+ assert.Equal(t, "sha256:abc123", rec.Header().Get("Docker-Content-Digest"))
152+ assert.Contains(t, rec.Header().Get("Location"), "/v2/myapp/manifests/latest")
153+}
154+
155+func TestHandler_PutManifest_MissingContentType(t *testing.T) {
156+ registrySvc := inmocks.NewMockRegistryService(t)
157+
158+ handler := NewHandler(registrySvc, testLogger())
159+
160+ manifestData := []byte(`{"schemaVersion": 2}`)
161+
162+ req := httptest.NewRequest("PUT", "/v2/myapp/manifests/latest", bytes.NewReader(manifestData))
163+ // No Content-Type header
164+ rec := httptest.NewRecorder()
165+
166+ handler.ServeHTTP(rec, req)
167+
168+ assert.Equal(t, http.StatusBadRequest, rec.Code)
169+}
170+
171+func TestHandler_PutManifest_StorageError(t *testing.T) {
172+ registrySvc := inmocks.NewMockRegistryService(t)
173+
174+ handler := NewHandler(registrySvc, testLogger())
175+
176+ manifestData := []byte(`{"schemaVersion": 2}`)
177+ registrySvc.EXPECT().PutManifest(mock.Anything, mock.Anything).Return("", assert.AnError)
178+
179+ req := httptest.NewRequest("PUT", "/v2/myapp/manifests/latest", bytes.NewReader(manifestData))
180+ req.Header.Set("Content-Type", "application/vnd.docker.distribution.manifest.v2+json")
181+ rec := httptest.NewRecorder()
182+
183+ handler.ServeHTTP(rec, req)
184+
185+ assert.Equal(t, http.StatusInternalServerError, rec.Code)
186+}
187+
188+func TestHandler_ManifestRoutes_MethodNotAllowed(t *testing.T) {
189+ registrySvc := inmocks.NewMockRegistryService(t)
190+
191+ handler := NewHandler(registrySvc, testLogger())
192+
193+ req := httptest.NewRequest("DELETE", "/v2/myapp/manifests/latest", nil)
194+ rec := httptest.NewRecorder()
195+
196+ handler.ServeHTTP(rec, req)
197+
198+ assert.Equal(t, http.StatusMethodNotAllowed, rec.Code)
199+}
200+
201+func TestHandler_GetBlob_Success(t *testing.T) {
202+ registrySvc := inmocks.NewMockRegistryService(t)
203+
204+ handler := NewHandler(registrySvc, testLogger())
205+
206+ // Create a temp file for http.ServeFile
207+ tmpFile, err := os.CreateTemp("", "blob-*")
208+ assert.NoError(t, err)
209+ defer tmpFile.Close()
210+
211+ blobContent := []byte("blob content here")
212+ _, err = tmpFile.Write(blobContent)
213+ assert.NoError(t, err)
214+
215+ registrySvc.EXPECT().GetBlobPath(mock.Anything, "sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4").Return(tmpFile.Name(), nil)
216+
217+ req := httptest.NewRequest("GET", "/v2/myapp/blobs/sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4", nil)
218+ rec := httptest.NewRecorder()
219+
220+ handler.ServeHTTP(rec, req)
221+
222+ assert.Equal(t, http.StatusOK, rec.Code)
223+ assert.Equal(t, blobContent, rec.Body.Bytes())
224+}
225+
226+func TestHandler_GetBlob_NotFound(t *testing.T) {
227+ registrySvc := inmocks.NewMockRegistryService(t)
228+
229+ handler := NewHandler(registrySvc, testLogger())
230+
231+ registrySvc.EXPECT().GetBlobPath(mock.Anything, "sha256:0000000000000000000000000000000000000000000000000000000000000000").Return("", assert.AnError)
232+
233+ req := httptest.NewRequest("GET", "/v2/myapp/blobs/sha256:0000000000000000000000000000000000000000000000000000000000000000", nil)
234+ rec := httptest.NewRecorder()
235+
236+ handler.ServeHTTP(rec, req)
237+
238+ assert.Equal(t, http.StatusNotFound, rec.Code)
239+}
240+
99241 func TestHandler_BlobRoutes_MethodNotAllowed(t *testing.T) {
100242 registrySvc := inmocks.NewMockRegistryService(t)
101243
@@ -5,6 +5,7 @@ import (
5 "encoding/json"5 "encoding/json"
6 "net/http"6 "net/http"
7 "net/http/httptest"7 "net/http/httptest"
8+ "os"
8 "testing"9 "testing"
9 10
10 "github.com/bnema/zerowrap"11 "github.com/bnema/zerowrap"
@@ -33,6 +34,19 @@ func TestHandler_Base(t *testing.T) {
33 assert.Equal(t, "registry/2.0", rec.Header().Get("Docker-Distribution-API-Version"))34 assert.Equal(t, "registry/2.0", rec.Header().Get("Docker-Distribution-API-Version"))
34 }35 }
35 36
37+func TestHandler_NotFound(t *testing.T) {
38+ registrySvc := inmocks.NewMockRegistryService(t)
39+
40+ handler := NewHandler(registrySvc, testLogger())
41+
42+ req := httptest.NewRequest("GET", "/v2/unknown/path", nil)
43+ rec := httptest.NewRecorder()
44+
45+ handler.ServeHTTP(rec, req)
46+
47+ assert.Equal(t, http.StatusNotFound, rec.Code)
48+}
49+
36 func TestHandler_GetManifest_Success(t *testing.T) {50 func TestHandler_GetManifest_Success(t *testing.T) {
37 registrySvc := inmocks.NewMockRegistryService(t)51 registrySvc := inmocks.NewMockRegistryService(t)
38 52
@@ -96,6 +110,134 @@ func TestHandler_GetManifest_NotFound(t *testing.T) {
96 assert.Equal(t, "application/json", rec.Header().Get("Content-Type"))110 assert.Equal(t, "application/json", rec.Header().Get("Content-Type"))
97 }111 }
98 112
113+func TestHandler_GetManifest_NestedName(t *testing.T) {
114+ registrySvc := inmocks.NewMockRegistryService(t)
115+
116+ handler := NewHandler(registrySvc, testLogger())
117+
118+ manifestData := []byte(`{"schemaVersion": 2}`)
119+ registrySvc.EXPECT().GetManifest(mock.Anything, "org/project/app", "v1.0").Return(&domain.Manifest{
120+ Name: "org/project/app",
121+ Reference: "v1.0",
122+ ContentType: "application/vnd.docker.distribution.manifest.v2+json",
123+ Data: manifestData,
124+ }, nil)
125+
126+ req := httptest.NewRequest("GET", "/v2/org/project/app/manifests/v1.0", nil)
127+ rec := httptest.NewRecorder()
128+
129+ handler.ServeHTTP(rec, req)
130+
131+ assert.Equal(t, http.StatusOK, rec.Code)
132+}
133+
134+func TestHandler_PutManifest_Success(t *testing.T) {
135+ registrySvc := inmocks.NewMockRegistryService(t)
136+
137+ handler := NewHandler(registrySvc, testLogger())
138+
139+ manifestData := []byte(`{"schemaVersion": 2}`)
140+ registrySvc.EXPECT().PutManifest(mock.Anything, mock.MatchedBy(func(m *domain.Manifest) bool {
141+ return m.Name == "myapp" && m.Reference == "latest" && m.ContentType == "application/vnd.docker.distribution.manifest.v2+json"
142+ })).Return("sha256:abc123", nil)
143+
144+ req := httptest.NewRequest("PUT", "/v2/myapp/manifests/latest", bytes.NewReader(manifestData))
145+ req.Header.Set("Content-Type", "application/vnd.docker.distribution.manifest.v2+json")
146+ rec := httptest.NewRecorder()
147+
148+ handler.ServeHTTP(rec, req)
149+
150+ assert.Equal(t, http.StatusCreated, rec.Code)
151+ assert.Equal(t, "sha256:abc123", rec.Header().Get("Docker-Content-Digest"))
152+ assert.Contains(t, rec.Header().Get("Location"), "/v2/myapp/manifests/latest")
153+}
154+
155+func TestHandler_PutManifest_MissingContentType(t *testing.T) {
156+ registrySvc := inmocks.NewMockRegistryService(t)
157+
158+ handler := NewHandler(registrySvc, testLogger())
159+
160+ manifestData := []byte(`{"schemaVersion": 2}`)
161+
162+ req := httptest.NewRequest("PUT", "/v2/myapp/manifests/latest", bytes.NewReader(manifestData))
163+ // No Content-Type header
164+ rec := httptest.NewRecorder()
165+
166+ handler.ServeHTTP(rec, req)
167+
168+ assert.Equal(t, http.StatusBadRequest, rec.Code)
169+}
170+
171+func TestHandler_PutManifest_StorageError(t *testing.T) {
172+ registrySvc := inmocks.NewMockRegistryService(t)
173+
174+ handler := NewHandler(registrySvc, testLogger())
175+
176+ manifestData := []byte(`{"schemaVersion": 2}`)
177+ registrySvc.EXPECT().PutManifest(mock.Anything, mock.Anything).Return("", assert.AnError)
178+
179+ req := httptest.NewRequest("PUT", "/v2/myapp/manifests/latest", bytes.NewReader(manifestData))
180+ req.Header.Set("Content-Type", "application/vnd.docker.distribution.manifest.v2+json")
181+ rec := httptest.NewRecorder()
182+
183+ handler.ServeHTTP(rec, req)
184+
185+ assert.Equal(t, http.StatusInternalServerError, rec.Code)
186+}
187+
188+func TestHandler_ManifestRoutes_MethodNotAllowed(t *testing.T) {
189+ registrySvc := inmocks.NewMockRegistryService(t)
190+
191+ handler := NewHandler(registrySvc, testLogger())
192+
193+ req := httptest.NewRequest("DELETE", "/v2/myapp/manifests/latest", nil)
194+ rec := httptest.NewRecorder()
195+
196+ handler.ServeHTTP(rec, req)
197+
198+ assert.Equal(t, http.StatusMethodNotAllowed, rec.Code)
199+}
200+
201+func TestHandler_GetBlob_Success(t *testing.T) {
202+ registrySvc := inmocks.NewMockRegistryService(t)
203+
204+ handler := NewHandler(registrySvc, testLogger())
205+
206+ // Create a temp file for http.ServeFile
207+ tmpFile, err := os.CreateTemp("", "blob-*")
208+ assert.NoError(t, err)
209+ defer tmpFile.Close()
210+
211+ blobContent := []byte("blob content here")
212+ _, err = tmpFile.Write(blobContent)
213+ assert.NoError(t, err)
214+
215+ registrySvc.EXPECT().GetBlobPath(mock.Anything, "sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4").Return(tmpFile.Name(), nil)
216+
217+ req := httptest.NewRequest("GET", "/v2/myapp/blobs/sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4", nil)
218+ rec := httptest.NewRecorder()
219+
220+ handler.ServeHTTP(rec, req)
221+
222+ assert.Equal(t, http.StatusOK, rec.Code)
223+ assert.Equal(t, blobContent, rec.Body.Bytes())
224+}
225+
226+func TestHandler_GetBlob_NotFound(t *testing.T) {
227+ registrySvc := inmocks.NewMockRegistryService(t)
228+
229+ handler := NewHandler(registrySvc, testLogger())
230+
231+ registrySvc.EXPECT().GetBlobPath(mock.Anything, "sha256:0000000000000000000000000000000000000000000000000000000000000000").Return("", assert.AnError)
232+
233+ req := httptest.NewRequest("GET", "/v2/myapp/blobs/sha256:0000000000000000000000000000000000000000000000000000000000000000", nil)
234+ rec := httptest.NewRecorder()
235+
236+ handler.ServeHTTP(rec, req)
237+
238+ assert.Equal(t, http.StatusNotFound, rec.Code)
239+}
240+
99 func TestHandler_BlobRoutes_MethodNotAllowed(t *testing.T) {241 func TestHandler_BlobRoutes_MethodNotAllowed(t *testing.T) {
100 registrySvc := inmocks.NewMockRegistryService(t)242 registrySvc := inmocks.NewMockRegistryService(t)
101 243
M internal/usecase/container/routes_test.go +3 -0
@@ -79,6 +79,9 @@ func TestService_ListRoutesWithDetails_Empty(t *testing.T) {
7979 svc := NewService(runtime, nil, nil, nil, Config{NetworkPrefix: "gordon"})
8080 ctx := testContext()
8181
82+ // getAllAttachments is called even with empty containers
83+ runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)
84+
8285 results := svc.ListRoutesWithDetails(ctx)
8386
8487 assert.Empty(t, results)
@@ -79,6 +79,9 @@ func TestService_ListRoutesWithDetails_Empty(t *testing.T) {
79 svc := NewService(runtime, nil, nil, nil, Config{NetworkPrefix: "gordon"})79 svc := NewService(runtime, nil, nil, nil, Config{NetworkPrefix: "gordon"})
80 ctx := testContext()80 ctx := testContext()
81 81
82+ // getAllAttachments is called even with empty containers
83+ runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)
84+
82 results := svc.ListRoutesWithDetails(ctx)85 results := svc.ListRoutesWithDetails(ctx)
83 86
84 assert.Empty(t, results)87 assert.Empty(t, results)
M internal/usecase/container/service.go +47 -1
@@ -326,6 +326,9 @@ func (s *Service) ListRoutesWithDetails(ctx context.Context) []domain.RouteInfo
326326 maps.Copy(containers, s.containers)
327327 s.mu.RUnlock()
328328
329+ // Fetch all attachments once to avoid N+1 queries
330+ attachmentsByDomain := s.getAllAttachments(ctx)
331+
329332 results := make([]domain.RouteInfo, 0, len(containers))
330333 for domainName, container := range containers {
331334 network := ""
@@ -352,7 +355,7 @@ func (s *Service) ListRoutesWithDetails(ctx context.Context) []domain.RouteInfo
352355 ContainerID: containerID,
353356 ContainerStatus: status,
354357 Network: network,
355- Attachments: s.getAttachmentsForDomain(ctx, domainName),
358+ Attachments: attachmentsByDomain[domainName],
356359 })
357360 }
358361
@@ -940,6 +943,49 @@ func (s *Service) getAttachmentsForDomain(ctx context.Context, domainName string
940943 return nil
941944 }
942945
946+ return s.filterAttachments(containers, domainName)
947+}
948+
949+// getAllAttachments fetches all containers once and returns attachments grouped by domain.
950+// This avoids N+1 queries when listing multiple routes.
951+func (s *Service) getAllAttachments(ctx context.Context) map[string][]domain.Attachment {
952+ containers, err := s.runtime.ListContainers(ctx, true)
953+ if err != nil {
954+ return nil
955+ }
956+
957+ result := make(map[string][]domain.Attachment)
958+ for _, container := range containers {
959+ if container.Labels == nil {
960+ continue
961+ }
962+ if container.Labels[domain.LabelAttachment] != "true" {
963+ continue
964+ }
965+ ownerDomain := container.Labels[domain.LabelAttachedTo]
966+ if ownerDomain == "" {
967+ continue
968+ }
969+ image := container.Image
970+ serviceName := container.Name
971+ if labelImage, ok := container.Labels[domain.LabelImage]; ok && labelImage != "" {
972+ image = labelImage
973+ serviceName = extractServiceName(labelImage)
974+ }
975+ attachment := domain.Attachment{
976+ Name: serviceName,
977+ Image: image,
978+ ContainerID: container.ID,
979+ Status: container.Status,
980+ }
981+ result[ownerDomain] = append(result[ownerDomain], attachment)
982+ }
983+
984+ return result
985+}
986+
987+// filterAttachments extracts attachments for a specific domain from a container list.
988+func (s *Service) filterAttachments(containers []*domain.Container, domainName string) []domain.Attachment {
943989 attachments := make([]domain.Attachment, 0)
944990 for _, container := range containers {
945991 if container.Labels == nil {
@@ -326,6 +326,9 @@ func (s *Service) ListRoutesWithDetails(ctx context.Context) []domain.RouteInfo
326 maps.Copy(containers, s.containers)326 maps.Copy(containers, s.containers)
327 s.mu.RUnlock()327 s.mu.RUnlock()
328 328
329+ // Fetch all attachments once to avoid N+1 queries
330+ attachmentsByDomain := s.getAllAttachments(ctx)
331+
329 results := make([]domain.RouteInfo, 0, len(containers))332 results := make([]domain.RouteInfo, 0, len(containers))
330 for domainName, container := range containers {333 for domainName, container := range containers {
331 network := ""334 network := ""
@@ -352,7 +355,7 @@ func (s *Service) ListRoutesWithDetails(ctx context.Context) []domain.RouteInfo
352 ContainerID: containerID,355 ContainerID: containerID,
353 ContainerStatus: status,356 ContainerStatus: status,
354 Network: network,357 Network: network,
355- Attachments: s.getAttachmentsForDomain(ctx, domainName),358+ Attachments: attachmentsByDomain[domainName],
356 })359 })
357 }360 }
358 361
@@ -940,6 +943,49 @@ func (s *Service) getAttachmentsForDomain(ctx context.Context, domainName string
940 return nil943 return nil
941 }944 }
942 945
946+ return s.filterAttachments(containers, domainName)
947+}
948+
949+// getAllAttachments fetches all containers once and returns attachments grouped by domain.
950+// This avoids N+1 queries when listing multiple routes.
951+func (s *Service) getAllAttachments(ctx context.Context) map[string][]domain.Attachment {
952+ containers, err := s.runtime.ListContainers(ctx, true)
953+ if err != nil {
954+ return nil
955+ }
956+
957+ result := make(map[string][]domain.Attachment)
958+ for _, container := range containers {
959+ if container.Labels == nil {
960+ continue
961+ }
962+ if container.Labels[domain.LabelAttachment] != "true" {
963+ continue
964+ }
965+ ownerDomain := container.Labels[domain.LabelAttachedTo]
966+ if ownerDomain == "" {
967+ continue
968+ }
969+ image := container.Image
970+ serviceName := container.Name
971+ if labelImage, ok := container.Labels[domain.LabelImage]; ok && labelImage != "" {
972+ image = labelImage
973+ serviceName = extractServiceName(labelImage)
974+ }
975+ attachment := domain.Attachment{
976+ Name: serviceName,
977+ Image: image,
978+ ContainerID: container.ID,
979+ Status: container.Status,
980+ }
981+ result[ownerDomain] = append(result[ownerDomain], attachment)
982+ }
983+
984+ return result
985+}
986+
987+// filterAttachments extracts attachments for a specific domain from a container list.
988+func (s *Service) filterAttachments(containers []*domain.Container, domainName string) []domain.Attachment {
943 attachments := make([]domain.Attachment, 0)989 attachments := make([]domain.Attachment, 0)
944 for _, container := range containers {990 for _, container := range containers {
945 if container.Labels == nil {991 if container.Labels == nil {

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