repos / pico

pico services mono repo
git clone https://github.com/picosh/pico.git

pico / pkg / apps / pgs
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}