package web import ( "errors" "io" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "testing" "time" "gitbay.org/cmc/gallery/internal/format" "gitbay.org/cmc/gallery/internal/library" "gitbay.org/cmc/gallery/internal/render" ) type fakeFormat struct{ fail bool } func (fakeFormat) Name() string { return "fake" } func (fakeFormat) Match(p string) bool { return strings.HasSuffix(p, ".jpg") } func (fakeFormat) Kind() format.Kind { return format.KindImage } func (fakeFormat) Metadata(string) (format.Meta, error) { return format.Meta{ Width: 3000, Height: 2000, Taken: time.Date(2025, 3, 14, 0, 0, 0, 0, time.UTC), Camera: "X-T5", Aperture: "f/2", Shutter: "1/125", ISO: 1600, }, nil } func (f fakeFormat) Resize(_ string, _ int, dst io.Writer) error { if f.fail { return errors.New("magick failed") } _, err := io.WriteString(dst, "JPEGDATA") return err } type setup struct { fail bool about bool opt Options } func newServer(t *testing.T, s setup) http.Handler { t.Helper() root := t.TempDir() files := map[string]string{ "tokyo/a.jpg": "x", "tokyo/b.jpg": "x", "tokyo/album.org": "#+title: Tokyo\n#+date: 2025-03\n", } if s.about { files["about.org"] = "Hello /there/.\n" } for rel, body := range files { p := filepath.Join(root, rel) os.MkdirAll(filepath.Dir(p), 0o755) os.WriteFile(p, []byte(body), 0o644) } reg := &format.Registry{} reg.Register(fakeFormat{fail: s.fail}) cache, err := library.OpenMetaCache(filepath.Join(t.TempDir(), "meta.json")) if err != nil { t.Fatal(err) } lib, err := library.Scan(root, reg, cache) if err != nil { t.Fatal(err) } store := &library.Store{} store.Set(lib) if s.opt.Title == "" { s.opt.Title = "gallery" } h, err := New(store, render.New(t.TempDir(), 2), s.opt) if err != nil { t.Fatal(err) } return h } func get(h http.Handler, path string) *httptest.ResponseRecorder { rec := httptest.NewRecorder() h.ServeHTTP(rec, httptest.NewRequest("GET", path, nil)) return rec } func TestPages(t *testing.T) { h := newServer(t, setup{about: true}) for _, tc := range []struct { path string code int want []string reject []string }{ {"/", 200, []string{`href="/tokyo/"`, `data-ar="1.5000"`, "Tokyo", "2025", `href="/info/"`}, nil}, {"/tokyo/", 200, []string{`href="/tokyo/a.jpg/"`, `href="/tokyo/b.jpg/"`, `/img/tokyo/a.jpg/960.jpg?v=`, `480w`, `class="on"`, `alt="a.jpg"`}, []string{`alt=""`}}, {"/tokyo/a.jpg/", 200, []string{"a.jpg — 1 / 2", "X-T5 · f/2 · 1/125 · ISO 1600 · 2025-03-14", `rel="next" href="/tokyo/b.jpg/"`, `id="up" href="/tokyo/"`, `alt="a.jpg"`, `rel="preload" as="image" imagesrcset="/img/tokyo/b.jpg/480.jpg?v=`, ` 480w, /img/tokyo/b.jpg/960.jpg?v=`, `imagesizes="100vw"`}, []string{`rel="prev"`, `alt=""`}}, {"/tokyo/b.jpg/", 200, []string{`rel="prev" href="/tokyo/a.jpg/"`, "2 / 2"}, []string{`rel="next"`}}, {"/info/", 200, []string{"there"}, nil}, {"/nope/", 404, []string{"404"}, nil}, {"/tokyo/nope.jpg/", 404, []string{"404"}, nil}, {"/tokyo/a.jpg/extra/", 404, nil, nil}, } { rec := get(h, tc.path) if rec.Code != tc.code { t.Errorf("%s: code %d, want %d", tc.path, rec.Code, tc.code) continue } body := rec.Body.String() for _, w := range tc.want { if !strings.Contains(body, w) { t.Errorf("%s: missing %q", tc.path, w) } } for _, r := range tc.reject { if strings.Contains(body, r) { t.Errorf("%s: unexpected %q", tc.path, r) } } } } func TestInfoHiddenWithoutAbout(t *testing.T) { h := newServer(t, setup{}) if strings.Contains(get(h, "/").Body.String(), `href="/info/"`) { t.Error("nav links Info without about.org") } if code := get(h, "/info/").Code; code != 404 { t.Errorf("/info/ = %d, want 404", code) } } func TestImages(t *testing.T) { h := newServer(t, setup{}) rec := get(h, "/img/tokyo/a.jpg/960.jpg?v=1") if rec.Code != 200 || rec.Body.String() != "JPEGDATA" { t.Fatalf("code %d body %q", rec.Code, rec.Body.String()) } if cc := rec.Header().Get("Cache-Control"); cc != "public, max-age=31536000, immutable" { t.Errorf("Cache-Control = %q", cc) } if ct := rec.Header().Get("Content-Type"); ct != "image/jpeg" { t.Errorf("Content-Type = %q", ct) } for path, code := range map[string]int{ "/img/tokyo/a.jpg/961.jpg": 400, "/img/tokyo/a.jpg/960.png": 400, "/img/tokyo/a.jpg/big.jpg": 400, "/img/nope/a.jpg/960.jpg": 404, "/img/tokyo/nope.jpg/960.jpg": 404, } { if got := get(h, path).Code; got != code { t.Errorf("%s: %d, want %d", path, got, code) } } // The mux may 404 or redirect these; either way no file is served. for _, path := range []string{"/img/%2e%2e/a.jpg/960.jpg", "/img/tokyo/..%2fa.jpg/960.jpg"} { if rec := get(h, path); rec.Code == 200 { t.Errorf("%s: served %q", path, rec.Body.String()) } } } func TestImageRenderFailure(t *testing.T) { h := newServer(t, setup{fail: true}) if code := get(h, "/img/tokyo/a.jpg/480.jpg").Code; code != 500 { t.Fatalf("code %d, want 500", code) } } func TestTheme(t *testing.T) { rec := get(newServer(t, setup{}), "/_/theme.css") if rec.Code != 200 || !strings.HasPrefix(rec.Header().Get("Content-Type"), "text/css") || !strings.Contains(rec.Body.String(), "--bg") { t.Fatalf("code %d type %q", rec.Code, rec.Header().Get("Content-Type")) } custom := filepath.Join(t.TempDir(), "theme.css") os.WriteFile(custom, []byte(":root{--bg:red}"), 0o644) rec = get(newServer(t, setup{opt: Options{ThemePath: custom}}), "/_/theme.css") if rec.Body.String() != ":root{--bg:red}" { t.Fatalf("custom theme not served: %q", rec.Body.String()) } } func TestCustomTemplates(t *testing.T) { dir := t.TempDir() for _, name := range []string{"base", "home", "album", "photo", "info", "timeline", "error"} { body := `{{define "content"}}custom-` + name + `{{end}}` if name == "base" { body = `{{template "content" .}}` } os.WriteFile(filepath.Join(dir, name+".html"), []byte(body), 0o644) } h := newServer(t, setup{opt: Options{Templates: os.DirFS(dir)}}) if body := get(h, "/").Body.String(); body != "custom-home" { t.Fatalf("body = %q", body) } } func TestScript(t *testing.T) { h := newServer(t, setup{}) rec := get(h, "/_/gallery.js") if rec.Code != 200 || !strings.HasPrefix(rec.Header().Get("Content-Type"), "text/javascript") { t.Fatalf("code %d type %q", rec.Code, rec.Header().Get("Content-Type")) } if !strings.Contains(get(h, "/tokyo/").Body.String(), ``) { t.Fatal("album page missing script tag") } } func TestEmptyLibraryBeforeFirstScan(t *testing.T) { store := &library.Store{} store.Set(&library.Library{}) h, err := New(store, render.New(t.TempDir(), 1), Options{Title: "gallery"}) if err != nil { t.Fatal(err) } for path, code := range map[string]int{"/": 200, "/tokyo/": 404, "/tokyo/a.jpg/": 404, "/img/tokyo/a.jpg/480.jpg": 404, "/info/": 404} { if got := get(h, path).Code; got != code { t.Errorf("%s: %d, want %d", path, got, code) } } } func TestTimelinePage(t *testing.T) { h := newServer(t, setup{}) rec := get(h, "/timeline/") if rec.Code != 200 { t.Fatalf("code %d", rec.Code) } body := rec.Body.String() for _, want := range []string{"March 2025", `href="/tokyo/a.jpg/"`, `href="/tokyo/b.jpg/"`, `href="/timeline/" class="on"`, `data-ar="1.5000"`, `alt="a.jpg"`} { if !strings.Contains(body, want) { t.Errorf("timeline missing %q", want) } } if !strings.Contains(get(h, "/").Body.String(), `href="/timeline/"`) { t.Error("home nav missing Timeline link") } }