Eric Bower
·
2025-07-04
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 pubsub := NewPubsubChan()
322 defer func() {
323 _ = pubsub.Close()
324 }()
325 cfg := NewPgsConfig(logger, dbpool, st, pubsub)
326 cfg.Domain = "pgs.test"
327 router := NewWebRouter(cfg)
328 router.ServeHTTP(responseRecorder, request)
329
330 if responseRecorder.Code != tc.status {
331 t.Errorf("Want status '%d', got '%d'", tc.status, responseRecorder.Code)
332 }
333
334 ct := responseRecorder.Header().Get("content-type")
335 if ct != tc.contentType {
336 t.Errorf("Want content type '%s', got '%s'", tc.contentType, ct)
337 }
338
339 body := strings.TrimSpace(responseRecorder.Body.String())
340 if body != tc.want {
341 t.Errorf("Want '%s', got '%s'", tc.want, body)
342 }
343
344 if tc.wantUrl != "" {
345 location, err := responseRecorder.Result().Location()
346 if err != nil {
347 t.Errorf("err: %s", err.Error())
348 }
349 if location == nil {
350 t.Error("no location header in response")
351 return
352 }
353 if tc.wantUrl != location.String() {
354 t.Errorf("Want '%s', got '%s'", tc.wantUrl, location.String())
355 }
356 }
357 })
358 }
359}
360
361type ImageStorageMemory struct {
362 *storage.StorageMemory
363 Opts *storage.ImgProcessOpts
364 Fpath string
365}
366
367func (s *ImageStorageMemory) ServeObject(r *http.Request, bucket sst.Bucket, fpath string, opts *storage.ImgProcessOpts) (io.ReadCloser, *sst.ObjectInfo, error) {
368 s.Opts = opts
369 s.Fpath = fpath
370 info := sst.ObjectInfo{
371 Metadata: make(http.Header),
372 }
373 info.Metadata.Set("content-type", "image/jpeg")
374 return io.NopCloser(strings.NewReader("hello world!")), &info, nil
375}
376
377func TestImageManipulation(t *testing.T) {
378 logger := slog.Default()
379 dbpool := NewPgsDb(logger)
380 bucketName := shared.GetAssetBucketName(dbpool.Users[0].ID)
381
382 tt := []ApiExample{
383 {
384 name: "root-img",
385 path: "/app.jpg/s:500/rt:90",
386 want: "hello world!",
387 status: http.StatusOK,
388 contentType: "image/jpeg",
389
390 storage: map[string]map[string]string{
391 bucketName: {
392 "/test/app.jpg": "hello world!",
393 },
394 },
395 },
396 {
397 name: "root-subdir-img",
398 path: "/subdir/app.jpg/rt:90/s:500",
399 want: "hello world!",
400 status: http.StatusOK,
401 contentType: "image/jpeg",
402
403 storage: map[string]map[string]string{
404 bucketName: {
405 "/test/subdir/app.jpg": "hello world!",
406 },
407 },
408 },
409 }
410
411 for _, tc := range tt {
412 t.Run(tc.name, func(t *testing.T) {
413 request := httptest.NewRequest("GET", dbpool.mkpath(tc.path), strings.NewReader(""))
414 responseRecorder := httptest.NewRecorder()
415
416 memst, _ := storage.NewStorageMemory(tc.storage)
417 st := &ImageStorageMemory{
418 StorageMemory: memst,
419 Opts: &storage.ImgProcessOpts{
420 Ratio: &storage.Ratio{},
421 },
422 }
423 pubsub := NewPubsubChan()
424 defer func() {
425 _ = pubsub.Close()
426 }()
427 cfg := NewPgsConfig(logger, dbpool, st, pubsub)
428 cfg.Domain = "pgs.test"
429 router := NewWebRouter(cfg)
430 router.ServeHTTP(responseRecorder, request)
431
432 if responseRecorder.Code != tc.status {
433 t.Errorf("Want status '%d', got '%d'", tc.status, responseRecorder.Code)
434 }
435
436 ct := responseRecorder.Header().Get("content-type")
437 if ct != tc.contentType {
438 t.Errorf("Want content type '%s', got '%s'", tc.contentType, ct)
439 }
440
441 body := strings.TrimSpace(responseRecorder.Body.String())
442 if body != tc.want {
443 t.Errorf("Want '%s', got '%s'", tc.want, body)
444 }
445
446 if st.Opts.Ratio.Width != 500 {
447 t.Errorf("Want ratio width '500', got '%d'", st.Opts.Ratio.Width)
448 return
449 }
450
451 if st.Opts.Rotate != 90 {
452 t.Errorf("Want rotate '90', got '%d'", st.Opts.Rotate)
453 return
454 }
455 })
456 }
457}