internal/web/server_test.go

main
gallery/internal/web/server_test.go history · blame · raw

242 lines · 7610 bytes

  1package web
  2
  3import (
  4	"errors"
  5	"io"
  6	"net/http"
  7	"net/http/httptest"
  8	"os"
  9	"path/filepath"
 10	"strings"
 11	"testing"
 12	"time"
 13
 14	"gitbay.org/cmc/gallery/internal/format"
 15	"gitbay.org/cmc/gallery/internal/library"
 16	"gitbay.org/cmc/gallery/internal/render"
 17)
 18
 19type fakeFormat struct{ fail bool }
 20
 21func (fakeFormat) Name() string        { return "fake" }
 22func (fakeFormat) Match(p string) bool { return strings.HasSuffix(p, ".jpg") }
 23func (fakeFormat) Kind() format.Kind   { return format.KindImage }
 24func (fakeFormat) Metadata(string) (format.Meta, error) {
 25	return format.Meta{
 26		Width: 3000, Height: 2000,
 27		Taken:  time.Date(2025, 3, 14, 0, 0, 0, 0, time.UTC),
 28		Camera: "X-T5", Aperture: "f/2", Shutter: "1/125", ISO: 1600,
 29	}, nil
 30}
 31func (f fakeFormat) Resize(_ string, _ int, dst io.Writer) error {
 32	if f.fail {
 33		return errors.New("magick failed")
 34	}
 35	_, err := io.WriteString(dst, "JPEGDATA")
 36	return err
 37}
 38
 39type setup struct {
 40	fail  bool
 41	about bool
 42	opt   Options
 43}
 44
 45func newServer(t *testing.T, s setup) http.Handler {
 46	t.Helper()
 47	root := t.TempDir()
 48	files := map[string]string{
 49		"tokyo/a.jpg":     "x",
 50		"tokyo/b.jpg":     "x",
 51		"tokyo/album.org": "#+title: Tokyo\n#+date: 2025-03\n",
 52	}
 53	if s.about {
 54		files["about.org"] = "Hello /there/.\n"
 55	}
 56	for rel, body := range files {
 57		p := filepath.Join(root, rel)
 58		os.MkdirAll(filepath.Dir(p), 0o755)
 59		os.WriteFile(p, []byte(body), 0o644)
 60	}
 61	reg := &format.Registry{}
 62	reg.Register(fakeFormat{fail: s.fail})
 63	cache, err := library.OpenMetaCache(filepath.Join(t.TempDir(), "meta.json"))
 64	if err != nil {
 65		t.Fatal(err)
 66	}
 67	lib, err := library.Scan(root, reg, cache)
 68	if err != nil {
 69		t.Fatal(err)
 70	}
 71	store := &library.Store{}
 72	store.Set(lib)
 73	if s.opt.Title == "" {
 74		s.opt.Title = "gallery"
 75	}
 76	h, err := New(store, render.New(t.TempDir(), 2), s.opt)
 77	if err != nil {
 78		t.Fatal(err)
 79	}
 80	return h
 81}
 82
 83func get(h http.Handler, path string) *httptest.ResponseRecorder {
 84	rec := httptest.NewRecorder()
 85	h.ServeHTTP(rec, httptest.NewRequest("GET", path, nil))
 86	return rec
 87}
 88
 89func TestPages(t *testing.T) {
 90	h := newServer(t, setup{about: true})
 91	for _, tc := range []struct {
 92		path   string
 93		code   int
 94		want   []string
 95		reject []string
 96	}{
 97		{"/", 200, []string{`href="/tokyo/"`, `data-ar="1.5000"`, "Tokyo", "2025", `href="/info/"`}, nil},
 98		{"/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=""`}},
 99		{"/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=""`}},
100		{"/tokyo/b.jpg/", 200, []string{`rel="prev" href="/tokyo/a.jpg/"`, "2 / 2"}, []string{`rel="next"`}},
101		{"/info/", 200, []string{"<em>there</em>"}, nil},
102		{"/nope/", 404, []string{"404"}, nil},
103		{"/tokyo/nope.jpg/", 404, []string{"404"}, nil},
104		{"/tokyo/a.jpg/extra/", 404, nil, nil},
105	} {
106		rec := get(h, tc.path)
107		if rec.Code != tc.code {
108			t.Errorf("%s: code %d, want %d", tc.path, rec.Code, tc.code)
109			continue
110		}
111		body := rec.Body.String()
112		for _, w := range tc.want {
113			if !strings.Contains(body, w) {
114				t.Errorf("%s: missing %q", tc.path, w)
115			}
116		}
117		for _, r := range tc.reject {
118			if strings.Contains(body, r) {
119				t.Errorf("%s: unexpected %q", tc.path, r)
120			}
121		}
122	}
123}
124
125func TestInfoHiddenWithoutAbout(t *testing.T) {
126	h := newServer(t, setup{})
127	if strings.Contains(get(h, "/").Body.String(), `href="/info/"`) {
128		t.Error("nav links Info without about.org")
129	}
130	if code := get(h, "/info/").Code; code != 404 {
131		t.Errorf("/info/ = %d, want 404", code)
132	}
133}
134
135func TestImages(t *testing.T) {
136	h := newServer(t, setup{})
137	rec := get(h, "/img/tokyo/a.jpg/960.jpg?v=1")
138	if rec.Code != 200 || rec.Body.String() != "JPEGDATA" {
139		t.Fatalf("code %d body %q", rec.Code, rec.Body.String())
140	}
141	if cc := rec.Header().Get("Cache-Control"); cc != "public, max-age=31536000, immutable" {
142		t.Errorf("Cache-Control = %q", cc)
143	}
144	if ct := rec.Header().Get("Content-Type"); ct != "image/jpeg" {
145		t.Errorf("Content-Type = %q", ct)
146	}
147	for path, code := range map[string]int{
148		"/img/tokyo/a.jpg/961.jpg":    400,
149		"/img/tokyo/a.jpg/960.png":    400,
150		"/img/tokyo/a.jpg/big.jpg":    400,
151		"/img/nope/a.jpg/960.jpg":     404,
152		"/img/tokyo/nope.jpg/960.jpg": 404,
153	} {
154		if got := get(h, path).Code; got != code {
155			t.Errorf("%s: %d, want %d", path, got, code)
156		}
157	}
158	// The mux may 404 or redirect these; either way no file is served.
159	for _, path := range []string{"/img/%2e%2e/a.jpg/960.jpg", "/img/tokyo/..%2fa.jpg/960.jpg"} {
160		if rec := get(h, path); rec.Code == 200 {
161			t.Errorf("%s: served %q", path, rec.Body.String())
162		}
163	}
164}
165
166func TestImageRenderFailure(t *testing.T) {
167	h := newServer(t, setup{fail: true})
168	if code := get(h, "/img/tokyo/a.jpg/480.jpg").Code; code != 500 {
169		t.Fatalf("code %d, want 500", code)
170	}
171}
172
173func TestTheme(t *testing.T) {
174	rec := get(newServer(t, setup{}), "/_/theme.css")
175	if rec.Code != 200 || !strings.HasPrefix(rec.Header().Get("Content-Type"), "text/css") || !strings.Contains(rec.Body.String(), "--bg") {
176		t.Fatalf("code %d type %q", rec.Code, rec.Header().Get("Content-Type"))
177	}
178
179	custom := filepath.Join(t.TempDir(), "theme.css")
180	os.WriteFile(custom, []byte(":root{--bg:red}"), 0o644)
181	rec = get(newServer(t, setup{opt: Options{ThemePath: custom}}), "/_/theme.css")
182	if rec.Body.String() != ":root{--bg:red}" {
183		t.Fatalf("custom theme not served: %q", rec.Body.String())
184	}
185}
186
187func TestCustomTemplates(t *testing.T) {
188	dir := t.TempDir()
189	for _, name := range []string{"base", "home", "album", "photo", "info", "timeline", "error"} {
190		body := `{{define "content"}}custom-` + name + `{{end}}`
191		if name == "base" {
192			body = `{{template "content" .}}`
193		}
194		os.WriteFile(filepath.Join(dir, name+".html"), []byte(body), 0o644)
195	}
196	h := newServer(t, setup{opt: Options{Templates: os.DirFS(dir)}})
197	if body := get(h, "/").Body.String(); body != "custom-home" {
198		t.Fatalf("body = %q", body)
199	}
200}
201
202func TestScript(t *testing.T) {
203	h := newServer(t, setup{})
204	rec := get(h, "/_/gallery.js")
205	if rec.Code != 200 || !strings.HasPrefix(rec.Header().Get("Content-Type"), "text/javascript") {
206		t.Fatalf("code %d type %q", rec.Code, rec.Header().Get("Content-Type"))
207	}
208	if !strings.Contains(get(h, "/tokyo/").Body.String(), `<script src="/_/gallery.js" defer></script>`) {
209		t.Fatal("album page missing script tag")
210	}
211}
212
213func TestEmptyLibraryBeforeFirstScan(t *testing.T) {
214	store := &library.Store{}
215	store.Set(&library.Library{})
216	h, err := New(store, render.New(t.TempDir(), 1), Options{Title: "gallery"})
217	if err != nil {
218		t.Fatal(err)
219	}
220	for path, code := range map[string]int{"/": 200, "/tokyo/": 404, "/tokyo/a.jpg/": 404, "/img/tokyo/a.jpg/480.jpg": 404, "/info/": 404} {
221		if got := get(h, path).Code; got != code {
222			t.Errorf("%s: %d, want %d", path, got, code)
223		}
224	}
225}
226
227func TestTimelinePage(t *testing.T) {
228	h := newServer(t, setup{})
229	rec := get(h, "/timeline/")
230	if rec.Code != 200 {
231		t.Fatalf("code %d", rec.Code)
232	}
233	body := rec.Body.String()
234	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"`} {
235		if !strings.Contains(body, want) {
236			t.Errorf("timeline missing %q", want)
237		}
238	}
239	if !strings.Contains(get(h, "/").Body.String(), `href="/timeline/"`) {
240		t.Error("home nav missing Timeline link")
241	}
242}