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")
}
}