package web
import (
"net"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strings"
"testing"
"github.com/lukaszkasprzak/lectio/internal/bible"
"github.com/lukaszkasprzak/lectio/internal/config"
"github.com/lukaszkasprzak/lectio/internal/liturgy"
)
// TestServer exercises NewServer's handler tree end to end via httptest. Every
// reading is computed offline (no network, no cache, no fixture server).
func TestServer(t *testing.T) {
srv := NewServer(config.Default())
t.Run("index page", func(t *testing.T) {
rec := httptest.NewRecorder()
srv.ServeHTTP(rec, httptest.NewRequest("GET", "/?date=2026-07-22&v=wuj", nil))
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
body := rec.Body.String()
// config.Default() -> UILanguage "en", so the gospel heading's part
// label is localised to "Gospel" (render.LocalizeHeading).
if !strings.Contains(body, "Gospel") {
t.Errorf("body missing reading heading: %q", body)
}
// ?v=wuj -> the gospel is rendered from the Wujek corpus ("grobu" is
// distinctly Polish Wujek verse text, not Latin/English).
if !strings.Contains(body, "grobu") {
t.Errorf("body missing Wujek verse text: %q", body)
}
if !strings.Contains(body, "htmx") {
t.Errorf("body missing htmx reference")
}
if !strings.Contains(body, `id="theme"`) {
t.Errorf("body missing theme ")
}
// The offline engine localises the celebration name to the UI language
// (en): 2026-07-22 is Saint Mary Magdalene.
if !strings.Contains(body, `class="dayinfo"`) || !strings.Contains(body, "Saint Mary Magdalene") {
t.Errorf("body missing day-info header: %q", body)
}
})
t.Run("readings partial", func(t *testing.T) {
rec := httptest.NewRecorder()
srv.ServeHTTP(rec, httptest.NewRequest("GET", "/readings?date=2026-07-22&v=wuj&all=1", nil))
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
body := rec.Body.String()
if strings.Contains(body, " today()
if baseline.Code != http.StatusOK {
t.Fatalf("baseline status = %d, want 200", baseline.Code)
}
rec := httptest.NewRecorder()
srv.ServeHTTP(rec, httptest.NewRequest("GET", "/readings?date=../evil&v=wuj", nil))
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
if rec.Body.String() != baseline.Body.String() {
t.Errorf("traversal date did not fall back to today() identically to omitting date\n got: %q\nwant: %q", rec.Body.String(), baseline.Body.String())
}
})
t.Run("date query with many ../ segments falls back to today, same as omitting date", func(t *testing.T) {
baseline := httptest.NewRecorder()
srv.ServeHTTP(baseline, httptest.NewRequest("GET", "/readings?v=wuj", nil)) // no date -> today()
rec := httptest.NewRecorder()
srv.ServeHTTP(rec, httptest.NewRequest("GET", "/readings?date=../../../../etc/hostname&v=wuj", nil))
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
if rec.Body.String() != baseline.Body.String() {
t.Errorf("traversal-shaped date did not behave identically to omitting date\n got: %q\nwant: %q", rec.Body.String(), baseline.Body.String())
}
})
}
// TestBTCheckboxRemoved checks index.html no longer renders a "bt" version
// checkbox (the niedziela.pl corpus was retired; bibleVersions is now
// wuj,vul,grb,drb) for either lectionary, while the real version boxes remain.
func TestBTCheckboxRemoved(t *testing.T) {
srv := NewServer(config.Default())
for _, lect := range []string{"traditional", "new"} {
rec := httptest.NewRecorder()
srv.ServeHTTP(rec, httptest.NewRequest("GET", "/?lectionary="+lect, nil))
b := rec.Body.String()
if strings.Contains(b, `id="ver-bt"`) {
t.Errorf("%s: bt checkbox should no longer be rendered", lect)
}
if !strings.Contains(b, `id="ver-wuj"`) {
t.Errorf("%s: wuj checkbox missing", lect)
}
}
}
func TestReaderDefault(t *testing.T) {
srv := NewServer(config.Default())
rec := httptest.NewRecorder()
srv.ServeHTTP(rec, httptest.NewRequest("GET", "/reader", nil))
if rec.Code != 200 {
t.Fatalf("status %d", rec.Code)
}
body := rec.Body.String()
if !strings.Contains(body, `id="reader-root"`) || !strings.Contains(body, `id="pane"`) {
t.Errorf("reader page missing root/pane")
}
if !strings.Contains(body, "Genesis") { // book label (en dialect)
t.Errorf("book options missing Genesis")
}
}
func TestReaderPassage(t *testing.T) {
srv := NewServer(config.Default())
rec := httptest.NewRecorder()
srv.ServeHTTP(rec, httptest.NewRequest("GET", "/reader?book=John&chap=3&v=wuj", nil))
if rec.Code != 200 {
t.Fatalf("status %d", rec.Code)
}
if b := rec.Body.String(); !strings.Contains(b, "John 3") || !strings.Contains(b, "3:16") {
t.Errorf("passage missing heading/verse")
}
}
func TestReaderCompareAndBTFilter(t *testing.T) {
srv := NewServer(config.Default())
rec := httptest.NewRecorder()
// bt must be dropped (no corpus); wuj+vul compared side by side.
srv.ServeHTTP(rec, httptest.NewRequest("GET", "/reader?book=John&chap=3&v=bt&v=wuj&v=vul&display=vertical", nil))
if rec.Code != 200 {
t.Fatalf("status %d", rec.Code)
}
b := rec.Body.String()
if !strings.Contains(b, "display-vertical") {
t.Errorf("vertical compare layout missing")
}
}
func TestReaderInvalidBook(t *testing.T) {
srv := NewServer(config.Default())
rec := httptest.NewRecorder()
srv.ServeHTTP(rec, httptest.NewRequest("GET", "/reader?book=Nonsense", nil))
if rec.Code != 200 { // defaults to the first book, never 500
t.Errorf("invalid book status %d want 200", rec.Code)
}
}
// TestRenderOrErrorNoSectionsLang checks the "no readings at all for this
// day" fragment (finding §1) follows lang instead of always being Polish.
func TestRenderOrErrorNoSectionsLang(t *testing.T) {
html := string(renderOrError(nil, nil, "new", "horizontal", "en", liturgy.DayInfo{}, nil))
if !strings.Contains(html, "no readings for this day") {
t.Errorf("en renderOrError(no secs) = %q, want it to contain %q", html, "no readings for this day")
}
if strings.Contains(html, "czyta") {
t.Errorf("en renderOrError(no secs) should not carry Polish wording: %q", html)
}
html = string(renderOrError(nil, nil, "new", "horizontal", "pl", liturgy.DayInfo{}, nil))
if !strings.Contains(html, "brak czytań na ten dzień") {
t.Errorf("pl renderOrError(no secs) = %q, want it to contain %q", html, "brak czytań na ten dzień")
}
}
// TestIndexHTMLLangAttribute checks index.html's follows
// cfg.UILanguage (finding §6) instead of being hardcoded "pl".
func TestIndexHTMLLangAttribute(t *testing.T) {
cfg := config.Default()
cfg.UILanguage = "en"
rec := httptest.NewRecorder()
NewServer(cfg).ServeHTTP(rec, httptest.NewRequest("GET", "/?date=2026-07-22&v=wuj", nil))
if !strings.Contains(rec.Body.String(), ``) {
t.Errorf(`en index page missing : %q`, rec.Body.String()[:min(400, rec.Body.Len())])
}
cfg.UILanguage = "pl"
rec = httptest.NewRecorder()
NewServer(cfg).ServeHTTP(rec, httptest.NewRequest("GET", "/?date=2026-07-22&v=wuj", nil))
if !strings.Contains(rec.Body.String(), ``) {
t.Errorf(`pl index page missing : %q`, rec.Body.String()[:min(400, rec.Body.Len())])
}
}
// TestChooseListener exercises chooseListener's port-selection logic
// directly (no HTTP serving): port==0 prefers defaultWebPort (1099) and
// falls back to a free OS port when 1099 is taken, and a non-zero port is
// bound exactly. Sandboxed/CI environments may not permit binding 1099 (or
// may race another process for it), so those assertions t.Skip rather than
// fail the suite.
func TestChooseListener(t *testing.T) {
t.Run("zero port returns a listener with a non-zero port", func(t *testing.T) {
ln, err := chooseListener(0)
if err != nil {
t.Fatalf("chooseListener(0) error: %v", err)
}
defer ln.Close()
port := ln.Addr().(*net.TCPAddr).Port
if port == 0 {
t.Errorf("chooseListener(0) returned port 0, want non-zero")
}
})
t.Run("falls back to a free port when 1099 is taken", func(t *testing.T) {
pre, err := net.Listen("tcp", ":1099")
if err != nil {
t.Skipf("cannot bind :1099 in this environment, skipping fallback assertion: %v", err)
}
defer pre.Close()
ln, err := chooseListener(0)
if err != nil {
t.Fatalf("chooseListener(0) error while 1099 is taken: %v", err)
}
defer ln.Close()
port := ln.Addr().(*net.TCPAddr).Port
if port == 1099 {
t.Errorf("chooseListener(0) returned 1099 even though it was already taken")
}
})
t.Run("explicit non-zero port is bound exactly", func(t *testing.T) {
probe, err := net.Listen("tcp", ":0")
if err != nil {
t.Skipf("cannot bind :0 to pick a free port in this environment: %v", err)
}
want := probe.Addr().(*net.TCPAddr).Port
probe.Close()
ln, err := chooseListener(want)
if err != nil {
t.Skipf("could not bind explicit port %d (likely a race with another process): %v", want, err)
}
defer ln.Close()
got := ln.Addr().(*net.TCPAddr).Port
if got != want {
t.Errorf("chooseListener(%d) bound port %d, want exactly %d", want, got, want)
}
})
}
// TestVsetEmptyShowsNothing guards the "uncheck all -> show nothing" behavior:
// a form submit (vset present) with no v param yields an empty pane, while a
// fresh visit (no vset) checks the config default and renders it.
func TestVsetEmptyShowsNothing(t *testing.T) {
srv := NewServer(config.Default())
// Form submit, every box unchecked: empty pane, nothing checked.
empty := httptest.NewRecorder()
srv.ServeHTTP(empty, httptest.NewRequest("GET", "/readings?vset=1&lectionary=traditional", nil))
if b := strings.TrimSpace(empty.Body.String()); b != "" {
t.Errorf("readings with vset and no v should be empty, got %q", b)
}
// Fresh visit (no vset): the config default (vul) box is checked.
fresh := httptest.NewRecorder()
srv.ServeHTTP(fresh, httptest.NewRequest("GET", "/", nil))
if !strings.Contains(fresh.Body.String(), `value="vul" checked`) {
t.Errorf("fresh visit should check the config default (vul) version box")
}
// Full page with vset and no v: no VERSION box checked (mono may be).
idx := httptest.NewRecorder()
srv.ServeHTTP(idx, httptest.NewRequest("GET", "/?vset=1", nil))
for _, v := range bibleVersions {
if strings.Contains(idx.Body.String(), `value="`+v+`" checked`) {
t.Errorf("index with vset and no v: %q box should not be checked", v)
}
}
}
// TestBTSubstitutesToWuj: the legacy "bt" version has no corpus, so an explicit
// ?v=bt now renders the Wujek column instead (render.EffectiveVersions maps
// bt->wuj unconditionally, offline) for both lectionaries -- there is no longer
// a traditional-only "drop bt" special case. A fresh visit checks the config
// default (vul), never bt.
func TestBTSubstitutesToWuj(t *testing.T) {
srv := NewServer(config.Default())
for _, lect := range []string{"new", "traditional"} {
rec := httptest.NewRecorder()
srv.ServeHTTP(rec, httptest.NewRequest("GET", "/readings?vset=1&date=2026-07-22&lectionary="+lect+"&v=bt", nil))
b := rec.Body.String()
if strings.TrimSpace(b) == "" {
t.Errorf("%s: vset+v=bt should substitute wuj and render, got empty", lect)
}
if !strings.Contains(b, "Wujek") {
t.Errorf("%s: vset+v=bt should render the Wujek column: %q", lect, b)
}
}
// Fresh traditional visit (no vset): the config default (vul) is checked.
fresh := httptest.NewRecorder()
srv.ServeHTTP(fresh, httptest.NewRequest("GET", "/?lectionary=traditional", nil))
if !strings.Contains(fresh.Body.String(), `value="vul" checked`) {
t.Errorf("fresh traditional visit should default to vul (checked)")
}
}
// TestReaderPaneOutsideForm guards the mono fix: #pane must render AFTER the
// controls so its reading text inherits body's --font-reading (which
// the mono toggle flips) instead of the form's --font-ui.
func TestReaderPaneOutsideForm(t *testing.T) {
srv := NewServer(config.Default())
rec := httptest.NewRecorder()
srv.ServeHTTP(rec, httptest.NewRequest("GET", "/reader", nil))
b := rec.Body.String()
f := strings.Index(b, "")
p := strings.Index(b, `id="pane"`)
if f < 0 || p < 0 || p < f {
t.Errorf("#pane must render after (form@%d pane@%d)", f, p)
}
}
// TestReaderArrowsDriveChapSelect guards the chapter-nav fix: the arrows drive
// the chap (single source of truth) rather than a competing hx-vals
// "chap" param, and the server renders the requested book+chapter.
func TestReaderArrowsDriveChapSelect(t *testing.T) {
srv := NewServer(config.Default())
rec := httptest.NewRecorder()
srv.ServeHTTP(rec, httptest.NewRequest("GET", "/reader?book=Luke&chap=12", nil))
b := rec.Body.String()
if !strings.Contains(b, "select[name=chap]") {
t.Errorf("chapter arrows should drive the chap ")
}
if strings.Contains(b, `hx-vals='{"chap"`) {
t.Errorf("chapter arrows must not use a competing hx-vals chap param")
}
if !strings.Contains(b, "Luke 12") {
t.Errorf("server did not render the requested Luke 12")
}
}
func TestSettingsGet(t *testing.T) {
srv := NewServer(config.Default())
rec := httptest.NewRecorder()
srv.ServeHTTP(rec, httptest.NewRequest("GET", "/settings", nil))
if rec.Code != 200 {
t.Fatalf("status %d", rec.Code)
}
b := rec.Body.String()
if !strings.Contains(b, `name="ui_language"`) || !strings.Contains(b, `name="books"`) {
t.Errorf("settings form missing fields")
}
}
func TestSettingsPostAppliesLive(t *testing.T) {
t.Setenv("LECTIO_CONFIG", filepath.Join(t.TempDir(), "config.toml"))
srv := NewServer(config.Default()) // starts ui_language=en
form := url.Values{}
form.Set("lectionary", "new")
form.Set("ui_language", "pl") // change it
form.Set("sigla_style", "auto")
form.Set("web_display", "vertical")
form.Set("web_theme", "transfiguration")
form.Set("default_version", "vul")
form["versions"] = []string{"wuj", "vul", "grb", "drb"}
form.Set("books", string(bibleDefaultBooks()))
post := httptest.NewRequest("POST", "/settings", strings.NewReader(form.Encode()))
post.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rec := httptest.NewRecorder()
srv.ServeHTTP(rec, post)
if rec.Code != http.StatusSeeOther {
t.Fatalf("post status %d, want 303", rec.Code)
}
// Live-applied: the daily page now renders Polish chrome (ui_language=pl).
idx := httptest.NewRecorder()
srv.ServeHTTP(idx, httptest.NewRequest("GET", "/", nil))
if !strings.Contains(idx.Body.String(), `lang="pl"`) {
t.Errorf("ui_language change not applied live")
}
}
func TestSettingsPostInvalidBooks(t *testing.T) {
t.Setenv("LECTIO_CONFIG", filepath.Join(t.TempDir(), "config.toml"))
srv := NewServer(config.Default())
form := url.Values{}
form.Set("lectionary", "new")
form.Set("ui_language", "en")
form.Set("web_display", "vertical")
form.Set("web_theme", "transfiguration")
form.Set("default_version", "vul")
form["versions"] = []string{"wuj"}
form.Set("books", "this is not [valid toml")
post := httptest.NewRequest("POST", "/settings", strings.NewReader(form.Encode()))
post.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rec := httptest.NewRecorder()
srv.ServeHTTP(rec, post)
if rec.Code != 200 || !strings.Contains(rec.Body.String(), "books.ini") {
t.Errorf("invalid books should re-render with an error, got %d", rec.Code)
}
}
func bibleDefaultBooks() []byte { return bible.DefaultBooksINI() }
// TestSettingsPostEmptyBooksPreservesFile guards that a submit without a books
// field never clobbers an existing books.ini override.
func TestSettingsPostEmptyBooksPreservesFile(t *testing.T) {
dir := t.TempDir()
t.Setenv("LECTIO_CONFIG", filepath.Join(dir, "config.toml"))
booksPath := filepath.Join(dir, "books.ini")
original := []byte("[en]\nJohn = [\"Jn\", \"John\"]\n")
if err := os.WriteFile(booksPath, original, 0o644); err != nil {
t.Fatal(err)
}
srv := NewServer(config.Default())
form := url.Values{}
form.Set("lectionary", "new")
form.Set("ui_language", "en")
form.Set("web_display", "vertical")
form.Set("web_theme", "transfiguration")
form.Set("default_version", "vul")
form["versions"] = []string{"wuj"} // no "books" field
post := httptest.NewRequest("POST", "/settings", strings.NewReader(form.Encode()))
post.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rec := httptest.NewRecorder()
srv.ServeHTTP(rec, post)
if rec.Code != http.StatusSeeOther {
t.Fatalf("status %d", rec.Code)
}
if got, _ := os.ReadFile(booksPath); string(got) != string(original) {
t.Errorf("empty books submit clobbered books.ini: got %q", got)
}
}
func TestBookmarksFlow(t *testing.T) {
t.Setenv("XDG_DATA_HOME", t.TempDir())
srv := NewServer(config.Default())
// Add via the reader form.
form := url.Values{}
form.Set("book", "John")
form.Set("chap", "3")
form.Set("verse", "16")
form.Set("note", "for God so loved")
form.Set("tags", "grace, gospel")
post := httptest.NewRequest("POST", "/reader/bookmark", strings.NewReader(form.Encode()))
post.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rec := httptest.NewRecorder()
srv.ServeHTTP(rec, post)
if rec.Code != http.StatusSeeOther {
t.Fatalf("add status %d", rec.Code)
}
// Listed on /bookmarks.
list := httptest.NewRecorder()
srv.ServeHTTP(list, httptest.NewRequest("GET", "/bookmarks", nil))
b := list.Body.String()
if !strings.Contains(b, "John") || !strings.Contains(b, "for God so loved") ||
!strings.Contains(b, `book=John&chap=3`) || !strings.Contains(b, "John 3:16") {
t.Errorf("bookmark not listed with verse:\n%s", b)
}
// Tag filter.
byTag := httptest.NewRecorder()
srv.ServeHTTP(byTag, httptest.NewRequest("GET", "/bookmarks?tag=grace", nil))
if !strings.Contains(byTag.Body.String(), "John") {
t.Errorf("tag filter dropped the bookmark")
}
}
// TestExportSucceeds: the offline engine computes readings for any valid date,
// so /export always has content to render (there is no "no readings" 404/500
// path for a normal date anymore). Even a far-future date exports successfully.
func TestExportSucceeds(t *testing.T) {
srv := NewServer(config.Default())
rec := httptest.NewRecorder()
srv.ServeHTTP(rec, httptest.NewRequest("GET", "/export?fmt=md&date=2099-01-01", nil))
if rec.Code != http.StatusOK {
t.Fatalf("export status=%d (want 200), body=%q", rec.Code, rec.Body.String())
}
if ct := rec.Header().Get("Content-Type"); !strings.Contains(ct, "markdown") {
t.Errorf("Content-Type = %q, want markdown", ct)
}
if !strings.Contains(rec.Body.String(), "## Gospel") {
t.Errorf("export body missing a gospel section:\n%s", rec.Body.String())
}
}
func TestCalendarPDF(t *testing.T) {
t.Setenv("XDG_CACHE_HOME", t.TempDir())
cfg := config.Default()
cfg.Offline = true // no network; empty cells are fine, PDF still renders
srv := NewServer(cfg)
rec := httptest.NewRecorder()
srv.ServeHTTP(rec, httptest.NewRequest("GET", "/calendar?month=2026-07", nil))
if rec.Code != 200 {
t.Fatalf("calendar status %d", rec.Code)
}
if ct := rec.Header().Get("Content-Type"); ct != "application/pdf" {
t.Errorf("content-type %q", ct)
}
if b := rec.Body.Bytes(); len(b) < 1000 || string(b[:4]) != "%PDF" {
t.Errorf("not a pdf (len=%d)", len(b))
}
}
// TestSourceOffer covers the AGPL-3.0 §13 obligation: a user interacting with
// lectio-web over a network must be offered the corresponding source. That is
// a licence term, not a feature, so it is pinned here -- if someone later
// deletes the footer or the route, this fails rather than quietly putting the
// operator out of compliance.
func TestSourceOffer(t *testing.T) {
cfg := config.Default()
cfg.Offline = true // no network; the offer must not depend on readings
srv := NewServer(cfg)
t.Run("/source names the licence and where to get the code", func(t *testing.T) {
rec := httptest.NewRecorder()
srv.ServeHTTP(rec, httptest.NewRequest("GET", "/source", nil))
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/plain") {
t.Errorf("content-type = %q, want text/plain", ct)
}
body := rec.Body.String()
for _, want := range []string{config.SourceURL, config.License, config.Version} {
if !strings.Contains(body, want) {
t.Errorf("body missing %q:\n%s", want, body)
}
}
})
// Every page a user can actually land on carries the link. Fragments
// (htmx partials) are excluded -- they render inside a full page that
// already has it.
for _, path := range []string{
"/?date=2026-07-22&v=wuj",
"/reader?v=wuj&book=John&chapter=1",
"/settings",
"/bookmarks",
} {
t.Run("footer on "+path, func(t *testing.T) {
rec := httptest.NewRecorder()
srv.ServeHTTP(rec, httptest.NewRequest("GET", path, nil))
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
if !strings.Contains(rec.Body.String(), `href="/source"`) {
t.Errorf("page has no source link (AGPL §13):\n%s", rec.Body.String())
}
})
}
}