Eric Bower
·
2025-04-05
web_test.go
1package pgs
2
3import (
4 "fmt"
5 "io"
6 "log/slog"
7 "net/http"
8 "net/http/httptest"
9 "strings"
10 "testing"
11 "time"
12
13 pgsdb "github.com/picosh/pico/pkg/apps/pgs/db"
14 sst "github.com/picosh/pico/pkg/pobj/storage"
15 "github.com/picosh/pico/pkg/shared"
16 "github.com/picosh/pico/pkg/shared/storage"
17)
18
19type ApiExample struct {
20 name string
21 path string
22 reqHeaders map[string]string
23 want string
24 wantUrl string
25 status int
26 contentType string
27
28 storage map[string]map[string]string
29}
30
31type PgsDb struct {
32 *pgsdb.MemoryDB
33}
34
35func NewPgsDb(logger *slog.Logger) *PgsDb {
36 sb := pgsdb.NewDBMemory(logger)
37 sb.SetupTestData()
38 _, err := sb.InsertProject(sb.Users[0].ID, "test", "test")
39 if err != nil {
40 panic(err)
41 }
42 return &PgsDb{
43 MemoryDB: sb,
44 }
45}
46
47func (p *PgsDb) mkpath(path string) string {
48 return fmt.Sprintf("https://%s-test.pgs.test%s", p.Users[0].Name, path)
49}
50
51func TestApiBasic(t *testing.T) {
52 logger := slog.Default()
53 dbpool := NewPgsDb(logger)
54 bucketName := shared.GetAssetBucketName(dbpool.Users[0].ID)
55
56 tt := []*ApiExample{
57 {
58 name: "basic",
59 path: "/",
60 want: "hello world!",
61 status: http.StatusOK,
62 contentType: "text/html",
63
64 storage: map[string]map[string]string{
65 bucketName: {
66 "/test/index.html": "hello world!",
67 },
68 },
69 },
70 {
71 name: "direct-file",
72 path: "/test.html",
73 want: "hello world!",
74 status: http.StatusOK,
75 contentType: "text/html",
76
77 storage: map[string]map[string]string{
78 bucketName: {
79 "/test/test.html": "hello world!",
80 },
81 },
82 },
83 {
84 name: "subdir-301-redirect",
85 path: "/subdir",
86 want: `<a href="/subdir/">Moved Permanently</a>.`,
87 status: http.StatusMovedPermanently,
88 contentType: "text/html; charset=utf-8",
89
90 storage: map[string]map[string]string{
91 bucketName: {
92 "/test/subdir/index.html": "hello world!",
93 },
94 },
95 },
96 {
97 name: "redirects-file-301",
98 path: "/anything",
99 want: `<a href="/about.html">Moved Permanently</a>.`,
100 status: http.StatusMovedPermanently,
101 contentType: "text/html; charset=utf-8",
102
103 storage: map[string]map[string]string{
104 bucketName: {
105 "/test/_redirects": "/anything /about.html 301",
106 "/test/about.html": "hello world!",
107 },
108 },
109 },
110 {
111 name: "subdir-direct",
112 path: "/subdir/index.html",
113 want: "hello world!",
114 status: http.StatusOK,
115 contentType: "text/html",
116
117 storage: map[string]map[string]string{
118 bucketName: {
119 "/test/subdir/index.html": "hello world!",
120 },
121 },
122 },
123 {
124 name: "spa",
125 path: "/anything",
126 want: "hello world!",
127 status: http.StatusOK,
128 contentType: "text/html",
129
130 storage: map[string]map[string]string{
131 bucketName: {
132 "/test/_redirects": "/* /index.html 200",
133 "/test/index.html": "hello world!",
134 },
135 },
136 },
137 {
138 name: "not-found",
139 path: "/anything",
140 want: "404 not found",
141 status: http.StatusNotFound,
142 contentType: "text/plain; charset=utf-8",
143
144 storage: map[string]map[string]string{
145 bucketName: {},
146 },
147 },
148 {
149 name: "_redirects",
150 path: "/_redirects",
151 want: "404 not found",
152 status: http.StatusNotFound,
153 contentType: "text/plain; charset=utf-8",
154
155 storage: map[string]map[string]string{
156 bucketName: {
157 "/test/_redirects": "/ok /index.html 200",
158 },
159 },
160 },
161 {
162 name: "_headers",
163 path: "/_headers",
164 want: "404 not found",
165 status: http.StatusNotFound,
166 contentType: "text/plain; charset=utf-8",
167
168 storage: map[string]map[string]string{
169 bucketName: {
170 "/test/_headers": "/templates/index.html\n\tX-Frame-Options: DENY",
171 },
172 },
173 },
174 {
175 name: "_pgs_ignore",
176 path: "/_pgs_ignore",
177 want: "404 not found",
178 status: http.StatusNotFound,
179 contentType: "text/plain; charset=utf-8",
180
181 storage: map[string]map[string]string{
182 bucketName: {
183 "/test/_pgs_ignore": "# nothing",
184 },
185 },
186 },
187 {
188 name: "not-found-custom",
189 path: "/anything",
190 want: "boom!",
191 status: http.StatusNotFound,
192 contentType: "text/html",
193
194 storage: map[string]map[string]string{
195 bucketName: {
196 "/test/404.html": "boom!",
197 },
198 },
199 },
200 {
201 name: "images",
202 path: "/profile.jpg",
203 want: "image",
204 status: http.StatusOK,
205 contentType: "image/jpeg",
206
207 storage: map[string]map[string]string{
208 bucketName: {
209 "/test/profile.jpg": "image",
210 },
211 },
212 },
213 {
214 name: "redirects-query-param",
215 path: "/anything?query=param",
216 want: `<a href="/about.html?query=param">Moved Permanently</a>.`,
217 wantUrl: "/about.html?query=param",
218 status: http.StatusMovedPermanently,
219 contentType: "text/html; charset=utf-8",
220
221 storage: map[string]map[string]string{
222 bucketName: {
223 "/test/_redirects": "/anything /about.html 301",
224 "/test/about.html": "hello world!",
225 },
226 },
227 },
228 {
229 name: "conditional-if-modified-since-future",
230 path: "/test.html",
231 reqHeaders: map[string]string{
232 "If-Modified-Since": time.Now().UTC().Add(time.Hour).Format(http.TimeFormat),
233 },
234 want: "",
235 status: http.StatusNotModified,
236 contentType: "",
237
238 storage: map[string]map[string]string{
239 bucketName: {
240 "/test/test.html": "hello world!",
241 },
242 },
243 },
244 {
245 name: "conditional-if-modified-since-past",
246 path: "/test.html",
247 reqHeaders: map[string]string{
248 "If-Modified-Since": time.Now().UTC().Add(-time.Hour).Format(http.TimeFormat),
249 },
250 want: "hello world!",
251 status: http.StatusOK,
252 contentType: "text/html",
253
254 storage: map[string]map[string]string{
255 bucketName: {
256 "/test/test.html": "hello world!",
257 },
258 },
259 },
260 {
261 name: "conditional-if-none-match-pass",
262 path: "/test.html",
263 reqHeaders: map[string]string{
264 "If-None-Match": "\"static-etag-for-testing-purposes\"",
265 },
266 want: "",
267 status: http.StatusNotModified,
268 contentType: "",
269
270 storage: map[string]map[string]string{
271 bucketName: {
272 "/test/test.html": "hello world!",
273 },
274 },
275 },
276 {
277 name: "conditional-if-none-match-fail",
278 path: "/test.html",
279 reqHeaders: map[string]string{
280 "If-None-Match": "\"non-matching-etag\"",
281 },
282 want: "hello world!",
283 status: http.StatusOK,
284 contentType: "text/html",
285
286 storage: map[string]map[string]string{
287 bucketName: {
288 "/test/test.html": "hello world!",
289 },
290 },
291 },
292 {
293 name: "conditional-if-none-match-and-if-modified-since",
294 path: "/test.html",
295 reqHeaders: map[string]string{
296 // The matching etag should take precedence over the past mod time
297 "If-None-Match": "\"static-etag-for-testing-purposes\"",
298 "If-Modified-Since": time.Now().UTC().Add(-time.Hour).Format(http.TimeFormat),
299 },
300 want: "",
301 status: http.StatusNotModified,
302 contentType: "",
303
304 storage: map[string]map[string]string{
305 bucketName: {
306 "/test/test.html": "hello world!",
307 },
308 },
309 },
310 }
311
312 for _, tc := range tt {
313 t.Run(tc.name, func(t *testing.T) {
314 request := httptest.NewRequest("GET", dbpool.mkpath(tc.path), strings.NewReader(""))
315 for key, val := range tc.reqHeaders {
316 request.Header.Set(key, val)
317 }
318 responseRecorder := httptest.NewRecorder()
319
320 st, _ := storage.NewStorageMemory(tc.storage)
321 cfg := NewPgsConfig(logger, dbpool, st)
322 cfg.Domain = "pgs.test"
323 router := NewWebRouter(cfg)
324 router.ServeHTTP(responseRecorder, request)
325
326 if responseRecorder.Code != tc.status {
327 t.Errorf("Want status '%d', got '%d'", tc.status, responseRecorder.Code)
328 }
329
330 ct := responseRecorder.Header().Get("content-type")
331 if ct != tc.contentType {
332 t.Errorf("Want content type '%s', got '%s'", tc.contentType, ct)
333 }
334
335 body := strings.TrimSpace(responseRecorder.Body.String())
336 if body != tc.want {
337 t.Errorf("Want '%s', got '%s'", tc.want, body)
338 }
339
340 if tc.wantUrl != "" {
341 location, err := responseRecorder.Result().Location()
342 if err != nil {
343 t.Errorf("err: %s", err.Error())
344 }
345 if location == nil {
346 t.Error("no location header in response")
347 return
348 }
349 if tc.wantUrl != location.String() {
350 t.Errorf("Want '%s', got '%s'", tc.wantUrl, location.String())
351 }
352 }
353 })
354 }
355}
356
357type ImageStorageMemory struct {
358 *storage.StorageMemory
359 Opts *storage.ImgProcessOpts
360 Fpath string
361}
362
363func (s *ImageStorageMemory) ServeObject(bucket sst.Bucket, fpath string, opts *storage.ImgProcessOpts) (io.ReadCloser, *sst.ObjectInfo, error) {
364 s.Opts = opts
365 s.Fpath = fpath
366 info := sst.ObjectInfo{
367 Metadata: make(http.Header),
368 }
369 info.Metadata.Set("content-type", "image/jpeg")
370 return io.NopCloser(strings.NewReader("hello world!")), &info, nil
371}
372
373func TestImageManipulation(t *testing.T) {
374 logger := slog.Default()
375 dbpool := NewPgsDb(logger)
376 bucketName := shared.GetAssetBucketName(dbpool.Users[0].ID)
377
378 tt := []ApiExample{
379 {
380 name: "root-img",
381 path: "/app.jpg/s:500/rt:90",
382 want: "hello world!",
383 status: http.StatusOK,
384 contentType: "image/jpeg",
385
386 storage: map[string]map[string]string{
387 bucketName: {
388 "/test/app.jpg": "hello world!",
389 },
390 },
391 },
392 {
393 name: "root-subdir-img",
394 path: "/subdir/app.jpg/rt:90/s:500",
395 want: "hello world!",
396 status: http.StatusOK,
397 contentType: "image/jpeg",
398
399 storage: map[string]map[string]string{
400 bucketName: {
401 "/test/subdir/app.jpg": "hello world!",
402 },
403 },
404 },
405 }
406
407 for _, tc := range tt {
408 t.Run(tc.name, func(t *testing.T) {
409 request := httptest.NewRequest("GET", dbpool.mkpath(tc.path), strings.NewReader(""))
410 responseRecorder := httptest.NewRecorder()
411
412 memst, _ := storage.NewStorageMemory(tc.storage)
413 st := &ImageStorageMemory{
414 StorageMemory: memst,
415 Opts: &storage.ImgProcessOpts{
416 Ratio: &storage.Ratio{},
417 },
418 }
419 cfg := NewPgsConfig(logger, dbpool, st)
420 cfg.Domain = "pgs.test"
421 router := NewWebRouter(cfg)
422 router.ServeHTTP(responseRecorder, request)
423
424 if responseRecorder.Code != tc.status {
425 t.Errorf("Want status '%d', got '%d'", tc.status, responseRecorder.Code)
426 }
427
428 ct := responseRecorder.Header().Get("content-type")
429 if ct != tc.contentType {
430 t.Errorf("Want content type '%s', got '%s'", tc.contentType, ct)
431 }
432
433 body := strings.TrimSpace(responseRecorder.Body.String())
434 if body != tc.want {
435 t.Errorf("Want '%s', got '%s'", tc.want, body)
436 }
437
438 if st.Opts.Ratio.Width != 500 {
439 t.Errorf("Want ratio width '500', got '%d'", st.Opts.Ratio.Width)
440 return
441 }
442
443 if st.Opts.Rotate != 90 {
444 t.Errorf("Want rotate '90', got '%d'", st.Opts.Rotate)
445 return
446 }
447 })
448 }
449}