internal/web/server_test.go
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}