Skip to content

Commit 65fd714

Browse files
committed
test(tilepack-api): add a few tests for the go tilepack api to critical paths
Assisted by: Opus 4.6 LLM
1 parent e7855af commit 65fd714

3 files changed

Lines changed: 313 additions & 10 deletions

File tree

backend/tilepack-api/internal/handler/handler.go

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -185,16 +185,7 @@ func (h *Handler) postTilepack(w http.ResponseWriter, r *http.Request) {
185185
}
186186
if !hasAsset && s3Exists {
187187
log.Printf("reconcile: stac_id=%s format=%s state=s3_only action=patch_stac", id, format)
188-
asset := pgstac.Asset{
189-
Href: h.s3.PublicURL(outputKey),
190-
Type: map[string]string{"mbtiles": "application/vnd.mbtiles", "pmtiles": "application/vnd.pmtiles"}[format],
191-
Roles: []string{"tiles"},
192-
Title: strings.ToUpper(format) + " archive",
193-
ProjCode: 3857,
194-
}
195-
if outputSize > 0 {
196-
asset.FileSize = outputSize
197-
}
188+
asset := canonicalTilepackAsset(format, h.s3.PublicURL(outputKey), outputSize)
198189
if err := h.pgstac.AddAsset(ctx, id, h.cfg.STACCollection, format, asset); err != nil {
199190
log.Printf("reconcile failed: stac_id=%s format=%s action=patch_stac err=%v", id, format, err)
200191
writeJSON(w, http.StatusBadGateway, response{Status: "error", Message: "could not patch stac asset"})
@@ -281,6 +272,20 @@ func (h *Handler) postTilepack(w http.ResponseWriter, r *http.Request) {
281272
writeJSON(w, http.StatusAccepted, response{Status: "started"})
282273
}
283274

275+
func canonicalTilepackAsset(format, href string, fileSize int64) pgstac.Asset {
276+
asset := pgstac.Asset{
277+
Href: href,
278+
Type: map[string]string{"mbtiles": "application/vnd.mbtiles", "pmtiles": "application/vnd.pmtiles"}[format],
279+
Roles: []string{"tiles"},
280+
Title: strings.ToUpper(format) + " archive",
281+
ProjCode: 3857,
282+
}
283+
if fileSize > 0 {
284+
asset.FileSize = fileSize
285+
}
286+
return asset
287+
}
288+
284289
func parseZooms(minStr, maxStr string) (int, int, error) {
285290
if minStr == "" && maxStr == "" {
286291
return 0, 0, nil
Lines changed: 243 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,243 @@
1+
package handler
2+
3+
import (
4+
"encoding/json"
5+
"net/http"
6+
"net/http/httptest"
7+
"strings"
8+
"testing"
9+
)
10+
11+
func TestCanonicalTilepackAsset(t *testing.T) {
12+
tests := []struct {
13+
name string
14+
format string
15+
href string
16+
fileSize int64
17+
wantType string
18+
wantTitle string
19+
wantHasSize bool
20+
}{
21+
{
22+
name: "mbtiles with size",
23+
format: "mbtiles",
24+
href: "https://example.test/item.mbtiles",
25+
fileSize: 12345,
26+
wantType: "application/vnd.mbtiles",
27+
wantTitle: "MBTILES archive",
28+
wantHasSize: true,
29+
},
30+
{
31+
name: "pmtiles without size",
32+
format: "pmtiles",
33+
href: "https://example.test/item.pmtiles",
34+
fileSize: 0,
35+
wantType: "application/vnd.pmtiles",
36+
wantTitle: "PMTILES archive",
37+
wantHasSize: false,
38+
},
39+
}
40+
41+
for _, tt := range tests {
42+
t.Run(tt.name, func(t *testing.T) {
43+
asset := canonicalTilepackAsset(tt.format, tt.href, tt.fileSize)
44+
45+
if asset.Href != tt.href {
46+
t.Fatalf("Href = %q, want %q", asset.Href, tt.href)
47+
}
48+
if asset.Type != tt.wantType {
49+
t.Fatalf("Type = %q, want %q", asset.Type, tt.wantType)
50+
}
51+
if asset.Title != tt.wantTitle {
52+
t.Fatalf("Title = %q, want %q", asset.Title, tt.wantTitle)
53+
}
54+
if len(asset.Roles) != 1 || asset.Roles[0] != "tiles" {
55+
t.Fatalf("Roles = %#v, want [\"tiles\"]", asset.Roles)
56+
}
57+
if asset.ProjCode != 3857 {
58+
t.Fatalf("ProjCode = %d, want 3857", asset.ProjCode)
59+
}
60+
if tt.wantHasSize {
61+
if asset.FileSize != tt.fileSize {
62+
t.Fatalf("FileSize = %d, want %d", asset.FileSize, tt.fileSize)
63+
}
64+
} else if asset.FileSize != 0 {
65+
t.Fatalf("FileSize = %d, want 0 when size is not positive", asset.FileSize)
66+
}
67+
})
68+
}
69+
}
70+
71+
func TestParseZooms(t *testing.T) {
72+
tests := []struct {
73+
name string
74+
min string
75+
max string
76+
wantMin int
77+
wantMax int
78+
wantErrMsg string
79+
}{
80+
{name: "default zooms", min: "", max: "", wantMin: 0, wantMax: 0},
81+
{name: "explicit valid range", min: "3", max: "8", wantMin: 3, wantMax: 8},
82+
{name: "missing max", min: "3", max: "", wantErrMsg: "min_zoom and max_zoom must both be set"},
83+
{name: "invalid min", min: "x", max: "8", wantErrMsg: "min_zoom must be an integer"},
84+
{name: "invalid max", min: "3", max: "x", wantErrMsg: "max_zoom must be an integer"},
85+
{name: "min greater than max", min: "9", max: "8", wantErrMsg: "zoom must be 0..24 and min<=max"},
86+
{name: "out of range", min: "0", max: "25", wantErrMsg: "zoom must be 0..24 and min<=max"},
87+
}
88+
89+
for _, tt := range tests {
90+
t.Run(tt.name, func(t *testing.T) {
91+
gotMin, gotMax, err := parseZooms(tt.min, tt.max)
92+
if tt.wantErrMsg != "" {
93+
if err == nil {
94+
t.Fatalf("parseZooms(%q, %q) expected error %q", tt.min, tt.max, tt.wantErrMsg)
95+
}
96+
if err.Error() != tt.wantErrMsg {
97+
t.Fatalf("error = %q, want %q", err.Error(), tt.wantErrMsg)
98+
}
99+
return
100+
}
101+
if err != nil {
102+
t.Fatalf("parseZooms(%q, %q) unexpected error: %v", tt.min, tt.max, err)
103+
}
104+
if gotMin != tt.wantMin || gotMax != tt.wantMax {
105+
t.Fatalf("got (%d, %d), want (%d, %d)", gotMin, gotMax, tt.wantMin, tt.wantMax)
106+
}
107+
})
108+
}
109+
}
110+
111+
func TestBearerToken(t *testing.T) {
112+
tests := []struct {
113+
name string
114+
header string
115+
wantToken string
116+
wantOK bool
117+
}{
118+
{name: "valid", header: "Bearer abc123", wantToken: "abc123", wantOK: true},
119+
{name: "valid with extra spaces", header: "Bearer abc123 ", wantToken: "abc123", wantOK: true},
120+
{name: "wrong prefix", header: "Token abc123", wantToken: "", wantOK: false},
121+
{name: "missing token", header: "Bearer ", wantToken: "", wantOK: false},
122+
{name: "empty", header: "", wantToken: "", wantOK: false},
123+
}
124+
125+
for _, tt := range tests {
126+
t.Run(tt.name, func(t *testing.T) {
127+
gotToken, gotOK := bearerToken(tt.header)
128+
if gotOK != tt.wantOK || gotToken != tt.wantToken {
129+
t.Fatalf("bearerToken(%q) = (%q, %v), want (%q, %v)", tt.header, gotToken, gotOK, tt.wantToken, tt.wantOK)
130+
}
131+
})
132+
}
133+
}
134+
135+
func TestRoutes_Healthz(t *testing.T) {
136+
h := &Handler{}
137+
rr := httptest.NewRecorder()
138+
req := httptest.NewRequest(http.MethodGet, "/healthz", nil)
139+
140+
h.Routes().ServeHTTP(rr, req)
141+
142+
if rr.Code != http.StatusOK {
143+
t.Fatalf("status = %d, want %d", rr.Code, http.StatusOK)
144+
}
145+
if strings.TrimSpace(rr.Body.String()) != "ok" {
146+
t.Fatalf("body = %q, want %q", rr.Body.String(), "ok")
147+
}
148+
}
149+
150+
func TestPostTilepack_EarlyValidationPaths(t *testing.T) {
151+
tests := []struct {
152+
name string
153+
url string
154+
wantStatus int
155+
wantMsg string
156+
}{
157+
{
158+
name: "invalid stac id",
159+
url: "/tilepacks/bad$id?format=pmtiles",
160+
wantStatus: http.StatusBadRequest,
161+
wantMsg: "invalid stac id",
162+
},
163+
{
164+
name: "invalid format",
165+
url: "/tilepacks/valid_id-123?format=zip",
166+
wantStatus: http.StatusBadRequest,
167+
wantMsg: "format must be pmtiles or mbtiles",
168+
},
169+
{
170+
name: "missing max zoom",
171+
url: "/tilepacks/valid_id-123?format=pmtiles&min_zoom=1",
172+
wantStatus: http.StatusBadRequest,
173+
wantMsg: "min_zoom and max_zoom must both be set",
174+
},
175+
}
176+
177+
for _, tt := range tests {
178+
t.Run(tt.name, func(t *testing.T) {
179+
h := &Handler{}
180+
rr := httptest.NewRecorder()
181+
req := httptest.NewRequest(http.MethodPost, tt.url, nil)
182+
183+
h.Routes().ServeHTTP(rr, req)
184+
185+
if rr.Code != tt.wantStatus {
186+
t.Fatalf("status = %d, want %d", rr.Code, tt.wantStatus)
187+
}
188+
var resp response
189+
if err := json.NewDecoder(rr.Body).Decode(&resp); err != nil {
190+
t.Fatalf("decode response: %v", err)
191+
}
192+
if resp.Message != tt.wantMsg {
193+
t.Fatalf("message = %q, want %q", resp.Message, tt.wantMsg)
194+
}
195+
})
196+
}
197+
}
198+
199+
func TestClientIP(t *testing.T) {
200+
tests := []struct {
201+
name string
202+
xff string
203+
remoteAddr string
204+
want string
205+
}{
206+
{
207+
name: "uses first x-forwarded-for entry",
208+
xff: "203.0.113.9, 10.0.0.1",
209+
remoteAddr: "192.0.2.1:54321",
210+
want: "203.0.113.9",
211+
},
212+
{
213+
name: "uses single x-forwarded-for value with spaces",
214+
xff: " 203.0.113.11 ",
215+
remoteAddr: "192.0.2.1:54321",
216+
want: "203.0.113.11",
217+
},
218+
{
219+
name: "falls back to remote host",
220+
xff: "",
221+
remoteAddr: "192.0.2.77:8080",
222+
want: "192.0.2.77",
223+
},
224+
{
225+
name: "returns raw remote addr on split failure",
226+
xff: "",
227+
remoteAddr: "not-a-host-port",
228+
want: "not-a-host-port",
229+
},
230+
}
231+
232+
for _, tt := range tests {
233+
t.Run(tt.name, func(t *testing.T) {
234+
req := httptest.NewRequest(http.MethodGet, "/", nil)
235+
req.Header.Set("X-Forwarded-For", tt.xff)
236+
req.RemoteAddr = tt.remoteAddr
237+
got := clientIP(req)
238+
if got != tt.want {
239+
t.Fatalf("clientIP() = %q, want %q", got, tt.want)
240+
}
241+
})
242+
}
243+
}
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
package pgstac
2+
3+
import (
4+
"encoding/json"
5+
"testing"
6+
)
7+
8+
func TestAssetJSONIncludesExtensionFields(t *testing.T) {
9+
asset := Asset{
10+
Href: "https://example.test/item.pmtiles",
11+
Type: "application/vnd.pmtiles",
12+
Roles: []string{"tiles"},
13+
Title: "PMTILES archive",
14+
FileSize: 987654,
15+
ProjCode: 3857,
16+
}
17+
18+
b, err := json.Marshal(asset)
19+
if err != nil {
20+
t.Fatalf("Marshal() error = %v", err)
21+
}
22+
23+
var got map[string]any
24+
if err := json.Unmarshal(b, &got); err != nil {
25+
t.Fatalf("Unmarshal() error = %v", err)
26+
}
27+
28+
if _, ok := got["file:size"]; !ok {
29+
t.Fatalf("JSON missing file:size: %s", string(b))
30+
}
31+
if _, ok := got["proj:code"]; !ok {
32+
t.Fatalf("JSON missing proj:code: %s", string(b))
33+
}
34+
}
35+
36+
func TestAssetJSONOmitsZeroValueExtensionFields(t *testing.T) {
37+
asset := Asset{Href: "https://example.test/item.pmtiles"}
38+
39+
b, err := json.Marshal(asset)
40+
if err != nil {
41+
t.Fatalf("Marshal() error = %v", err)
42+
}
43+
44+
var got map[string]any
45+
if err := json.Unmarshal(b, &got); err != nil {
46+
t.Fatalf("Unmarshal() error = %v", err)
47+
}
48+
49+
if _, ok := got["file:size"]; ok {
50+
t.Fatalf("JSON unexpectedly included file:size: %s", string(b))
51+
}
52+
if _, ok := got["proj:code"]; ok {
53+
t.Fatalf("JSON unexpectedly included proj:code: %s", string(b))
54+
}
55+
}

0 commit comments

Comments
 (0)